Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/oauth-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The first time `Client` sends a request, the server answers `401`. The provider
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.

After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. That holds across restarts: a new process that finds a refresh token in storage answers the first `401` by rediscovering the authorization server and refreshing, not by sending anyone back to the browser.

You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `validate_resource_url`), and this file needs neither. `client_metadata_url` is the one worth knowing about; it gets its own section below.

Expand Down
50 changes: 37 additions & 13 deletions src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
if self.context.client_metadata.redirect_uris is None:
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
if not self.context.redirect_handler:
raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover
raise OAuthFlowError("No redirect handler provided for authorization code grant")
if not self.context.callback_handler:
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover

Expand Down Expand Up @@ -521,6 +521,8 @@
if response.status_code != 200:
logger.warning(f"Token refresh failed: {response.status_code}")
self.context.clear_tokens()
# Re-read storage on the next request: the failure may have been transient.
self._initialized = False
return False

try:
Expand All @@ -545,6 +547,7 @@
except ValidationError: # pragma: no cover
logger.exception("Invalid refresh response")
self.context.clear_tokens()
self._initialized = False
return False

async def _initialize(self) -> None:
Expand Down Expand Up @@ -586,14 +589,15 @@
# Capture protocol version from request headers
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)

if not self.context.is_token_valid() and self.context.can_refresh_token():
# Try to refresh token
refresh_request = await self._refresh_token()
refresh_response = yield refresh_request

if not await self._handle_refresh_response(refresh_response):
# Refresh failed, need full re-authentication
self._initialized = False
# Refresh ahead of the request only when the token endpoint is already known; on a cold
# start the request goes out and the 401 branch discovers, then refreshes.
if (
not self.context.is_token_valid()
and self.context.can_refresh_token()
and self.context.oauth_metadata is not None
):
refresh_response = yield await self._refresh_token()
await self._handle_refresh_response(refresh_response)

if self.context.is_token_valid():
self._add_auth_header(request)
Expand Down Expand Up @@ -648,6 +652,18 @@
# 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
elif (
self.context.client_info is not None
and self.context.client_info.client_id == self.context.client_metadata_url
and self.context.auth_server_url is not None
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
and self.context.client_info.issuer not in (None, self.context.auth_server_url)
):
# A CIMD client_id is portable across authorization servers; the tokens issued
# under it and the cached metadata are not. Keep the record, re-stamped.
self.context.clear_tokens()
self.context.oauth_metadata = None
self.context.client_info.issuer = self.context.auth_server_url
await self.context.storage.set_client_info(self.context.client_info)

asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
self.context.auth_server_url, self.context.server_url
Expand Down Expand Up @@ -741,10 +757,18 @@
client_information.issuer = discovered_issuer
self.context.client_info = client_information
await self.context.storage.set_client_info(client_information)

# Step 5: Perform authorization and complete token exchange
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
# Held tokens belong to a previous client and cannot be refreshed by this one.
self.context.clear_tokens()

# Step 5: Refresh with the stored refresh token first (RFC 6749 §6); run the full
# authorization only when there is none or the server rejects it.
refreshed = False
if self.context.can_refresh_token():
refresh_response = yield await self._refresh_token()

Check warning on line 767 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

