Skip to content

Complete OAuth discovery + DCR response for hosted clients (0.6.1) - #7

Merged
Azdaroth merged 3 commits into
masterfrom
oauth-appended-metadata-discovery
Jul 20, 2026
Merged

Complete OAuth discovery + DCR response for hosted clients (0.6.1)#7
Azdaroth merged 3 commits into
masterfrom
oauth-appended-metadata-discovery

Conversation

@Azdaroth

@Azdaroth Azdaroth commented Jul 18, 2026

Copy link
Copy Markdown
Member

Two additive, bridge-gated fixes for hosted MCP clients whose OAuth setup couldn't complete against the 0.6.0 bridge. Byte-identical to 0.6.0 when the bridge is off, or for a client that already worked.

Fix 1 — metadata at the path-appended discovery location

The bridge served its authorization-server / protected-resource metadata only at the RFC 8414 §3.1 path-inserted location (/.well-known/oauth-authorization-server/mcp). Some clients instead request the path-appended https://host/mcp/.well-known/oauth-authorization-server and got a RoutingError 404 with no fallback (confirmed in prod logs). The bridge now answers at BOTH forms — AS + protected-resource + the openid-configuration OIDC alias — under the engine mount, gated on oauth_bridge?. They stay under the mount, so they claim nothing origin-global; the "bare origin paths stay 404" test is unchanged.

Fix 2 — DCR response echoes the registered metadata (RFC 7591)

The register stub returned only a client_id plus a fixed auth method and grant/response types. A strict client validates that the redirect_uris it registered come back and abandons a registration that drops them — a plausible cause of the same "couldn't register" error, independent of Fix 1.

Field evidence for this: a hosted client (Seb's report) was unblocked simply by supplying a manual OAuth Client ID + Secret with junk values in the connector settings — which makes Claude skip DCR entirely. That the auth flow then completes proves (a) discovery and the auth/token legs are sound, (b) client_id/secret are ignored by design, and (c) the failure is isolated to the registration ceremony. Since discovery and our /register both demonstrably work when reached, the likeliest remaining cause is the client rejecting our bare 201.

register now reflects the client's redirect_uris, token_endpoint_auth_method, grant_types, response_types and client_name, and adds client_id_issued_at / client_id_expires_at: 0 (RFC 7591 §3.2.1).

Echoing authorizes nothing: no client is stored, and authorize/token still check every redirect_uri against the host allowlist independently — a value reflected here is not thereby permitted. The redirect allowlist + PKCE security model is untouched.

Why both

We can't fully isolate which of the two was the cause of the specific Cowork failure from the two-day-old logs, and both fixes are additive, RFC-correct, and touch no security control. Shipping both makes 0.6.1 robust to whichever it was. Validate after deploy by connecting Cowork with no manual client_id.

Tests

  • controller_methods_spec + bridge_end_to_end_spec (real Rails, child process): new examples assert the appended AS/protected-resource/OIDC documents (200, correct issuer + registration_endpoint + no-store) and that the DCR response echoes redirect_uris + RFC 7591 fields (and defaults them when the client sends none).
  • Full suite 613/0; RuboCop 65 files / 0; Brakeman 0. fullstack-lint green.

Ships as 0.6.1 (CHANGELOG + README updated).

🤖 Generated with Claude Code

Azdaroth and others added 2 commits July 18, 2026 13:00
…s discover the AS

A path-ful MCP issuer (https://host/mcp) has two readings of where its OAuth
metadata lives: RFC 8414 §3.1 INSERTS the well-known segment before the path
(/.well-known/oauth-authorization-server/mcp, already served), while some clients
APPEND it (host/mcp/.well-known/oauth-authorization-server). The latter 404'd with
no fallback, so a client using that convention never got a registration_endpoint
and reported a registration failure.

Draw the appended forms (AS + protected-resource + openid-configuration OIDC alias)
under the engine mount, gated on oauth_bridge?. They stay path-scoped, claim
nothing origin-global, and reuse the same actions, so each document is byte-
identical to its inserted twin and carries Cache-Control: no-store. Bump to 0.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The register stub returned only a client_id plus a fixed auth method and grant/
response types. A strict client validates that the redirect_uris it registered
come back and abandons a registration that drops them — a plausible cause of the
"couldn't register" failure independent of the appended-metadata discovery gap
(a hosted client was unblocked simply by supplying a manual client_id, which skips
DCR entirely, confirming the registration ceremony is the failure locus).

register now reflects the client's redirect_uris, token_endpoint_auth_method,
grant_types, response_types and client_name, and adds client_id_issued_at /
client_id_expires_at: 0. Echoing authorizes nothing: no client is stored, and
authorize/token still gate every redirect_uri against the host allowlist, so a
value reflected here is not thereby permitted. Still 0.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Azdaroth Azdaroth changed the title Serve OAuth metadata path-appended too, so appended-convention clients discover the AS Complete OAuth discovery + DCR response for hosted clients (0.6.1) Jul 20, 2026
f2bc141 reflected the client's token_endpoint_auth_method, grant_types and
response_types back verbatim. That contradicts our own discovery document — the
client fetched registration_endpoint FROM authorization_server, which advertises
token_endpoint_auth_methods_supported: ["none"] and grant_types_supported:
["authorization_code"] — and it promises flows the token endpoint rejects: a
reflected refresh_token is a refresh answered unsupported_grant_type, and a
reflected implicit response_type is a request authorize never honours.

RFC 7591 §3.2.1 states the metadata as REGISTERED and lets a server replace what
it does not support, so those three are now substituted rather than echoed. The
supported sets are named once (SUPPORTED_GRANT_TYPES / SUPPORTED_RESPONSE_TYPES)
and shared by both documents, so they cannot drift apart; a spec pins the two
against each other. redirect_uris and client_name are still echoed — that is the
compatibility payload the commit was for, and the only part with evidence behind
it.

Also drops client_id_expires_at, which RFC 7591 does not define (it defines
client_secret_expires_at, and no client_secret is ever issued here). It was added
under a comment citing §3.2.1, which is the class of error where a comment claims
a compliance the code does not have.

The E2E now registers asking for refresh_token, an implicit response type and
client_secret_post, so substitution is exercised over a real request cycle rather
than the defaults an empty body would take. 617 examples, 0 failures across three
random orders; rubocop 65/0; brakeman 0 with CI's flags. Still 0.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Azdaroth
Azdaroth merged commit 8ff8d18 into master Jul 20, 2026
10 checks passed
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