Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion mcpauth/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -139,14 +147,21 @@ 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.

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
Expand Down
6 changes: 5 additions & 1 deletion mcpauth/utils/_create_verify_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [],
Expand Down
77 changes: 57 additions & 20 deletions tests/utils/create_verify_jwt_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == []