Implement Bearer token validation using jsonwebtoken with JWKS caching and automatic key rotation handling. AuthState fetches keys from the configured OIDC provider's jwks_uri at startup and refreshes on unknown kid. Axum middleware (require_auth) protects write routes, injecting AuthenticatedUser into request extensions. The auth_check admin endpoint now performs real JWT validation when oauth2 is configured. Includes architecture plan for the full publish API with RBAC at docs/ai/plans/2026-04-09-publish-api-oidc.md.
8.2 KiB
Publish API with OIDC Authentication
Date: 2026-04-09
Status: Active
Issue: #3 Server Publish
Goal
A transactional REST API for publishing packages to a pkg6 repository, protected by OIDC Bearer token authentication. Clients upload packages as .p6p or .p5p archives, or via individual manifest+file transactions. No pkg5 pkgsend wire compatibility — we define our own clean protocol.
Decisions
- No pkg5 client compatibility for publish. We accept
.p5parchives (legacy format) and.p6parchives (our format), plus individual manifest+file transactions. - No transaction timeout. Transactions persist until explicitly committed or abandoned.
- RBAC model for publisher-level access control. JWT claims map to roles per publisher.
Publication Protocol
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /{publisher}/open/0/{fmri} |
Required | Start a transaction, returns Transaction-ID |
| POST | /{publisher}/add/0/{txn-id} |
Required | Add manifest or file payload to transaction |
| POST | /{publisher}/close/0/{txn-id} |
Required | Commit transaction → package is published |
| POST | /{publisher}/abandon/0/{txn-id} |
Required | Abort transaction, discard all data |
| GET | /{publisher}/status/0/{txn-id} |
Required | Query transaction state (what files are still needed) |
| POST | /{publisher}/publish/0/archive |
Required | Upload a .p6p or .p5p archive (batch publish) |
Transaction Lifecycle
Client Depot
│ │
├─ POST /open/0/{fmri} ───────►│ Create Transaction
│◄── 200 { txn_id, needed: [] }│ Return txn ID
│ │
├─ POST /add/0/{txn-id} ──────►│ Add manifest (multipart)
│ Content-Type: text/plain │ Parse manifest, compute needed files
│◄── 200 { needed: [hash1,…] } │ Return list of missing payloads
│ │
├─ POST /add/0/{txn-id} ──────►│ Upload file payload
│ X-IPkg-File-Hash: {hash} │ Store in transaction staging area
│◄── 200 { needed: [hash2,…] } │ Return remaining needed files
│ │
├─ POST /close/0/{txn-id} ────►│ Commit: move to repo atomically
│◄── 200 { fmri, published } │ Rebuild catalog, return published FMRI
│ │
(or) │
├─ POST /abandon/0/{txn-id} ──►│ Discard transaction data
│◄── 200 │
Transaction State
pub struct PublishTransaction {
id: String,
publisher: String,
fmri: Fmri,
state: TransactionState, // Open, Submitted, Published, Abandoned
manifest: Option<Manifest>,
received_files: HashSet<String>, // hashes of received payloads
needed_files: Vec<String>, // hashes still missing
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
subject: String, // OIDC subject (who opened this)
staging_dir: PathBuf, // temp dir for this transaction's files
}
Transactions are stored in {repo}/trans/{txn-id}/ on disk. On crash recovery, incomplete transactions can be resumed via the status endpoint. No automatic timeout — transactions persist until the client commits or abandons.
OIDC Authentication
Approach
Use jsonwebtoken (v10) + reqwest for JWKS fetching with a cached key set. This gives us full control over validation and doesn't pull in a heavy framework.
Config
Extend the existing Oauth2Config:
oauth2 {
issuer "https://id.example.com"
jwks-uri "https://id.example.com/.well-known/jwks.json"
audience "pkg6depotd"
required-scopes "pkg:publish"
}
Middleware Design
// New file: pkg6depotd/src/http/middleware/auth.rs
pub struct AuthState {
jwks: Arc<RwLock<JwkSet>>, // cached JWKS
issuer: String,
audience: String,
required_scopes: Vec<String>,
jwks_uri: String,
}
/// Extracted from a validated JWT and injected into request extensions.
pub struct AuthenticatedUser {
pub subject: String, // OIDC `sub` claim
pub scopes: Vec<String>, // from `scope` claim
pub roles: Vec<String>, // from custom `roles` claim
}
The middleware:
- Extracts
Authorization: Bearer {token}header - Decodes JWT header to get
kid(key ID) - Looks up signing key in cached JWKS (refresh if
kidnot found) - Validates signature,
iss,aud,exp,nbf - Checks
scopeclaim againstrequired_scopes - Extracts
subandrolesclaims - Injects
AuthenticatedUserinto request extensions
Route Protection
// In routes.rs — publish routes get the auth layer
let publish_routes = Router::new()
.route("/{publisher}/open/0/{fmri}", post(publish::open))
.route("/{publisher}/add/0/{txn_id}", post(publish::add))
.route("/{publisher}/close/0/{txn_id}", post(publish::close))
.route("/{publisher}/abandon/0/{txn_id}", post(publish::abandon))
.route("/{publisher}/status/0/{txn_id}", get(publish::status))
.route("/{publisher}/publish/0/archive", post(publish::upload_archive))
.layer(from_fn_with_state(auth_state, require_auth));
// Read routes remain unprotected
let read_routes = Router::new()
.route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest))
// ... etc
Only registered when repository mode is not readonly.
RBAC Model
Role-based access control per publisher. Roles are stored in the repository and mapped from OIDC subjects.
Roles
| Role | Permissions |
|---|---|
admin |
All operations on a publisher (publish, delete, manage roles) |
publisher |
Open transactions, upload packages, commit |
reader |
Read-only (default for unauthenticated, no special handling needed) |
Storage
// In libips repository config or a dedicated SQLite DB
pub struct PublisherRole {
pub publisher: String,
pub subject: String, // OIDC subject identifier
pub role: Role, // admin, publisher
pub granted_by: String, // who granted this role
pub granted_at: DateTime<Utc>,
}
Stored in {repo}/publisher/{name}/rbac.db (SQLite). The auth middleware checks the role after JWT validation:
- Validate JWT → get
AuthenticatedUser.subject - Look up
subjectinrbac.dbfor the targetpublisher - Check if the role permits the requested operation
- Reject with 403 if insufficient permissions
Management
POST /admin/rbac/{publisher}/grant — grant a role to a subject
DELETE /admin/rbac/{publisher}/revoke — revoke a role
GET /admin/rbac/{publisher}/list — list role assignments
These admin endpoints require the admin role on the target publisher (or a global admin scope).
Implementation Phases
Phase 1: OIDC Token Validation
- Add
jsonwebtokento pkg6depotd deps - Implement JWKS fetcher with caching (
AuthState) - Implement
require_authaxum middleware - Wire up to existing
auth_checkendpoint (replace placeholder) - Add
audiencefield toOauth2Config - Test with a real OIDC provider
Phase 2: Publish Transaction API
- Implement
PublishTransactionstate machine in libips - Add
open/0handler — creates transaction, returns ID - Add
add/0handler — accepts manifest or file payload - Add
close/0handler — commits viaFileBackend::Transaction - Add
abandon/0andstatus/0handlers - Only register publish routes when mode != readonly
Phase 3: Archive Upload
- Add
publish/0/archivehandler — accepts .p6p or .p5p upload - Detect format by extension/magic bytes
- For .p5p: use existing
Pkg5Importerpipeline - For .p6p: use
ArchiveBackend+ receiver pipeline - Returns summary of published packages
Phase 4: RBAC
- Create
rbac.dbschema and access layer in libips - Add role check after JWT validation in middleware
- Add
/admin/rbac/management endpoints - Audit logging (who published what, when)
Dependencies to Add
# pkg6depotd/Cargo.toml
jsonwebtoken = "10"