| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Add the OAuth 2.0 token-exchange grant (urn:ietf:params:oauth:grant-type:token-exchange), the wire mechanism behind SEP-990. A client exchanges an enterprise IdP-issued ID-JAG for an MCP access token at the authorization server's token endpoint. Client: TokenExchangeOAuthProvider posts the exchange, sourcing the ID-JAG from an async subject_token_provider(audience) callback. Server: OAuthAuthorizationServerProvider.exchange_token validates the subject token and issues the access token; gated by AuthSettings.token_exchange_enabled, which also advertises the grant (and the public-client 'none' auth method) in metadata. TokenError gains invalid_target; TokenExchangeToken carries RFC 8693 issued_token_type.
AuthSettings.issuer_url and resource_server_url are typed AnyHttpUrl, which normalized a path-less URL with a trailing slash before the model's config could apply. The authorization server therefore advertised issuer as https://as.example.com/ instead of https://as.example.com, inconsistent with the exact string comparison RFC 8414/9207 require. Apply url_preserve_empty_path=True to AuthSettings (matching #2925 for the metadata models) so a string issuer_url/resource_server_url keeps its canonical form end to end.
…tic-stirring-treehouse
There was a problem hiding this comment.
1 issue found across 15 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
There was a problem hiding this comment.
I didn't find any bugs in my review, but this PR adds a new OAuth grant type across the server token endpoint, provider protocol, AS metadata, and a new client auth extension — security-sensitive surface that warrants a human reviewer's sign-off.
Extended reasoning...This PR implements SEP-990 / RFC 8693 token exchange end to end: a new TokenExchangeRequest branch in the server token handler, a new exchange_token method on OAuthAuthorizationServerProvider, a token_exchange_enabled flag threaded through AuthSettings, create_auth_routes, and AS metadata, a new TokenExchangeToken response model, and a new client-side TokenExchangeOAuthProvider extension, plus docs, snippets, and substantial test coverage (~480 lines of server tests, ~220 of client tests).
The change is squarely in the auth path. The design looks careful — the grant is opt-in and gated both at metadata advertisement and at the /token endpoint, the default exchange_token rejects with unsupported_grant_type, the handler still enforces the client's registered grant_types, requested scopes are passed to the provider rather than granted verbatim, and RFC 8693 actor-token pairing is validated. Subject-token validation is delegated entirely to provider implementations, and advertising the none token-endpoint auth method when the flag is on is a deliberate policy choice — both are decisions a human maintainer should explicitly endorse. There is also an outstanding bot comment about an assert in the example server snippet.
High. New OAuth grant handling on the authorization server token endpoint is among the most security-sensitive code in the SDK, and the PR also introduces new public API surface (provider method, settings flag, client extension, shared model) that implies long-term maintenance commitments. This is well outside the simple/mechanical category eligible for shadow approval.
The automated bug-hunting pass found no issues, tests are extensive (success, error, gating, disabled-flag, actor-token pairing, basic-auth, metadata advertisement cases), and the existing authorization-code/refresh-token paths are only touched by removing now-covered pragma: no cover markers. Those are positive signals, but they don't substitute for human judgment on the spec-conformance and API-design questions above.
Sorry, something went wrong.
Exercise the full stack - the real TokenExchangeOAuthProvider client against the real authorization-server token endpoint with InMemoryAuthorizationServerProvider implementing exchange_token - rather than shimming the grant as the M2M providers do. Covers: successful exchange + bearer authorizes the request, the subject_token_provider callback audience, server-disabled rejection (unsupported_grant_type), an unaccepted subject token, scope adoption from AS metadata, and metadata advertisement of the grant + public-client auth method. Adds the client-auth:token-exchange* requirement entries and threads token_exchange_enabled through the auth harness. Removes a now-covered pragma in OAuthClientProvider._handle_token_response: these tests are the first to drive a non-2xx token response through the full client flow.
- Negative tests assert the specific OAuth error (unsupported_grant_type when disabled, invalid_grant for a rejected subject token) instead of any token-endpoint failure. - Reorder positive tests recording-first, matching the suite's stated debugging style. - Add a full-stack interaction test for a confidential (client_secret_basic) token-exchange client, exercising the TokenExchangeOAuthProvider / ClientAuthenticator seam.
There was a problem hiding this comment.
I went through this fairly carefully against the SEP-990 normative text, RFC 8693, RFC 7523, and the sibling SDKs. There's one architectural issue that I think needs resolving before the rest is worth iterating on, plus a cluster of client-side discovery concerns and some smaller hardening points.
Reviewed at 55c2413.
The normative SEP-990 text — ext-auth/specification/stable/enterprise-managed-authorization.mdx §5 — specifies that the client presents the ID-JAG to the MCP authorization server using the RFC 7523 jwt-bearer grant:
The MCP Client presents the ID-JAG to the Resource Authorization Server's token endpoint as defined in [§4.4 of draft-ietf-oauth-identity-assertion-authz-grant-04], using grant_type urn:ietf:params:oauth:grant-type:jwt-bearer and the ID-JAG as the assertion.
RFC 8693 token-exchange is leg 1 only (client → enterprise IdP, ID-token → ID-JAG), which this PR correctly scopes out via subject_token_provider. But for leg 2 the PR sends and accepts grant_type=urn:ietf:params:oauth:grant-type:token-exchange with subject_token=<ID-JAG> instead.
The other SDKs all match the spec on this — typescript-sdk's crossAppAccess.ts uses token-exchange against the IdP and jwt-bearer against the MCP AS; go-sdk's auth/extauth/enterprise_handler.go and csharp-sdk's IdentityAssertionGrant.cs likewise. A python-sdk AS built from this PR rejects all of them with unsupported_grant_type (the discriminated union doesn't include jwt-bearer), and the python client is rejected by any spec-conforming AS.
Beyond interop, the grant-type choice has security weight. RFC 7523 §3 carries normative validation rules (the JWT MUST contain aud identifying the AS, iss/exp/signature MUST be checked) that map directly onto SEP-990 §5.1's processing rules — typ=oauth-id-jag+jwt, aud = own issuer, client_id claim ↔ authenticated client. RFC 8693 imposes nothing on subject_token, so those checks become provider-discretionary rather than something the SDK can structurally encourage. The §5.1 MUST that "the issued access token MUST be audience-restricted to the MCP Server identified by the resource claim in the ID-JAG" is similarly hard to surface when the SDK never decodes the assertion.
Discovery is also different from what's implemented: ext-auth §6 says clients detect support via authorization_grant_profiles_supported containing urn:ietf:params:oauth:grant-profile:id-jag (per draft-04 §7.2), not via grant_types_supported containing the token-exchange URN.
I think this means a reshape rather than a patch: a JwtBearerRequest (grant_type + assertion) on the server, a provider hook that's documented against RFC 7523 §3 + the §5.1 processing rules, and a client that posts assertion instead of subject_token. A lot of the smaller findings below (issued_token_type, token_type: N_A, actor-token pairing, multi-valued resource/audience) become moot under jwt-bearer since the response is plain RFC 6749.
TokenExchangeOAuthProvider reuses the base OAuthClientProvider 401→PRM→ASM discovery flow, which was designed around DCR — the client_secret comes from the discovered AS, so sending it back there is harmless. Here the secret is pre-supplied for a specific known AS, but the constructor offers no way to pin that AS, and _fixed_client_info is built with issuer=None (token_exchange.py#L98-L105), so the existing credentials_match_issuer guard always passes.
Three concrete consequences I was able to demonstrate with httpx.MockTransport driving the real async_auth_flow:
I'd suggest an expected_issuer: str constructor parameter, set _fixed_client_info.issuer = expected_issuer so the existing guard fires, and assert str(oauth_metadata.issuer) == expected_issuer before invoking subject_token_provider or attaching the secret. The legacy-path issuer-check skip is arguably a pre-existing gap in the base class, but this PR is what makes it carry pre-registered credentials.
The callback receives only audience (token_exchange.py#L59). ext-auth §4 has the leg-1 request to the IdP carrying both audience (the AS issuer — which is what's passed, correctly) and resource (the MCP server's RFC 9728 identifier); the resulting ID-JAG MUST carry that resource claim (§4.3) for §5.1 to bind against. The SDK has the PRM-derived value via self.context.get_resource_url() but never surfaces it to the callback. Widening to (audience, resource) would let callers send a correct leg-1 request without hardcoding the server URL.
A few things here that I'd tighten regardless of how the grant-type question lands, since this is the file people will copy:
Happy to share the repro tests for any of the above if useful.
Sorry, something went wrong.
Addresses the maintainer review: SEP-990 §5 specifies that the client presents the ID-JAG to the MCP authorization server using grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the ID-JAG as the assertion (RFC 7523), not the RFC 8693 token-exchange grant. The prior implementation was non-conformant and non-interoperable with the other MCP SDKs and any spec-conforming AS. Reshape: - Server: JwtBearerRequest (grant_type + assertion); provider hook exchange_identity_assertion taking IdentityAssertionParams; response is a plain RFC 6749 OAuthToken (TokenExchangeToken / issued_token_type removed). - Discovery: advertise the id-jag profile in authorization_grant_profiles_supported (ext-auth §6), not the token-exchange grant in grant_types_supported. - Client: IdentityAssertionOAuthProvider posts assertion; AuthSettings.identity_assertion_enabled. Security hardening from the review: - Confidential clients only: the handler rejects token_endpoint_auth_method=none, DCR refuses the jwt-bearer grant (pre-registration required), and metadata no longer advertises none. - AS pinning: the client takes expected_issuer, binds credentials to it, and refuses to send the ID-JAG or secret to a mismatched (resource-server-advertised) issuer. - The assertion_provider callback receives (audience, resource) so the ID-JAG can carry the resource claim §5.1 binds against; provider docs spell out the RFC 7523 §3 / §5.1 duties. Also fixes two bot-review findings: the example provider now implements get_client (it would otherwise 401 before the exchange), and the client honours its configured scope instead of letting the discovery scope-selection step overwrite it.
|
Thanks for the careful review against the normative text - you were right on the core point, and I've reshaped the PR accordingly (pushed in 7783020). Grant type (leg 2). Now uses grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the ID-JAG as assertion, per ext-auth §5. Server: JwtBearerRequest + exchange_identity_assertion(IdentityAssertionParams) returning a plain RFC 6749 OAuthToken. Client: IdentityAssertionOAuthProvider posts assertion. Discovery advertises urn:ietf:params:oauth:grant-profile:id-jag in authorization_grant_profiles_supported (§6), not the token-exchange grant. As you noted, the issued_token_type / N_A / actor-token / multi-resource findings all became moot and are gone. Confidential clients only. The handler rejects token_endpoint_auth_method=none, DCR refuses the jwt-bearer grant (pre-registration required), and metadata no longer advertises none. Client AS pinning. New expected_issuer parameter: _fixed_client_info.issuer is set to it (so the SEP-2352 guard fires) and the provider asserts the discovered issuer matches before releasing the ID-JAG or secret. The assertion_provider callback now takes (audience, resource) so the ID-JAG can carry the resource claim §5.1 binds against. The provider docstring spells out the RFC 7523 §3 / §5.1 duties (decode the assertion, match the client_id claim to the authenticated client, audience-restrict to the ID-JAG's resource, no refresh token). Example. Rewritten to fail closed (raises rather than trusting), bind to the ID-JAG resource, and implement get_client (it would otherwise 401 before the exchange). Two server-side items I'd flag for your call rather than assume: requested-scope validation and binding the issued token to the ID-JAG's resource claim are documented as the provider's responsibility (the SDK can't enforce them without decoding the assertion). If you'd prefer the SDK take a firmer stance there, happy to iterate. Validated end to end (full client+server stack, including an issuer-pinning case); 100% coverage, strict-no-cover/pyright/ruff clean. I have not been able to test against a live Okta CAA tenant (gated preview), so real-IdP interop is unverified - flagging that explicitly. AI DisclaimerThis PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes. |
Sorry, something went wrong.
There was a problem hiding this comment.
3 issues found across 25 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/server/auth/routes.py">
<violation number="1" location="src/mcp/server/auth/routes.py:180">
P2: Authorization-server metadata no longer advertises `none` even though the server still accepts `token_endpoint_auth_method="none"`, creating a protocol/metadata mismatch for public clients.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
| response_modes_supported=None, | ||
| grant_types_supported=["authorization_code", "refresh_token"], | ||
| grant_types_supported=grant_types_supported, | ||
| token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], |
There was a problem hiding this comment.
P2: Authorization-server metadata no longer advertises none even though the server still accepts token_endpoint_auth_method="none", creating a protocol/metadata mismatch for public clients.
Prompt for AI agentsCheck if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/auth/routes.py, line 180:
<comment>Authorization-server metadata no longer advertises `none` even though the server still accepts `token_endpoint_auth_method="none"`, creating a protocol/metadata mismatch for public clients.</comment>
<file context>
@@ -171,14 +177,15 @@ def build_metadata(
response_modes_supported=None,
grant_types_supported=grant_types_supported,
- token_endpoint_auth_methods_supported=token_endpoint_auth_methods_supported,
+ token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"],
token_endpoint_auth_signing_alg_values_supported=None,
service_documentation=service_documentation_url,
</file context>
| token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], | |
| token_endpoint_auth_methods_supported=["none", "client_secret_post", "client_secret_basic"], |
Sorry, something went wrong.
| issuer = str(self.context.oauth_metadata.issuer) | ||
| if issuer != self._expected_issuer: | ||
| raise OAuthFlowError( | ||
| f"Authorization server issuer {issuer} does not match expected {self._expected_issuer}" | ||
| ) |
There was a problem hiding this comment.
Issuer pinning does not validate token_endpoint — credentials can leak on legacy discovery path
The expected_issuer pin only compares oauth_metadata.issuer — a self-reported field from an RS-sourced document — and never validates the token_endpoint the credentials are actually POSTed to. On the legacy no-PRM fallback, ASM is fetched from the resource server's own origin and validate_metadata_issuer is skipped (auth_server_url is None), so a hostile RS can serve {issuer: <expected_issuer>, token_endpoint: https://attacker/...}; both the SEP-2352 guard and this check pass, and the pre-provisioned client_secret plus a real-AS-audienced ID-JAG are sent to the attacker.
This is the second bullet of the review's RS-controlled-discovery finding, which the commit message/docstring/docs claim expected_issuer fixes. Consider also asserting that token_endpoint (and/or the ASM fetch origin) matches expected_issuer's origin, or fetching ASM directly from expected_issuer instead of inheriting the base flow's RS-driven discovery.
Sorry, something went wrong.
| if not self.context.oauth_metadata: | ||
| raise OAuthFlowError("Missing OAuth metadata for identity assertion grant") # pragma: no cover |
There was a problem hiding this comment.
[nit] Reachable # pragma: no cover on oauth_metadata guard
This # pragma: no cover marks a reachable branch: if PRM and ASM discovery both 404 (legacy server), oauth_metadata stays None, the pinned _fixed_client_info means Step 4 is skipped, and Step 5 calls _exchange_assertion() straight into this raise. The maintainer review already flagged this under "Smaller / informational" and it wasn't picked up in the reshape — per the repo convention # pragma: no cover is only for genuinely unreachable code, so add a test for the ASM-404 path and drop the pragma.
Sorry, something went wrong.
| if self._scopes: | ||
| token_data["scope"] = self._scopes |
There was a problem hiding this comment.
[nit] 403 insufficient_scope step-up is a no-op — re-exchange sends constructor _scopes, not the stepped-up union
Reading self._scopes here fixes the 401-path overwrite but breaks the inherited SEP-2350 403 insufficient_scope step-up: the base async_auth_flow writes the unioned scope to context.client_metadata.scope and re-calls _perform_authorization(), but _exchange_assertion ignores that and re-sends the constructor value, so the retried exchange requests the same scope and the 403 recurs.
The sibling ClientCredentialsOAuthProvider reads client_metadata.scope and so honours step-up. Consider sending union_scopes(self._scopes, self.context.client_metadata.scope) (or restoring self._scopes onto client_metadata.scope after the 401-path scope-selection step instead of caching).
Sorry, something went wrong.
There was a problem hiding this comment.
Thanks for the quick reshape — went back through at 7783020 and everything you listed checks out. The grant-type, discovery field, confidential-only posture, DCR rejection, callback signature, AS-pinning constructor, fail-closed example, and the docstring duties are all in place, and a TS/Go/C#-shaped jwt-bearer request now reaches exchange_identity_assertion end-to-end.
On your two flagged items: I'm fine with scope bounding and resource-claim binding staying as documented provider responsibilities. The SDK can't enforce either without decoding the assertion, and the docstring + example now make the contract unambiguous.
There's one thing I think still needs closing before this is safe to ship, plus a smaller hardening point and some nits.
The expected_issuer pin at identity_assertion.py#L131-L135 checks the metadata document's self-reported issuer field — but not where the document was fetched from, nor where token_endpoint points. On the legacy fallback (PRM 404s → ASM fetched from the MCP server's own origin, utils.py#L164-L168), validate_metadata_issuer is still skipped because auth_server_url is None. So a hostile resource server that 404s PRM and serves {issuer: <expected_issuer>, token_endpoint: "https://evil/steal"} from its own well-known passes both the SEP-2352 guard and the new pin; assertion_provider is called with audience=<real-AS> (so an IdP audience allowlist is satisfied), and the ID-JAG plus client_secret go to the attacker. I have a MockTransport repro that completes the flow silently with an attacker-issued access token — happy to attach.
Since expected_issuer is now a required input, the simplest fix is to set self.context.auth_server_url = self._expected_issuer in _initialize(). ASM is then fetched directly from the pinned AS's well-known, validate_metadata_issuer runs, and the resource server can't forge the document at all — which is what go-sdk and csharp-sdk effectively do by taking the AS URL as config. That also closes a smaller side-effect on the PRM path: when PRM names an unexpected AS, the SEP-2352 guard currently clears client_info and the base flow attempts DCR against the attacker's /register (no secret leaked, but it discloses the client metadata and overwrites storage) before the pin at line 132 raises.
token.py#L255 rejects only token_endpoint_auth_method == "none", but ClientAuthenticator (unchanged) only compares secrets if client.client_secret: — a stored record with client_secret=None is returned without any credential check. So a pre-registered client with token_endpoint_auth_method="client_secret_post" and no secret passes both and reaches the hook unauthenticated, which makes the docstring's "the handler guarantees client is confidential" stronger than what's enforced. DCR-minted clients aren't affected (register.py always issues a secret for non-none); it only bites operator-provisioned records. Gating on client_info.client_secret being set (or having ClientAuthenticator reject a non-none client with no stored secret) would make the guarantee true.
The carry-over info-level items from the first round (request model parsed before the gate; Protocol default body only inherited by explicit subclasses; the # pragma: no cover at identity_assertion.py#L125 still being reachable; scope.split(" ") empty entries) all still apply but I don't think any of them block.
Sorry, something went wrong.
Addresses the round-2 review on the jwt-bearer reshape: - Client pins the token_endpoint origin, not just the metadata issuer (maxisbey): the issuer-only check is bypassable on the legacy no-PRM path where ASM is fetched from the resource server's origin, letting a hostile RS serve the expected issuer with an attacker token_endpoint. Now both must be on the expected issuer's origin. - Confidential-client gate now requires a stored secret on both sides (cubic): the server rejects a secret-based client registered without a secret (which ClientAuthenticator does not actually verify), and the client constructor rejects an empty client_secret / expected_issuer. Reverts the gate from a "none"-only check. - 403 insufficient_scope step-up now works (maxisbey): the exchange unions the configured scope with the base-flow-selected/challenged scope and writes it back to client_metadata.scope, so the retried request escalates and the stored token's scope backfill records the right value. - Removed a reachable "pragma: no cover" on the missing-metadata guard; added a test. - Example: corrected a stale RFC 8693 citation to RFC 7523 section 3.1. Did not apply cubic's suggestion to advertise the "none" auth method in token_endpoint_auth_methods_supported: SEP-990 section 5.1 is confidential-only, so the fix is to reject "none", not advertise it.
There was a problem hiding this comment.
2 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/auth/extensions/identity_assertion.py">
<violation number="1" location="src/mcp/client/auth/extensions/identity_assertion.py:37">
P2: Origin comparison does not normalize default ports, so valid same-origin endpoints can be rejected. This can fail exchanges against metadata that publishes an explicit default port.</violation>
<violation number="2" location="src/mcp/client/auth/extensions/identity_assertion.py:181">
P1: Configured scopes can be unintentionally expanded with server-advertised scopes. This may request broader privileges than the caller asked for during initial 401 authorization.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
Addresses the round-3 review (maxisbey), which reviewed 7783020 before the round-2 hardening landed: - Close the legacy no-PRM credential leak (blocking). The expected_issuer pin previously only compared the metadata's self-reported issuer; a hostile resource server could 404 PRM and serve a document naming the expected issuer but an attacker token_endpoint (validate_metadata_ issuer is skipped on that path). _initialize now pins auth_server_url to expected_issuer so ASM is discovered from the pinned AS's well-known and validate_metadata_issuer runs, and _validate_resource_match rejects a PRM that names any other AS - stopping the attack before any DCR discloses client metadata. - 403 step-up scope union and the stored-token scope backfill: the exchange unions the configured scope with the base-flow-selected/challenged scope and writes it back to client_metadata.scope. - Confidential-client gate returns unauthorized_client (RFC 6749 5.2: the client is authenticated but not permitted this grant), not invalid_client. - Docs: invalid_target now cited against RFC 8707; the exchange_identity_assertion checklist adds the RFC 7523 sub MUST and jti/replay guidance; the "confidential client" wording now says the spec requires client authentication and this SDK requires a shared secret. Did not dedupe the JWT_BEARER_GRANT_TYPE literal across four files (no natural shared home); the carry-over informational items from earlier rounds remain non-blocking per the reviewer.
|
Thanks again. Your review landed against 7783020 just before two follow-up pushes (0f4052c, 05f60ce); everything is now addressed. Legacy no-PRM credential leak (blocking). Fixed as you suggested: _initialize pins auth_server_url to expected_issuer, so ASM is discovered from the pinned AS's well-known and validate_metadata_issuer runs. I also override _validate_resource_match to reject a PRM that names any other AS, which closes the PRM-path DCR-disclosure side-effect you noted (the flow now aborts before any /register). An interaction test drives the attack end to end. Confidential-client gate. Already tightened in 0f4052c (predated your review): it now gates on a stored client_secret, so a secret-less client_secret_post record is rejected before the hook. Changed the error to unauthorized_client per your RFC 6749 §5.2 point. Docs/nits. invalid_target now cited against RFC 8707; the exchange_identity_assertion checklist adds the RFC 7523 sub MUST and jti/replay guidance; example citation fixed to RFC 7523 §3.1 (0f4052c); reworded the "confidential client" line to "the spec requires client authentication; this SDK requires a shared secret." On the two you're fine leaving as provider responsibilities (scope bounding, resource-claim binding) - kept as documented. Did not dedupe the JWT_BEARER_GRANT_TYPE literal (no natural shared home); the remaining info-level carry-overs I left as non-blocking per your note. 100% coverage, strict-no-cover/pyright/ruff clean. Still no live-Okta-CAA validation (gated preview) - flagging again. AI DisclaimerThis PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes. |
Sorry, something went wrong.
There was a problem hiding this comment.
1 issue found across 7 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/server/auth/routes.py">
<violation number="1" location="src/mcp/server/auth/routes.py:180">
P2: Authorization-server metadata no longer advertises `none` even though the server still accepts `token_endpoint_auth_method="none"`, creating a protocol/metadata mismatch for public clients.</violation>
</file>
<file name="src/mcp/client/auth/extensions/identity_assertion.py">
<violation number="1" location="src/mcp/client/auth/extensions/identity_assertion.py:150">
P1: Issuer-pinning check is too permissive: it accepts PRMs where the pinned issuer is present but not selected, allowing attacker-first authorization_servers entries to bypass the new guard.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
Addresses the cubic review: - Scope no longer broadens on the initial exchange (P1). The previous union pulled the server-advertised scopes (which the base 401 scope-selection step writes onto client_metadata.scope) into the request, widening beyond what the caller asked for. The initial exchange now sends exactly the configured scope; only a 403 insufficient_scope step-up - distinguished by an already-issued token - unions in the challenged scope, so SEP-2350 escalation still works. - _origin normalizes the scheme's default port (P2), so an expected_issuer written with an explicit :443/:80 compares equal to the port-less token endpoint and a valid same-origin exchange is not rejected. Tests: initial-exchange-does-not-broaden, step-up-unions (now with a prior token present), and _origin default-port normalization.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/server/auth/routes.py">
<violation number="1" location="src/mcp/server/auth/routes.py:180">
P2: Authorization-server metadata no longer advertises `none` even though the server still accepts `token_endpoint_auth_method="none"`, creating a protocol/metadata mismatch for public clients.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Sorry, something went wrong.
The round-5 fix keyed the scope union on current_tokens to tell an initial exchange from a 403 step-up, but that discriminator is wrong (both bots flagged it): this grant issues no refresh token, so a renewal after expiry - or a restart that reloads an expired token - re- enters the 401 branch with a stale token still loaded and Step 3 having overwritten client_metadata.scope with the server-advertised set, so the union branch fired and broadened the request on every exchange after the first. Drop the union/step-up branch entirely: the provider now sends exactly the configured scope on every exchange. SEP-990's model is that the authorization server derives the granted scope from the validated ID-JAG and policy, so client-driven scope escalation does not apply - a broader grant comes from re-issuing the ID-JAG, not from requesting more scope here. This guarantees the request is never broadened on any path (initial, renewal, restart). The test now checks both the no-token and token-present (renewal) cases.
The previous implementation inherited OAuthClientProvider.async_auth_flow, whose trust model (RS chooses the AS via PRM, client DCRs there, AS-advertised scopes win) is the inverse of SEP-990's (AS is enterprise configuration, RS is untrusted for AS selection, client is pre-provisioned). Each inherited step was neutralized by a guard, and successive review rounds kept finding paths around them - PRM authorization_servers[0] overwriting the pin, the legacy ASM-from-RS fallback, SEP-2352 clear-and-re-DCR, scope-selection overwrite. This drops the inheritance. IdentityAssertionOAuthProvider is now a standalone httpx.Auth: the AS issuer is a constructor argument, ASM is fetched from that issuer's RFC 8414 well-known, validate_metadata_issuer and a same-origin token_endpoint check run, and the jwt-bearer request goes only there. There is no PRM fetch, no DCR step, no credentials_match_issuer step, and no mutable client_metadata.scope - the resource server has no input into where the ID-JAG or client secret are sent. This matches go-sdk and csharp-sdk, which take the AS URL as configuration. Also: - ClientAuthenticator now rejects a secret-based auth method registered without a stored secret (the gap the per-grant confidential gate was compensating for). - JWT_BEARER_GRANT_TYPE is defined once in mcp.shared.auth and imported. - expected_issuer renamed to issuer (it is the address, not a check); scopes renamed to scope for consistency with the other extensions. - Client tests rewritten around the new flow, including a by-construction test asserting the RS is never consulted for AS selection.
…house' into worktree-synthetic-stirring-treehouse # Conflicts: # src/mcp/client/auth/extensions/identity_assertion.py # tests/client/auth/extensions/test_identity_assertion.py
| issuer=ISSUER, | ||
| assertion_provider=assertion_provider, | ||
| scope=scope, | ||
| token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type] |
There was a problem hiding this comment.
🟡 The new test helper make_provider types token_endpoint_auth_method as plain str and then suppresses the resulting type error with a fresh # type: ignore[arg-type] on line 79, which AGENTS.md explicitly asks to avoid adding. Typing the parameter as Literal["client_secret_basic", "client_secret_post"] (matching the IdentityAssertionOAuthProvider constructor) removes the suppression with no change to any call site.
Extended reasoning...The issue. AGENTS.md (Coverage section, lines 107–113) says: "Avoid adding new # pragma: no cover, # type: ignore, or # noqa comments", and even prescribes auditing the diff before pushing with git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' — a command that flags exactly this addition. The new test file tests/client/auth/extensions/test_identity_assertion.py introduces a fresh suppression at line 79: the make_provider helper declares token_endpoint_auth_method: str = "client_secret_post" and passes it to IdentityAssertionOAuthProvider, whose constructor parameter is typed Literal["client_secret_basic", "client_secret_post"], so pyright reports an arg-type mismatch that the # type: ignore[arg-type] silences.\n\nWhy the suppression is avoidable. The mismatch exists only because the helper widened the parameter type. Every call site passes a string literal — the default "client_secret_post" and make_provider(token_endpoint_auth_method="client_secret_basic") in test_client_secret_basic_sends_basic_header_not_body_secret — so changing the helper's annotation to Literal["client_secret_basic", "client_secret_post"] (importing Literal from typing) type-checks cleanly with zero changes to any caller and zero behavioral change. The ignore can then simply be deleted.\n\nStep-by-step proof. (1) As written, pyright sees token_endpoint_auth_method: str flowing into a Literal[...]-typed parameter and emits reportArgumentType; the # type: ignore[arg-type] suppresses it. (2) Change the helper signature to token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post". (3) Both call shapes — the omitted default and the explicit "client_secret_basic" literal — are members of that Literal, so pyright accepts them. (4) The argument now flows literal-to-literal into the constructor, so the ignore is unnecessary and pyright's reportUnnecessaryTypeIgnoreComment-style hygiene is preserved. (5) Tests run identically, since the runtime value is the same string either way.\n\nOn the counter-argument that this is a non-actionable style nit. It is true that the existing suite contains some # type: ignore comments and that the AGENTS.md alternative (assert isinstance) doesn't literally apply here — the right fix is narrowing the parameter annotation, not an isinstance assert. But the guideline is not merely soft preference: it is an explicitly documented repo rule with its own pre-push audit command, and this case has a one-line fix that loses nothing (no call-site churn, no readability cost). Pre-existing ignores elsewhere in tests/ predate or fall outside this PR; the rule is specifically about not adding new ones. That said, there is no runtime, coverage, or type-safety impact on library code, which is why this is filed as a nit rather than a blocking finding.\n\nImpact and fix. Impact is purely hygiene: an avoidable suppression in a test helper that the repo's own audit step would surface. Fix: annotate the make_provider parameter as Literal["client_secret_basic", "client_secret_post"] and drop the # type: ignore[arg-type]. (The new # pragma: no cover on the mock transport's defensive raise AssertionError at line 116 is defensible — that line is intentionally unreachable — so no change is suggested there.)
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary
Implements SEP-990 (enterprise IdP policy controls during MCP OAuth flows). The wire mechanism is the RFC 8693 token-exchange grant (urn:ietf:params:oauth:grant-type:token-exchange): a client exchanges an enterprise IdP-issued Identity Assertion Authorization Grant (ID-JAG) for an MCP access token at the authorization server's token endpoint.
This is additive and opt-in on both sides; existing flows are unchanged.
Client
TokenExchangeOAuthProvider (mcp.client.auth.extensions.token_exchange) is an httpx.Auth that posts the exchange, mirroring the client_credentials extension. The ID-JAG is supplied lazily via an async subject_token_provider(audience) callback — the SDK does not implement IdP login or the first exchange against the IdP (deployment-specific). Public (default, none) and confidential (client_secret_post/client_secret_basic) clients are supported; requested_token_type defaults to the access-token URN.
Server
OAuthAuthorizationServerProvider.exchange_token validates the subject token (signature/issuer/audience/expiry/policy — the provider's responsibility) and issues the token. Gated by AuthSettings(token_exchange_enabled=True), which:
TokenError gains RFC 8693's invalid_target; TokenExchangeToken carries the required issued_token_type (the handler defaults it so responses are compliant regardless of provider). TokenExchangeRequest enforces RFC 8693 actor-token pairing.
The provider owns scope decisions: requested scopes are never granted verbatim. The example server narrows the request against an allowed set so a valid ID-JAG cannot be exchanged for broader access than policy permits.
Review
Reviewed by Codex across two rounds. Round one raised seven should-fix findings; all were addressed (dispatch gating, issued_token_type, scope-escalation hardening in the example, invalid_target, actor-token pairing, Basic-auth confidential client, public-client advertisement). The follow-up review confirmed every finding resolved with no new Critical/Should-fix issues.
Testing
This exercise surfaced a separate AS-metadata issuer normalization bug, fixed in #2987 (merged); this branch builds on it.
Docs: new SEP-990 section in docs/migration.md; client + server snippets under examples/snippets/.
AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.