nit, pre-existing extended by this diff: the new 401-branch Step 5 refresh (and the pre-request refresh it complements) builds the refresh request via the base `_refresh_token()` -> `prepare_token_auth()`, which adds no client authentication for `token_en

nit, pre-existing extended by this diff: the new 401-branch Step 5 refresh (and the pre-request refresh it complements) builds the refresh request via the base `_refresh_token()` -> `prepare_token_auth()`, which adds no client authentication for `token_endpoint_auth_method="private_key_jwt"` (oauth2.py:271-274 explicitly defers the assertion to "the provider that implements it"), but `PrivateKeyJWTOAuthProvider` only adds its `client_assertion` in `_exchange_token_client_credentials` (src/mcp/cl
Comment on lines +766 to +767

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.

🟡 nit, pre-existing extended by this diff: the new 401-branch Step 5 refresh (and the pre-request refresh it complements) builds the refresh request via the base _refresh_token() -> prepare_token_auth(), which adds no client authentication for token_endpoint_auth_method="private_key_jwt" (oauth2.py:271-274 explicitly defers the assertion to "the provider that implements it"), but PrivateKeyJWTOAuthProvider only adds its client_assertion in _exchange_token_client_credentials (src/mcp/client/auth/extensions/client_credentials.py:303-315) and never overrides _refresh_token. Every refresh_token grant sent by a private_key_jwt client is therefore unauthenticated and is rejected by the AS with invalid_client, so refresh can never succeed for these clients; the diff adds a second…

Extended reasoning...

A PrivateKeyJWTOAuthProvider talks to an AS that issues refresh tokens on the client_credentials grant (e.g. Keycloak's legacy default). When the access token expires server-side, the next request 401s; discovery runs and Step 5 at oauth2.py:766-768 sees can_refresh_token() True and POSTs grant_type=refresh_token with only client_id in the body — no client_assertion — so the AS answers 400/401 invalid_client. _handle_refresh_response logs a warning, clears tokens and resets _initialized, and the flow falls back to a fresh client_credentials exchange, which succeeds. Net effect on every token expiry: one guaranteed-rejected token-endpoint round trip plus a spurious "Token refresh failed" warning, and the refresh token the SDK deliberately carries forward (lines 539-540) is dead weight that can never be used. Fix belongs in _refresh_token (add the RFC 7523 assertion for private_key_jwt, mirroring _add_client_authentication_jwt) or in can_refresh_token for that provider.

Verification: nit — the factual claim is verifiable in code, though the consequence is milder than a brick because the client_credentials fallback recovers headlessly. Chain, all in HEAD: (1) The new Step 5 at /home/claude/python-sdk/src/mcp/client/auth/oauth2.py:766-768 gates only on can_refresh_token() (line 191-193: tokens with a refresh_token plus client_info — no auth-method check), then yields `self._re

refreshed = await self._handle_refresh_response(refresh_response)

Check failure on line 768 in src/mcp/client/auth/oauth2.py

View check run for this annotation

Claude / Claude Code Review

The new 401-branch Step-5 refresh POSTs the refresh token (and client secret) to the origin-guessed `{resource-server-origin}/token` whenever AS metadata discovery fails, because `_refresh_token` (lines 497-501) still falls back to `urljoin(get_authorizat

The new 401-branch Step-5 refresh POSTs the refresh token (and client secret) to the origin-guessed `{resource-server-origin}/token` whenever AS metadata discovery fails, because `_refresh_token` (lines 497-501) still falls back to `urljoin(get_authorization_base_url(self.context.server_url), "/token")` and, unlike the newly gated pre-request site (lines 594-598), the Step-5 call site is not conditioned on `oauth_metadata is not None`. When PRM succeeded and named an AS that is not at the resour
Comment thread
maxisbey marked this conversation as resolved.
Comment on lines +753 to +758

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.

🟡 nit: 401-branch Step 5 re-refreshes a token the pre-request branch minted seconds earlier in the same flow — no flag records that a refresh already ran this request, so a 401 on the fresh token triggers an immediate second refresh_token POST instead of falling through to authorization.

Extended reasoning...

Concrete cost: doubled token-endpoint traffic and refresh-token rotation churn with no recovery path. When the pre-request refresh (lines 594-600) succeeds but the resource server still 401s the freshly minted access token (verifier/introspection lag, RS-side revocation, audience misconfig), can_refresh_token() is still True at line 769 (the carried-forward refresh_token from lines 539-540), so every request performs refresh POST -> 401 -> second refresh POST -> retry 401, and because the AS keeps answering 200 to refreshes, refreshed stays True and the interactive re-authorization at line 772 is never reached — the caller just sees repeating 401s at twice the token-endpoint cost. Tracking 'refreshed this flow' (skip Step 5's refresh when the pre-request one already succeeded) removes the duplicate POST.

Verification: nit — the claim is factually true. In /home/claude/python-sdk/src/mcp/client/auth/oauth2.py the pre-request branch (lines 594-600) refreshes an expired token when oauth_metadata is known; on success _handle_refresh_response stores the fresh token and carries the refresh token forward (lines 539-540: `if token_response.refresh_token is None and prior is not None: token_response.refresh_token =

Comment on lines +763 to +768

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.

