From 573d6f24982f24d7b26b540aec2e269a40257a24 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 21 May 2025 18:16:13 -0700 Subject: [PATCH 1/3] refactor: loose client id requirement and accept azp --- mcpauth/types.py | 9 +++- mcpauth/utils/_create_verify_jwt.py | 6 ++- tests/utils/create_verify_jwt_test.py | 77 ++++++++++++++++++++------- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/mcpauth/types.py b/mcpauth/types.py index b1844dd..5ef48ba 100644 --- a/mcpauth/types.py +++ b/mcpauth/types.py @@ -139,7 +139,7 @@ class JwtPayload(BaseModel): - https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier """ - client_id: NonEmptyString + client_id: Optional[str] = None """ The client ID of the OAuth client that the token was issued to. This is typically the client ID registered with the OAuth / OIDC provider. @@ -147,6 +147,13 @@ class JwtPayload(BaseModel): Some providers may use 'application ID' or similar terms instead of 'client ID'. """ + azp: Optional[str] = None + """ + The `azp` (authorized party) claim of the token, which indicates the client ID of the party + that authorized the request. Many providers use this claim to indicate the client ID of the + application instead of `client_id`. + """ + sub: NonEmptyString """ The `sub` (subject) claim of the token, which typically represents the user ID or principal diff --git a/mcpauth/utils/_create_verify_jwt.py b/mcpauth/utils/_create_verify_jwt.py index 2680d62..ce47164 100644 --- a/mcpauth/utils/_create_verify_jwt.py +++ b/mcpauth/utils/_create_verify_jwt.py @@ -60,7 +60,11 @@ def verify_jwt(token: str) -> AuthInfo: return AuthInfo( token=token, issuer=base_model.iss, - client_id=base_model.client_id, + client_id=( + base_model.client_id + if base_model.client_id is not None + else base_model.azp + ), subject=base_model.sub, audience=base_model.aud, scopes=(scopes.split(" ") if isinstance(scopes, str) else scopes) or [], diff --git a/tests/utils/create_verify_jwt_test.py b/tests/utils/create_verify_jwt_test.py index a78f7c7..95fbdf0 100644 --- a/tests/utils/create_verify_jwt_test.py +++ b/tests/utils/create_verify_jwt_test.py @@ -79,29 +79,16 @@ def test_should_throw_error_if_jwt_payload_missing_iss(self): == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) - def test_should_throw_error_if_jwt_payload_missing_client_id(self): - # Test different invalid JWT payloads - jwt_missing_client_id = create_jwt( - {"iss": "https://logto.io/", "sub": "user12345"} - ) - jwt_invalid_client_id_type = create_jwt( + def test_should_throw_error_if_client_id_is_not_string(self): + token = create_jwt( {"iss": "https://logto.io/", "client_id": 12345, "sub": "user12345"} ) - jwt_empty_client_id = create_jwt( - {"iss": "https://logto.io/", "client_id": "", "sub": "user12345"} - ) - for token in [ - jwt_missing_client_id, - jwt_invalid_client_id_type, - jwt_empty_client_id, - ]: - with pytest.raises(MCPAuthTokenVerificationException) as exc_info: - verify_jwt(token) - assert ( - exc_info.value.code - == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN - ) + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: + verify_jwt(token) + assert ( + exc_info.value.code == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN + ) def test_should_throw_error_if_jwt_payload_missing_sub(self): # Test different invalid JWT payloads @@ -226,3 +213,53 @@ def test_should_return_verified_jwt_payload_without_scopes(self): assert result.subject == claims["sub"] assert result.audience == claims["aud"] assert result.scopes == [] + + def test_should_return_verified_jwt_payload_without_client_id(self): + # Create JWT without client_id + claims = { + "iss": "https://logto.io/", + "sub": "user12345", + "aud": "audience12345", + } + jwt_token = create_jwt(claims) + + # Verify + result = verify_jwt(jwt_token) + + # Assertions + assert result.issuer == claims["iss"] + assert result.client_id is None + assert result.subject == claims["sub"] + assert result.audience == claims["aud"] + assert result.scopes == [] + + # Empty client_id should not raise an error + claims["client_id"] = "" + claims["azp"] = "client12345" # Should be ignored if client_id is a string + jwt_token = create_jwt(claims) + result = verify_jwt(jwt_token) + assert result.issuer == claims["iss"] + assert result.client_id is "" + assert result.subject == claims["sub"] + assert result.audience == claims["aud"] + assert result.scopes == [] + + def test_should_fall_back_to_azp_if_client_id_is_missing(self): + # Create JWT with azp instead of client_id + claims = { + "iss": "https://logto.io/", + "azp": "client12345", + "sub": "user12345", + "aud": "audience12345", + } + jwt_token = create_jwt(claims) + + # Verify + result = verify_jwt(jwt_token) + + # Assertions + assert result.issuer == claims["iss"] + assert result.client_id == claims["azp"] + assert result.subject == claims["sub"] + assert result.audience == claims["aud"] + assert result.scopes == [] From fda692b8dc5d843b935e2fe31cfa9bf6237a5c5a Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 21 May 2025 18:20:31 -0700 Subject: [PATCH 2/3] refactor: add comments to `client_id` field --- mcpauth/types.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mcpauth/types.py b/mcpauth/types.py index 5ef48ba..f69b355 100644 --- a/mcpauth/types.py +++ b/mcpauth/types.py @@ -35,6 +35,14 @@ class AuthInfo(BaseModel): registered with the OAuth / OIDC provider. Some providers may use 'application ID' or similar terms instead of 'client ID'. + + Note: + This value accept either `client_id` (RFC 9068) or `azp` claim for better compatibility. + While `client_id` is required by RFC 9068 for JWT access tokens, many providers (Auth0, + Microsoft, Google) may use or support `azp` claim. + + See Also: + https://github.com/mcp-auth/js/issues/28 for detailed discussion """ scopes: List[str] = [] From 59e7967708f903ab9946a2cf9b8d8a5e40c530aa Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 21 May 2025 18:26:08 -0700 Subject: [PATCH 3/3] refactor: apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/utils/create_verify_jwt_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/create_verify_jwt_test.py b/tests/utils/create_verify_jwt_test.py index 95fbdf0..df43d30 100644 --- a/tests/utils/create_verify_jwt_test.py +++ b/tests/utils/create_verify_jwt_test.py @@ -239,7 +239,7 @@ def test_should_return_verified_jwt_payload_without_client_id(self): jwt_token = create_jwt(claims) result = verify_jwt(jwt_token) assert result.issuer == claims["iss"] - assert result.client_id is "" + assert result.client_id == "" assert result.subject == claims["sub"] assert result.audience == claims["aud"] assert result.scopes == []