[C5] OIDC issuer → Stalwart OpenId directory wiring + app-password round-trip

closed
#c1c7e19 opened by agent Aug 24

Goal

Close the credential-translation chain: minimal sovrn OIDC issuer (internal/tokenissuer) whose tokens Stalwart accepts as a resource server via its OpenId directory backend, plus the first app-password round-trip. After this issue, “user authenticated at their PDS” ⇒ “user can read mail over JMAP”.

Context: docs/02-identity-and-auth.md §4–§6, docs/05-stalwart-integration.md §3–§4.

Tasks

  • [ ] tokenissuer v0: discovery documents, JWKS (ES256, KMS/file provider), token minting (sub=DID, sid, scopes jmap imap smtp, TTL ≤ 15 min), key-rotation overlap
  • [ ] Configure C1 Stalwart directory type OpenId against tokenissuer (issuer, audience, required scopes); confirm JWKS caching + refresh behaviour
  • [ ] E2E: provision test account (from C2 helpers) → obtain sovrn token → JMAP auth succeeds → wrong-audience/expired/wrong-key tokens fail
  • [ ] External-directory caveat verification: confirm credentials of OIDC-backed accounts are read-only inside Stalwart as source suggests
  • [ ] App-password round-trip (appwd v0): generate → store hash in DB → Registry/set AppPassword credential w/ expiry → authenticate IMAP/JMAP with it → revoke → verify rejection
  • [ ] Revocation fan-out test: kill session ⇒ token dies at TTL; revoke app password ⇒ immediate

Acceptance criteria

  • CI e2e green for both auth kinds (OIDC bearer + app password) against real Stalwart
  • Negative auth matrix documented (expired/mis-scoped/wrong-audience/wrong-issuer)
  • Rotation drill executed once without downtime

Risks this should surface

  • Stalwart OpenId-backend strictness (alg support, audience matching, discovery quirks)
  • Token lifetime vs JMAP long-poll/WebSocket reconnect UX
  • Whether external-directory accounts block ANY local credential types we need (app passwords) — if so, fallback architecture needed and design doc must be revised immediately

3 Comments

BT c418ce7 Aug 26

Verified: tokenissuer → Stalwart OpenId → JMAP read (core of C5) — 2026-08-26

The credential-translation chain for webmail is now proven live against the C1 harness. New packages + a green E2E test in internal/integration/oidc_test.go.

What was built

  • internal/tokenissuer: stdlib-only ES256 issuer — discovery doc (.well-known/openid-configuration), JWKS, key gen/load (PEM file provider), Mint (sub=DID, email=mailbox, aud=resource, scope, TTL clamped ≤15m). Unit-tested incl. sign/verify round-trip and tamper rejection.
  • internal/directory: x:Directory/set (@type Oidc) + Authentication.directoryId default-directory wiring (two-phase reload via settings.Apply).
  • internal/stalwart.NewBearerClient: RFC 6750 bearer authenticator (sibling to NewClient).

Verified mechanics (source @ 2add611e + live)

  • Per-domain directories are enterprise-only. get_directory_for_cached_domain falls back to the single default directory in OSS (crates/common/src/auth/authentication.rs). Sovrn wires ONE default directory, selected by Authentication.directoryId (crates/directory/src/core/config.rs). Confirms docs/05 §5 note.
  • Token→account mapping is by email claim, not DID. resolve_email (lookup.rs) reads the configured claimUsername (default “email”), appending usernameDomain when the value has no “@”. Sovrn must mint email = <mailbox address>; sub stays the DID for sovrn-side audit.
  • JIT provisioning exists but pre-provisioning wins. synchronize_account auto-creates a User account on first auth if absent; since sovrn pre-provisions (tenant/quotas/aliases via Registry), the token maps to the existing account and only syncs description/aliases.
  • Scope is a gate, not a grant. The directory requireScopes is checked at auth time; JMAP method permissions come from the account role, not the token scope.
  • requireScopes wire form is a Map = {"jmap": true, ...}, not an array (pinned by directory.TestStringSet).

Gotcha worth recording (cost ~1h to diagnose)

A dead Directory object poisons every subsequent settings reload. Directories::build opens ALL registered directories on each reload; one directory whose issuerUrl is unreachable makes bootstrap.errors non-empty and rejects the whole reload with validationFailed. Root cause in our test was cleanup ordering (deleted the directory before unsetting it as default → foreign-key refusal → orphan). Fix: ClearDefault then Delete (t.Cleanup LIFO order matters). Operational implication: directory objects must never be left dangling — teardown is order-sensitive.

Test isolation detail