🔴 The new 401-branch Step-5 refresh POSTs the refresh token (and client secret) to the origin-guessed {resource-server-origin}/token whenever AS metadata discovery fails, because _refresh_token (lines 497-501) still falls back to urljoin(get_authorization_base_url(self.context.server_url), "/token") and, unlike the newly gated pre-request site (lines 594-598), the Step-5 call site is not conditioned on oauth_metadata is not None. When PRM succeeded and named an AS that is not at the resource server's origin (different host, or a path-based AS), that guess targets the wrong server entirely — contradicting the PR's own claim that the client "never guesses {origin}/token"; the manifest note in tests/interaction/_requirements.py only blesses the fallback for the legacy…

Extended reasoning...

A restarted headless client holds a valid persisted refresh token for an AS at https://as.example.com/oauth2/v1 (RS at https://rs.example.com). The stale bearer draws a 401; PRM discovery succeeds and sets auth_server_url to the AS; the AS's metadata endpoint returns a transient 502, so handle_auth_metadata_response (utils.py:233-234) returns (False, None), the Step-2 loop breaks, and oauth_metadata stays None (OAuthMetadata.token_endpoint is required, so the fallback fires exactly when discovery failed). Step 5 then runs: can_refresh_token() is True, _refresh_token() builds token_url = "https://rs.example.com/token" — the resource server's origin, never the AS — and POSTs grant_type=refresh_token with the refresh token and the client secret (prepare_token_auth) to that host, disclosing long-lived credentials to a party that was only ever meant to see the access token. The guaranteed 404/non-200 makes _handle_refresh_response discard the tokens and fall through to _perform_authorization, which raises OAuthFlowError for the headless client — so one transient metadata 5xx both leaks

Verification: normal — the candidate is mechanically accurate and the failure is newly reachable through the diff-added Step-5 call site. Chain, all in /home/claude/python-sdk/src/mcp/client/auth/oauth2.py at HEAD: (1) the new 401-branch Step 5 (lines 765-768) is if self.context.can_refresh_token(): refresh_response = yield await self._refresh_token() — unlike the pre-request site this PR gated (lines 594-598

Comment on lines +756 to +758

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.

🔴 Security: 401-branch Step 5 still POSTs a refresh token of unconfirmed issuer provenance to whatever AS the resource server's PRM currently names — for pre-registered/unstamped registrations always, and for CIMD across restarts because the SEP-2352 token drop in _apply_issuer_binding is memory-only (clear_tokens never touches storage). [also at: src/mcp/client/auth/oauth2.py:756 - Re-filing still-present security gap: for pre-registered/unstamped (non-CIMD) client_info, the new 401-branch Step 5…; src/mcp/client/auth/oauth2.py:723 - Fresh-registration token clearing is applied only in the DCR branch of Step 4 (line 751) — the CIMD branch (lines…; +1 more]

Extended reasoning...

A client with pre-registered credentials (client_info.issuer is None) holds a persisted refresh token. A compromised or malicious resource server changes its PRM to name an attacker-controlled AS; credentials_match_issuer (src/mcp/client/auth/utils.py:352-353) returns True for the unstamped record, _apply_issuer_binding's token-drop branch (oauth2.py:569-573) applies only when client_id == client_metadata_url, so tokens survive, and Step 5 (oauth2.py:756-758) silently POSTs the long-lived refresh token (plus client secret via prepare_token_auth) to the attacker's advertised token_endpoint with no user-visible signal. The CIMD case is only fixed in-process: clear_tokens (oauth2.py:195-198) does not delete tokens from storage while the re-stamped record IS persisted (line 572), so the next restarted process reloads the old-issuer refresh token under a record now stamped with the new issuer, _apply_issuer_binding finds issuer == issuer and keeps it, and Step 5 presents the previous issuer's refresh token to the new AS anyway. Prior to this PR the 401 branch never refreshed, so this harv

Verification: normal — both prongs are mechanically real at HEAD. (1) Pre-registered/unstamped: utils.py:352-353 (if client_info.issuer is None: return True) makes credentials_match_issuer pass, and the token-drop branch in _apply_issuer_binding (oauth2.py:569, if client_info.client_id == self.context.client_metadata_url ...) is CIMD-only, so tokens survive; the RS's PRM alone sets the AS (oauth2.py:657

if not refreshed:
Comment thread
maxisbey marked this conversation as resolved.
token_response = yield await self._perform_authorization()
await self._handle_token_response(token_response)
except Exception:
logger.exception("OAuth flow error")
raise
Expand Down
78 changes: 78 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3253,3 +3253,81 @@ async def echo_callback() -> AuthorizationCodeResult:
await auth_flow.asend(httpx2.Response(200, request=final_req))
except StopAsyncIteration:
pass


@pytest.mark.anyio
async def test_expired_token_is_not_refreshed_ahead_of_the_request_before_metadata_is_discovered(
oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken
) -> None:
"""With no authorization-server metadata yet, an expired token is not refreshed at a guessed endpoint.

The request goes out unauthenticated instead, so the 401 branch discovers the real token
endpoint before the refresh token is presented anywhere (#3240).
"""
oauth_provider.context.current_tokens = valid_tokens
oauth_provider.context.token_expiry_time = time.time() - 60
oauth_provider.context.client_info = OAuthClientInformationFull(client_id="c", redirect_uris=None)
oauth_provider.context.oauth_metadata = None
oauth_provider._initialized = True

request = httpx2.Request("POST", "https://api.example.com/v1/mcp")
auth_flow = oauth_provider.async_auth_flow(request)
first = await auth_flow.__anext__()

assert first is request
assert "Authorization" not in first.headers

with pytest.raises(StopAsyncIteration):
await auth_flow.asend(httpx2.Response(200, request=request))


@pytest.mark.anyio
async def test_cimd_record_is_restamped_and_its_tokens_and_cached_metadata_dropped_when_prm_names_a_new_issuer(
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
) -> None:
"""SEP-2352 for CIMD: the URL client_id survives an authorization-server change, nothing else does.

A long-lived provider holds a CIMD record stamped with the old issuer, tokens minted there, and
the old server's cached metadata. As soon as PRM names a different issuer, the tokens and the
cached metadata are dropped and the record is re-stamped and persisted, so a failed
rediscovery cannot leave the old endpoints in play and no refresh reaches the new server.
"""
cimd_url = "https://client.example.com/.well-known/mcp-client"
provider = OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
client_metadata_url=cimd_url,
)
provider.context.client_info = OAuthClientInformationFull(
client_id=cimd_url, token_endpoint_auth_method="none", issuer="https://old-as.example.com"
)
provider.context.current_tokens = valid_tokens
provider.context.token_expiry_time = time.time() + 1800
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://old-as.example.com"),
authorization_endpoint=AnyHttpUrl("https://old-as.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://old-as.example.com/token"),
)
provider._initialized = True

