Skip to content

Commit 205158c

Browse files
gao-sunCopilot
andauthored
refactor: loose client id requirement and accept azp (#23)
* refactor: loose client id requirement and accept azp * refactor: add comments to `client_id` field * refactor: apply suggestions from code review Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent bab801b commit 205158c

File tree

3 files changed

+78
-22
lines changed

3 files changed

+78
-22
lines changed

mcpauth/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ class AuthInfo(BaseModel):
3535
registered with the OAuth / OIDC provider.
3636
3737
Some providers may use 'application ID' or similar terms instead of 'client ID'.
38+
39+
Note:
40+
This value accept either `client_id` (RFC 9068) or `azp` claim for better compatibility.
41+
While `client_id` is required by RFC 9068 for JWT access tokens, many providers (Auth0,
42+
Microsoft, Google) may use or support `azp` claim.
43+
44+
See Also:
45+
https://github.com/mcp-auth/js/issues/28 for detailed discussion
3846
"""
3947

4048
scopes: List[str] = []
@@ -139,14 +147,21 @@ class JwtPayload(BaseModel):
139147
- https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier
140148
"""
141149

142-
client_id: NonEmptyString
150+
client_id: Optional[str] = None
143151
"""
144152
The client ID of the OAuth client that the token was issued to. This is typically the client ID
145153
registered with the OAuth / OIDC provider.
146154
147155
Some providers may use 'application ID' or similar terms instead of 'client ID'.
148156
"""
149157

158+
azp: Optional[str] = None
159+
"""
160+
The `azp` (authorized party) claim of the token, which indicates the client ID of the party
161+
that authorized the request. Many providers use this claim to indicate the client ID of the
162+
application instead of `client_id`.
163+
"""
164+
150165
sub: NonEmptyString
151166
"""
152167
The `sub` (subject) claim of the token, which typically represents the user ID or principal

mcpauth/utils/_create_verify_jwt.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ def verify_jwt(token: str) -> AuthInfo:
6060
return AuthInfo(
6161
token=token,
6262
issuer=base_model.iss,
63-
client_id=base_model.client_id,
63+
client_id=(
64+
base_model.client_id
65+
if base_model.client_id is not None
66+
else base_model.azp
67+
),
6468
subject=base_model.sub,
6569
audience=base_model.aud,
6670
scopes=(scopes.split(" ") if isinstance(scopes, str) else scopes) or [],

tests/utils/create_verify_jwt_test.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -79,29 +79,16 @@ def test_should_throw_error_if_jwt_payload_missing_iss(self):
7979
== MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN
8080
)
8181

82-
def test_should_throw_error_if_jwt_payload_missing_client_id(self):
83-
# Test different invalid JWT payloads
84-
jwt_missing_client_id = create_jwt(
85-
{"iss": "https://logto.io/", "sub": "user12345"}
86-
)
87-
jwt_invalid_client_id_type = create_jwt(
82+
def test_should_throw_error_if_client_id_is_not_string(self):
83+
token = create_jwt(
8884
{"iss": "https://logto.io/", "client_id": 12345, "sub": "user12345"}
8985
)
90-
jwt_empty_client_id = create_jwt(
91-
{"iss": "https://logto.io/", "client_id": "", "sub": "user12345"}
92-
)
9386

94-
for token in [
95-
jwt_missing_client_id,
96-
jwt_invalid_client_id_type,
97-
jwt_empty_client_id,
98-
]:
99-
with pytest.raises(MCPAuthTokenVerificationException) as exc_info:
100-
verify_jwt(token)
101-
assert (
102-
exc_info.value.code
103-
== MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN
104-
)
87+
with pytest.raises(MCPAuthTokenVerificationException) as exc_info:
88+
verify_jwt(token)
89+
assert (
90+
exc_info.value.code == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN
91+
)
10592

10693
def test_should_throw_error_if_jwt_payload_missing_sub(self):
10794
# Test different invalid JWT payloads
@@ -226,3 +213,53 @@ def test_should_return_verified_jwt_payload_without_scopes(self):
226213
assert result.subject == claims["sub"]
227214
assert result.audience == claims["aud"]
228215
assert result.scopes == []
216+
217+
def test_should_return_verified_jwt_payload_without_client_id(self):
218+
# Create JWT without client_id
219+
claims = {
220+
"iss": "https://logto.io/",
221+
"sub": "user12345",
222+
"aud": "audience12345",
223+
}
224+
jwt_token = create_jwt(claims)
225+
226+
# Verify
227+
result = verify_jwt(jwt_token)
228+
229+
# Assertions
230+
assert result.issuer == claims["iss"]
231+
assert result.client_id is None
232+
assert result.subject == claims["sub"]
233+
assert result.audience == claims["aud"]
234+
assert result.scopes == []
235+
236+
# Empty client_id should not raise an error
237+
claims["client_id"] = ""
238+
claims["azp"] = "client12345" # Should be ignored if client_id is a string
239+
jwt_token = create_jwt(claims)
240+
result = verify_jwt(jwt_token)
241+
assert result.issuer == claims["iss"]
242+
assert result.client_id == ""
243+
assert result.subject == claims["sub"]
244+
assert result.audience == claims["aud"]
245+
assert result.scopes == []
246+
247+
def test_should_fall_back_to_azp_if_client_id_is_missing(self):
248+
# Create JWT with azp instead of client_id
249+
claims = {
250+
"iss": "https://logto.io/",
251+
"azp": "client12345",
252+
"sub": "user12345",
253+
"aud": "audience12345",
254+
}
255+
jwt_token = create_jwt(claims)
256+
257+
# Verify
258+
result = verify_jwt(jwt_token)
259+
260+
# Assertions
261+
assert result.issuer == claims["iss"]
262+
assert result.client_id == claims["azp"]
263+
assert result.subject == claims["sub"]
264+
assert result.audience == claims["aud"]
265+
assert result.scopes == []

0 commit comments

Comments
 (0)