ips/docs/ai/plans/2026-04-09-publish-api-oidc.md
Till Wegmueller 8f048f6b2a
Some checks are pending
Rust CI / Format (push) Waiting to run
Rust CI / Clippy (push) Waiting to run
Rust CI / Build (Linux) (push) Blocked by required conditions
Rust CI / Build (Illumos) (push) Blocked by required conditions
Rust CI / Test (push) Blocked by required conditions
Rust CI / End-to-End Tests (push) Blocked by required conditions
Rust CI / Documentation (push) Blocked by required conditions
feat: Add OIDC JWT authentication middleware for pkg6depotd
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.
2026-04-09 22:39:20 +02:00

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 .p5p archives (legacy format) and .p6p archives (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:

  1. Extracts Authorization: Bearer {token} header
  2. Decodes JWT header to get kid (key ID)
  3. Looks up signing key in cached JWKS (refresh if kid not found)
  4. Validates signature, iss, aud, exp, nbf
  5. Checks scope claim against required_scopes
  6. Extracts sub and roles claims
  7. Injects AuthenticatedUser into 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:

  1. Validate JWT → get AuthenticatedUser.subject
  2. Look up subject in rbac.db for the target publisher
  3. Check if the role permits the requested operation
  4. 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

  1. Add jsonwebtoken to pkg6depotd deps
  2. Implement JWKS fetcher with caching (AuthState)
  3. Implement require_auth axum middleware
  4. Wire up to existing auth_check endpoint (replace placeholder)
  5. Add audience field to Oauth2Config
  6. Test with a real OIDC provider

Phase 2: Publish Transaction API

  1. Implement PublishTransaction state machine in libips
  2. Add open/0 handler — creates transaction, returns ID
  3. Add add/0 handler — accepts manifest or file payload
  4. Add close/0 handler — commits via FileBackend::Transaction
  5. Add abandon/0 and status/0 handlers
  6. Only register publish routes when mode != readonly

Phase 3: Archive Upload

  1. Add publish/0/archive handler — accepts .p6p or .p5p upload
  2. Detect format by extension/magic bytes
  3. For .p5p: use existing Pkg5Importer pipeline
  4. For .p6p: use ArchiveBackend + receiver pipeline
  5. Returns summary of published packages

Phase 4: RBAC

  1. Create rbac.db schema and access layer in libips
  2. Add role check after JWT validation in middleware
  3. Add /admin/rbac/ management endpoints
  4. Audit logging (who published what, when)

Dependencies to Add

# pkg6depotd/Cargo.toml
jsonwebtoken = "10"