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), 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 identity provider the server is tested against is Keycloak.
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 (Identity and PKI). 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, 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. One cause string
covering several conditions leaves an operator unable to tell which one fired. The wire does
not carry the cause — 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.