Skip to content

Commit 4185d1c

Browse files
committed
Scope down to model fields and add subject to auth integration test
- Drop Context.subject/Context.claims; handlers use get_access_token() directly for now. A designed Context auth surface can follow separately. - Collapse AccessToken.subject/claims docstrings to one-line comments to match surrounding fields. - Move the client_id TODO to a # comment and tighten its docstring note. - Replace the trivial model and contextvar unit tests with subject propagation through MockOAuthProvider in test_auth_integration.py, so test_authorization_get now asserts subject survives the full /authorize -> /token -> refresh flow over HTTP.
1 parent 7e542e9 commit 4185d1c

5 files changed

Lines changed: 16 additions & 147 deletions

File tree

src/mcp/server/auth/provider.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,8 @@ class AccessToken(BaseModel):
4242
scopes: list[str]
4343
expires_at: int | None = None
4444
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-
For `client_credentials` grants there is no end-user; `sub` may then
51-
identify the client itself (RFC 9068 §2.2) or be absent, depending on the
52-
authorization server."""
53-
claims: dict[str, Any] | None = None
54-
"""Additional verified claims (e.g. `iss`, `act`) for request handlers."""
45+
subject: str | None = None # RFC 7662/9068 `sub`: resource owner the token was issued for
46+
claims: dict[str, Any] | None = None # additional verified claims (e.g. `iss`, `act`)
5547

5648

5749
RegistrationErrorCode = Literal[

src/mcp/server/mcpserver/context.py

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

66
from pydantic import AnyUrl, BaseModel
77

8-
from mcp.server.auth.middleware.auth_context import get_access_token
98
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
109
from mcp.server.elicitation import (
1110
ElicitationResult,
@@ -209,42 +208,16 @@ async def log(
209208
related_request_id=self.request_id,
210209
)
211210

211+
# TODO(maxisbey): see if this is needed otherwise remove
212212
@property
213213
def client_id(self) -> str | None:
214214
"""Get the client ID if available.
215215
216-
Note: this reads from the MCP request's `_meta` params, not from the
217-
OAuth bearer token. It is unrelated to `subject` and `claims` below,
218-
which come from the authenticated `AccessToken`. For the OAuth
219-
`client_id`, use `get_access_token().client_id`.
220-
221-
TODO(maxisbey): see if this is needed otherwise remove
216+
Note: this reads from the MCP request's `_meta` params, not the OAuth
217+
bearer token. For that, use `get_access_token().client_id`.
222218
"""
223219
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
224220

225-
@property
226-
def subject(self) -> str | None:
227-
"""The authenticated resource owner (`sub`) for this request, if any.
228-
229-
Returns `AccessToken.subject` from the bearer token that authenticated
230-
the current request, or `None` when the request is unauthenticated or
231-
the token verifier did not populate a subject.
232-
"""
233-
token = get_access_token()
234-
return token.subject if token is not None else None
235-
236-
@property
237-
def claims(self) -> dict[str, Any] | None:
238-
"""Additional verified claims from the bearer token, if any.
239-
240-
Returns `AccessToken.claims` for the current request so handlers can
241-
read values like `iss` or `act` without importing `get_access_token`
242-
and handling the raw token directly. `None` when unauthenticated or
243-
when the token verifier did not populate claims.
244-
"""
245-
token = get_access_token()
246-
return token.claims if token is not None else None
247-
248221
@property
249222
def request_id(self) -> str:
250223
"""Get the unique ID for this request."""

tests/server/auth/test_provider.py

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

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"
3+
from mcp.server.auth.provider import construct_redirect_uri
444

455

466
def test_construct_redirect_uri_no_existing_params():

tests/server/mcpserver/auth/test_auth_integration.py

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

@@ -79,6 +80,7 @@ async def exchange_authorization_code(
7980
client_id=client.client_id,
8081
scopes=authorization_code.scopes,
8182
expires_at=int(time.time()) + 3600,
83+
subject=authorization_code.subject,
8284
)
8385

8486
self.refresh_tokens[refresh_token] = access_token
@@ -108,6 +110,7 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t
108110
client_id=token_info.client_id,
109111
scopes=token_info.scopes,
110112
expires_at=token_info.expires_at,
113+
subject=token_info.subject,
111114
)
112115

113116
return refresh_obj
@@ -141,6 +144,7 @@ async def exchange_refresh_token(
141144
client_id=client.client_id,
142145
scopes=scopes or token_info.scopes,
143146
expires_at=int(time.time()) + 3600,
147+
subject=refresh_token.subject,
144148
)
145149

146150
self.refresh_tokens[new_refresh_token] = new_access_token
@@ -169,6 +173,7 @@ async def load_access_token(self, token: str) -> AccessToken | None:
169173
client_id=token_info.client_id,
170174
scopes=token_info.scopes,
171175
expires_at=token_info.expires_at,
176+
subject=token_info.subject,
172177
)
173178

174179
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
@@ -832,6 +837,7 @@ async def test_authorization_get(
832837
assert auth_info.client_id == client_info["client_id"]
833838
assert "read" in auth_info.scopes
834839
assert "write" in auth_info.scopes
840+
assert auth_info.subject == "test-user"
835841

836842
# 6. Refresh the token
837843
response = await test_client.post(
@@ -852,6 +858,10 @@ async def test_authorization_get(
852858
assert new_token_response["access_token"] != access_token
853859
assert new_token_response["refresh_token"] != refresh_token
854860

861+
refreshed_auth_info = await mock_oauth_provider.load_access_token(new_token_response["access_token"])
862+
assert refreshed_auth_info
863+
assert refreshed_auth_info.subject == "test-user"
864+
855865
# 7. Revoke the token
856866
response = await test_client.post(
857867
"/revoke",

tests/server/mcpserver/auth/test_context_subject.py

Lines changed: 0 additions & 66 deletions
This file was deleted.

0 commit comments

Comments
 (0)