fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)

* fix: use IdP-granted scopes instead of client-requested scopes in OAuthProxy

* fix: use parse_scopes instead of split for IdP scope strings

Some providers (e.g. GitHub) return comma-delimited scopes like
"repo,gist" rather than the RFC 6749 space-delimited format.

* Merge origin/main into fix/oauth-proxy-use-idp-granted-scopes

* fix: remove unrelated transform and http.py changes from PR scope

* fix: remove accidentally staged worktree directories
This commit is contained in:
Jeremiah Lowin 2026-03-15 11:29:58 -04:00 committed by GitHub
commit 3bea192c8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -86,6 +86,7 @@ from fastmcp.server.auth.oauth_proxy.models import (
_hash_token,
)
from fastmcp.server.auth.oauth_proxy.ui import create_error_html
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
@ -930,6 +931,16 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
# Get stored upstream tokens
idp_tokens = code_model.idp_tokens
# Use IdP-granted scopes when available (RFC 6749 §5.1: the IdP MUST
# include a scope parameter when the granted scope differs from the
# requested scope). Fall back to requested scopes only when the IdP
# omits scope, meaning it granted exactly what was requested.
granted_scopes: list[str] = (
parse_scopes(idp_tokens["scope"]) or []
if "scope" in idp_tokens
else list(authorization_code.scopes)
)
# Clean up client code (one-time use)
await self._code_store.delete(key=authorization_code.code)
@ -996,7 +1007,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
refresh_token_expires_at=refresh_token_expires_at,
expires_at=time.time() + expires_in,
token_type=idp_tokens.get("token_type", "Bearer"),
scope=" ".join(authorization_code.scopes),
scope=" ".join(granted_scopes),
client_id=client.client_id or "",
created_at=time.time(),
raw_token_data=idp_tokens,
@ -1018,7 +1029,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
raise TokenError("invalid_client", "Client ID is required")
fastmcp_access_token = self.jwt_issuer.issue_access_token(
client_id=client.client_id,
scopes=authorization_code.scopes,
scopes=granted_scopes,
jti=access_jti,
expires_in=expires_in,
upstream_claims=upstream_claims,
@ -1030,7 +1041,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
if refresh_jti and refresh_expires_in:
fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token(
client_id=client.client_id,
scopes=authorization_code.scopes,
scopes=granted_scopes,
jti=refresh_jti,
expires_in=refresh_expires_in,
upstream_claims=upstream_claims,
@ -1063,7 +1074,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
key=_hash_token(fastmcp_refresh_token),
value=RefreshTokenMetadata(
client_id=client.client_id,
scopes=authorization_code.scopes,
scopes=granted_scopes,
expires_at=int(time.time()) + refresh_expires_in,
created_at=time.time(),
),
@ -1083,7 +1094,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_type="Bearer",
expires_in=expires_in,
refresh_token=fastmcp_refresh_token,
scope=" ".join(authorization_code.scopes),
scope=" ".join(granted_scopes),
)
# -------------------------------------------------------------------------
@ -1265,6 +1276,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
upstream_token_set.access_token = token_response["access_token"]
upstream_token_set.expires_at = time.time() + new_expires_in
# Prefer IdP-granted scopes from refresh response (RFC 6749 §5.1)
refreshed_scopes: list[str] = (
parse_scopes(token_response["scope"]) or []
if "scope" in token_response
else scopes
)
upstream_token_set.scope = " ".join(refreshed_scopes)
# Handle upstream refresh token rotation and expiry
new_refresh_expires_in = None
if new_upstream_refresh := token_response.get("refresh_token"):
@ -1323,7 +1342,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
new_access_jti = secrets.token_urlsafe(32)
new_fastmcp_access = self.jwt_issuer.issue_access_token(
client_id=client.client_id,
scopes=scopes,
scopes=refreshed_scopes,
jti=new_access_jti,
expires_in=new_expires_in,
upstream_claims=upstream_claims,
@ -1345,7 +1364,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
new_refresh_jti = secrets.token_urlsafe(32)
new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
client_id=client.client_id,
scopes=scopes,
scopes=refreshed_scopes,
jti=new_refresh_jti,
expires_in=new_refresh_expires_in
or 60 * 60 * 24 * 30, # Fallback to 30 days
@ -1375,7 +1394,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
key=_hash_token(new_fastmcp_refresh),
value=RefreshTokenMetadata(
client_id=client.client_id,
scopes=scopes,
scopes=refreshed_scopes,
expires_at=int(time.time()) + refresh_ttl,
created_at=time.time(),
),
@ -1398,7 +1417,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_type="Bearer",
expires_in=new_expires_in,
refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated)
scope=" ".join(scopes),
scope=" ".join(refreshed_scopes),
)
# -------------------------------------------------------------------------