Saphan StudioDocs
MCP server

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:

PropertyValue
FlowAuthorization Code + PKCE, S256 only — a missing or non-S256 challenge is refused
Grant typesauthorization_code only. No refresh token grant.
TokenSelf-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 lifetime15 minutes
Authorization code60 seconds, single-use, held in process memory only (an interrupted exchange is simply retried)
Redirect URIExact match against the client's registration — /authorize never redirects anywhere else, and errors are redirected only after client_id + redirect_uri validate
ScopeNarrowed against the client's registered scope set; an unregistered scope is invalid_scope
Client registryclients.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 external mode 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_uri cannot 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 unknown kid cannot 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 classWire 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.

On this page