-
Notifications
You must be signed in to change notification settings - Fork 3.6k
OAuth client: harden SEP-2352/SEP-2350 edge cases; fix conformance comment #2936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
984ad41
676cc1f
a808d14
d4fbec4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -424,6 +424,13 @@ | |
| # Parse and validate response with scope validation | ||
| token_response = await handle_token_response_scopes(response) | ||
|
|
||
| # RFC 6749 §5.1: an omitted scope means the granted scope equals the requested | ||
| # scope. Record it explicitly so the persisted token is self-describing — the | ||
| # SEP-2350 step-up union reads it after a restart, when client_metadata.scope | ||
| # has reverted to its constructor value. | ||
| if token_response.scope is None: | ||
| token_response.scope = self.context.client_metadata.scope | ||
|
|
||
| # Store tokens in context | ||
| self.context.current_tokens = token_response | ||
| self.context.update_token_expiry(token_response) | ||
|
|
@@ -470,6 +477,12 @@ | |
| content = await response.aread() | ||
| token_response = OAuthToken.model_validate_json(content) | ||
|
|
||
| # RFC 6749 §6: an omitted scope on refresh means the scope is unchanged from | ||
| # the prior access token. Carry it forward so the persisted token stays | ||
| # self-describing for the SEP-2350 step-up union after a restart. | ||
| if token_response.scope is None and self.context.current_tokens is not None: | ||
| token_response.scope = self.context.current_tokens.scope | ||
|
|
||
|
Check notice on line 485 in src/mcp/client/auth/oauth2.py
|
||
| self.context.current_tokens = token_response | ||
| self.context.update_token_expiry(token_response) | ||
| await self.context.storage.set_tokens(token_response) | ||
|
|
@@ -578,6 +591,9 @@ | |
| logger.debug("Authorization server changed; discarding bound credentials and re-registering") | ||
| self.context.client_info = None | ||
| self.context.clear_tokens() | ||
| # Any cached AS metadata is for the old server; drop it so a failed | ||
| # rediscovery cannot leak the old registration/token endpoints into Step 4. | ||
| self.context.oauth_metadata = None | ||
|
|
||
| asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls( | ||
| self.context.auth_server_url, self.context.server_url | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
@@ -600,6 +616,23 @@ | |
| else: | ||
| logger.debug(f"OAuth metadata discovery failed: {url}") | ||
|
|
||
| # SEP-2352: on the legacy no-PRM path the issuer is only known after ASM | ||
| # discovery, so re-evaluate the binding here using the discovered metadata | ||
| # issuer (mirroring the bound_issuer fallback in Step 4). | ||
| if ( | ||
| self.context.client_info is not None | ||
| and self.context.auth_server_url is None | ||
| and self.context.oauth_metadata is not None | ||
| and not credentials_match_issuer( | ||
| self.context.client_info, | ||
| str(self.context.oauth_metadata.issuer), | ||
| self.context.client_metadata_url, | ||
| ) | ||
| ): | ||
| logger.debug("Authorization server changed; discarding bound credentials and re-registering") | ||
| self.context.client_info = None | ||
| self.context.clear_tokens() | ||
|
|
||
| # Step 3: Apply scope selection strategy | ||
| self.context.client_metadata.scope = get_client_metadata_scopes( | ||
| extract_scope_from_www_auth(response), | ||
|
|
@@ -610,23 +643,22 @@ | |
|
|
||
| # Step 4: Register client or use URL-based client ID (CIMD) | ||
| if not self.context.client_info: | ||
| # SEP-2352: bind the credentials to the issuing AS. Prefer the PRM-advertised | ||
| # authorization server; on the legacy no-PRM path fall back to the issuer from | ||
| # the discovered metadata so the binding is still recorded. | ||
| bound_issuer = self.context.auth_server_url | ||
| if bound_issuer is None and self.context.oauth_metadata is not None: | ||
| bound_issuer = str(self.context.oauth_metadata.issuer) | ||
| # SEP-2352: the issuer to bind these credentials to, when known. | ||
| discovered_issuer: str | None = None | ||
| if self.context.oauth_metadata is not None: | ||
| discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer) | ||
|
|
||
| if should_use_client_metadata_url( | ||
| self.context.oauth_metadata, self.context.client_metadata_url | ||
| ): | ||
| # Use URL-based client ID (CIMD) | ||
| # Use URL-based client ID (CIMD). CIMD records are portable across | ||
| # authorization servers, so the issuer stamp is informational. | ||
| logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}") | ||
| client_information = create_client_info_from_metadata_url( | ||
| self.context.client_metadata_url, # type: ignore[arg-type] | ||
| redirect_uris=self.context.client_metadata.redirect_uris, | ||
| ) | ||
| client_information.issuer = bound_issuer | ||
| client_information.issuer = discovered_issuer | ||
| self.context.client_info = client_information | ||
| await self.context.storage.set_client_info(client_information) | ||
| else: | ||
|
|
@@ -638,7 +670,16 @@ | |
| ) | ||
| registration_response = yield registration_request | ||
| client_information = await handle_registration_response(registration_response) | ||
| client_information.issuer = bound_issuer | ||
| # Only record the issuer when the registration above actually targeted | ||
| # the discovered AS's registration_endpoint. With no metadata, or | ||
| # metadata that omits registration_endpoint, DCR fell back to the | ||
| # resource-server origin's /register — recording that as bound to a | ||
| # PRM-advertised AS would persist a binding that was never established. | ||
| if ( | ||
| self.context.oauth_metadata is not None | ||
| and self.context.oauth_metadata.registration_endpoint is not None | ||
| ): | ||
| client_information.issuer = discovered_issuer | ||
|
Check warning on line 682 in src/mcp/client/auth/oauth2.py
|
||
|
Comment on lines
+673
to
+682
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The new Extended reasoning...The over-suppression. The new gate at Concrete walkthrough (legacy same-origin embedded AS, no PRM):
Why nothing else catches it. The new post-ASM re-check earlier in the flow only helps when a binding exists to compare; once records are persisted unstamped, every later 401 reuses them regardless of which AS the resource now advertises. The PR's new parametrized test ( Why this is distinct from the existing review comments. The two prior inline comments argued the stamp was wrong when the fallback Scope and severity. The trigger is narrow: an AS that publishes RFC 8414 metadata without Suggested fix. Instead of gating purely on |
||
| self.context.client_info = client_information | ||
| await self.context.storage.set_client_info(client_information) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing issue in the block this PR touches:
_handle_refresh_responsenow carries an omittedscopeforward per RFC 6749 §6, but an omittedrefresh_token(also allowed by §6 and common with non-rotating ASes) is not — the parsed token hasrefresh_token=Noneand unconditionally overwrites bothcontext.current_tokensand persistent storage, so the next expiry forces a full interactive re-authorization (or fails for headless clients). The fix is the same one-line pattern this PR adds for scope: backfilltoken_response.refresh_tokenfromself.context.current_tokens.refresh_tokenwhen the response omits it.Extended reasoning...
The bug.
_handle_refresh_response(src/mcp/client/auth/oauth2.py:469-495) parses the refresh response into a freshOAuthTokenand then unconditionally doesself.context.current_tokens = token_responsefollowed byawait self.context.storage.set_tokens(token_response).OAuthToken.refresh_tokenisOptionalwith a default ofNone(src/mcp/shared/auth.py), so when the authorization server omitsrefresh_tokenfrom the refresh response, the still-valid stored refresh token is overwritten withNoneboth in memory and in persistent storage. RFC 6749 §6 explicitly allows this server behavior — the AS MAY issue a new refresh token; when it does not, the client is expected to keep using the previously issued one. Non-rotating refresh tokens are common in practice (Google and many enterprise ASes omitrefresh_tokenfrom refresh responses).Code path.
async_auth_flow→can_refresh_token()true →_refresh_token()builds the request →_handle_refresh_responsehandles the 200. The handler validates the JSON, applies the new scope carry-forward this PR adds (lines 480-484), and then replaces the stored token wholesale. Nothing anywhere insrc/mcp/client/authpreserves the priorrefresh_token— the only other uses arecan_refresh_token()(which requires it) and building the refresh request itself. The only carry-forward logic in the module is the scope backfill introduced two lines above the overwrite.Step-by-step proof.
{access_token: A1, refresh_token: R1, expires_in: 3600}; both are persisted.async_auth_flowseescan_refresh_token()is true (R1 is stored) and issues the refresh.200 {"access_token": "A2", "token_type": "Bearer", "expires_in": 3600}— norefresh_token, which §6 permits and non-rotating ASes routinely do.OAuthToken.model_validate_jsonproduces a token withrefresh_token=None. The handler stores it incontext.current_tokensand callsstorage.set_tokens, wiping R1 from memory and from persistent storage.can_refresh_token()(oauth2.py:142-144) returnsFalsebecausecurrent_tokens.refresh_tokenisNone. The client silently falls back to a full interactive 401 re-authorization (redirect + callback) — an unnecessary user interaction for interactive clients, and an outright failure for headless or long-running ones. After a process restart the persisted record has also lost the refresh token, so the degradation survives restarts.Why nothing prevents it. The existing tests always include
refresh_tokenin mocked refresh responses, so the omitted case is uncovered, and there is no other code path that carries the prior value forward.Severity / scope. This is pre-existing — the overwrite line predates this PR and the PR does not change runtime behavior here. It is flagged because the PR edits this exact handler and implements the identical RFC 6749 §6 carry-forward principle for the sibling
scopefield two lines above, so this is the natural place to fix it.Fix. Mirror the scope backfill the PR adds:
placed alongside the new scope carry-forward, before
current_tokensis replaced and the token is persisted. A test analogous totest_handle_refresh_response_carries_prior_scope_when_response_omits_it(asserting the stored token keeps the oldrefresh_token) would cover it.