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 — 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 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-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 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