matrix-axon

ADR 0054 — OAuth 2.0 authorization server (Apple / Google / Microsoft sign-in)

Context

ADR 0053 named OAuth 2.0 + PKCE + Sign-in-with-Apple as a hard blocker before an iOS client ships, and explicitly deferred the design to a follow-on ADR:

This ADR does not design the OAuth flow — implementation should be gated on a dedicated follow-on ADR (authorization-code + PKCE flow, Apple identity-token verification, session/refresh-token model) before any code is written.

This is that ADR. It depends on ADR 0053’s decision that OAuth is required at all; 0053 is still only open PR #214 at the time of writing, and should merge before this one, or this ADR reads as gated on a decision not yet in main.

Apple is a real (not just UX) requirement here, not merely a preference: App Store Review Guideline 4.8 requires that any app offering a third-party/social login (Google, Microsoft, etc.) also offer Sign-in-with-Apple as an equivalent option. Since this ADR adds Google and Microsoft sign-in alongside Apple, Apple stops being optional the moment those ship on iOS — this is an App Store compliance requirement, not only a design preference. A client offering no social login at all (pure CLI-token-paste) would be exempt from Guideline 4.8, but that is not the scope commissioned here.

Three prior decisions constrain the design:

Scope decisions taken before writing this ADR:

Decision

Two protocol roles, one process

Axon plays two different OAuth/OIDC roles simultaneously, and keeping them conceptually separate is the key to the rest of this design:

  1. Axon is an OAuth 2.0 Authorization Server (RFC 6749) to its own clients — axon-tui, a future web client, a future iOS client. It mints axon’s own access and refresh tokens. Every axon-registered client is a public client (RFC 8252): no client secret, PKCE (S256 only, plain rejected) mandatory on any flow that has a redirect leg.
  2. Axon is an OpenID Connect Relying Party to the upstream IdP (Apple / Google / Microsoft) purely to answer one question: is the human on the other end of this request the bound owner of this axon instance? Axon never hands an upstream (Apple/Google/Microsoft) token to a client to use against /v1/. Upstream tokens are consumed internally and never leave the server process.

These compose into two independent entry paths that both terminate in “mint an axon access + refresh token,” corresponding to the two paths ADR 0053 called out:

Axon’s own token response is plain OAuth 2.0 (access_token/refresh_token/token_type/expires_in) — not an OIDC id_token. Axon is not an OIDC provider to its own clients; there is only one owner, so there is no profile/claims surface a client needs beyond “here is your bearer token.”

OidcProvider abstraction

#[async_trait]
pub trait OidcProvider: Send + Sync {
    fn name(&self) -> &'static str; // "apple" | "google" | "microsoft"

    /// Build the upstream authorize URL for Path A.
    fn authorize_url(&self, state: &str, nonce: &str, redirect_uri: &str) -> String;

    /// Exchange an upstream authorization code for upstream tokens (Path A).
    async fn exchange_code(&self, code: &str, redirect_uri: &str)
        -> Result<UpstreamTokens, OidcError>;

    /// Verify a raw JWT — either the id_token from exchange_code (Path A) or
    /// a native-SDK identity token handed straight to the client (Path B) —
    /// against this provider's JWKS + expected claims (iss/aud/exp/nonce).
    async fn verify_identity_token(&self, token: &str, nonce: Option<&str>)
        -> Result<VerifiedIdentity, OidcError>;
}

pub struct VerifiedIdentity {
    pub subject: String,
    pub email: Option<String>,
    /// The token's `jti` claim, or a hash of the token if the provider
    /// omits `jti`. Used by the Path B handler for single-use replay
    /// consumption — see "Schema" and "Robustness at this boundary" below.
    pub replay_key: String,
}

Schema

Four new tables, one extension to an existing table:

Provider client secrets/Apple private keys live in config only (see below), consistent with store_key’s existing precedent of “plain Option<String>, validated lazily at use, no secrets-manager integration.”

Router boundary

