[v1.x] Add subject and claims to AccessToken (#2690)

This commit is contained in:
Max 2026-05-26 15:49:44 +01:00 • committed by GitHub
commit 1abcca2408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 27 additions and 2 deletions

View file

@ -123,6 +123,8 @@ def create_authorization_server(server_settings: AuthServerSettings, auth_settin
"iat": int(time.time()), "iat": int(time.time()),
"token_type": "Bearer", "token_type": "Bearer",
"aud": access_token.resource, # RFC 8707 audience claim "aud": access_token.resource, # RFC 8707 audience claim
"sub": access_token.subject, # RFC 7662 subject
"iss": str(server_settings.server_url),
} }
) )

View file

@ -186,6 +186,7 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Re
scopes=[self.settings.mcp_scope], scopes=[self.settings.mcp_scope],
code_challenge=code_challenge, code_challenge=code_challenge,
resource=resource, # RFC 8707 resource=resource, # RFC 8707
subject=username,
) )
self.auth_codes[new_code] = auth_code self.auth_codes[new_code] = auth_code
@ -224,6 +225,7 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Re
scopes=authorization_code.scopes, scopes=authorization_code.scopes,
expires_at=int(time.time()) + 3600, expires_at=int(time.time()) + 3600,
resource=authorization_code.resource, # RFC 8707 resource=authorization_code.resource, # RFC 8707
subject=authorization_code.subject,
) )
# Store user data mapping for this token # Store user data mapping for this token

View file

@ -75,6 +75,8 @@ class IntrospectionTokenVerifier(TokenVerifier):
scopes=data.get("scope", "").split() if data.get("scope") else [], scopes=data.get("scope", "").split() if data.get("scope") else [],
expires_at=data.get("exp"), expires_at=data.get("exp"),
resource=data.get("aud"), # Include resource in token resource=data.get("aud"), # Include resource in token
subject=data.get("sub"), # RFC 7662 subject (resource owner)
claims=data,
) )
except Exception as e: except Exception as e:
logger.warning(f"Token introspection failed: {e}") logger.warning(f"Token introspection failed: {e}")

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generic, Literal, Protocol, TypeVar from typing import Any, Generic, Literal, Protocol, TypeVar
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from pydantic import AnyUrl, BaseModel from pydantic import AnyUrl, BaseModel
@ -25,6 +25,7 @@ class AuthorizationCode(BaseModel):
redirect_uri: AnyUrl redirect_uri: AnyUrl
redirect_uri_provided_explicitly: bool redirect_uri_provided_explicitly: bool
resource: str | None = None # RFC 8707 resource indicator resource: str | None = None # RFC 8707 resource indicator
subject: str | None = None # resource owner; propagate to the issued AccessToken
class RefreshToken(BaseModel): class RefreshToken(BaseModel):
@ -32,6 +33,7 @@ class RefreshToken(BaseModel):
client_id: str client_id: str
scopes: list[str] scopes: list[str]
expires_at: int | None = None expires_at: int | None = None
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
class AccessToken(BaseModel): class AccessToken(BaseModel):
@ -40,6 +42,8 @@ class AccessToken(BaseModel):
scopes: list[str] scopes: list[str]
expires_at: int | None = None expires_at: int | None = None
resource: str | None = None # RFC 8707 resource indicator resource: str | None = None # RFC 8707 resource indicator
subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer
claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`)
RegistrationErrorCode = Literal[ RegistrationErrorCode = Literal[

View file

@ -1282,9 +1282,14 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
related_request_id=self.request_id, related_request_id=self.request_id,
) )
# TODO(maxisbey): see if this is needed otherwise remove
@property @property
def client_id(self) -> str | None: def client_id(self) -> str | None:
"""Get the client ID if available.""" """Get the client ID if available.
Note: this reads from the MCP request's `_meta` params, not the OAuth
bearer token. For that, use `get_access_token().client_id`.
"""
return ( return (
getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None
) # pragma: no cover ) # pragma: no cover

View file

@ -54,6 +54,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
expires_at=time.time() + 300, expires_at=time.time() + 300,
scopes=params.scopes or ["read", "write"], scopes=params.scopes or ["read", "write"],
subject="test-user",
) )
self.auth_codes[code.code] = code self.auth_codes[code.code] = code
@ -80,6 +81,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
client_id=client.client_id, client_id=client.client_id,
scopes=authorization_code.scopes, scopes=authorization_code.scopes,
expires_at=int(time.time()) + 3600, expires_at=int(time.time()) + 3600,
subject=authorization_code.subject,
) )
self.refresh_tokens[refresh_token] = access_token self.refresh_tokens[refresh_token] = access_token
@ -109,6 +111,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
client_id=token_info.client_id, client_id=token_info.client_id,
scopes=token_info.scopes, scopes=token_info.scopes,
expires_at=token_info.expires_at, expires_at=token_info.expires_at,
subject=token_info.subject,
) )
return refresh_obj return refresh_obj
@ -142,6 +145,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
client_id=client.client_id, client_id=client.client_id,
scopes=scopes or token_info.scopes, scopes=scopes or token_info.scopes,
expires_at=int(time.time()) + 3600, expires_at=int(time.time()) + 3600,
subject=refresh_token.subject,
) )
self.refresh_tokens[new_refresh_token] = new_access_token self.refresh_tokens[new_refresh_token] = new_access_token
@ -170,6 +174,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
client_id=token_info.client_id, client_id=token_info.client_id,
scopes=token_info.scopes, scopes=token_info.scopes,
expires_at=token_info.expires_at, expires_at=token_info.expires_at,
subject=token_info.subject,
) )
async def revoke_token(self, token: AccessToken | RefreshToken) -> None: async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
@ -783,6 +788,7 @@ class TestAuthEndpoints:
assert auth_info.client_id == client_info["client_id"] assert auth_info.client_id == client_info["client_id"]
assert "read" in auth_info.scopes assert "read" in auth_info.scopes
assert "write" in auth_info.scopes assert "write" in auth_info.scopes
assert auth_info.subject == "test-user"
# 6. Refresh the token # 6. Refresh the token
response = await test_client.post( response = await test_client.post(
@ -803,6 +809,10 @@ class TestAuthEndpoints:
assert new_token_response["access_token"] != access_token assert new_token_response["access_token"] != access_token
assert new_token_response["refresh_token"] != refresh_token assert new_token_response["refresh_token"] != refresh_token
refreshed_auth_info = await mock_oauth_provider.load_access_token(new_token_response["access_token"])
assert refreshed_auth_info
assert refreshed_auth_info.subject == "test-user"
# 7. Revoke the token # 7. Revoke the token
response = await test_client.post( response = await test_client.post(
"/revoke", "/revoke",