Skip to content

Preserve empty URL paths on OAuth metadata models#2925

Merged
Kludex merged 2 commits into
mainfrom
auth-url-preserve-empty-path
Jun 20, 2026
Merged

Preserve empty URL paths on OAuth metadata models#2925
Kludex merged 2 commits into
mainfrom
auth-url-preserve-empty-path

Conversation

@Kludex

@Kludex Kludex commented Jun 20, 2026

Copy link
Copy Markdown
Member

Sets url_preserve_empty_path=True (Pydantic 2.12+) on OAuthMetadata, ProtectedResourceMetadata, and OAuthClientMetadata.

Why

A path-less URL parsed from the wire previously acquired a trailing slash (https://as.example.comhttps://as.example.com/). RFC 9207 / RFC 8414 issuer comparisons require simple string comparison (RFC 3986 §6.2.1), which a spurious trailing slash defeats. With this flag, issuer/resource/authorization_servers round-trip as transmitted.

This is a prerequisite for #2921 (validate the iss authorization-response parameter): once this lands, that PR can drop its _strip_authority_trailing_slash workaround and compare issuers directly.

Scope

  • The flag only affects URLs parsed from strings/JSON (the wire path). URLs constructed in Python from an already-built AnyHttpUrl object are unchanged (they were normalized at construction), so model-constructed test fixtures and existing served-metadata output are unaffected — only one test assertion needed updating.
  • The pydantic floor is already >=2.12.0; no dependency change.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Set url_preserve_empty_path=True (Pydantic 2.12+) on OAuthMetadata,
ProtectedResourceMetadata, and OAuthClientMetadata so a path-less URL parsed
from the wire keeps its empty path instead of acquiring a trailing slash. RFC
9207 / RFC 8414 issuer comparisons require simple string comparison (RFC 3986
6.2.1), which a spurious trailing slash defeats: an issuer of
https://as.example.com now round-trips unchanged rather than as
https://as.example.com/.

Only values parsed from strings/JSON change; URLs built from an already
normalized AnyHttpUrl object are unaffected.
@Kludex Kludex enabled auto-merge (squash) June 20, 2026 15:31
@Kludex Kludex merged commit b7a5bff into main Jun 20, 2026
31 checks passed
@Kludex Kludex deleted the auth-url-preserve-empty-path branch June 20, 2026 15:32
Kludex added a commit that referenced this pull request Jun 20, 2026
With url_preserve_empty_path on the OAuth metadata models (#2925), the issuer
parsed from the wire keeps its empty path, so str(oauth_metadata.issuer) is
already the byte-exact value the authorization server transmitted. Remove
_strip_authority_trailing_slash / raw_issuer and compare directly.

This also fixes the false rejection the heuristic introduced for an issuer that
genuinely ends in a trailing slash (e.g. Auth0's https://tenant.auth0.com/):
its redirect iss now matches its advertised issuer instead of being stripped.
Comment thread src/mcp/shared/auth.py Outdated
Comment on lines +40 to +42
# Preserve empty URL paths so identifiers are compared as transmitted (RFC 3986 6.2.1)
# instead of acquiring a trailing slash; defaults in Pydantic v3.
model_config = ConfigDict(url_preserve_empty_path=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Adding url_preserve_empty_path=True to OAuthClientMetadata changes the wire serialization of redirect_uris, not just metadata parsing: a path-less redirect URI passed as a string (e.g. redirect_uris=['http://localhost:8080']) previously serialized as http://localhost:8080/ but now keeps the empty path, and the client sends this value verbatim in the /authorize and token-exchange requests (oauth2.py:337, :393). A client that registered with the old SDK (registration persisted in TokenStorage, so no re-registration) will now send a redirect_uri that no longer exact-string-matches the one recorded at an RFC 6749-compliant authorization server, breaking a previously working flow. Consider scoping the flag to OAuthMetadata/ProtectedResourceMetadata (which is all the issuer-comparison goal needs) or calling out the redirect_uri implication in the migration note.

Extended reasoning...

What the bug is. The PR's stated goal is to make issuer/resource/authorization_servers round-trip as transmitted for RFC 9207 / RFC 8414 issuer comparison. But the flag is also applied to OAuthClientMetadata, where the comparison-sensitive field on the wire is redirect_uris — and that field's serialized form is exact-string-matched by an external party (the authorization server), persistently, across SDK upgrades.

The PR's scope claim doesn't fully hold. The description says "URLs constructed in Python from an already-built AnyHttpUrl object are unchanged ... only values parsed from strings/JSON change." That is true for pre-built AnyHttpUrl objects, but the common way client metadata is constructed is with plain strings: OAuthClientMetadata(redirect_uris=['http://localhost:8080']). Verified on pydantic 2.12.5 (the repo's installed floor): with url_preserve_empty_path=True, python-mode validation of that string now yields 'http://localhost:8080' (and model_dump_json emits it without the slash), whereas before this PR it normalized to 'http://localhost:8080/'. So user code that hasn't changed at all produces a different wire value after upgrading.

The code path that puts it on the wire. src/mcp/client/auth/oauth2.py sends str(self.context.client_metadata.redirect_uris[0]) directly in both the authorization request (oauth2.py:337) and the token-exchange request (oauth2.py:393), and the DCR registration body is serialized from this same model. Nothing re-normalizes the URI before transmission.

Step-by-step regression scenario.

  1. With the previous SDK release, an app constructs OAuthClientMetadata(redirect_uris=['http://localhost:8080']) and performs dynamic client registration. The registration request carries "redirect_uris": ["http://localhost:8080/"] (old normalization), so the AS records exactly that string. The resulting client_id/client info is persisted in TokenStorage — the SDK's normal mode of operation — so no re-registration happens later.
  2. The app upgrades to an SDK containing this PR. Same user code, but redirect_uris[0] now serializes as http://localhost:8080 (no trailing slash).
  3. The next OAuth flow sends redirect_uri=http://localhost:8080 in the /authorize request (oauth2.py:337). RFC 6749 §3.1.2.3 requires the AS to compare redirect URIs using simple string comparison, so a spec-compliant external AS rejects the request with invalid_redirect_uri — a hard, confusing auth failure for a flow that worked before the upgrade, until the user clears stored client info and re-registers.

Why nothing else prevents it. The SDK's own server-side AuthorizationHandler is not affected, because pydantic AnyUrl equality treats http://localhost:8080 and http://localhost:8080/ as equal (verified), so validate_redirect_uri still matches. The breakage is specifically against third-party authorization servers doing the RFC-mandated literal string match on the transmitted value, where pydantic equality is irrelevant. Neither the migration note nor the PR description mentions that OAuthClientMetadata.redirect_uris output changes on the wire.

Mitigating factors and how to fix. The trigger is fairly narrow: it requires a path-less redirect URI (uncommon — the SDK examples use /callback), a DCR registration persisted across the upgrade, and an AS that does strict string matching; recovery is re-registering. Still, the fix is cheap: scope url_preserve_empty_path=True to OAuthMetadata and ProtectedResourceMetadata only (which is everything the issuer-comparison goal and #2921 need), or — if the new behavior on OAuthClientMetadata is intentional — explicitly document the redirect_uri wire-format change in the migration note so users with persisted registrations know to re-register.

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.

2 participants