auth_flow = provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
request = await auth_flow.__anext__()
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
prm_response = httpx2.Response(
200,
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://new-as.example.com"]}',
request=prm_req,
)
asm_req = await auth_flow.asend(prm_response)

assert str(asm_req.url) == "https://new-as.example.com/.well-known/oauth-authorization-server"
assert provider.context.current_tokens is None
assert provider.context.oauth_metadata is None
assert provider.context.client_info is not None
assert (provider.context.client_info.client_id, provider.context.client_info.issuer) == (
cimd_url,
"https://new-as.example.com",
)
assert mock_storage._client_info is provider.context.client_info

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Asserting mock_storage._client_info is provider.context.client_info reaches into the mock's private attribute and checks object identity, which only passes because set_client_info stores the exact mutated object. Use the public API instead so the test verifies the re-stamped issuer is actually persisted rather than that the same object was assigned: stored = await mock_storage.get_client_info(); assert stored is not None; assert stored.client_id == cimd_url; assert stored.issuer == "https://new-as.example.com".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/client/test_auth.py, line 3332:

<comment>Asserting `mock_storage._client_info is provider.context.client_info` reaches into the mock's private attribute and checks object identity, which only passes because `set_client_info` stores the exact mutated object. Use the public API instead so the test verifies the re-stamped issuer is actually persisted rather than that the same object was assigned: `stored = await mock_storage.get_client_info(); assert stored is not None; assert stored.client_id == cimd_url; assert stored.issuer == "https://new-as.example.com"`.</comment>

<file context>
@@ -3279,3 +3279,55 @@ async def test_expired_token_is_not_refreshed_ahead_of_the_request_before_metada
+        cimd_url,
+        "https://new-as.example.com",
+    )
+    assert mock_storage._client_info is provider.context.client_info
+    await auth_flow.aclose()
</file context>
Suggested change
assert mock_storage._client_info is provider.context.client_info
stored = await mock_storage.get_client_info()
assert stored is not None
assert (stored.client_id, stored.issuer) == (cimd_url, "https://new-as.example.com")

