Authentication: the OAuth front door
Two modes, one middleware: the self-issued authorization server, external issuers, and the validation chain.
Two modes, one middleware
[oauth] mode is self-issued (default) or external. In both modes the same bearer
middleware gates the transport: it extracts, verifies and resolves the token before any
handler sees the request, forking on the token's issuer.
The self-issued authorization server
For a fleet that wants zero external identity infrastructure, the server ships its own minimal OAuth 2.1 AS:
| Property | Value |
|---|---|
| Flow | Authorization Code + PKCE, S256 only — a missing or non-S256 challenge is refused |
| Grant types | authorization_code only. No refresh token grant. |
| Token | Self-issued RS256 JWT; keypair minted on first run, persisted 0600, held in memory only as key material — never logged, never on any wire; only the public half leaves the process |
| Access token lifetime | 15 minutes |
| Authorization code | 60 seconds, single-use, held in process memory only (an interrupted exchange is simply retried) |
| Redirect URI | Exact match against the client's registration — /authorize never redirects anywhere else, and errors are redirected only after client_id + redirect_uri validate |
| Scope | Narrowed against the client's registered scope set; an unregistered scope is invalid_scope |
| Client registry | clients.json, 0600, per-workspace state; one pre-registered demo client exists for the guest flow |
One honest asymmetry, recorded in the metadata rather than papered over: the
protected-resource metadata advertises offline_access (clients deriving registration
requests from it must see it, or their later authorize request fails — measured live: 2 of
16 dynamically-registered clients), while the authorization-server metadata does not — this
AS implements no refresh grant, and an AS must not claim what it cannot honor.
And one deliberate refusal: the self-issued AS refuses /authorize and /token over a
forwarded (tunnel) hop. A quick tunnel is for reaching the resource; minting first-party
credentials through it is a different trust decision, made explicitly with an external IdP.
External issuers: trusted by a human, verified per token
mode = external (or any token whose iss is not this server) resolves against the
workspace trust list — <workspace>/bridge/registry/ISSUERS.md, a human-gated document,
not a discovery mechanism:
- A missing trust list means only the self-issued issuer is trusted. Fail-closed.
- Each row pins the issuer, its exactly one expected audience (no wildcard), its
jwks_uri, and its signing algorithm from a closed allow-list {RS256, ES256} —none/HS*/anything else never builds a verifier, and the resolved JWKS key's type must match the declared algorithm (an RS256 row with an EC key is a refusal, not a fallback). - In
externalmode the discovery metadata advertises only the external AS; the self-issued issuer stays trusted for tokens it already minted but is no longer advertised — clients are steered to your IdP, and cutting the self-issued issuer out entirely is a separate, deliberate act. - JWKS fetches happen only for issuers a human already listed, over https only, with
timeouts at every stage, no redirect following, and a dial guard that blocks resolved
addresses in loopback, link-local, private and cloud-metadata ranges — a hostile
jwks_uricannot be used to probe your network from the server. Cache TTL is 5 minutes, stale-as-miss (rotated keys picked up promptly), with a refetch cooldown that doubles as a negative-kid cache so an unknownkidcannot be used to hammer the IdP. - The self-issued path never touches the network — an IdP outage cannot lock the local operator out of their own fleet.
The reference external-IdP integration is Keycloak, maintained realm-as-code (Enterprise §10).
The validation chain, and why every failure has its own name
Order of checks: bearer present and well-formed → token parses → issuer resolves against
trust → signing key resolves → signature verifies → expiry → audience → structural claims →
actor binding (the token's actor must map to the client registry and resolve in the
signed actor registry — an unmapped token is refused, class token-unmapped, even with a
perfectly valid signature). The actor is never read from a token claim directly: it is
derived from the token's subject through a workspace-scoped binding, re-read fresh per
request — an external IdP authenticates, but the authorization vocabulary and actor binding
remain the engine's (Security §10). Read authorization and signing
authority resolve through the same identity vocabulary while never sharing a cryptographic
mechanism.
Each distinct failure condition ledgers its own named cause — twelve of them, from
no bearer token presented through signing key unavailable (JWKS unreachable) versus
signing key could not be resolved from the trust list (transient versus permanent —
deliberately separate, because one self-heals and the other needs a human to fix
ISSUERS.md) to bearer token audience does not match this resource. This granularity was
bought, not designed: one conflated cause string once covered six distinct conditions, and
a live endpoint went dark for five days in one era and a day in another while half a day of
investigation could not tell which condition had fired, because the ledger never named it.
The wire is unchanged by the split — every cause funnels through the same two response
shapes below; only the record knows the details.
What a failed request actually receives
| Failure class | Wire response |
|---|---|
| Authentication (no/bad/expired token) | 401 + WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource/mcp" (plus error="invalid_token" when a token was presented), body = the one denial shape |
| Authorization (valid token, insufficient scope; unmapped actor) | 403, body = the one denial shape |
The 401-versus-403 split is deliberate and follows hard-won client behaviour: 401 tells a
client to re-authenticate; 403 with a scope problem must not send it through a
re-authentication that would grant the same insufficient scopes again. The
resource_metadata URL in the challenge resolves (200) — a challenge pointing at a 404
means a client that can never discover the AS and re-authorizes forever.