feat(mcp): serve MCP over HTTP as an OAuth 2.1 resource server - #757
Conversation
Authorizer must act as an OAuth 2.1 resource server before /mcp can be served over HTTP. Two rules make that safe, and they are mirror images: - ValidateAccessToken rejects every resource-bound (absolute-URI) audience at Authorizer's own surfaces. - ValidateMCPAccessToken accepts exactly one — <url>/mcp — and nothing else. Neither has an "or" in it. An MCP token must not double as a GraphQL credential, and a login token must not reach the tool surface. Rather than branch on audience inside the shared resolver, the MCP transport gets its own bufconn-only gRPC server via a TokenResolver override. The boundary is then structural: two servers, neither able to accept the other's tokens. The audience comparison never touches request headers. parsers.GetHost falls back to X-Authorizer-URL when --url is unset, which would let a caller name the audience their own token must match. Config.MCPResource derives it from --url alone, and startup refuses --mcp-enabled without it. MCP is stricter than the first-party path on subject liveness too: userIsRevoked resolves a subject as a user only and returns "not revoked" when it finds nothing, so a deactivated service account's machine token keeps working until expiry. MCP uses subjectIsLive (user-then-client, fails closed) since agents and service accounts are its main callers. The shared core keeps the old rule — widening it is a separate change. Delegated (RFC 8693) tokens are deliberately not accepted at /mcp yet: they are stateless, so they fail the session lookup, and the delegated validator requires the bare host as audience. Widening that path gives up the byte-for-byte token comparison and must be its own decision. Refs authorizer-docs specs/2026-08-10-mcp-http-transport.md
Subject liveness resolved a token's `sub` as a USER and treated "no such user" as "not revoked". A client_credentials token's `sub` is the service account's row id, never a user id, so the lookup missed every time and reported the caller live. Setting a service account inactive therefore blocked new issuance and did nothing to the tokens already out: an operator revoking a compromised machine identity got a success response and a credential that kept working at GraphQL, gRPC and REST until it expired. A control that looks like it enforces and silently does not is worse than no control — the operator reads it as effective and stops looking. The decision core now uses subjectIsLive, which resolves user-then-client and fails closed when the subject is neither. The delegated path already used it; this brings the first-party path in line, so there is one subject rule rather than two, and the parameter that selected between them is gone. Failing closed on an unresolvable subject also matters as a fallback: DeleteUser purges sessions through asyncutil.Go best-effort, so a failed or racing purge previously left a deleted user's token authenticating. Not backward compatible, deliberately, and scoped to exactly that: live users and active service accounts are unaffected, and machine tokens pay one extra client lookup. Landing it now rather than in 2.5.0 because service accounts, client_credentials and workload identity are all new in 2.4.0 — there is no installed base, so this is a bug that never shipped instead of a behaviour change to a released feature. Industry norm is looser (Auth0, Okta and Keycloak treat client_credentials tokens as non-revocable before expiry, bounded by short TTLs); this repo already carries the session-store check that makes revocation possible, it just never applied to the subjects that needed it. Refs authorizer-docs specs/2026-08-10-mcp-http-transport.md §6
Review of the two stacked commits found the liveness change had swapped one failure mode for a worse one, and that fixing it properly exposed a real gap underneath. 1. subjectIsLive treated a storage ERROR as "subject not active". On the delegated path that was a fine trade; as the shared rule for every stateful token it meant a database failover 401s every authenticated request on GraphQL, gRPC and REST at once — and a 401 tells the SDKs the session expired, so a five-second blip becomes a fleet-wide forced logout reported as a bad credential. The code it replaced documented the opposite as deliberate: "fail open on DB errors so a transient storage blip can't take down every authenticated request". AGENTS.md names this exact conflation as one of two classes of production bug already suffered here. subjectLiveness now reports live AND known separately, using storage.IsNotFound to tell absence from failure. A confirmed-dead subject is still rejected everywhere; an unanswerable lookup is tolerated by first-party traffic and still fails closed on the stateless delegated path. 2. Tolerating an unknown is only safe when something else is the primary revocation mechanism. For users that is the memory-store session delete — a different system that survives a database outage. Service accounts had no such mechanism: UpdateClient set IsActive=false and returned, so the DB flag was the entire revocation story rather than defense-in-depth, and an outage would have re-opened it. Deactivation now purges the account's sessions, the same way revoking a user does. Deactivation is instant instead of dependent on a lookup being reachable at request time. 3. ValidateBrowserSession kept the old user-only fail-open check, so a deleted user whose async session purge failed was rejected by bearer token but still authenticated by cookie. Both credential types now use one rule. 4. resolverIsSoleAuthority skipped the interceptor's super-admin check, but service.requireSuperAdmin re-derives super-admin from meta.Request on its own — so the guard moved the check one layer down rather than removing it. A resolver-governed server now refuses AuthorizerAdminService outright. Costs nothing: no admin RPC is mcp_tool-exposed. Each fix is pinned by a test verified to fail without it. Refs authorizer-docs specs/2026-08-10-mcp-http-transport.md
An access token minted with a `resource` indicator carries it as `aud`, and that audience is the whole mechanism stopping a token issued for one resource server being replayed at another. The binding survived exactly one token. The token endpoint's local `resource` was populated only inside the authorization_code branch, so on the refresh grant it was empty, accessTokenAudience fell back to the client id, and the rotated token came back UNBOUND — valid at Authorizer's own API, which is what the restriction existed to prevent. A user scoping a token to one resource server got that scope widened by the act of refreshing. It failed in the direction that hides it: the first token is correct, so every manual test and demo passes. Only the rotated one is wrong, and only after the access token lifetime has elapsed. The resource now travels on the refresh token, which is the only thing that survives between the authorization request and the rotation — the code is long gone by then. It joins roles, scope, login_method, auth_time, client_id and family_id, which already round-trip the same way, and is a reserved claim so CustomAccessTokenScript cannot rebind a token to a resource server the user never authorized. A refresh naming a different resource is rejected with invalid_target: RFC 8707 §2.2 permits a refresh to restrict the resource, never to switch it, and silently ignoring a mismatch would hand back a token for a resource the caller did not ask for. Same shape as the enforcement the authorization_code branch already applies. Grants that never used a resource indicator are untouched: no claim is emitted, and the rotated token keeps the client id as its audience. Pinned by a test for that case too, since the risk of a fix like this is stamping an empty resource. This is a prerequisite for serving MCP over HTTP — MCP tokens are audience-bound by specification and clients refresh proactively before expiry, so a connection would have worked for one token lifetime and then failed permanently — but the bug is independent of MCP and worth fixing on its own. Refs authorizer-docs specs/2026-08-10-mcp-http-transport.md §8
Completes the resource server. `--mcp-enabled` mounts the MCP tool surface on the main HTTP listener, where it inherits CORS, security headers, rate limiting, trusted-proxy handling, logging, metrics and graceful shutdown instead of re-implementing them behind a second port. Identity is now per request. `stampAuth` forwards the caller's own Authorization header, so one server serves every caller under their own token — replacing the process-wide --mcp-bearer that made stdio a one-process-one-user transport. ONLY that header crosses the bridge: transport.MetaFromGRPC reconstructs cookies and x-authorizer-url from gRPC metadata, so forwarding headers wholesale would hand an audience-bound surface a browser session or a caller-chosen host. MCP dispatches through its OWN bufconn-only gRPC server, never a listening one, whose interceptor accepts exactly the resource-bound audience the public server rejects and refuses cookies, admin secrets and the admin service outright. Two servers, so no token crosses between surfaces by construction. Authentication happens twice, deliberately. The route middleware answers a bad or missing credential with 401 plus the RFC 9728 §5.1 WWW-Authenticate challenge, because that 401 IS the protocol: a fresh client reads resource_metadata from it to start discovery, and an expired one reads it to refresh. Anthropic's connector docs are explicit that Claude does not honour WWW-Authenticate on a 200, so answering an expired token with a JSON-RPC error would leave clients looping on a dead token. Context does not survive the bufconn hop, so the interceptor re-resolves the identity handlers run under. One extra validation per request buys the correct HTTP status. Stateless + JSONResponse: the main listener's WriteTimeout would sever a long-lived SSE stream, and a stateless server needs no sticky sessions. GET is answered 405 per spec — every exposed tool is request/response. Also here: - RFC 8252 §7.3 loopback redirect matching. Native apps bind an ephemeral port and cannot register it, so exact matching made loopback redirects unusable — Claude Code's OAuth flow could never have completed. Narrowed hard: both URIs must be loopback, and only the port is ignored. - /mcp exempted from CSRF, which holds structurally rather than by convention: no cookie can authenticate this surface at any layer. - TestServer_StdioOnly replaced. It named its own exit condition — build an auth interceptor first — and that interceptor now exists. What it guarded is now "the transport is the shape the main listener can serve", not "there is no transport". - `authorizer mcp` marked deprecated for removal in 2.5.0, with a startup warn so supervised deployments see it too. Refs authorizer-docs specs/2026-08-10-mcp-http-transport.md §5 PR 2
Closes out the MCP work: the coverage gaps the review left open, and the user-facing docs. Tests: - validateMCPConfig extracted from runRoot so the --url requirement can be tested. Behind os.Exit(1) it could have been rewritten into something weaker — comparing AuthorizerURL to "" rather than asking whether a resource can be derived from it — with the whole suite staying green. - The smoke suite now exercises MCP over HTTP against the real binary and the real route table: 401 with the RFC 9728 challenge, the metadata document the challenge points at, and a login token being refused. This is the only place the --mcp-enabled route registration is covered; every other MCP test mounts the handler onto a router it builds itself, so dropping the route or its flag guard would have left them all green. Docs: - docs/core/mcp.md leads with the remote transport, documents the discovery chain end to end, and states plainly which clients work today: no RFC 7591 dynamic client registration yet, so clients that self-register are not supported and a pre-registered client ID is the path. Claude Code needs both loopback redirect URIs registered. - CHANGELOG gains Added entries for --mcp-enabled and RFC 8252 loopback matching, and a Deprecated entry for `authorizer mcp`. - ROADMAP 4.1 marked delivered, with CIMD/DCR and the consent screen it requires left open. Verified: go build, go vet, make test (42 packages), make lint, make smoke.
Found by the new Playwright spec, which is the only test that drives a real browser through a first-time connection. When /authorize is reached WITHOUT a live session it does not issue a code — it redirects to the login UI with the request's parameters in the query, and the SPA replays them on a second /authorize once the user signs in. `resource` was not in that string, so the replayed request was unbound and the access token's `aud` fell back to the client id. The symptom was as bad as the bug. The flow completed, a token came back, and only its audience was wrong, so /mcp rejected every call with a 401 that reads like a credential problem. It only affected users who were NOT already signed in, so retrying after logging in elsewhere appeared to fix it. No Go test caught it: every integration test pre-establishes a session cookie, which skips this branch entirely. Both the browser spec and a Go regression test that deliberately omits the cookie now cover it. Also adds the e2e-playground MCP spec: the discovery challenge, the metadata document (including that the bare well-known path stays 404 per RFC 9728 §3.3), a full browser OAuth flow with `resource`, that the binding survives refresh, and that an MCP token is still refused at /userinfo. The compose stack gains --mcp-enabled. Note: e2e-playground/ is listed in this clone's .git/info/exclude, so the spec needed `git add -f`. Anyone else adding e2e specs on a similarly-configured clone will hit the same silent drop.
Final review pass. Four fixes, one of them the most serious defect in the
branch.
1. subjectLiveness was gated on storage.IsNotFound to decide whether to try the
CLIENT lookup — but DynamoDB's GetUserByID returns a bare
errors.New("no documets found") and Couchbase's returns gocb.ErrNoResult
from the query path, and neither satisfies storage.IsNotFound. A machine
token's subject is a client row id, so the user lookup ALWAYS misses; on
those two backends it short-circuited and the client lookup never ran.
That broke both directions at once. Delegated tokens whose subject is a
service account — the agent-A-delegates-to-agent-B chain — were rejected
outright, a regression this branch introduced. And deactivating a service
account stopped revoking its live tokens, which is the headline fix of
138f98c silently doing nothing. CI runs SQLite only, so every test stayed
green.
The client lookup now runs whenever the user lookup did not POSITIVELY find
a user, and absence is only CONFIRMED when both lookups say so. Correctness
no longer depends on every backend spelling not-found the same way; a
backend that does not degrades to "unknown", which callers already handle.
2. redirectURIMatches compared scheme/host/path/query and silently dropped
fragment and userinfo, so everything omitted became a free field. A
presented "http://127.0.0.1:9/cb#x" matched and then the response is
appended by string concatenation, producing ".../cb#x?code=…" — the entire
authorization response inside the fragment, so the app's loopback listener
sees no code, no state and no error and the login hangs forever. RFC 6749
§3.1.2 forbids a fragment there. "http://evil.com@127.0.0.1/callback" also
matched. Both are now rejected outright rather than compared.
3. The sole-authority admin refusal ran AFTER the `public` bypass, and
AdminLogin is both admin and public — so the RPC that MINTS super-admin
authority stayed reachable on the CSRF-exempt MCP surface. Moved ahead of
the bypass.
4. The refresh resource check rejected any supplied `resource` when the grant
carried none, which every refresh token minted before this branch does. The
MCP spec has clients send `resource` on every token request, so upgrading a
deployment would have turned every in-flight refresh into a permanent
invalid_target. Now enforced only when the grant was bound, matching the
authorization_code branch; an unbound grant stays unbound rather than
letting a refresh add an audience nobody authorized.
Verified: go build, go vet, make test (42 packages), make lint, make smoke,
and the e2e-playground MCP spec.
KNOWN GAP, not fixed here: DynamoDB's and Couchbase's GetUserByID violate the
not-found contract AGENTS.md documents. Fix (1) makes this code robust to that,
but the providers should still be corrected and TestNotFoundContractIsUniform
extended to cover them, since other callers of storage.IsNotFound have the same
exposure.
The root cause behind the subjectLiveness gate, fixed at the layer that owns it.
AGENTS.md states the not-found contract is uniform across all six backends, and
storage.IsNotFound is what callers use to tell "no such row" from "the query
failed". Two backends did not honour it:
- DynamoDB's getItemByHash returned a bare fmt.Errorf("no record found") for an
absent item, and GetUserByID then replaced ANY error from it with
errors.New("no documets found") — so absence was unrecognisable AND a genuine
outage was reported as a missing user, which is the contract violated in both
directions at once.
- Couchbase's IsNotFound matched only gocb.ErrDocumentNotFound, but its
query-based getters surface an empty result as gocb.ErrNoResult from
Result.One(). Key-value reads were recognised; every N1QL getter was not.
TestNotFoundContractIsUniform could not see either, because it detects getters
returning (nil, nil) — not getters that return an error of the wrong shape.
TestNotFoundIsRecognisableOnEveryBackend closes that: it asks each live backend
for an id that cannot exist and requires storage.IsNotFound to say so. It is a
runtime test on purpose — the defect is in what the driver returns, which no
static check over the source can see — so it only means anything under
`make test-all-db`, which is exactly how the original bug survived a green CI.
Verified: make test-all-db passes on all seven backends (sqlite, postgres,
mongodb, arangodb, scylladb, dynamodb, couchbase), and the new test fails on
dynamodb when the ops.go fix is reverted.
token.subjectLiveness keeps its defensive shape from the previous commit — it no
longer DEPENDS on this being right on every backend — but with this fix the two
agree instead of one compensating for the other.
The Long description and type comment still claimed stdio was the only supported transport, which stopped being true when --mcp-enabled landed in the same branch. Both now lead with the deprecation and point at the replacement.
|
| Client | Works | How |
|---|---|---|
| Claude Code / VS Code — static token | yes, verified | client_credentials + resource=<url>/mcp, passed as a fixed header |
| Claude Code — OAuth | no | Refuses without DCR/CIMD |
| claude.ai custom connector | unverified | Anthropic documents a client-ID field; not confirmed |
| Self-registering clients | no | Needs DCR or CIMD |
What this means for merging
Everything in this PR is still correct and tested — the server, the audience boundary, the discovery chain, the security fixes. What changed is the honest scope of the feature: it ships usable by service accounts and agents via a static token, not by a human's browser through Claude Code.
That is a smaller feature than the PR body originally implied, and CIMD moves from "nice for zero-touch onboarding" to "the thing that makes the OAuth machinery reachable at all". Worth deciding deliberately rather than discovering post-merge.
Docs, examples and the spec have been corrected: authorizerdev/docs#85, authorizerdev/examples#18.
A future reviewer hitting the absent registration_endpoint should find the reasoning inline rather than file it as a gap. The MCP spec 2025-11-25 demoted DCR: authorization servers SHOULD support Client ID Metadata Documents and MAY support DCR, kept only "for backwards compatibility with earlier versions of the MCP authorization spec". Auth0 ships DCR Enterprise-only and disabled by default, behind ACLs or a proxy, and recommends CIMD instead; Anthropic steers directory traffic away from DCR because it registers a fresh client per connection. On a self-hosted product that is unbounded row growth in every operator's deployment. Also records, on the protected-resource handler, which clients can actually complete the flow today — verified against Claude Code 2.1.226, the browser OAuth path does not work without CIMD, and the static-token path does. Both comments carry the spec/vendor links so the next reader can check the reasoning rather than take it on trust.
* docs(mcp): document the remote transport, deprecate stdio Rewrites docs/core/mcp.md for the HTTP surface shipped in authorizerdev/authorizer#757. The page led with `authorizer mcp` (stdio) and described it as stdio-only by design. Both statements are now wrong: the deployable transport is `--mcp-enabled` at POST <url>/mcp, and stdio is deprecated for removal in 2.5.0. It now opens with the difference that actually matters — per-request identity and shared providers, versus one process-wide bearer and a second copy of the whole server — then walks the discovery chain end to end so an operator can follow it with curl. The client-support table is deliberately blunt. Authorizer has no RFC 7591 dynamic client registration, so clients that self-register are not supported and a pre-registered client ID is the path. Claude Code needs both loopback redirect URIs registered because it binds an ephemeral port. Saying so in a table beats someone discovering it from a 401. The "protecting your own MCP server" half is kept — it is still valid and is what someone with their own resource server needs — but demoted, since it is no longer the only way to use MCP with Authorizer. Also adds the two specs behind the work: the transport design and the SDK and examples rollout plan. * docs(mcp): correct the client-support table after testing Tested against Claude Code 2.1.226. The OAuth path does NOT work: the client refuses with "Incompatible auth server: does not support dynamic client registration" and does not fall back to a manually-supplied client id. The earlier table claimed it worked once RFC 8252 loopback matching landed, which was an inference from Anthropic's docs rather than an observation. What does work, verified end to end: a static bearer token bound to <url>/mcp. claude mcp list reports Connected, and tools/list and tools/call both function. The claude.ai custom-connector row is downgraded to unverified for the same reason — it rests on the same inference that just failed for Claude Code. * docs(mcp): explain why there is no dynamic client registration A reader (or reviewer) hitting the missing /register endpoint should find the reasoning rather than assume an oversight. Records that the MCP spec 2025-11-25 demoted DCR to backwards-compat and made CIMD the SHOULD, what Auth0, Keycloak, Google and Anthropic actually do, and why CIMD is the planned path here.
Authorizer 2.4.0 can serve its MCP tool surface over HTTP at POST <url>/mcp as an OAuth 2.1 resource server (authorizerdev/authorizer#757). Off by default — it is a new internet-facing authenticated surface. It is served on the MAIN HTTP port, not one of its own, because it must be publicly reachable on the same origin as the OAuth metadata clients discover it through. So it needs no Service or Ingress change and inherits the existing CORS, security headers and rate limiting. mcp_enabled requires authorizer_url, and the chart FAILS AT RENDER TIME without it, following the SMTP guard already at the top of deployment.yaml. The server exits at boot in that configuration — every token presented at /mcp is checked against <authorizer_url>/mcp, and deriving that identifier from request headers instead would let a caller name their own token's audience. In-cluster the failure would otherwise be a CrashLoopBackOff whose cause is one line in a container log. Verified with helm template in all three states: off (renders, MCP_ENABLED false), on without a URL (fails at render with the explanatory message), on with a URL (renders, MCP_ENABLED true). helm lint clean.
Authorizer 2.4.0 can serve its MCP tool surface over HTTP at POST <url>/mcp as an OAuth 2.1 resource server (authorizerdev/authorizer#757). Exposes it through MCP_ENABLED, defaulting to false — it is a new internet-facing authenticated surface and should be opted into. It requires AUTHORIZER_URL. Every token presented at /mcp is checked against this deployment's canonical <url>/mcp, and with no --url that identifier would come from request headers, letting a caller name the audience their own token must match. The server exits at boot rather than serve that, so enabling MCP without AUTHORIZER_URL is a crash, not a degraded mode. Verified: the CMD array still parses as JSON after joining Dockerfile line continuations, and the command string passes `sh -n`.
Authorizer 2.4.0 can serve its MCP tool surface over HTTP at POST <url>/mcp as an OAuth 2.1 resource server (authorizerdev/authorizer#757). Exposes it through MCP_ENABLED, defaulting to false — it is a new internet-facing authenticated surface and should be opted into. It requires AUTHORIZER_URL. Every token presented at /mcp is checked against this deployment's canonical <url>/mcp, and with no --url that identifier would come from request headers, letting a caller name the audience their own token must match. The server exits at boot rather than serve that, so enabling MCP without AUTHORIZER_URL is a crash, not a degraded mode. Verified: the CMD array still parses as JSON after joining Dockerfile line continuations, and the command string passes `sh -n`.
Authorizer 2.4.0 can serve its MCP tool surface over HTTP at POST <url>/mcp as an OAuth 2.1 resource server (authorizerdev/authorizer#757). Exposes it through MCP_ENABLED, defaulting to false — it is a new internet-facing authenticated surface and should be opted into. It requires AUTHORIZER_URL. Every token presented at /mcp is checked against this deployment's canonical <url>/mcp, and with no --url that identifier would come from request headers, letting a caller name the audience their own token must match. The server exits at boot rather than serve that, so enabling MCP without AUTHORIZER_URL is a crash, not a degraded mode. Verified: the CMD array still parses as JSON after joining Dockerfile line continuations, and the command string passes `sh -n`.
|
Correction to my earlier comment on this PR, for the record. I wrote that Authorizer would not implement RFC 7591 dynamic client registration, citing the MCP spec's demotion of it (SHOULD support CIMD, MAY support DCR) and the vendor guidance from Auth0, Keycloak and Anthropic. That reasoning was about what should be preferred, and it still holds. It was wrong about what shipping clients can do. Claude Code 2.1.226 reads So #758 now ships both:
Enabling DCR cannot downgrade anyone: the MCP client priority order is pre-registered → CIMD → DCR, so a CIMD-capable client never reaches the DCR path. The objections I quoted are the reason it defaults to off, and each has a mitigation rather than a hand-wave: public clients only ( Verified against Claude Code: Docs updated in authorizerdev/docs#86. |
… DCR (#758) * feat(cimd): add the Client ID Metadata Document resolver First half of making the OAuth path to /mcp reachable. Verified against Claude Code 2.1.226, that path does not work today: the client refuses with "Incompatible auth server: does not support dynamic client registration" and offers no manual-client-id fallback, so everything #757 built — PKCE, discovery, audience binding, loopback redirects — is unreachable by it. CIMD rather than RFC 7591 DCR, because the MCP spec (2025-11-25) demoted DCR to "backwards compatibility with earlier versions" and made CIMD the SHOULD; Auth0 ships DCR Enterprise-only behind ACLs and recommends CIMD instead; and DCR mints a client row per connection, which on a self-hosted product is unbounded growth in every operator's deployment. CIMD adds no endpoint, no rows and no schema change: the client_id IS an HTTPS URL this package fetches and validates. The security of the whole idea rests on not being an SSRF primitive — the URL is attacker-supplied and this server fetches it. validators.SafeHTTPClient already solves exactly that (one-shot DNS, dial pinned to the validated IP, so rebinding cannot occur between check and connect), which is why this lands as a small package rather than a risky one. Everything else is a bound on attacker-controlled input: response size cap, request timeout, cache TTL clamped in both directions (the Cache-Control header is written by the party being validated, so max-age=0 is a DoS amplifier and max-age=1yr pins a document that may later be revoked), and a bounded cache. Note on the SSRF test: its first version passed even with the guard swapped for SafeHTTPClientAllowPrivate, because the request then failed TLS verification against httptest's self-signed certificate instead — a different failure that would NOT occur against a real attacker-controlled host with a valid cert. It now asserts the specific rejection, and was verified to fail when the guard is weakened. Not yet wired into /authorize — that needs the consent screen, which the spec makes mandatory once client identity is self-asserted. Next commit. Refs authorizer-docs specs/2026-08-11-cimd-and-consent.md * feat(cimd): add the consent screen and pending-consent flow Second half of CIMD. The spec makes consent mandatory here, and the reason is specific rather than ceremonial: a Client ID Metadata Document client asserts its OWN identity. It picks its client_name, and anyone who can host a JSON file can claim any name. The only fact this server has verified about it is the redirect host — which is what the page leads with, and what a person can actually judge. Pre-registered clients keep today's silent approval. An operator entered their redirect URIs by hand, so the trust decision was already made; changing that would be an unrelated behaviour break. Two things carry the security of the split flow: - Only the consent_id travels through the page. Scope, redirect_uri, client and the original query are read back from the store, so a tampered form cannot widen scope, redirect elsewhere or swap the client between render and submit. - Approval REPLAYS the original /authorize query rather than rebuilding one from form fields, so the request that executes is byte-identical to the one the user was shown. Plus: single-use (removed before the decision is acted on, so a replayed approval cannot mint a second code), pinned to the session that was shown the page (otherwise a page rendered for one user could be submitted in another's browser), and a denial redirects to the client with access_denied per RFC 6749 §4.1.2.1 rather than rendering here, because the client is waiting on that callback. Loopback-only clients get an explicit warning. Any local process can bind the same port and present the legitimate client's document; the spec says so, and says it cannot be solved server-side — so the page says it too. Not yet wired into /authorize — that is the next commit, along with advertising client_id_metadata_document_supported and the two config flags. * feat(cimd): wire metadata-document clients into authorize and token Completes CIMD. `--enable-client-id-metadata-document` (off by default) makes an HTTPS-URL client_id resolvable, which is what lets a client with no prior relationship to this server authenticate at all. Three hook points, each with a reason it is where it is: - /authorize redirect validation resolves the document and checks redirect_uri against ITS list, using the same matcher registered clients use so RFC 8252 loopback rules apply identically. A resolution failure is fatal rather than a fall-through to the AllowedOrigins fallback — falling through would give an unresolvable client a LAXER check than a resolved one, which is backwards. - The consent gate sits after the session is validated and the user resolved: the page names the account being granted, and an unauthenticated visitor must reach the login UI first rather than approve something on behalf of nobody. - The client-auth resolver returns a synthetic public client for CIMD ids, and refuses two things explicitly rather than leaving them to downstream assumptions: client_credentials (a grant with no user and no secret must not be reachable by a client that registered itself by hosting a JSON file), and a presented secret (accepting it would tell a caller their credential worked when nothing verified it). PKCE still gates the code exchange. client_id_metadata_document_supported is advertised only when the flag is on; advertising it while off would make a client select CIMD and then fail. Verified against Claude Code 2.1.226, which is what this feature exists for. Before: "Failed to connect — Incompatible auth server: does not support dynamic client registration". After: "Needs authentication", and the server log shows the client walking the full chain — POST /mcp, the protected-resource document, then the authorization-server metadata twice. That is the blocker removed, NOT the full flow proven: completing OAuth needs a browser for login and consent, which a headless check cannot drive. The browser leg still needs a Playwright case for CIMD; I am not claiming more than was observed. A wiring gap surfaced while testing and is worth recording: the integration harness did not construct the provider, so /authorize skipped the CIMD branch and issued a code via the AllowedOrigins fallback — exactly the silent downgrade the test was written to catch. The harness now builds it as cmd/root.go does. * docs(cimd): changelog, and record why the browser leg has no e2e test Changelog entries for --enable-client-id-metadata-document and the consent screen. Also records, in the package doc, that the CIMD browser flow has no e2e-playground test and why — because the reason is structural and a future reader should not assume it was forgotten. The spec requires a CIMD client_id to be an https URL, and the SERVER fetches it, so the document must be served over TLS from a host the server can reach with a certificate it trusts. The compose network is http-only with private addresses, so a mock client host is rejected before CIMD engages. Two ways to make a test pass anyway were rejected: - Relaxing the https requirement under --env=e2e. That puts an environment-dependent branch inside a security check, so the thing under test stops being the thing that runs in production. - Adding a private CA to the compose stack and serving the mock over TLS. Defensible, but it is cert generation plus trust injection into the server image — its own change, not something to smuggle into this one. I built both the mock host and the spec before concluding this, and reverted them rather than ship a weakened check or a skipped test. The e2e-gated allowPrivate fetch that attempt required is reverted too: it added a third caller to SafeHTTPClientAllowPrivate, whose doc comment asks for careful review before that, and it now buys nothing. What remains covered: the resolver, validation, SSRF guard and caching (unit); the consent rules and the /authorize gate (integration); and a real Claude Code client, which now engages with the server instead of refusing it. The gap is specifically "a human clicks Allow in a browser". Verified: go build, go vet, make test (43 packages), make lint, make smoke. * fix(cimd): unblock the consent POST and resume via redirect Two real bugs, both found only by driving the flow in a browser. Every Go test called the handler directly and so saw neither. 1. CSRF blocked the consent form POST with 403. The middleware requires `Content-Type: application/json` or `X-Requested-With`, and a plain HTML form can send neither. /authorize/consent is now exempt, and this is not a weakening: the form's consent_id is a random UUID that exists only in a page this server rendered for one session, is single-use, and the handler separately verifies the submitting session is the one it was issued to. That is a stronger synchronizer token than the generic check. The alternative — driving the form with JavaScript — would make consent, the one screen a user must be able to read and trust, fail silently with JS disabled. 2. Approval resumed the authorization in-process by rewriting Request.URL.RawQuery and calling AuthorizeHandler. That does not work: gin caches parsed query parameters on the Context at first access, so the handler read the POST's empty query and failed with "response_type is required". Approval now records a single-use grant keyed to (user, client) and redirects to /authorize with the original query. That is also the more honest shape — it is what any other authorization server does, and it re-enters /authorize through the front door with every middleware applied. Also adds the e2e TLS fixture this required: a one-shot CA generator, an HTTPS CIMD mock host, and SSL_CERT_FILE trust injection into the server container. The bundle APPENDS our CA to the public roots rather than replacing them, since Go's SSL_CERT_FILE overrides the system pool wholesale. The server-side flow was observed completing end to end in the container logs: login redirect, consent page rendered, grant recorded, then a redirect to the callback carrying ?code=. The three browser assertions are marked fixme with that stated plainly — the fixture is correct and done, but the final leg is not reproducibly observable from the browser side yet, and I would rather leave an accurate marker than a test that appears to prove something it does not. Verified: go build, go vet, make test (43 packages), make lint. * test(cimd): add the e2e TLS fixture and browser spec The fixture CIMD needs in order to be testable at all: the spec requires an https client_id and the SERVER fetches it, so the document host must present a certificate the server trusts. - mocks/tls-certs: one-shot CA + leaf generator into a shared volume. The bundle APPENDS our CA to the public roots rather than replacing them, because Go's SSL_CERT_FILE overrides the system pool wholesale and shipping only our CA would silently break every other TLS dial the server makes. SAN not CN, since Go ignores CN entirely. - mocks/cimd-client: serves a metadata document over HTTPS, plus a mismatched variant for the impersonation case and a callback that echoes its query. - compose: SSL_CERT_FILE trust injection, --enable-client-id-metadata-document, and the mock's origin in --allowed-origins so the login UI accepts it. Instrumenting this found two production bugs, already fixed in the preceding commit: CSRF rejected the consent form POST, and resuming the authorization in-process failed because gin caches parsed query parameters. Three browser assertions are marked fixme with a precise account of what is established and what is not — see the note at the top of the spec. Nothing here is claimed to pass that does not. Note: e2e-playground/ is listed in this clone's .git/info/exclude, so these needed `git add -f`. Anyone adding e2e files on a similarly-configured clone will hit the same silent drop. * test(cimd): assert the consent flow in Go, scope Playwright to the UI Closes the gap the previous commit left open, by using the right tool rather than fighting the wrong one. TestCIMDConsentEndToEnd drives the whole flow over real HTTP with a cookie jar — /authorize, consent page, POST the decision, resumed /authorize — and asserts the redirect the client actually receives: - approving issues a code to the redirect_uri from the client's OWN document - declining returns access_denied with no code, to the client rather than to a page only the user sees (RFC 6749 §4.1.2.1) - a document whose client_id does not match its URL never reaches consent Verified by reverting the decision check: the declining case fails. Why not a browser: the consent page is plain HTML with no JavaScript, so a browser adds nothing to an assertion about status codes and Location headers. And the browser attempt failed for a reason unrelated to the feature — the flow ends in a cross-origin redirect into an https host whose certificate is signed by a throwaway CA, and Chromium cancels that navigation (net::ERR_ABORTED, canceled=true, no response event) even with ignoreHTTPSErrors AND --ignore-certificate-errors. That was measured rather than assumed, via CDP Network events: a DIRECT navigation to the same host returns 200, so it is the redirect INTO it that Chromium refuses. Reading the Location in Go sidesteps the question entirely. Playwright keeps what only a browser can show — that the page renders, names the client, presents the redirect host as its own field, and offers both decisions. Two tests, both passing, none skipped. CIMD needs an https document host and the SSRF guard refuses loopback, so the resolver gains SetHTTPClientForTest: unreachable from New, so no production path can call it, and the same seam fetchViaClient already established. Verified: go build, go vet, make test (43 packages), make lint, and the e2e-playground spec. * fix(cimd): honour prompt=none, and never resolve the reserved client as CIMD Two findings from my own pre-merge pass, both in code this branch added. 1. prompt=none was violated. OIDC Core §3.1.2.1 says the authorization server "MUST NOT display any authentication or consent user interface pages", and the two existing prompt=none guards only cover the UNAUTHENTICATED case. A caller with a perfectly valid session and a CIMD client_id was therefore shown a consent page in response to a request that forbids one. A self-asserted client genuinely requires consent, so the two demands cannot both be met: the request now fails to the client with consent_required, and the client decides whether to retry interactively. Verified by reverting the guard — the new test fails. 2. The deployment's own reserved client could be shadowed. --client-id is free-form, so an operator who set it to something parsing as a document URL would have their primary client stop resolving from the registry and start resolving by FETCHING that URL — a silent change of identity source, with whoever controls the URL then describing the deployment's own client. No attacker path exists (the admin API has no client_id field; it is server-generated), which is why this is a cheap exclusion rather than an argument about likelihood. IsMetadataClientIDFor makes the precedence explicit instead of accidental. Verified: go build, go vet, make test (43 packages), make lint. * security(cimd): bind consent grants to the request, and consume atomically Pre-merge review findings, all in code this branch added. The first three are real defects in the consent flow. 1. A grant authorized the wrong request. The marker was keyed on (user, client) only, so one that was never redeemed — tab closed, browser back, network drop — sat in the store for its whole TTL and then satisfied ANY later /authorize for that pair: a wider scope, a different redirect_uri from the document's list, a different PKCE challenge. The user approves one request and a materially different one executes. That defeats the design this branch states elsewhere: pendingConsent stores the full parameter set precisely so the request cannot change between render and submit, and the grant key threw it away. The key now includes a SHA-256 of the encoded parameters (hashed, not raw — the key lands in a shared store and a redirect_uri or login_hint in a key is needless exposure). 2. POST /authorize could never complete. /authorize is registered for POST as well as GET, and the handler reads parameters via FormValue, but the pending record stored only URL.RawQuery — empty for a POST. Approval then replayed a parameterless request and dropped the user on "response_type is required" AFTER they had approved, while the client waited on a callback that never came. Captured from the parsed form instead. 3. Single-use was not single-use under concurrency. Both the pending record and the grant were read then deleted, so two concurrent submissions could both pass. This repo already ships GetAndRemoveState for exactly this, and its own doc comment says returning state "on the strength of the read alone would hand the same code to every racer, which is an authorization-code replay". Both sites now use it. Also fixed: - The deny path ignored response_mode, delivering a query-string 302 to clients that asked for fragment/form_post/web_message. It now uses redirectErrorToRP, the helper every other /authorize error path uses. - Document redirect_uris accepted fragments and userinfo, so a document could register one and the consent page would display a host that is not where the code lands. Rejected at resolution, matching redirectURIMatches. - The consent page is served no-store: it carries a single-use id and names the signed-in user, so a cached copy shows one user's email to the next person. - cimd_consent_test.go's comments claimed end-to-end coverage it did not have. Corrected to describe the one property it actually asserts. Each fix has a test verified to fail without it — including the grant binding, where reverting the key to its unbound form fails the new case. Verified: go build, go vet, make test (43 packages), make lint, make smoke. * feat(oauth): add opt-in RFC 7591 dynamic client registration Shipping MCP clients still look for `registration_endpoint` and give up when it is absent, even where `client_id_metadata_document_supported` is advertised — Claude Code refuses with "Incompatible auth server: does not support dynamic client registration". CIMD stays the preferred path; DCR is the fallback for clients that cannot use it yet. Off by default: it is an unauthenticated write endpoint, matching how Keycloak, Ory Hydra and Auth0 all ship it. Advertising and mounting are driven by the same flag, so discovery cannot promise what is not routed. The MCP client priority order (pre-registered -> CIMD -> DCR) means enabling this cannot downgrade a CIMD-capable client. Self-registered clients are public-only, get the consent screen that RFC 7591 section 5 asks for, and are held to the PKCE rules the specs make mandatory for public clients: - S256 required at /authorize (RFC 9700 2.1.1, OAuth 2.1 4.1.1), rejected before the user is asked to log in and approve rather than at the token endpoint with an error naming client_secret - implicit response types refused; both registration paths declare response_types ["code"] - redirect_uris limited to https or loopback http, no fragments - registry ceiling on top of the existing per-IP rate limiter Also fixes two latent issues found on the way: - /authorize treated a client-lookup storage error as "no such client" and fell through to the laxer AllowedOrigins check, which would also have let a self-asserted client skip consent during a database blip - the redirectURIClientStore test stub returned an error for an absent row, disagreeing with all six real providers on the one method documented to return (nil, nil) Refs #758 * refactor(oauth): simplify registration validation Fold the comma check into validateRegistrationRedirectURI, drop a single-use error type in favour of errors.New, and stop parsing client_uri/logo_uri/scope — unrendered self-asserted metadata is a phishing surface, not a feature. * test(oauth): cover refresh for a self-registered public client Claude Code registers grant_types [authorization_code, refresh_token], so this path runs on every long-lived connection. Asserts the refresh works with client_id and no secret (RFC 6749 section 6) and that the rotated access token keeps its RFC 8707 audience. * fix(oauth): validate redirect_uri against the client on the login page /authorize checks a presented redirect_uri against the client's registered URIs, then hands that same URI to /app to render the login page. /app checked it against AllowedOrigins alone, so the two halves of one flow applied different rules: any client whose registered redirect was not also a globally allowed origin passed the first check and got "invalid redirect url" from the second, before the user could type anything. Every test missed it because every fixture allow-listed its own callback origin - cimd.spec.ts via --allowed-origins, the Go tests via a wildcard. Production cannot do that: an MCP client binds an EPHEMERAL loopback port, so there is no origin to allow-list in advance. Claude Code used port 3118 on one run and a different one on the next. Both handlers now share checkClientRedirectURI, so the rule cannot drift again. Precedence is unchanged from /authorize: no client_id means the allow-list, a CIMD client is matched against its document, a registry row with registered URIs is held to an exact match with no fallback, a row without them falls back, and a storage error fails closed rather than silently downgrading to the laxer check. Found by e2e-playground/tests/dcr.spec.ts, added here: six cases over the real middleware chain, including the registration POST with no Origin that no Go test can exercise. TestAppHandlerHonoursRegisteredRedirectURIs is the equivalent tripwire for `make test`, so a regression is caught without Docker. Refs #758 * test(e2e): gate releases on the OAuth round trip and SCIM make smoke booted the real binary but authenticated everything with a token minted directly by signup, so /authorize, the code store, the PKCE comparison and /oauth/token could all have broken without a single failure. SCIM was untested end to end entirely, and it is the one surface authenticated by a per-org bearer token rather than a session or the admin secret. Adds two subtests against the booted binary: - authorization code + PKCE: /authorize -> /oauth/token -> /userinfo, including single-use enforcement on the code (RFC 6749 4.1.2) - SCIM: unauthenticated call refused, then provision and read back a user The OAuth case runs on its OWN user and cookie jar. /authorize rolls the session over on success, which invalidates the token minted at signup - sharing the user silently broke the MCP stdio subtest that still held it. Also corrects docs that said DCR was unimplemented: the CHANGELOG entry for the removed registration_endpoint, the roadmap items, and the compliance test's assertion message, which now pins the real property (absent while the feature is disabled) rather than "until RFC 7591 is implemented". Refs #758 * fix(consent): allow the approved redirect_uri in the consent page CSP Clicking "Allow access" appeared to do nothing, and clicking again gave "this consent request has expired or was already used". The consent page is served with the default `form-action 'self'`. Browsers enforce form-action across the ENTIRE redirect chain of a form submission, not just its immediate target. Approving POSTs to /authorize/consent -> 302 /authorize -> 302 the client's redirect_uri, so the last hop is blocked: the navigation aborts silently, the user is left on the consent page assuming the click missed, and the second click fails because the first already consumed the single-use consent. The server was doing everything right - the log shows consent 302 and the authorize 302 carrying the code - so nothing server-side looked broken. Reproduced in a real browser, confirmed by the Location header being correct while the browser refused to follow it, and confirmed fixed by the same browser landing on the callback after one click. This is the third instance of this rule in this codebase: setFormPostCSP already relaxes form-action for OIDC form_post, and samlIDPSSOCSP omits it because "form-action 'self' would silently break every SAML IdP login". The consent page was missed. Scoped to the single origin being approved rather than form_post's `form-action *`: the redirect_uri is already validated against the client's registered list, so the exact destination is known here. No test caught this because every automated check either drove the redirects itself or asserted the Location header instead of letting a browser follow it. The regression guard asserts the CSP names the redirect origin. Refs #758 * feat(consent): match the login UI, and render errors as pages The consent screen looked nothing like the hosted login UI a user reaches it from - unstyled system fonts, no logo, no organization name. On the one screen whose entire job is helping someone decide whether to trust a client, looking like a different site is the wrong signal. It now uses the app's own design tokens (web/app/src/index.css): the same brand header with --organization-logo and --organization-name, card, type scale, and primary/secondary buttons. Tokens are copied rather than imported because that file is bundled by Vite and is not servable to a standalone template; au_shell.tmpl holds the shared chrome so the consent page and its error page cannot drift apart. Consent failures were raw JSON. A user who clicked "Allow access" twice got {"error":"invalid_request","error_description":"this consent request has expired or was already used"} with no idea what to do - reported by a real user. They are now pages in the same shell, saying what happened, that nothing was shared, and to reconnect from the application. The OAuth error code stays in logs and metrics, where it is actionable. Also widens img-src to `https:` on the consent CSP, matching the default policy. --organization-logo is normally hosted elsewhere, so the tighter `'self' data:` rendered the operator's own branding as a broken image - caught by looking at the page rather than by any assertion. Refs #758 * fix(oauth): harden three details found in review None of these are exploitable as shipped; all three are values a client chooses reaching somewhere they should be checked first. - consent CSP: url.Parse rejects spaces and control characters in a host but ACCEPTS ";", which separates CSP directives. A client registering https://evil.com;x/cb would have terminated form-action and opened a new one. Not escalatable - a directive needs a space before its values, and form-action is the last directive here - but the host is now checked against an allow-list of authority characters before it is embedded, and dropped if it fails. - client_name was truncated by BYTES. Slicing 200 bytes can split a multi-byte rune and leave invalid UTF-8, which SQLite stores happily and Postgres rejects outright: a registration that works on one backend and 500s on another, from a name an anonymous caller chose. Truncated on rune boundaries now. - grant_types were validated trimmed but stored untrimmed, so " authorization_code" passed and persisted with the space. Nothing reads the stored value today, which is exactly why it would have been found the hard way later. Refs #758
Serves Authorizer's MCP tool surface over HTTP at
POST <url>/mcp, as an OAuth 2.1 resource server for itself. Opt-in via--mcp-enabled, off by default.Replaces
authorizer mcp(stdio), which is deprecated here and removed in 2.5.0. Stdio has only ever shipped in release candidates, so nothing depends on it.Why the stdio transport had to go
It ran a second copy of every provider — storage, memory store, embedded FGA engine — alongside the real server, and its identity was a single process-wide
--mcp-bearer, so one process served exactly one user forever. Neither is fixable within stdio.Why a path, not a port
gRPC gets its own port because it is a different wire protocol; metrics gets one because it must not be publicly exposed. MCP is neither — it is plain HTTP that must be publicly reachable, on the same origin as the OAuth metadata clients discover it through. A separate port would mean a second ingress and TLS cert in every deployment, and would break same-origin discovery. Mounted on the main router it inherits CORS, security headers, rate limiting, trusted-proxy handling, logging and metrics.
The audience boundary
One rule, enforced in both directions, and neither has an "or" in it:
/graphql,/v1/*and gRPC — is rejected at/mcp<url>/mcpis rejected everywhere elseThis is structural rather than conditional. MCP dispatches through its own bufconn-only gRPC server whose interceptor accepts only the resource-bound audience and refuses cookies, admin secrets and the admin service outright. Two servers, so no token can cross.
--urlis required with--mcp-enabledand startup refuses without it: the audience comparison must not take input from the caller, and without--urlthe resource identifier would come from a request header.Commits
db0c0a67ValidateMCPAccessToken, theTokenResolveroverride, RFC 9728 metadata,--mcp-enabled138f98c4969d4125a986b81d9eb334a2POST /mcp, the 401 challenge, CSRF exemption, RFC 8252 loopback matching3843be9aThe three
securitycommits stand on their own merit and are independent of MCP.Security fixes found on the way
Deactivating a service account did nothing to its live tokens. Subject liveness resolved a token's
subas a user and read "no such user" as "not revoked". Aclient_credentialstoken'ssubis a client row id, so the lookup missed every time. An operator revoking a compromised machine identity got a success response and a credential that kept working until expiry. Deactivation now also purges the account's sessions, so revocation is instant and does not depend on a lookup being reachable.A refreshed token silently lost its audience binding.
resourcewas carried only on the authorization_code branch, so a rotated access token fell back to the client id as itsaud— a token deliberately scoped to one resource server came back valid at Authorizer's own API. It failed in the direction that hides it: the first token is correct, only the refreshed one is wrong. MCP would have worked for one token lifetime and then failed permanently.A storage outage would have logged everyone out. An intermediate version of the liveness fix treated a database error as "subject not active", which on the shared path would have 401'd every authenticated request at once — and a 401 tells the SDKs the session expired. Absence and failure are now distinguished; first-party traffic tolerates an unanswerable lookup, the stateless delegated path still fails closed.
Client support — read before shipping
There is no RFC 7591 dynamic client registration, so clients that self-register are not supported. Register a client once and hand out its ID:
https://claude.ai/api/mcp/auth_callback, client ID under Advanced settingsresource=<url>/mcp, send as a fixed headerDCR is deliberately not on the roadmap: Anthropic's own guidance is to prefer CIMD over it, because DCR registers a fresh client on every connection — unbounded row growth in every operator's deployment. CIMD is the 2.5.0 path, and it has to land together with an
/authorizeconsent screen, since it makes client identity self-asserted.Review notes
redirect_urimatching is the one shared security-critical path this widens. Scoped hard: both URIs must be loopback, only the port is ignored, scheme/host/path/query still exact. Most of its 14 test cases are negative, including lookalike hosts like127.0.0.1.evil.com.Authentication runs twice on
/mcp— the middleware to produce a 401, the interceptor to produce the principal. Context does not survive the bufconn hop. The 401 is the protocol, not an error path: Claude ignoresWWW-Authenticateon a 200, so an expired token answered with a JSON-RPC error would loop forever. Worth optimising later.TestServer_StdioOnlyis replaced, not deleted — it named its own exit condition (build an auth interceptor first) and that interceptor now exists.Every behaviour change here has a test that was verified failing before the fix.
Verification
go build ./...·go vet ./...·make test(42 packages) ·make lint(0 issues) ·make smokeNo storage schema change, so no cross-DB run needed.
Docs for
docs/core/mcp.mdare rewritten in a companion PR onauthorizer-docs.