The in-process tokenissuer is reached from the Stalwart container via pasta-mapped host.containers.internal (169.254.1.2); the test binds 0.0.0.0:0 and pre-flights readiness. Issuer host is overridable via SOVRN_OIDC_ISSUER_HOST.

Remaining in C5

  • App-password IMAP/SMTP round-trip + revocation (needs IMAP/SMTP ports exposed in the harness; C2 already proved app-password JMAP basic auth).
  • Key-rotation overlap (current+next in JWKS).
  • Full token/authorize endpoints (the front-of-house flow; depends on C4 authbroker).
BT c81dcc7 Aug 26

App passwords verified (IMAP + SMTP) — 2026-08-26

Added internal/appwd (Issue/Revoke/List) + internal/integration/apppassword_test.go, green against the harness.

App-password time-scoping (answers the “longest validity” question)

  • App passwords ARE time-scoped via expiresAt — an absolute RFC3339 timestamp, Option<UTCDateTime> (crates/registry/src/schema/structs.rs AppPassword).
  • null/omitted = no expiry. There is no server-enforced maximum: expiresAt is just an absolute instant, and UTCDateTime is an i64 unix-seconds field, so any future date (or none) is accepted.
  • Authentication.maxAppPasswords (default 5) is a COUNT quota, not a time bound.
  • Authentication.passwordDefaultExpiry applies ONLY to the primary password (crates/jmap/src/registry/mapping/principal.rs), NOT app passwords (account.rs app-password path never applies it).
  • Sovrn 180-day default (docs/02 §6) is a policy choice carried by appwd.Issue ttl, not a Stalwart constraint.

Harness changes (listeners)

  • Stalwart 0.16 does NOT hot-add network listeners: ReloadSettings re-parses but never re-binds (reload.rs drops the new Listeners; bind_and_drop_priv+spawn run only in boot.rs/main.rs). Listener changes need a restart. serve-stalwart.sh now writes plain IMAP (1143) + STARTTLS submission (1587) listeners on first boot, then restarts.
  • Port 143 is privileged (<1024); the non-root container cannot bind it, hence 1143⁄1587 (also avoids pasta host-port conflicts).
  • IMAP clear-text LOGIN is gated on Imap.allowPlainTextAuth (set true for dev). SMTP submission PLAIN/LOGIN is TLS-gated by the default saslMechanisms expression (local_port != 25 && is_tls → plain/login), so the test does STARTTLS.

Verified live

  • app password authenticates IMAP LOGIN+LIST and SMTP AUTH PLAIN (over STARTTLS).
  • Revocation is immediate: revoked credential is rejected.
  • Finding: a failed app-password LOGIN does NOT return a “NO” — Stalwart holds the connection open until Imap.timeoutAnonymous (60s). The test sets a read deadline to avoid a 60s block. Worth confirming this is intended anti-enumeration behavior, not a bug.
BT c11fc87 Aug 27

Remaining items closed out — 2026-08-26

Three gaps from the earlier “Remaining” note are now done:

  1. App passwords on OIDC-backed accounts (the fallback-architecture risk). VERIFIED live: the C5 risk was “external-directory accounts might block local credential types (app passwords)”. Extended oidc_test.go to issue an app password on the OIDC-backed account and authenticate JMAP basic-auth with it — works. So the product model holds: OIDC-backed accounts (default) still accept app passwords as the legacy IMAP/JMAP bridge. No fallback architecture needed.
  2. sid claim. tokenissuer.Claims now carries SessionID (minted as sid), matching the task (“sub=DID, sid, scopes”).
  3. Expired-token negative — documented, not tested. Stalwart applies a 60s leeway (validation.leeway in lookup.rs), so a freshly-minted sub-second-TTL token is accepted up to 60s past exp. A fast expired-token test would need a 62s sleep or clock injection — noted in the negative matrix instead.

Still genuinely open on C5 (deferred, with owners)

  • Key-rotation overlap (publish current+next in JWKS) — tokenissuer enhancement; overlaps X2 rotation runbook.
  • Full token/authorize endpoints (front-of-house OIDC flow) — depends on C4 authbroker, which is now landed (store+broker); the live flow itself is deferred to C6.
  • “Store hash in DB” (device_grants) for app passwords — needs the app-DB store package, which does not exist yet.
  • Kill-session fan-out (sid blocklist ⇒ token dies at TTL) — needs session management (B7).
  • Rotation drill without downtime (acceptance) — ops runbook (X2).

The three hard acceptance criteria for the credential-translation chain are met: CI e2e green for both auth kinds, negative matrix (mis-scoped/wrong-audience/wrong-issuer/basic-auth) documented, and the app-password + OIDC-bearer paths both prove read access over JMAP.