/v1/oauth/* must be reachable with no bearer token — that’s the entire point. crates/axon-api/src/lib.rs’s router() builds a gated authed sub-router (route_layer(require_bearer)) and merges /healthz and /v1/ws outside it. This adds a third sibling: an un-gated oauth sub-router merged at the same level, carrying:

Each handler checks oauth.enabled (and the specific provider’s enabled) in config and returns 404 when off — unlike GET /v1/search, which returns 503 for the same “feature disabled” case. The two routes are deliberately different: /v1/search is a route behind the bearer gate, so its existence is already known to any authenticated client, and 503 (“exists, temporarily/config unavailable”) is the honest signal. /v1/oauth/* is unauthenticated surface, where not confirming the route exists at all is the more defensible default; 404 is chosen for that reason, not because it mirrors /v1/search. Either way, no conditional router construction is needed — the handler itself checks config and returns early.

/v1/ws is already proof, in the live router, that a specific route beats the authed sub-router’s /v1/{*path} catch-all regardless of merge order — it is merged as an un-gated sibling today and axum resolves it correctly. The same guarantee applies to /v1/oauth/*. A regression test must still assert this explicitly for /v1/oauth/token, rather than relying on the /v1/ws precedent by inference.

Robustness at this boundary

This is now the only unauthenticated surface in the whole API capable of minting credentials, which raises the bar per AGENTS.md’s “robustness at boundaries” checklist:

CLI bind command (axon oauth bind)

The one client-shaped deliverable in this ADR. The central design problem: axon supports both “on the same box as your browser” and “home server behind a VPN, admin’s browser is elsewhere” deployments (M13). A naive loopback-listener design (gh auth login-style: CLI spins up a local HTTP listener, browser redirects to it) only works for the first case. Instead, this piggybacks on axon’s already-running /v1/ HTTP surface — the same connectivity path every other axon client already uses — rather than inventing a new listener/port:

  1. axon oauth bind --provider apple connects directly to the Store (same pattern as axon token issue — no dependency on the sync engine). It generates a device_code (opaque, server-side) and a short human-typeable user_code (8 chars, RFC 8628-style), INSERTs a pending oauth_bind_requests row (10-minute TTL), and prints: “Open https://<oauth.external_base_url>/v1/oauth/bind?user_code=XXXX-XXXX in any browser and sign in with Apple.”
  2. The CLI then polls the same row directly via Store (not over HTTP) until status becomes completed/expired — this deliberately keeps the CLI’s own dependency surface identical to axon token’s (DB-only), while the browser’s leg depends on the long-running axon server process already being up (a new, explicit, documented precondition — axon token issue has no such requirement; axon oauth bind does).
  3. The admin’s browser hits GET /v1/oauth/bind?user_code=... on the running server, which looks up the pending row and redirects into the normal Path A upstream flow (state carries a reference to the pending oauth_bind_requests row). On successful upstream verification, the callback handler UPSERTs oauth_identities and marks the bind request completed.
  4. The CLI’s poll loop reports success and exits.

This works identically for localhost and remote/VPN deployments, since it reuses whatever reachability the deployment already has — no new port, no new firewall rule. axon oauth identities list / axon oauth identities unbind <id> round out the management surface; unbind revokes the oauth_identities row and every tokens/oauth_refresh_tokens row carrying that oauth_identity_id — the “sign out everywhere for this identity” operation.

Config shape

A new oauth section, following the existing sync/search/media sectioning convention (#[serde(default)], defaults off):

[oauth]
enabled = false                     # default off; CLI-token flow unaffected
external_base_url = "https://myaxon.example.com"   # what `oauth bind` prints
access_token_ttl_secs = 3600
refresh_token_ttl_secs = 2592000

[[oauth.clients]]                   # axon's own pre-registered public clients
client_id = "axon-ios"
redirect_uris = ["axon://oauth/callback"]

[[oauth.clients]]
client_id = "axon-web"
redirect_uris = ["https://myaxon.example.com/callback"]

[oauth.providers.apple]
enabled = true
client_id = "com.example.axon.service"   # Apple Services ID
native_audiences = ["com.example.axon.ios"]
team_id = "ABCDE12345"
key_id = "XYZ98765"
private_key = "-----BEGIN PRIVATE KEY-----..."   # or private_key_path
redirect_uri = "https://myaxon.example.com/v1/oauth/apple/callback"

[oauth.providers.google]
enabled = false
issuer = "https://accounts.google.com"
client_id = "..."
client_secret = "..."

[oauth.providers.microsoft]
enabled = false
issuer = "https://login.microsoftonline.com/common/v2.0"
client_id = "..."
client_secret = "..."

Secrets (private_key, client_secret) are plain Option<String>, validated lazily at first use, matching sync.store_key’s precedent — no secrets-manager integration exists in this codebase and this ADR does not introduce one.

The axon-ios client’s redirect_uris = ["axon://oauth/callback"] uses a custom URL scheme, which any app on the device is free to register — a second app claiming axon:// could intercept the redirect. PKCE-S256 is the stated mitigation (the intercepting app gets a code it cannot redeem without the original app’s in-memory code_verifier), which is sufficient. Whether M14c’s iOS client should prefer a universal link (https:// redirect, which iOS uniquely routes to the registered app, no scheme-squatting possible) or ASWebAuthenticationSession with the custom scheme is a client implementation choice, not a wire-contract change either way — left for M14c to decide.

Explicitly out of scope

Consequences

Open Questions

Implementation addendum: Apple rollout

This addendum records the Apple implementation sequence as of September 2026 and supersedes conflicting Apple-specific details above. Google and Microsoft sign-in, Axon access/refresh tokens, provider discovery, owner binding, browser clients, and the Tauri shell now exist. Apple’s credentialed browser callback and binding integration is now implemented; real Apple deployment verification remains pending. This authenticates the owner of an Axon instance; it is separate from Matrix homeserver OAuth in ADR 0097.

Deployment and trust

Axon remains a single-owner service. Ordinary sign-in accepts only an explicitly bound (provider, subject) identity. An email address, including an Apple private relay address, never establishes ownership or links identities across providers. Binding requires either existing-owner authorization or the existing explicit bootstrap capability; no trust-on-first-use is introduced.

Apple’s browser flow needs a Services ID associated with a primary App ID, registered domains/return URLs, and a developer-controlled signing key. Apple limits registered website URLs and instructs developers not to share signing keys outside their team. Therefore, the distributed Axon application must not give its developer-team signing key to self-hosters or embed it in the client. Browser Apple OAuth is for deployments with their own appropriately registered credentials. The distributed iOS app will instead use native Sign in with Apple and forward its identity token to the selected Axon instance for public-key verification. The native-only verifier must be constructible without a Services ID or private key when that runtime integration lands; this first provider constructor is for the credentialed browser flow. No central authentication broker is introduced by this decision. See Apple’s web registration guide and environment/key guidance.

Guideline 4.8 currently requires an equivalent login option with specified privacy properties, rather than mandating Apple by name in every case. Sign in with Apple is the chosen way to meet that requirement for Axon’s mobile social-login experience. Shipping a provider library alone does not establish App Store readiness. See App Review Guidelines, section 4.8.

PR 1: provider foundation and this addendum

The server library now implements AppleProvider alongside GenericOidcProvider. Both use shared signature/claims verification and authorization-code exchange with the existing bounded HTTP/JWKS infrastructure. The extraction preserves Google’s issuer and Microsoft’s tenant-template handling, with explicit bounds on JWT size, overflow-safe timestamp comparisons, nonempty subjects, and sanitized errors that do not echo token claims.

Apple’s authorization request uses response_type=code, response_mode=form_post, scope=email, state, and nonce. Axon does not request names or depend on the unsigned, first-login-only user object. Apple’s token response is bounded and only its identity token leaves the provider adapter; upstream access and refresh tokens are discarded. Errors include fixed categories and HTTP status, never response bodies, authorization codes, generated client secrets, or key material. The existing error boundary can log those categories; no secret-bearing developer mode is added.

Client authentication uses an ES256 JWT with the configured key ID, team ID as issuer, Services ID as subject, and https://appleid.apple.com as audience. Generate a fresh five-minute JWT for each token exchange instead of maintaining a renewal timer or secret cache. Backdate iat by the verifier’s 60-second clock-skew allowance while keeping exp at signing time plus five minutes. This tolerates a slightly fast host clock; real Apple acceptance remains part of callback integration verification. This automatically renews credentials after idle periods and restarts, stays well within Apple’s six-month ceiling, and needs no background-task ownership or lock. Construction validates required fields and proves the PEM key can sign ES256 before accepting login attempts. Underlying key revocation still needs operator action.

Web verification accepts only the configured Services ID and requires the stored upstream nonce. The explicit native verification method accepts only configured bundle IDs and requires a nonce supplied by the future server challenge handler. Unlike the original union-of-audiences proposal, one path cannot substitute the other’s audience. The Apple implementation rejects the existing nonce-free identity-token grant; enabling the browser provider later must not silently enable an unbound native flow.

The binary startup and CLI binding gates remain in place in this PR. Removing them before POST callbacks land would advertise a provider whose login cannot finish. No HTTP API, migration, or client-rendering change is introduced here, so no OpenAPI regeneration or demo scene is needed.

Verification needs Rust and the repository’s normal build dependencies, but no Apple account, database, or external service for these focused tests:

cargo test -p axon-api --lib oauth::

Apple tests generate ephemeral keys in memory using a test-only dependency on the existing AWS-LC backend, with real ES256 signing and RS256 verification and local HTTP endpoints. No private signing material is committed or persisted. They exercise authorization encoding, client-secret renewal, code exchange, separate web/native audiences, issuer/nonce/time/signature rejection, relay email, absent repeat-login profile data, deterministic replay keys, body limits, transport timeouts, and secret-safe errors. The shared Google/Microsoft verifier also needs signed-token regression coverage, not only issuer predicate tests.

PR 2: browser callbacks, runtime configuration, and owner binding

Implemented by the second server change, with real Apple acceptance still required before browser support is called complete. Callbacks accept at most 16 KiB, do not depend on cookies, ignore unsigned profile data, and consume cancellations without issuing credentials. Login failures return fixed OAuth errors only to the stored, currently registered client destination; CLI binding and bootstrap use retry instructions instead. Unknown or invalid flows get a static failure page, never a caller-supplied redirect. Callback responses disable caching and referrer disclosure. This includes rate-limit, oversized-body, and body-timeout rejections. Both GET and POST callbacks use browser-facing HTML for error responses; GET previously returned the JSON API error envelope. Binding nonces are created with the CLI request before printing its URL, so repeated GET/HEAD redirects reuse the nonce without consuming the flow. Bootstrap flow consumption is transactional with first-identity/token creation. The existing expired terminal state also represents canceled attempts, so no migration is required. Configuration Debug output redacts Apple key material and paths (issue #463). Exactly one of private_key or private_key_path is required; files are bounded to 16 KiB and require owner-only permissions on Unix. Relative key paths use the process working directory, so the CLI and server must use the same resolved file. Automated tests use local signed-token providers and isolated Postgres, never live Apple credentials.

Add bounded form POST handling beside GET on /v1/oauth/{provider}/callback, with one shared completion implementation for login, CLI binding, and first-run bootstrap. Decode success and cancellation/error responses explicitly. Validate state, provider, expiry, and flow purpose before dispatch, and preserve atomic single-use completion. Do not require a browser cookie to survive Apple’s cross-site POST. Errors must lead to a retryable UI without reflecting upstream descriptions or leaving the client waiting indefinitely. See Apple’s callback contract.

Load the signing key from protected configuration or a bounded private-key file, redact config diagnostics, and use one validated callback resolver across authorize, exchange, binding, and bootstrap. Require the exact registered HTTPS callback for Apple; reject conflicting configured and derived callback URLs. Wire the provider into startup and discovery, then remove both enablement gates together. Update configuration examples, README, OpenAPI where applicable, and the implementation tracker in the same server PR. Validate real Apple browser login and CLI/bootstrap binding on a registered test deployment before calling browser support complete.

PR 3: native server contract and owner binding

Implemented by the final core-server layer; see the native contract and verification guide. The additive endpoints are POST /v1/oauth/apple/native/challenge and POST /v1/oauth/apple/native/token. Native-only discovery uses GET /v1/oauth/providers?flow=native; the default list retains browser-only semantics for old clients. Apple receives the server-returned base64 SHA-256 nonce verbatim, without an additional client hash. An independent client-held challenge capability is stored only as a hash and is never sent to Apple. Flows expire after five minutes, bind the configured instance URL, public client ID, purpose, and authorizing capability, and are capped separately at 1,024 public login rows and 64 authorized bind/bootstrap rows. Owner binding requires the same still-active bearer at creation and redemption; bootstrap requires the same explicitly armed capability and retains the first-credential transaction lock. The shared identity-token transaction also closes the legacy Google/Microsoft replay-before-mint gap. Browser authorization-code and refresh-token orchestration are unchanged. Native device acceptance and Apple App ID/Services ID subject-grouping verification remain outstanding until the native client integration.

Add a short-lived, server-issued challenge for Apple native login, bound to an explicit flow purpose and selected Axon instance. Specify precisely whether the native SDK receives the raw nonce or its digest and verify the corresponding signed claim. The expected nonce comes from server state, never from an untrusted token or request field. Keep the public API additive for existing Google/Microsoft clients. Construct native verification independently of browser credentials and expose its availability accurately in provider discovery.

Consume the challenge and replay key in the transaction that mints Axon tokens. The existing Path B implementation consumes the replay record before a separate mint operation; close that crash/failure window here. Concurrent redemption may mint at most once, and an interrupted attempt must be recoverable by starting a fresh challenge. Authorize native binding through an existing owner session or the explicit bootstrap capability, so self-hosters need no Apple browser credentials to establish ownership. Verify App ID/Services ID grouping and subject behavior with real Apple credentials rather than linking by matching emails.

PR 4: native iOS client

The Tauri iOS shell signs in with Apple through ASAuthorizationController, with no browser and no web callback. An in-repo plugin (clients/web/src-tauri/native-auth) exposes Apple’s sheet and the Keychain as ordinary Tauri commands that exist on every platform and are implemented only on iOS; its capabilities command tells the page which apply. It was written rather than taken from a third-party crate because the nonce must reach Apple verbatim, and the plugin sits on the credential path.

Discovery asks for both provider lists when the platform has a native Apple sheet. Native Apple replaces a browser Apple entry, or joins the list on a native-only server; Google and Microsoft continue through the system browser with PKCE, unchanged. The shell reuses its existing axon-desktop client registration: the native endpoints need only a registered client ID, so operators add nothing new. The challenge capability lives in one function scope and is never stored; cancellation or any failure abandons it, and the next attempt starts a fresh challenge and Apple request. The Apple request asks for no scopes, since ownership is the signed subject alone and profile data would only be discarded. Apple therefore never shows its Share My Email / Hide My Email choice to the native app: with no email requested, there is none to hide, which is the strongest form of Guideline 4.8’s private-email requirement. The browser flow still requests email, so Hide My Email remains a browser acceptance check.

A signed-in owner links an Apple ID from Settings with purpose=bind and their current bearer, then adopts the returned Apple session. (Amended by ADR 0109: that bearer must never expire, or belong to a session whose upstream sign-in was within ten minutes; otherwise the challenge is refused with recent_sign_in_required. And GET /v1/management/oauth/identities now lists linked identities, so the next sentence describes the client before that route existed.) Settings offers the link only when the current session is not an Apple one: an Apple session proves the link, while no /v1 route lists linked identities, so any other session cannot tell whether one exists. Re-linking an already-linked Apple ID is harmless (the server binds idempotently) but would look like a button that does nothing. This is how a self-hoster with no Apple web credentials makes Apple sign-in work: sign in once with a CLI token or another provider, then link. Native bootstrap of an empty instance is not offered in the app; the web bootstrap and CLI remain the first-run paths.

Axon’s own access and refresh tokens persist in the Keychain (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) rather than the webview’s localStorage. AuthPersistence reads synchronously, so the shell loads the Keychain once before the first render and writes behind in order. Server changes wait for pending Keychain writes before reloading, so a deleted token cannot come back. An upgraded shell moves existing tokens out of localStorage and deletes the plaintext copy only once the Keychain holds it. A reinstall clears the previous installation’s Keychain items, detected by a UserDefaults marker that does not survive deletion. Apple’s identity token is handed to the page once and is never stored.

The entitlement lives in clients/web/src-tauri/Entitlements.ios.plist. Tauri has no iOS entitlements setting, so scripts/package-ios.sh copies the file over the generated one, then checks the exported .ipa and refuses to install or upload one missing the entitlement or the org.matrixaxon.axon URL scheme (TestFlight build 25 shipped without the scheme, breaking browser sign-in). An unentitled build fails only at run time, with Apple error 1000, which the plugin reports with that diagnosis.

Verified on a TestFlight build on 2026-09-27: an unlinked Apple ID is refused with identity_not_bound, linking from Settings succeeds, and Sign in with Apple then signs in directly.

Still outstanding after this PR: Apple revocation and account deletion (below), black-box smoke, and the real-device release-gate checks.

Subsequent lifecycle and smoke PRs

Resolve Apple authorization revocation and applicable account-deletion requirements before App Store submission. Local Axon logout/unbinding does not revoke Apple’s authorization. The current provider discards upstream tokens, so it cannot promise unattended Apple revocation; a follow-up decision must specify a fresh-authorization revocation flow or explicitly revisit credential retention and key custody. Do not persist upstream credentials or introduce a credential broker as an incidental implementation detail. Deletion must define its Axon-data scope, local token invalidation, retries after partial failure, and recovery after restart. See Apple’s account-deletion guidance and TN3194.

Keep black-box smoke additions in their own smoke-silo PR. The release gate includes real-device first and repeat sign-in, binding, cancellation/retry, restart, refresh, logout, and deletion/revocation, plus Google/Microsoft regression checks. Logs and persisted test artifacts must contain no real tokens, authorization codes, private keys, or client-secret JWTs. App Review needs a working review deployment and instructions that exercise the actual supported flow.