Skip to content

Commit c4fe4d2

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 token refresh. `Context.subject` exposes the value to tool and resource handlers. The simple-auth example is updated to thread the subject from login through introspection. Closes #1038. Reported-by: Thomas Steinacher <@thomasst> Reported-by: Yukuan Jia <@yukuanj> Reported-by: Shivam Aggarwal <@shivama205>
1 parent e8e6484 commit c4fe4d2

7 files changed

Lines changed: 111 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ async def introspect_handler(request: Request) -> Response:
120120
"iat": int(time.time()),
121121
"token_type": "Bearer",
122122
"aud": access_token.resource, # RFC 8707 audience claim
123+
"sub": access_token.subject, # RFC 7662 subject
123124
}
124125
)
125126

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
@@ -181,6 +181,7 @@ async def handle_simple_callback(self, username: str, password: str, state: str)
181181
scopes=[self.settings.mcp_scope],
182182
code_challenge=code_challenge,
183183
resource=resource, # RFC 8707
184+
subject=username,
184185
)
185186
self.auth_codes[new_code] = auth_code
186187

@@ -219,6 +220,7 @@ async def exchange_authorization_code(
219220
scopes=authorization_code.scopes,
220221
expires_at=int(time.time()) + 3600,
221222
resource=authorization_code.resource, # RFC 8707
223+
subject=authorization_code.subject,
222224
)
223225

224226
# 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: 11 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,14 @@ 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
46+
"""The resource owner this token was issued on behalf of — typically the
47+
`sub` from a JWT (RFC 9068) or introspection response (RFC 7662). Token
48+
verifiers should populate this whenever an end-user is involved so request
49+
handlers and transports can distinguish users that share an OAuth client.
50+
Conventionally unset for `client_credentials` tokens."""
51+
claims: dict[str, Any] | None = None
52+
"""Additional verified claims (e.g. `iss`, `act`) for request handlers."""
4353

4454

4555
RegistrationErrorCode = Literal[

src/mcp/server/mcpserver/context.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic import AnyUrl, BaseModel
77

8+
from mcp.server.auth.middleware.auth_context import get_access_token
89
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
910
from mcp.server.elicitation import (
1011
ElicitationResult,
@@ -213,6 +214,17 @@ def client_id(self) -> str | None:
213214
"""Get the client ID if available."""
214215
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
215216

217+
@property
218+
def subject(self) -> str | None:
219+
"""The authenticated resource owner (`sub`) for this request, if any.
220+
221+
Returns `AccessToken.subject` from the bearer token that authenticated
222+
the current request, or `None` when the request is unauthenticated or
223+
the token verifier did not populate a subject.
224+
"""
225+
token = get_access_token()
226+
return token.subject if token is not None else None
227+
216228
@property
217229
def request_id(self) -> str:
218230
"""Get the unique ID for this request."""

tests/server/auth/test_provider.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
"""Tests for mcp.server.auth.provider module."""
22

3-
from mcp.server.auth.provider import construct_redirect_uri
3+
from pydantic import AnyUrl
4+
5+
from mcp.server.auth.provider import AccessToken, AuthorizationCode, RefreshToken, construct_redirect_uri
6+
7+
8+
def test_access_token_subject_and_claims_default_to_none():
9+
token = AccessToken(token="t", client_id="c", scopes=["read"])
10+
assert token.subject is None
11+
assert token.claims is None
12+
13+
14+
def test_access_token_carries_subject_and_claims():
15+
token = AccessToken(
16+
token="t",
17+
client_id="c",
18+
scopes=["read"],
19+
subject="user-123",
20+
claims={"iss": "https://auth.example.com", "act": {"sub": "gateway"}},
21+
)
22+
assert token.subject == "user-123"
23+
assert token.claims is not None
24+
assert token.claims["iss"] == "https://auth.example.com"
25+
26+
27+
def test_authorization_code_carries_subject():
28+
code = AuthorizationCode(
29+
code="x",
30+
scopes=["read"],
31+
expires_at=0.0,
32+
client_id="c",
33+
code_challenge="cc",
34+
redirect_uri=AnyUrl("https://example.com/cb"),
35+
redirect_uri_provided_explicitly=True,
36+
subject="user-123",
37+
)
38+
assert code.subject == "user-123"
39+
40+
41+
def test_refresh_token_carries_subject():
42+
refresh = RefreshToken(token="r", client_id="c", scopes=["read"], subject="user-123")
43+
assert refresh.subject == "user-123"
444

545

646
def test_construct_redirect_uri_no_existing_params():
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Context.subject reads the resource owner from the request's access token."""
2+
3+
from mcp.server.auth.middleware.auth_context import auth_context_var
4+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
5+
from mcp.server.auth.provider import AccessToken
6+
from mcp.server.mcpserver import Context
7+
8+
9+
def test_subject_is_none_when_unauthenticated():
10+
assert Context().subject is None
11+
12+
13+
def test_subject_is_none_when_token_has_no_subject():
14+
user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[]))
15+
cv_token = auth_context_var.set(user)
16+
try:
17+
assert Context().subject is None
18+
finally:
19+
auth_context_var.reset(cv_token)
20+
21+
22+
def test_subject_reads_from_access_token():
23+
user = AuthenticatedUser(AccessToken(token="t", client_id="c", scopes=[], subject="user-123"))
24+
cv_token = auth_context_var.set(user)
25+
try:
26+
assert Context().subject == "user-123"
27+
finally:
28+
auth_context_var.reset(cv_token)
29+
30+
31+
def test_subject_tracks_current_auth_context():
32+
ctx = Context()
33+
assert ctx.subject is None
34+
35+
alice = AuthenticatedUser(AccessToken(token="a", client_id="c", scopes=[], subject="alice"))
36+
cv_token = auth_context_var.set(alice)
37+
try:
38+
assert ctx.subject == "alice"
39+
finally:
40+
auth_context_var.reset(cv_token)
41+
42+
assert ctx.subject is None

0 commit comments

Comments
 (0)