await auth_flow.aclose()
23 changes: 23 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3883,6 +3883,29 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="OAuth is HTTP-only.",
),
"client-auth:refresh:on-401": Requirement(
source="issue:#3250",
behavior=(
"A 401 received while a refresh token is held is answered, after rediscovery, with a "
"refresh_token grant before any interactive authorization, so a client constructed over "
"persisted tokens and client registration recovers from an expired access token headlessly."
),
transports=("streamable-http",),
note="OAuth is HTTP-only. RFC 6749 §1.5 (E)-(H); matches the TypeScript, C# and Rust SDKs.",
),
"client-auth:refresh:discovered-endpoint": Requirement(
source="issue:#3240",
behavior=(
"A refresh in a process that has not yet discovered the authorization server happens only after "
"protected-resource and authorization-server metadata discovery and posts to the token endpoint "
"that metadata advertises."
),
transports=("streamable-http",),
note=(
"OAuth is HTTP-only. When discovery yields no AS metadata at all, the 2025-03-26 origin-derived "
"fallback endpoint is still used, as it is for the authorization itself."
),
),
"client-auth:resource-parameter": Requirement(
source=f"{SPEC_BASE_URL}/basic/authorization#resource-parameter-implementation",
behavior=(
Expand Down
56 changes: 55 additions & 1 deletion tests/interaction/auth/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@
from mcp.server import Server
from mcp.server.auth.provider import AccessToken, ProviderTokenVerifier
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
OAuthToken,
ProtectedResourceMetadata,
)
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION
from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider
from tests.interaction.transports._bridge import StreamingASGITransport
Expand Down Expand Up @@ -273,6 +280,53 @@ def shim(
return lambda app: shimmed_app(app, not_found=not_found, serve=serve)


def path_prefixed_as_shim(prefix: str) -> AppShim:
"""Build an `app_shim` that presents the co-hosted authorization server as living under `prefix`.

The SDK server mounts `/authorize`, `/token` and `/register` at the origin root whatever the
issuer, so an AS whose endpoints sit under a path cannot be configured natively. This serves
PRM naming `{BASE_URL}{prefix}` as the AS, serves that issuer's metadata at the RFC 8414
path-inserted well-known URL with every endpoint under the prefix, forwards `{prefix}/x` to the
real `/x`, and 404s the bare root endpoints and root metadata so a client guessing origin-root
paths fails as it would against such a server. Pair with
`InMemoryAuthorizationServerProvider(issuer=f"{BASE_URL}{prefix}")` so the redirect `iss` matches.
"""
issuer = f"{BASE_URL}{prefix}"
prm = ProtectedResourceMetadata(resource=AnyHttpUrl(f"{BASE_URL}/mcp"), authorization_servers=[AnyHttpUrl(issuer)])
asm = OAuthMetadata(
issuer=AnyHttpUrl(issuer),
authorization_endpoint=AnyHttpUrl(f"{issuer}/authorize"),
token_endpoint=AnyHttpUrl(f"{issuer}/token"),
registration_endpoint=AnyHttpUrl(f"{issuer}/register"),
scopes_supported=["mcp"],
response_types_supported=["code"],
grant_types_supported=["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic", "none"],
code_challenge_methods_supported=["S256"],
)

def factory(app: ASGIApp) -> ASGIApp:
inner = shimmed_app(
app,
not_found=frozenset({"/token", "/authorize", "/register", "/.well-known/oauth-authorization-server"}),
serve={
"/.well-known/oauth-protected-resource/mcp": metadata_body(prm),
f"/.well-known/oauth-authorization-server{prefix}": metadata_body(asm),
},
)

async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope["path"].startswith(f"{prefix}/"):
path = scope["path"][len(prefix) :]
await app({**scope, "path": path, "raw_path": path.encode()}, receive, send)
return
await inner(scope, receive, send)

return wrapped

return factory


@dataclass
class _FirstChallenge:
"""ASGI shim that answers the first request to a path with 401 + a given WWW-Authenticate.
Expand Down
4 changes: 4 additions & 0 deletions tests/interaction/auth/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str
)
return access

def expire_access_token(self, token: str) -> None:
"""Move an issued access token's server-side expiry into the past so the bearer middleware 401s it."""
self.access_tokens[token] = self.access_tokens[token].model_copy(update={"expires_at": int(time.time()) - 1})

async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)

Expand Down
Loading
Loading