[C5] OIDC issuer → Stalwart OpenId directory wiring + app-password round-trip
closedGoal
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
- [ ]
tokenissuerv0: discovery documents, JWKS (ES256, KMS/file provider), token minting (sub=DID,sid, scopesjmap 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 (
appwdv0): generate → store hash in DB →Registry/setAppPassword 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
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.directoryIddefault-directory wiring (two-phase reload viasettings.Apply).internal/stalwart.NewBearerClient: RFC 6750 bearer authenticator (sibling to NewClient).Verified mechanics (source @ 2add611e + live)
get_directory_for_cached_domainfalls back to the single default directory in OSS (crates/common/src/auth/authentication.rs). Sovrn wires ONE default directory, selected byAuthentication.directoryId(crates/directory/src/core/config.rs). Confirms docs/05 §5 note.resolve_email(lookup.rs) reads the configuredclaimUsername(default “email”), appendingusernameDomainwhen the value has no “@”. Sovrn must mintemail = <mailbox address>;substays the DID for sovrn-side audit.synchronize_accountauto-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.requireScopesis checked at auth time; JMAP method permissions come from the account role, not the token scope.requireScopeswire form is a Map{"jmap": true, ...}, not an array (pinned bydirectory.TestStringSet).Gotcha worth recording (cost ~1h to diagnose)
A dead
Directoryobject poisons every subsequent settings reload.Directories::buildopens ALL registered directories on each reload; one directory whoseissuerUrlis unreachable makesbootstrap.errorsnon-empty and rejects the whole reload withvalidationFailed. Root cause in our test was cleanup ordering (deleted the directory before unsetting it as default → foreign-key refusal → orphan). Fix:ClearDefaultthenDelete(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 binds0.0.0.0:0and pre-flights readiness. Issuer host is overridable viaSOVRN_OIDC_ISSUER_HOST.Remaining in C5
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)
expiresAt— an absolute RFC3339 timestamp,Option<UTCDateTime>(crates/registry/src/schema/structs.rs AppPassword).null/omitted = no expiry. There is no server-enforced maximum:expiresAtis just an absolute instant, andUTCDateTimeis 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.passwordDefaultExpiryapplies ONLY to the primary password (crates/jmap/src/registry/mapping/principal.rs), NOT app passwords (account.rs app-password path never applies it).Harness changes (listeners)
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
Remaining items closed out — 2026-08-26
Three gaps from the earlier “Remaining” note are now done:
oidc_test.goto 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.tokenissuer.Claimsnow carriesSessionID(minted assid), matching the task (“sub=DID, sid, scopes”).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)
storepackage, which does not exist yet.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.