diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index 5632c8f65..fda590bae 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -1164,6 +1164,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin): error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] error_description="Client ID is required", ) + # Clients may omit `scope` entirely, in which case OAuth lets the + # authorization server apply its configured default. Resolve that default + # once, here, so the transaction records the scopes actually being + # authorized. Every later consumer — the consent screen, the issued + # authorization code, token exchange, and refresh — reads this one value + # instead of deciding for itself whether to substitute required_scopes. + effective_scopes = params.scopes or self.required_scopes or [] + transaction = OAuthTransaction( txn_id=txn_id, client_id=client.client_id, @@ -1171,7 +1179,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin): client_state=params.state or "", code_challenge=params.code_challenge, code_challenge_method=getattr(params, "code_challenge_method", "S256"), - scopes=params.scopes or [], + scopes=effective_scopes, created_at=time.time(), resource=getattr(params, "resource", None), proxy_code_verifier=proxy_code_verifier, diff --git a/tests/server/auth/oauth_proxy/test_authorization.py b/tests/server/auth/oauth_proxy/test_authorization.py index f3272094e..7b67ae934 100644 --- a/tests/server/auth/oauth_proxy/test_authorization.py +++ b/tests/server/auth/oauth_proxy/test_authorization.py @@ -55,6 +55,67 @@ class TestOAuthProxyAuthorization: assert transaction.client_state == "client-state-123" assert transaction.scopes == ["read", "write"] + async def test_authorize_records_configured_scopes_when_client_omits_scope( + self, oauth_proxy + ): + """A client that omits `scope` has the configured default recorded. + + The transaction is the single source of truth for what is being + authorized, so it must hold the effective scopes rather than an empty + list that each downstream consumer patches up on its own. + """ + client = OAuthClientInformationFull.model_validate( + { + "client_id": "test-client", + "client_secret": "test-secret", + "redirect_uris": ["http://localhost:54321/callback"], + "jwt_signing_key": "test-secret", + } + ) + await oauth_proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + scopes=None, + ) + + redirect_url = await oauth_proxy.authorize(client, params) + txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] + + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.scopes == ["read", "write"] + + async def test_authorize_does_not_widen_explicit_client_scopes(self, oauth_proxy): + """An explicit narrow scope request is never widened to the configured set.""" + client = OAuthClientInformationFull.model_validate( + { + "client_id": "test-client", + "client_secret": "test-secret", + "redirect_uris": ["http://localhost:54321/callback"], + "jwt_signing_key": "test-secret", + } + ) + await oauth_proxy.register_client(client) + + params = AuthorizationParams( + redirect_uri=AnyUrl("http://localhost:54321/callback"), + redirect_uri_provided_explicitly=True, + state="client-state-123", + code_challenge="challenge-abc", + scopes=["read"], + ) + + redirect_url = await oauth_proxy.authorize(client, params) + txn_id = parse_qs(urlparse(redirect_url).query)["txn_id"][0] + + transaction = await oauth_proxy._transaction_store.get(key=txn_id) + assert transaction is not None + assert transaction.scopes == ["read"] + class TestOAuthProxyPKCE: """Tests for OAuth proxy PKCE forwarding."""