diff --git a/mcpauth/types.py b/mcpauth/types.py index b1844dd..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] = [] @@ -139,7 +147,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 +155,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..df43d30 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 == "" + 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 == []