mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
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:
parent
b720fc5e38
commit
3bea192c8c
1 changed files with 28 additions and 9 deletions
|
|
@ -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),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue