From 984ad41ddda35499ada7bc65bc17388d7fa912a4 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:32:20 +0000 Subject: [PATCH 1/4] Correct PROTOCOL_VERSION comment for harness per-scenario default When --spec-version is omitted, the conformance harness picks the version per-scenario (LATEST_SPEC_VERSION for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones), not a flat LATEST_SPEC_VERSION. The variable is still log-only today, but the stateless 2026 client path will branch on it. --- .github/actions/conformance/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 9a234f79b..2a7fd1468 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -7,8 +7,9 @@ - MCP_CONFORMANCE_SCENARIO env var -> scenario name - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) - MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock - server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; defaults - to the harness's LATEST_SPEC_VERSION when --spec-version is omitted. + server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when + --spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION + for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones). - Server URL as last CLI argument (sys.argv[1]) - Must exit 0 within 30 seconds @@ -54,10 +55,11 @@ logger = logging.getLogger(__name__) #: Spec version the harness is running this scenario at (e.g. "2025-11-25", -#: "2026-07-28"). The harness always sets this (it falls back to its own -#: LATEST_SPEC_VERSION when --spec-version is omitted), so None means we were -#: invoked outside the harness. Handlers that need to take the stateless 2026 -#: path will branch on this once the SDK has one; today it is logged only. +#: "2026-07-28"). The harness always sets this (when --spec-version is omitted +#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios, +#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked +#: outside the harness. Handlers that need to take the stateless 2026 path will +#: branch on this once the SDK has one; today it is logged only. PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION") # Type for async scenario handler functions From 676cc1f36609f5a0a2ba43bbdc08ab05edc6476c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:32:39 +0000 Subject: [PATCH 2/4] Harden OAuth issuer-binding and step-up scope on edge-case paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes to the SEP-2352 / SEP-2350 work: - Backfill an omitted token-response `scope` with the requested scope (RFC 6749 §5.1/§6) before persisting, so the SEP-2350 step-up union still recovers prior grants after a process restart when the authorization server omits `scope` because granted == requested. Applied to both the authorization-code and refresh response handlers. - Clear cached `oauth_metadata` when the SEP-2352 issuer-mismatch block discards bound credentials, so a subsequent ASM-discovery failure for the new authorization server cannot leave the old server's registration/token endpoints in place for Step 4. - Re-evaluate the SEP-2352 issuer-binding check against `oauth_metadata.issuer` after ASM discovery on the legacy no-PRM path (PRM discovery failed, AS found via root well-known fallback), mirroring the existing stamping-side fallback so stale credentials are still discarded when the binding can only be learned post-ASM. --- src/mcp/client/auth/oauth2.py | 33 +++++++++++ tests/client/test_auth.py | 102 +++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 675bb92be..5d16db080 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -424,6 +424,13 @@ async def _handle_token_response(self, response: httpx.Response) -> None: # 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 @@ async def _handle_refresh_response(self, response: httpx.Response) -> bool: 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 + 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 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. 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 @@ -600,6 +616,23 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. 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), diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 404d7aab2..d213b7227 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -51,7 +51,7 @@ def __init__(self): self._client_info: OAuthClientInformationFull | None = None async def get_tokens(self) -> OAuthToken | None: - return self._tokens # pragma: no cover + return self._tokens async def set_tokens(self, tokens: OAuthToken) -> None: self._tokens = tokens @@ -2833,3 +2833,103 @@ def test_credentials_match_issuer_url_shaped_dcr_id_is_not_portable(): issuer="https://as.example.com", ) assert credentials_match_issuer(info, "https://other", "https://client.example/metadata.json") is False + + +@pytest.mark.anyio +async def test_handle_token_response_backfills_omitted_scope_from_request( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage +): + """RFC 6749 §5.1: an omitted token-response scope means granted == requested. + + The token is stored with the requested scope filled in so it remains self-describing + after a restart, when the SEP-2350 step-up union reads it but ``client_metadata.scope`` + has reverted to its constructor value. + """ + oauth_provider.context.client_metadata.scope = "read admin" + response = httpx.Response( + 200, + json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600}, + request=httpx.Request("POST", "https://auth.example.com/token"), + ) + await oauth_provider._handle_token_response(response) + + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.scope == "read admin" + stored = await mock_storage.get_tokens() + assert stored is not None + assert stored.scope == "read admin" + + +@pytest.mark.anyio +async def test_handle_refresh_response_carries_prior_scope_when_response_omits_it( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage +): + """RFC 6749 §6: an omitted refresh-response scope means scope is unchanged from the prior token.""" + oauth_provider.context.current_tokens = OAuthToken(access_token="old", scope="read write") + response = httpx.Response( + 200, + json={"access_token": "new", "token_type": "Bearer", "expires_in": 3600}, + request=httpx.Request("POST", "https://auth.example.com/token"), + ) + ok = await oauth_provider._handle_refresh_response(response) + + assert ok is True + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.access_token == "new" + assert oauth_provider.context.current_tokens.scope == "read write" + stored = await mock_storage.get_tokens() + assert stored is not None + assert stored.scope == "read write" + + +@pytest.mark.anyio +async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( + oauth_provider: OAuthClientProvider, +): + """SEP-2352: on the legacy no-PRM path the binding check uses the ASM-discovered issuer. + + PRM discovery fails (404) so ``auth_server_url`` stays ``None`` and the post-PRM check is + skipped; when ASM discovery then succeeds via the root well-known fallback, the discovered + metadata's issuer is compared against the stored credentials' bound issuer and a mismatch + triggers re-registration. + """ + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="stale-client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + issuer="https://old-as.example.com", + ) + + auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + response_401 = httpx.Response(401, request=request) + + # PRM discovery: path-based then root, both 404. + prm_req = await auth_flow.asend(response_401) + assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp" + prm_req = await auth_flow.asend(httpx.Response(404, request=prm_req)) + assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # ASM discovery via root fallback (no auth_server_url) succeeds with a different issuer. + asm_req = await auth_flow.asend(httpx.Response(404, request=prm_req)) + assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" + asm_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://api.example.com", ' + b'"authorization_endpoint": "https://api.example.com/authorize", ' + b'"token_endpoint": "https://api.example.com/token", ' + b'"registration_endpoint": "https://api.example.com/register"}' + ), + request=asm_req, + ) + + # The stale bound credentials are discarded, so the next yield is a DCR request + # rather than the authorize redirect. + next_req = await auth_flow.asend(asm_response) + assert oauth_provider.context.auth_server_url is None + assert next_req.method == "POST" + assert str(next_req.url) == "https://api.example.com/register" + await auth_flow.aclose() From a808d14371985b712e2314580b0130fbdc7ea799 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:16:47 +0000 Subject: [PATCH 3/4] Only bind issuer when registration targets the discovered AS When ASM discovery fails, dynamic client registration falls back to the resource-server origin's `/register`. Recording that registration as bound to a PRM-advertised authorization server we never actually reached would persist a mis-bound record that the SEP-2352 binding check then accepts on every later flow, wedging the client on the wrong `client_id`. Leave the issuer unset in that case so a later 401 with working discovery re-evaluates cleanly. --- src/mcp/client/auth/oauth2.py | 15 +++--- tests/client/test_auth.py | 94 ++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 5d16db080..d63bc75f2 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -643,12 +643,15 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. # 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: bind the credentials to the issuing AS — but only when ASM + # discovery succeeded, so the registration request below actually targets + # that issuer's registration_endpoint. With no metadata (discovery failed), + # DCR falls back to the resource-server origin's /register; recording that + # as bound to a PRM-advertised AS we never reached would persist a + # mis-bound record that the binding check then accepts indefinitely. + bound_issuer: str | None = None + if self.context.oauth_metadata is not None: + bound_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 diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index d213b7227..fe4a342eb 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -57,7 +57,7 @@ async def set_tokens(self, tokens: OAuthToken) -> None: self._tokens = tokens async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info # pragma: no cover + return self._client_info async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: self._client_info = client_info @@ -2933,3 +2933,95 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( assert next_req.method == "POST" assert str(next_req.url) == "https://api.example.com/register" await auth_flow.aclose() + + +@pytest.mark.anyio +async def test_issuer_is_not_stamped_when_registration_falls_back_after_asm_discovery_fails( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage +): + """SEP-2352: a fallback registration is not recorded as bound to an undiscovered AS. + + PRM advertises a new authorization server, so the stored credentials (bound to the old + issuer) are discarded. ASM discovery for the new server then fails, so DCR falls back to + the resource-server origin's ``/register``. That registration was not derived from the new + AS's metadata, so persisting it as bound to the new AS would wedge the binding check on + later flows; instead the issuer is left unset. + """ + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + oauth_provider.context.client_info = OAuthClientInformationFull( + client_id="stale-client", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + issuer="https://api.example.com/", + ) + + captured_state: str | None = None + + async def capture_redirect(url: str) -> None: + nonlocal captured_state + captured_state = parse_qs(urlparse(url).query).get("state", [None])[0] + + async def echo_callback() -> AuthorizationCodeResult: + return AuthorizationCodeResult(code="auth_code", state=captured_state) + + oauth_provider.context.redirect_handler = capture_redirect + oauth_provider.context.callback_handler = echo_callback + + auth_flow = oauth_provider.async_auth_flow(httpx.Request("GET", "https://api.example.com/v1/mcp")) + request = await auth_flow.__anext__() + response_401 = httpx.Response( + 401, + headers={ + "WWW-Authenticate": ( + 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + ) + }, + request=request, + ) + + # PRM succeeds and advertises a new AS — the discard block fires. + prm_req = await auth_flow.asend(response_401) + assert str(prm_req.url) == "https://api.example.com/.well-known/oauth-protected-resource" + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://new-as.example.com"]}' + ), + request=prm_req, + ) + + # ASM discovery for the new AS fails on every well-known URL. + asm_req = await auth_flow.asend(prm_response) + assert oauth_provider.context.client_info is None + assert oauth_provider.context.oauth_metadata is None + assert str(asm_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server" + asm_req = await auth_flow.asend(httpx.Response(404, request=asm_req)) + assert str(asm_req.url) == "https://new-as.example.com/.well-known/openid-configuration" + + # Step 4 falls back to the resource-server origin's /register. + dcr_req = await auth_flow.asend(httpx.Response(404, request=asm_req)) + assert dcr_req.method == "POST" + assert str(dcr_req.url) == "https://api.example.com/register" + dcr_response = httpx.Response( + 201, + json={"client_id": "fallback-client", "redirect_uris": ["http://localhost:3030/callback"]}, + request=dcr_req, + ) + token_req = await auth_flow.asend(dcr_response) + + # The persisted record carries no issuer binding — not the PRM-advertised AS we never reached. + stored = await mock_storage.get_client_info() + assert stored is not None + assert stored.client_id == "fallback-client" + assert stored.issuer is None + + # Drive the flow to completion so the context lock is released cleanly. + token_response = httpx.Response( + 200, json={"access_token": "t", "token_type": "Bearer", "expires_in": 3600}, request=token_req + ) + final_req = await auth_flow.asend(token_response) + try: + await auth_flow.asend(httpx.Response(200, request=final_req)) + except StopAsyncIteration: + pass From d4fbec4ab8346406e0ab927bfb5a3f2e17dbff7a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:54:55 +0000 Subject: [PATCH 4/4] Gate DCR issuer stamp on a discovered registration_endpoint `registration_endpoint` is optional in RFC 8414 metadata, and DCR falls back to the resource-server origin's `/register` when it is absent. Recording that fallback registration as bound to the discovered issuer asserts a binding that was never established, so only stamp the issuer in the DCR branch when the discovered metadata actually carried a `registration_endpoint`. The CIMD branch is unaffected since CIMD records are portable across authorization servers. --- src/mcp/client/auth/oauth2.py | 27 +++++++++++-------- tests/client/test_auth.py | 51 ++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index d63bc75f2..00a0b88b4 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -643,26 +643,22 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. # 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 — but only when ASM - # discovery succeeded, so the registration request below actually targets - # that issuer's registration_endpoint. With no metadata (discovery failed), - # DCR falls back to the resource-server origin's /register; recording that - # as bound to a PRM-advertised AS we never reached would persist a - # mis-bound record that the binding check then accepts indefinitely. - bound_issuer: str | None = None + # SEP-2352: the issuer to bind these credentials to, when known. + discovered_issuer: str | None = None if self.context.oauth_metadata is not None: - bound_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer) + 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: @@ -674,7 +670,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. ) 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 self.context.client_info = client_information await self.context.storage.set_client_info(client_information) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index fe4a342eb..06f7b8076 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2936,16 +2936,39 @@ async def test_issuer_binding_re_evaluated_after_asm_when_prm_discovery_failed( @pytest.mark.anyio -async def test_issuer_is_not_stamped_when_registration_falls_back_after_asm_discovery_fails( - oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage +@pytest.mark.parametrize( + "asm_responses", + [ + pytest.param( + [httpx.Response(404), httpx.Response(404)], + id="asm-discovery-failed", + ), + pytest.param( + [ + httpx.Response( + 200, + content=( + b'{"issuer": "https://new-as.example.com", ' + b'"authorization_endpoint": "https://new-as.example.com/authorize", ' + b'"token_endpoint": "https://new-as.example.com/token"}' + ), + ) + ], + id="asm-metadata-without-registration-endpoint", + ), + ], +) +async def test_issuer_is_not_stamped_when_registration_falls_back_to_the_resource_origin( + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, asm_responses: list[httpx.Response] ): - """SEP-2352: a fallback registration is not recorded as bound to an undiscovered AS. + """SEP-2352: a fallback registration is not recorded as bound to the PRM-advertised AS. PRM advertises a new authorization server, so the stored credentials (bound to the old - issuer) are discarded. ASM discovery for the new server then fails, so DCR falls back to - the resource-server origin's ``/register``. That registration was not derived from the new - AS's metadata, so persisting it as bound to the new AS would wedge the binding check on - later flows; instead the issuer is left unset. + issuer) are discarded. DCR then falls back to the resource-server origin's ``/register`` + because the new AS's metadata either could not be discovered or omits + ``registration_endpoint``. That registration was not derived from the new AS's metadata, + so persisting it as bound to the new AS would wedge the binding check on later flows; + instead the issuer is left unset. """ oauth_provider.context.current_tokens = None oauth_provider.context.token_expiry_time = None @@ -2991,16 +3014,18 @@ async def echo_callback() -> AuthorizationCodeResult: request=prm_req, ) - # ASM discovery for the new AS fails on every well-known URL. - asm_req = await auth_flow.asend(prm_response) + # ASM discovery for the new AS yields no usable registration_endpoint — either every + # well-known URL 404s, or metadata is returned without one. + next_req = await auth_flow.asend(prm_response) assert oauth_provider.context.client_info is None assert oauth_provider.context.oauth_metadata is None - assert str(asm_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server" - asm_req = await auth_flow.asend(httpx.Response(404, request=asm_req)) - assert str(asm_req.url) == "https://new-as.example.com/.well-known/openid-configuration" + assert str(next_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server" + for asm_response in asm_responses: + asm_response.request = next_req + next_req = await auth_flow.asend(asm_response) # Step 4 falls back to the resource-server origin's /register. - dcr_req = await auth_flow.asend(httpx.Response(404, request=asm_req)) + dcr_req = next_req assert dcr_req.method == "POST" assert str(dcr_req.url) == "https://api.example.com/register" dcr_response = httpx.Response(