Skip to content

feat(mcp): serve MCP over HTTP as an OAuth 2.1 resource server - #757

Merged
lakhansamani merged 11 commits into
mainfrom
feat/mcp-http-transport
Aug 11, 2026
Merged

feat(mcp): serve MCP over HTTP as an OAuth 2.1 resource server#757
lakhansamani merged 11 commits into
mainfrom
feat/mcp-http-transport

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

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:

  • an ordinary login token — the kind that authenticates /graphql, /v1/* and gRPC — is rejected at /mcp
  • a token bound to <url>/mcp is rejected everywhere else

This 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.

--url is required with --mcp-enabled and startup refuses without it: the audience comparison must not take input from the caller, and without --url the resource identifier would come from a request header.

Commits

db0c0a67 Resource-server foundation: ValidateMCPAccessToken, the TokenResolver override, RFC 9728 metadata, --mcp-enabled
138f98c4 security — deactivating a service account revokes its live tokens
969d4125 security — revocation survives a storage outage
a986b81d security — RFC 8707 resource binding survives token refresh
9eb334a2 The transport: POST /mcp, the 401 challenge, CSRF exemption, RFC 8252 loopback matching
3843be9a Docs, changelog, and the remaining coverage gaps

The three security commits 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 sub as a user and read "no such user" as "not revoked". A client_credentials token's sub is 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. resource was carried only on the authorization_code branch, so a rotated access token fell back to the client id as its aud — 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:

Client Works How
Claude.ai / Desktop / mobile connector yes Redirect URI https://claude.ai/api/mcp/auth_callback, client ID under Advanced settings
Claude Code, VS Code — OAuth yes Register both loopback redirect URIs; the port is ignored per RFC 8252 §7.3
Claude Code, VS Code — static token yes Mint with resource=<url>/mcp, send as a fixed header
Self-registering clients not yet Needs DCR or CIMD

DCR 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 /authorize consent screen, since it makes client identity self-asserted.

Review notes

redirect_uri matching 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 like 127.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 ignores WWW-Authenticate on a 200, so an expired token answered with a JSON-RPC error would loop forever. Worth optimising later.

TestServer_StdioOnly is 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 smoke

No storage schema change, so no cross-DB run needed.

Docs for docs/core/mcp.md are rewritten in a companion PR on authorizer-docs.

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.
@lakhansamani

Copy link
Copy Markdown
Contributor Author

⚠️ Correction: I tested with a real Claude client, and one of my claims above was wrong

The PR body's client-support table said Claude Code — OAuth: yes. That is false. Tested against Claude Code 2.1.226 with a live server (--mcp-enabled --url http://localhost:8099):

authorizer-local: http://localhost:8099/mcp (HTTP)
  - ✘ Failed to connect — Incompatible auth server: does not support dynamic client registration

Claude Code does not fall back to a manually-supplied client id — it refuses the server outright. The RFC 8252 loopback fix in this PR is necessary but nowhere near sufficient; without RFC 7591 DCR or a Client ID Metadata Document, the browser OAuth path is unreachable by the flagship client.

I had inferred "yes" from Anthropic's connector documentation instead of testing it. That inference was wrong.

What IS verified working

A static bearer token bound to <url>/mcp:

authorizer-local: http://localhost:8099/mcp (HTTP) - ✔ Connected

and over the wire: initialize → 200, tools/list['check_permissions', 'list_permissions', 'meta', 'profile'], tools/call meta → real payload.

Caveat: that token identifies the service account, not a human, so profile returns nothing useful and permission checks resolve to service_account:<client_id>.

The claude.ai custom-connector row is downgraded to unverified — it rests on the same inference that just failed for Claude Code.

Corrected client table

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.
lakhansamani added a commit to authorizerdev/docs that referenced this pull request Aug 11, 2026
* 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.
@lakhansamani
lakhansamani merged commit a0bf19d into main Aug 11, 2026
4 checks passed
@lakhansamani
lakhansamani deleted the feat/mcp-http-transport branch August 11, 2026 05:22
lakhansamani added a commit to authorizerdev/authorizer-helm-chart that referenced this pull request Aug 11, 2026
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.
lakhansamani added a commit to authorizerdev/authorizer-railway that referenced this pull request Aug 11, 2026
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`.
lakhansamani added a commit to authorizerdev/authorizer-heroku that referenced this pull request Aug 11, 2026
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`.
lakhansamani added a commit to authorizerdev/authorizer-render that referenced this pull request Aug 11, 2026
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`.
@lakhansamani

Copy link
Copy Markdown
Contributor Author

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 client_id_metadata_document_supported: true from our metadata, ignores it, and still refuses with "Incompatible auth server: does not support dynamic client registration" — because its released version predates CIMD. CIMD alone left the flagship client unable to connect at all.

So #758 now ships both:

  • CIMD (--enable-client-id-metadata-document) — preferred, unchanged
  • RFC 7591 DCR (--enable-dynamic-client-registration) — opt-in, off by default, for clients that predate CIMD

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 (token_endpoint_auth_method must be none, client_credentials refused), redirect_uris limited to https or loopback http, S256 PKCE mandatory at /authorize, implicit response types refused, the same consent screen CIMD clients get (which is what RFC 7591 §5 asks for), per-IP rate limiting plus a registry ceiling, and no RFC 7592 management surface.

Verified against Claude Code: POST /oauth/register → 201, registered as a public client with a loopback callback. The interactive browser leg still needs a human.

Docs updated in authorizerdev/docs#86.

lakhansamani added a commit that referenced this pull request Aug 13, 2026
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant