Skip to content

Commit fa829de

Browse files
committed
Add subject and claims to AccessToken
Adds optional `subject` and `claims` fields to `AccessToken` so token verifiers can surface the resource owner (`sub`) and any additional claims to request handlers. `subject` is also added to `AuthorizationCode` and `RefreshToken` so the value can be carried through code-for-token exchange and refresh. The simple-auth example threads the subject from login through the introspection response and back into the verifier; the integration test exercises the full code → token → refresh → load chain. v1.x backport of #2686. Closes #1038. Reported-by: Thomas Steinacher <@thomasst> Reported-by: Yukuan Jia <@yukuanj> Reported-by: Shivam Aggarwal <@shivama205>
1 parent 9773a3f commit fa829de

6 files changed

Lines changed: 27 additions & 2 deletions

File tree

examples/servers/simple-auth/mcp_simple_auth/auth_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ async def introspect_handler(request: Request) -> Response:
123123
"iat": int(time.time()),
124124
"token_type": "Bearer",
125125
"aud": access_token.resource, # RFC 8707 audience claim
126+
"sub": access_token.subject, # RFC 7662 subject
127+
"iss": str(server_settings.server_url),
126128
}
127129
)
128130

examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ async def handle_simple_callback(self, username: str, password: str, state: str)
186186
scopes=[self.settings.mcp_scope],
187187
code_challenge=code_challenge,
188188
resource=resource, # RFC 8707
189+
subject=username,
189190
)
190191
self.auth_codes[new_code] = auth_code
191192

@@ -224,6 +225,7 @@ async def exchange_authorization_code(
224225
scopes=authorization_code.scopes,
225226
expires_at=int(time.time()) + 3600,
226227
resource=authorization_code.resource, # RFC 8707
228+
subject=authorization_code.subject,
227229
)
228230

229231
# Store user data mapping for this token

examples/servers/simple-auth/mcp_simple_auth/token_verifier.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ async def verify_token(self, token: str) -> AccessToken | None:
7575
scopes=data.get("scope", "").split() if data.get("scope") else [],
7676
expires_at=data.get("exp"),
7777
resource=data.get("aud"), # Include resource in token
78+
subject=data.get("sub"), # RFC 7662 subject (resource owner)
79+
claims=data,
7880
)
7981
except Exception as e:
8082
logger.warning(f"Token introspection failed: {e}")

src/mcp/server/auth/provider.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from dataclasses import dataclass
2-
from typing import Generic, Literal, Protocol, TypeVar
2+
from typing import Any, Generic, Literal, Protocol, TypeVar
33
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
44

55
from pydantic import AnyUrl, BaseModel
@@ -25,13 +25,15 @@ class AuthorizationCode(BaseModel):
2525
redirect_uri: AnyUrl
2626
redirect_uri_provided_explicitly: bool
2727
resource: str | None = None # RFC 8707 resource indicator
28+
subject: str | None = None # resource owner; propagate to the issued AccessToken
2829

2930

3031
class RefreshToken(BaseModel):
3132
token: str
3233
client_id: str
3334
scopes: list[str]
3435
expires_at: int | None = None
36+
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
3537

3638

3739
class AccessToken(BaseModel):
@@ -40,6 +42,8 @@ class AccessToken(BaseModel):
4042
scopes: list[str]
4143
expires_at: int | None = None
4244
resource: str | None = None # RFC 8707 resource indicator
45+
subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer
46+
claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`)
4347

4448

4549
RegistrationErrorCode = Literal[

src/mcp/server/fastmcp/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1282,9 +1282,14 @@ async def log(
12821282
related_request_id=self.request_id,
12831283
)
12841284

1285+
# TODO(maxisbey): see if this is needed otherwise remove
12851286
@property
12861287
def client_id(self) -> str | None:
1287-
"""Get the client ID if available."""
1288+
"""Get the client ID if available.
1289+
1290+
Note: this reads from the MCP request's `_meta` params, not the OAuth
1291+
bearer token. For that, use `get_access_token().client_id`.
1292+
"""
12881293
return (
12891294
getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None
12901295
) # pragma: no cover

tests/server/fastmcp/auth/test_auth_integration.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat
5454
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
5555
expires_at=time.time() + 300,
5656
scopes=params.scopes or ["read", "write"],
57+
subject="test-user",
5758
)
5859
self.auth_codes[code.code] = code
5960

@@ -80,6 +81,7 @@ async def exchange_authorization_code(
8081
client_id=client.client_id,
8182
scopes=authorization_code.scopes,
8283
expires_at=int(time.time()) + 3600,
84+
subject=authorization_code.subject,
8385
)
8486

8587
self.refresh_tokens[refresh_token] = access_token
@@ -109,6 +111,7 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t
109111
client_id=token_info.client_id,
110112
scopes=token_info.scopes,
111113
expires_at=token_info.expires_at,
114+
subject=token_info.subject,
112115
)
113116

114117
return refresh_obj
@@ -142,6 +145,7 @@ async def exchange_refresh_token(
142145
client_id=client.client_id,
143146
scopes=scopes or token_info.scopes,
144147
expires_at=int(time.time()) + 3600,
148+
subject=refresh_token.subject,
145149
)
146150

147151
self.refresh_tokens[new_refresh_token] = new_access_token
@@ -170,6 +174,7 @@ async def load_access_token(self, token: str) -> AccessToken | None:
170174
client_id=token_info.client_id,
171175
scopes=token_info.scopes,
172176
expires_at=token_info.expires_at,
177+
subject=token_info.subject,
173178
)
174179

175180
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
@@ -783,6 +788,7 @@ async def test_authorization_get(
783788
assert auth_info.client_id == client_info["client_id"]
784789
assert "read" in auth_info.scopes
785790
assert "write" in auth_info.scopes
791+
assert auth_info.subject == "test-user"
786792

787793
# 6. Refresh the token
788794
response = await test_client.post(
@@ -803,6 +809,10 @@ async def test_authorization_get(
803809
assert new_token_response["access_token"] != access_token
804810
assert new_token_response["refresh_token"] != refresh_token
805811

812+
refreshed_auth_info = await mock_oauth_provider.load_access_token(new_token_response["access_token"])
813+
assert refreshed_auth_info
814+
assert refreshed_auth_info.subject == "test-user"
815+
806816
# 7. Revoke the token
807817
response = await test_client.post(
808818
"/revoke",

0 commit comments

Comments
 (0)