From 56a127cc90d505d591b95cc716e5c72d9cbc416d Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 17:36:43 +0000 Subject: [PATCH 1/2] feat(keycardai-oauth): add OIDC UserInfo and typed discovery endpoints Types userinfo_endpoint and end_session_endpoint on AuthorizationServerMetadata and adds Client.userinfo()/AsyncClient.userinfo() per OIDC Core 1.0 Section 5.3. Implements keycardai/keycard-sdk-spec#45. Co-Authored-By: Larry Osakwe --- packages/oauth/README.md | 25 ++ .../oauth/src/keycardai/oauth/__init__.py | 5 + packages/oauth/src/keycardai/oauth/client.py | 125 ++++++++++ .../keycardai/oauth/operations/_discovery.py | 2 + .../keycardai/oauth/operations/_userinfo.py | 227 ++++++++++++++++++ .../src/keycardai/oauth/types/__init__.py | 4 + .../oauth/src/keycardai/oauth/types/models.py | 43 ++++ .../oauth/operations/test_discovery.py | 30 +++ .../oauth/operations/test_userinfo.py | 214 +++++++++++++++++ .../tests/keycardai/oauth/test_client.py | 107 +++++++++ 10 files changed, 782 insertions(+) create mode 100644 packages/oauth/src/keycardai/oauth/operations/_userinfo.py create mode 100644 packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py diff --git a/packages/oauth/README.md b/packages/oauth/README.md index 374098e6..f896484f 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -100,6 +100,7 @@ asyncio.run(main()) - **Token Exchange (RFC 8693)** - Exchange tokens for different audiences, scopes, or token types - **Dynamic Client Registration (RFC 7591)** - Register OAuth clients programmatically - **Authorization Server Metadata (RFC 8414)** - Auto-discover server endpoints and capabilities +- **UserInfo (OIDC Core 1.0 Section 5.3)** - Fetch the signed-in user's identity claims - **Bearer Token Support (RFC 6750)** - Standard bearer token handling and utilities - **PKCE Support (RFC 7636)** - Proof Key for Code Exchange for public clients - **Multiple Auth Strategies** - BasicAuth, BearerAuth, and multi-zone authentication @@ -120,6 +121,7 @@ The SDK implements the following OAuth 2.0 specifications: | [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) | Token Introspection | Validate and inspect token metadata | | [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) | Token Revocation | Invalidate access and refresh tokens | | [RFC 9126](https://datatracker.ietf.org/doc/html/rfc9126) | Pushed Authorization Requests | Enhanced authorization request security | +| [OIDC Core 1.0 Section 5.3](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) | UserInfo | Fetch identity claims for the subject of an access token | ## Configuration @@ -393,8 +395,31 @@ with Client("https://oauth.example.com") as client: print(f"Supported grants: {metadata.grant_types_supported}") print(f"Supported scopes: {metadata.scopes_supported}") print(f"PKCE methods: {metadata.code_challenge_methods_supported}") + print(f"UserInfo endpoint: {metadata.userinfo_endpoint}") + print(f"End session endpoint: {metadata.end_session_endpoint}") ``` +### UserInfo (OIDC Core 1.0 Section 5.3) + +Fetch the identity claims for the user an access token was issued to. The +endpoint comes from discovery (`userinfo_endpoint`), and the access token is +sent as a Bearer credential instead of the client's own credentials: + +```python +from keycardai.oauth import Client + +with Client("https://oauth.example.com") as client: + user = client.userinfo(access_token) + + print(f"Subject: {user.sub}") + print(f"Email: {user.claims.get('email')}") + # Every claim the provider returned is preserved in user.claims +``` + +If the server's metadata has no `userinfo_endpoint`, `userinfo()` raises +`ConfigError` without making a request. An expired or revoked token raises +`InvalidTokenError`. + ## Error Handling The SDK provides a structured exception hierarchy with retry guidance. diff --git a/packages/oauth/src/keycardai/oauth/__init__.py b/packages/oauth/src/keycardai/oauth/__init__.py index 94896e91..5e00cde9 100644 --- a/packages/oauth/src/keycardai/oauth/__init__.py +++ b/packages/oauth/src/keycardai/oauth/__init__.py @@ -8,6 +8,7 @@ - RFC 7591: OAuth 2.0 Dynamic Client Registration - RFC 6750: OAuth 2.0 Bearer Token Usage - RFC 8414: OAuth 2.0 Authorization Server Metadata +- OpenID Connect Core 1.0 Section 5.3: UserInfo Example: # Simple usage @@ -56,6 +57,8 @@ Endpoints, TokenExchangeRequest, TokenResponse, + UserInfoRequest, + UserInfoResponse, ) from .types.oauth import ( GrantType, @@ -94,6 +97,8 @@ "ClientCredentialsRequest", "TokenExchangeRequest", "AuthorizationServerMetadata", + "UserInfoRequest", + "UserInfoResponse", # === Authorization === "build_authorize_url", # === OAuth Enums === diff --git a/packages/oauth/src/keycardai/oauth/client.py b/packages/oauth/src/keycardai/oauth/client.py index c0d0bd85..98c4e82b 100644 --- a/packages/oauth/src/keycardai/oauth/client.py +++ b/packages/oauth/src/keycardai/oauth/client.py @@ -39,6 +39,11 @@ exchange_token, exchange_token_async, ) +from .operations._userinfo import ( + fetch_userinfo, + fetch_userinfo_async, + resolve_userinfo_endpoint, +) from .types.models import ( AuthorizationServerMetadata, ClientConfig, @@ -49,6 +54,8 @@ ServerMetadataRequest, TokenExchangeRequest, TokenResponse, + UserInfoRequest, + UserInfoResponse, ) from .types.oauth import ( GrantType, @@ -316,6 +323,7 @@ def __init__( self._client_id = None self._client_secret = None self._discovered_endpoints: Endpoints | None = None + self._discovered_metadata: AuthorizationServerMetadata | None = None @property def base_url(self) -> str: @@ -345,6 +353,7 @@ async def _ensure_initialized(self) -> None: if self.config.enable_metadata_discovery: try: metadata = await self.discover_server_metadata() + self._discovered_metadata = metadata self._discovered_endpoints = resolve_endpoints( self.issuer, self._endpoint_overrides, @@ -631,6 +640,63 @@ async def discover_server_metadata(self, request: ServerMetadataRequest | None = context=context, ) + async def userinfo( + self, + access_token: str, + *, + metadata: AuthorizationServerMetadata | None = None, + timeout: float | None = None, + ) -> UserInfoResponse: + """Fetch the signed-in user's identity claims from the UserInfo endpoint. + + Zone access tokens are authorization-only, so identity claims such as + ``email`` live behind the issuer's ``userinfo_endpoint`` rather than in + the token. The endpoint is resolved from server metadata: metadata the + client already discovered is reused, otherwise discovery runs first. + + Simple usage: + async with AsyncClient("https://zone.keycard.cloud") as client: + user = await client.userinfo(access_token) + print(user.sub, user.claims.get("email")) + + With pre-discovered metadata: + metadata = await client.discover_server_metadata() + user = await client.userinfo(access_token, metadata=metadata) + + Args: + access_token: Access token issued for the signed-in user. + metadata: Optional pre-discovered server metadata. Discovery is + skipped when provided. + timeout: Optional request timeout override. + + Returns: + UserInfoResponse with ``sub`` and all returned claims. + + Raises: + ConfigError: If the metadata has no ``userinfo_endpoint`` + InvalidTokenError: If the access token is not accepted (HTTP 401) + OAuthHttpError: If the UserInfo endpoint returns another non-2xx status + OAuthProtocolError: If the response is not a JSON claims object with ``sub`` + NetworkError: If the network request fails + """ + request = UserInfoRequest(access_token=access_token, timeout=timeout) + + if metadata is None: + await self._ensure_initialized() + metadata = self._discovered_metadata or await self.discover_server_metadata() + + ctx = build_http_context( + endpoint=resolve_userinfo_endpoint(metadata), + transport=self.transport, + auth=self.auth_strategy, + issuer=self.issuer, + user_agent=self.config.user_agent, + custom_headers=self.config.custom_headers, + timeout=timeout or self.config.timeout, + ) + + return await fetch_userinfo_async(request, ctx) + @overload async def exchange_token( self, @@ -1019,6 +1085,7 @@ def __init__( self._client_id = None self._client_secret = None self._discovered_endpoints: Endpoints | None = None + self._discovered_metadata: AuthorizationServerMetadata | None = None @property def base_url(self) -> str: @@ -1048,6 +1115,7 @@ def _ensure_initialized(self) -> None: if self.config.enable_metadata_discovery: try: metadata = self.discover_server_metadata() + self._discovered_metadata = metadata self._discovered_endpoints = resolve_endpoints( self.issuer, self._endpoint_overrides, @@ -1300,6 +1368,63 @@ def discover_server_metadata(self, request: ServerMetadataRequest | None = None, context=context, ) + def userinfo( + self, + access_token: str, + *, + metadata: AuthorizationServerMetadata | None = None, + timeout: float | None = None, + ) -> UserInfoResponse: + """Fetch the signed-in user's identity claims from the UserInfo endpoint. + + Zone access tokens are authorization-only, so identity claims such as + ``email`` live behind the issuer's ``userinfo_endpoint`` rather than in + the token. The endpoint is resolved from server metadata: metadata the + client already discovered is reused, otherwise discovery runs first. + + Simple usage: + with Client("https://zone.keycard.cloud") as client: + user = client.userinfo(access_token) + print(user.sub, user.claims.get("email")) + + With pre-discovered metadata: + metadata = client.discover_server_metadata() + user = client.userinfo(access_token, metadata=metadata) + + Args: + access_token: Access token issued for the signed-in user. + metadata: Optional pre-discovered server metadata. Discovery is + skipped when provided. + timeout: Optional request timeout override. + + Returns: + UserInfoResponse with ``sub`` and all returned claims. + + Raises: + ConfigError: If the metadata has no ``userinfo_endpoint`` + InvalidTokenError: If the access token is not accepted (HTTP 401) + OAuthHttpError: If the UserInfo endpoint returns another non-2xx status + OAuthProtocolError: If the response is not a JSON claims object with ``sub`` + NetworkError: If the network request fails + """ + request = UserInfoRequest(access_token=access_token, timeout=timeout) + + if metadata is None: + self._ensure_initialized() + metadata = self._discovered_metadata or self.discover_server_metadata() + + ctx = build_http_context( + endpoint=resolve_userinfo_endpoint(metadata), + transport=self.transport, + auth=self.auth_strategy, + issuer=self.issuer, + user_agent=self.config.user_agent, + custom_headers=self.config.custom_headers, + timeout=timeout or self.config.timeout, + ) + + return fetch_userinfo(request, ctx) + @overload def exchange_token( self, diff --git a/packages/oauth/src/keycardai/oauth/operations/_discovery.py b/packages/oauth/src/keycardai/oauth/operations/_discovery.py index 2da7aa99..4d477a30 100644 --- a/packages/oauth/src/keycardai/oauth/operations/_discovery.py +++ b/packages/oauth/src/keycardai/oauth/operations/_discovery.py @@ -132,6 +132,8 @@ def normalize_array_field(field_name: str) -> list[str] | None: registration_endpoint=data.get("registration_endpoint"), pushed_authorization_request_endpoint=data.get("pushed_authorization_request_endpoint"), jwks_uri=data.get("jwks_uri"), + userinfo_endpoint=data.get("userinfo_endpoint"), + end_session_endpoint=data.get("end_session_endpoint"), response_types_supported=normalize_array_field("response_types_supported"), response_modes_supported=normalize_array_field("response_modes_supported"), diff --git a/packages/oauth/src/keycardai/oauth/operations/_userinfo.py b/packages/oauth/src/keycardai/oauth/operations/_userinfo.py new file mode 100644 index 00000000..adf4b88c --- /dev/null +++ b/packages/oauth/src/keycardai/oauth/operations/_userinfo.py @@ -0,0 +1,227 @@ +"""OpenID Connect UserInfo operations. + +This module implements the client side of the UserInfo endpoint +(OpenID Connect Core 1.0 Section 5.3) using the HTTP transport layer with +byte-level operations. + +Keycard zone access tokens are authorization-only: identity claims such as +``email`` or ``groups`` are not in the token and live behind the issuer's +``userinfo_endpoint``. +""" + +import json +import re + +from ..exceptions import ( + ConfigError, + InvalidTokenError, + OAuthHttpError, + OAuthProtocolError, +) +from ..http._context import HTTPContext +from ..http._wire import HttpRequest, HttpResponse +from ..types.models import ( + AuthorizationServerMetadata, + UserInfoRequest, + UserInfoResponse, +) + +_OPERATION = "GET /userinfo" + + +def resolve_userinfo_endpoint(metadata: AuthorizationServerMetadata) -> str: + """Resolve the UserInfo endpoint from discovered server metadata. + + Args: + metadata: Authorization server metadata from discovery. + + Returns: + The ``userinfo_endpoint`` URL. + + Raises: + ConfigError: If the metadata has no ``userinfo_endpoint``. + """ + if not metadata.userinfo_endpoint: + raise ConfigError( + f"Authorization server '{metadata.issuer}' does not advertise a " + "'userinfo_endpoint'; UserInfo is unavailable for this issuer." + ) + return metadata.userinfo_endpoint + + +def build_userinfo_http_request( + request: UserInfoRequest, context: HTTPContext +) -> HttpRequest: + """Build the HTTP request for a UserInfo fetch. + + The access token is presented as a Bearer credential (RFC 6750 Section 2.1), + which is the form OIDC recommends for UserInfo. The client's own auth + strategy is not applied: UserInfo authenticates the user, not the client. + + Args: + request: UserInfo request carrying the access token. + context: HTTP context with the resolved UserInfo endpoint and transport. + + Returns: + HttpRequest for the UserInfo endpoint. + """ + headers = { + "Accept": "application/json", + } + if context.headers: + headers.update(context.headers) + headers["Authorization"] = f"Bearer {request.access_token}" + + return HttpRequest( + method="GET", + url=context.endpoint, + headers=headers, + body=None, + ) + + +def _header(res: HttpResponse, name: str) -> str | None: + for key, value in res.headers.items(): + if key.lower() == name.lower(): + return value + return None + + +def _challenge_error(www_authenticate: str | None) -> str: + """Extract the RFC 6750 ``error`` code from a ``WWW-Authenticate`` challenge.""" + if not www_authenticate: + return "invalid_token" + match = re.search(r'error\s*=\s*"?([^",\s]+)"?', www_authenticate) + return match.group(1) if match else "invalid_token" + + +def parse_userinfo_http_response(res: HttpResponse) -> UserInfoResponse: + """Parse the HTTP response from the UserInfo endpoint. + + Args: + res: HTTP response from the UserInfo endpoint. + + Returns: + UserInfoResponse with ``sub`` and the full claims document. + + Raises: + InvalidTokenError: If the endpoint rejects the access token (HTTP 401). + OAuthHttpError: If the endpoint returns any other non-2xx status. + OAuthProtocolError: If the body is not a JSON claims object, is a signed + (``application/jwt``) response, or omits ``sub``. + """ + if res.status == 401: + error = _challenge_error(_header(res, "WWW-Authenticate")) + raise InvalidTokenError( + f"UserInfo request rejected with '{error}': the access token is " + "expired, revoked, or not accepted at the UserInfo endpoint." + ) + + if res.status >= 400: + raise OAuthHttpError( + status_code=res.status, + response_body=res.body[:512].decode("utf-8", "ignore"), + headers=dict(res.headers), + operation=_OPERATION, + ) + + content_type = _header(res, "Content-Type") or "" + if "application/jwt" in content_type.lower(): + raise OAuthProtocolError( + error="invalid_response", + error_description=( + f"Unsupported UserInfo response content type '{content_type}': " + "signed and encrypted UserInfo responses are not supported." + ), + operation=_OPERATION, + ) + + try: + claims = json.loads(res.body.decode("utf-8")) + except Exception as e: + raise OAuthProtocolError( + error="invalid_response", + error_description="Invalid JSON in UserInfo response", + operation=_OPERATION, + ) from e + + if not isinstance(claims, dict): + raise OAuthProtocolError( + error="invalid_response", + error_description="UserInfo response must be a JSON object of claims", + operation=_OPERATION, + ) + + if "error" in claims and "sub" not in claims: + raise OAuthProtocolError( + error=claims["error"], + error_description=claims.get("error_description"), + error_uri=claims.get("error_uri"), + operation=_OPERATION, + ) + + sub = claims.get("sub") + if not isinstance(sub, str) or not sub: + raise OAuthProtocolError( + error="invalid_response", + error_description="UserInfo response must include a 'sub' claim", + operation=_OPERATION, + ) + + return UserInfoResponse( + sub=sub, + claims=claims, + headers=dict(res.headers), + ) + + +def fetch_userinfo( + request: UserInfoRequest, + context: HTTPContext, +) -> UserInfoResponse: + """Fetch the signed-in user's claims from the UserInfo endpoint (sync version). + + Args: + request: UserInfo request carrying the access token. + context: HTTP context with the resolved UserInfo endpoint and transport. + + Returns: + UserInfoResponse with ``sub`` and all returned claims. + + Raises: + InvalidTokenError: If the access token is not accepted (HTTP 401) + OAuthHttpError: If the UserInfo endpoint returns another non-2xx status + OAuthProtocolError: If the response is not a JSON claims object with 'sub' + NetworkError: If the network request fails + + Reference: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + """ + http_req = build_userinfo_http_request(request, context) + http_res = context.transport.request_raw(http_req, timeout=context.timeout) + return parse_userinfo_http_response(http_res) + + +async def fetch_userinfo_async( + request: UserInfoRequest, + context: HTTPContext, +) -> UserInfoResponse: + """Fetch the signed-in user's claims from the UserInfo endpoint (async version). + + Args: + request: UserInfo request carrying the access token. + context: HTTP context with the resolved UserInfo endpoint and transport. + + Returns: + UserInfoResponse with ``sub`` and all returned claims. + + Raises: + InvalidTokenError: If the access token is not accepted (HTTP 401) + OAuthHttpError: If the UserInfo endpoint returns another non-2xx status + OAuthProtocolError: If the response is not a JSON claims object with 'sub' + NetworkError: If the network request fails + + Reference: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + """ + http_req = build_userinfo_http_request(request, context) + http_res = await context.transport.request_raw(http_req, timeout=context.timeout) + return parse_userinfo_http_response(http_res) diff --git a/packages/oauth/src/keycardai/oauth/types/__init__.py b/packages/oauth/src/keycardai/oauth/types/__init__.py index 70be8a90..1b64e967 100644 --- a/packages/oauth/src/keycardai/oauth/types/__init__.py +++ b/packages/oauth/src/keycardai/oauth/types/__init__.py @@ -17,6 +17,8 @@ ServerMetadataRequest, TokenExchangeRequest, TokenResponse, + UserInfoRequest, + UserInfoResponse, ) from .oauth import ( GrantType, @@ -46,6 +48,8 @@ "ServerMetadataRequest", "TokenExchangeRequest", "TokenResponse", + "UserInfoRequest", + "UserInfoResponse", # OAuth enums and constants "GrantType", "PKCECodeChallengeMethod", diff --git a/packages/oauth/src/keycardai/oauth/types/models.py b/packages/oauth/src/keycardai/oauth/types/models.py index 4c2d9337..aedebd16 100644 --- a/packages/oauth/src/keycardai/oauth/types/models.py +++ b/packages/oauth/src/keycardai/oauth/types/models.py @@ -275,6 +275,12 @@ class AuthorizationServerMetadata: # JWKS endpoint for server public keys jwks_uri: str | None = None + # OpenID Connect Discovery 1.0 Section 3 + userinfo_endpoint: str | None = None + + # OpenID Connect RP-Initiated Logout 1.0 Section 2.1 + end_session_endpoint: str | None = None + # Supported capabilities response_types_supported: list[str] | None = None response_modes_supported: list[str] | None = None @@ -307,6 +313,43 @@ class AuthorizationServerMetadata: raw: dict[str, Any] | None = None headers: dict[str, str] | None = None + +# ============================================================================= +# UserInfo (OpenID Connect Core 1.0 Section 5.3) +# ============================================================================= + + +class UserInfoRequest(BaseModel): + """UserInfo request as defined in OIDC Core 1.0 Section 5.3. + + Reference: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + """ + + access_token: str = Field( + ..., + min_length=1, + description="Access token presented as a Bearer credential at the UserInfo endpoint.", + ) + timeout: float | None = None + + +@dataclass +class UserInfoResponse: + """UserInfo response as defined in OIDC Core 1.0 Section 5.3. + + Claims are returned exactly as the provider sent them: nothing is filtered + to a known set. ``sub`` is the only claim OIDC requires and is validated + present. + + Reference: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + """ + + sub: str + claims: dict[str, Any] + + headers: dict[str, str] | None = None + + # ============================================================================= # JSON Web Key Set (RFC 7517) # ============================================================================= diff --git a/packages/oauth/tests/keycardai/oauth/operations/test_discovery.py b/packages/oauth/tests/keycardai/oauth/operations/test_discovery.py index 71f75cd0..d08e85d0 100644 --- a/packages/oauth/tests/keycardai/oauth/operations/test_discovery.py +++ b/packages/oauth/tests/keycardai/oauth/operations/test_discovery.py @@ -122,6 +122,36 @@ def test_parse_discovery_http_response_issuer_match_ignores_trailing_slash(self) assert result.issuer == "https://auth.example.com" + def test_parse_discovery_http_response_types_oidc_endpoints(self): + """OIDC userinfo/end_session endpoints are typed on the returned metadata.""" + http_response = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=( + b'{"issuer": "https://auth.example.com", ' + b'"userinfo_endpoint": "https://auth.example.com/userinfo", ' + b'"end_session_endpoint": "https://auth.example.com/logout"}' + ) + ) + + result = parse_discovery_http_response(http_response) + + assert result.userinfo_endpoint == "https://auth.example.com/userinfo" + assert result.end_session_endpoint == "https://auth.example.com/logout" + + def test_parse_discovery_http_response_without_oidc_endpoints(self): + """A document omitting the OIDC endpoints parses without error.""" + http_response = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'{"issuer": "https://auth.example.com"}' + ) + + result = parse_discovery_http_response(http_response) + + assert result.userinfo_endpoint is None + assert result.end_session_endpoint is None + def test_discover_server_metadata_rejects_issuer_mismatch(self): """The discovery operation validates the issuer against the request.""" mock_transport = Mock() diff --git a/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py b/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py new file mode 100644 index 00000000..d30012ab --- /dev/null +++ b/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py @@ -0,0 +1,214 @@ +"""Unit tests for the OpenID Connect UserInfo operation (OIDC Core 1.0 Section 5.3).""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from keycardai.oauth.exceptions import ( + ConfigError, + InvalidTokenError, + OAuthHttpError, + OAuthProtocolError, +) +from keycardai.oauth.http._context import build_http_context +from keycardai.oauth.http._wire import HttpResponse +from keycardai.oauth.operations._userinfo import ( + build_userinfo_http_request, + fetch_userinfo, + fetch_userinfo_async, + parse_userinfo_http_response, + resolve_userinfo_endpoint, +) +from keycardai.oauth.types.models import ( + AuthorizationServerMetadata, + UserInfoRequest, + UserInfoResponse, +) + +CLAIMS_BODY = ( + b'{"sub": "user-123", "email": "kim@example.com", "name": "Kim", ' + b'"groups": ["engineering"], "custom_claim": {"tier": "gold"}}' +) + + +def _context(transport): + return build_http_context( + endpoint="https://auth.example.com/userinfo", + transport=transport, + auth=Mock(apply_headers=Mock(return_value={"Authorization": "Basic client"})), + user_agent="TestClient/1.0", + timeout=30.0, + ) + + +class TestUserInfoRequestBuilding: + """Request construction (GET with a Bearer credential).""" + + def test_build_userinfo_http_request(self): + http_req = build_userinfo_http_request( + UserInfoRequest(access_token="user-access-token"), _context(Mock()) + ) + + assert http_req.method == "GET" + assert http_req.url == "https://auth.example.com/userinfo" + assert http_req.headers["Authorization"] == "Bearer user-access-token" + assert http_req.headers["Accept"] == "application/json" + assert http_req.headers["User-Agent"] == "TestClient/1.0" + assert http_req.body is None + + def test_build_userinfo_http_request_ignores_client_auth_strategy(self): + """UserInfo authenticates the user, so the client's own auth is not applied.""" + context = _context(Mock()) + + http_req = build_userinfo_http_request( + UserInfoRequest(access_token="user-access-token"), context + ) + + assert http_req.headers["Authorization"] == "Bearer user-access-token" + context.auth.apply_headers.assert_not_called() + + +class TestUserInfoEndpointResolution: + """Endpoint resolution from discovered metadata.""" + + def test_resolve_userinfo_endpoint(self): + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + userinfo_endpoint="https://auth.example.com/userinfo", + ) + + assert resolve_userinfo_endpoint(metadata) == "https://auth.example.com/userinfo" + + def test_resolve_userinfo_endpoint_missing_is_a_config_error(self): + metadata = AuthorizationServerMetadata(issuer="https://auth.example.com") + + with pytest.raises(ConfigError, match="userinfo_endpoint"): + resolve_userinfo_endpoint(metadata) + + +class TestUserInfoResponseParsing: + """Response parsing per the spec's unit test table.""" + + def test_claims_are_returned_unfiltered(self): + result = parse_userinfo_http_response( + HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=CLAIMS_BODY, + ) + ) + + assert isinstance(result, UserInfoResponse) + assert result.sub == "user-123" + assert result.claims["email"] == "kim@example.com" + assert result.claims["groups"] == ["engineering"] + assert result.claims["custom_claim"] == {"tier": "gold"} + assert result.claims["sub"] == "user-123" + + def test_missing_sub_is_a_protocol_error(self): + with pytest.raises(OAuthProtocolError, match="sub"): + parse_userinfo_http_response( + HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'{"email": "kim@example.com"}', + ) + ) + + def test_invalid_token_challenge_is_an_authorization_error(self): + with pytest.raises(InvalidTokenError, match="invalid_token") as exc_info: + parse_userinfo_http_response( + HttpResponse( + status=401, + headers={ + "WWW-Authenticate": 'Bearer error="invalid_token", ' + 'error_description="The access token expired"' + }, + body=b"", + ) + ) + + assert exc_info.value.error_code == "invalid_token" + + def test_401_without_challenge_is_still_an_invalid_token_error(self): + with pytest.raises(InvalidTokenError): + parse_userinfo_http_response( + HttpResponse(status=401, headers={}, body=b"") + ) + + def test_signed_response_is_a_protocol_error(self): + with pytest.raises(OAuthProtocolError, match="application/jwt"): + parse_userinfo_http_response( + HttpResponse( + status=200, + headers={"Content-Type": "application/jwt"}, + body=b"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.sig", + ) + ) + + def test_invalid_json_is_a_protocol_error(self): + with pytest.raises(OAuthProtocolError, match="Invalid JSON"): + parse_userinfo_http_response( + HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b"not json {", + ) + ) + + def test_non_object_body_is_a_protocol_error(self): + with pytest.raises(OAuthProtocolError, match="JSON object"): + parse_userinfo_http_response( + HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'["user-123"]', + ) + ) + + def test_other_non_2xx_is_an_http_error(self): + with pytest.raises(OAuthHttpError, match="HTTP 500"): + parse_userinfo_http_response( + HttpResponse(status=500, headers={}, body=b"boom") + ) + + +class TestUserInfoOperation: + """End-to-end operation behavior over a mocked transport.""" + + def test_fetch_userinfo_sync(self): + transport = Mock() + transport.request_raw.return_value = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=CLAIMS_BODY, + ) + + result = fetch_userinfo( + UserInfoRequest(access_token="user-access-token"), _context(transport) + ) + + assert result.sub == "user-123" + sent = transport.request_raw.call_args[0][0] + assert sent.method == "GET" + assert sent.headers["Authorization"] == "Bearer user-access-token" + + @pytest.mark.asyncio + async def test_fetch_userinfo_async(self): + transport = AsyncMock() + transport.request_raw.return_value = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=CLAIMS_BODY, + ) + + result = await fetch_userinfo_async( + UserInfoRequest(access_token="user-access-token"), _context(transport) + ) + + assert result.sub == "user-123" + assert result.claims["email"] == "kim@example.com" + + def test_empty_access_token_is_rejected(self): + with pytest.raises(ValueError): + UserInfoRequest(access_token="") diff --git a/packages/oauth/tests/keycardai/oauth/test_client.py b/packages/oauth/tests/keycardai/oauth/test_client.py index db850c8e..81f9e256 100644 --- a/packages/oauth/tests/keycardai/oauth/test_client.py +++ b/packages/oauth/tests/keycardai/oauth/test_client.py @@ -7,6 +7,7 @@ from keycardai.oauth import AsyncClient, Client, ClientConfig from keycardai.oauth.exceptions import ConfigError +from keycardai.oauth.http._wire import HttpResponse from keycardai.oauth.types.models import ( AuthorizationServerMetadata, ClientCredentialsRequest, @@ -721,3 +722,109 @@ def test_custom_user_agent_in_requests(self): mock_build_context.assert_called() call_kwargs = mock_build_context.call_args.kwargs assert call_kwargs['user_agent'] == custom_user_agent + +class TestUserInfo: + """Client-level UserInfo behavior (OIDC Core 1.0 Section 5.3).""" + + def _metadata(self, userinfo_endpoint: str | None = None): + return AuthorizationServerMetadata( + issuer="https://test.example.com", + token_endpoint="https://test.example.com/token", + userinfo_endpoint=userinfo_endpoint, + ) + + def _claims_response(self): + return HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'{"sub": "user-123", "email": "kim@example.com"}', + ) + + def test_sync_userinfo_uses_discovered_endpoint(self): + transport = Mock() + transport.request_raw.return_value = self._claims_response() + + client = Client("https://test.example.com", transport=transport) + + with patch.object( + client, + "discover_server_metadata", + return_value=self._metadata("https://test.example.com/userinfo"), + ): + with client: + result = client.userinfo("user-access-token") + + assert result.sub == "user-123" + assert result.claims["email"] == "kim@example.com" + sent = transport.request_raw.call_args[0][0] + assert sent.url == "https://test.example.com/userinfo" + assert sent.headers["Authorization"] == "Bearer user-access-token" + + def test_sync_userinfo_reuses_provided_metadata(self): + """Passing metadata skips discovery entirely.""" + transport = Mock() + transport.request_raw.return_value = self._claims_response() + + client = Client( + "https://test.example.com", + transport=transport, + config=ClientConfig(enable_metadata_discovery=False), + ) + + with patch.object(client, "discover_server_metadata") as mock_discover: + result = client.userinfo( + "user-access-token", + metadata=self._metadata("https://test.example.com/userinfo"), + ) + + mock_discover.assert_not_called() + assert result.sub == "user-123" + + def test_sync_userinfo_without_endpoint_makes_no_request(self): + transport = Mock() + + client = Client( + "https://test.example.com", + transport=transport, + config=ClientConfig(enable_metadata_discovery=False), + ) + + with pytest.raises(ConfigError, match="userinfo_endpoint"): + client.userinfo("user-access-token", metadata=self._metadata()) + + transport.request_raw.assert_not_called() + + @pytest.mark.asyncio + async def test_async_userinfo_uses_discovered_endpoint(self): + transport = AsyncMock() + transport.request_raw.return_value = self._claims_response() + + client = AsyncClient("https://test.example.com", transport=transport) + + with patch.object( + client, + "discover_server_metadata", + AsyncMock(return_value=self._metadata("https://test.example.com/userinfo")), + ): + async with client: + result = await client.userinfo("user-access-token") + + assert result.sub == "user-123" + sent = transport.request_raw.call_args[0][0] + assert sent.url == "https://test.example.com/userinfo" + assert sent.headers["Authorization"] == "Bearer user-access-token" + + @pytest.mark.asyncio + async def test_async_userinfo_without_endpoint_makes_no_request(self): + transport = AsyncMock() + + client = AsyncClient( + "https://test.example.com", + transport=transport, + config=ClientConfig(enable_metadata_discovery=False), + ) + + with pytest.raises(ConfigError, match="userinfo_endpoint"): + await client.userinfo("user-access-token", metadata=self._metadata()) + + transport.request_raw.assert_not_called() From 4d01d8064c096f2b74524f0cdc169e8db4ade079 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 17:46:52 +0000 Subject: [PATCH 2/2] fix(keycardai-oauth): carry challenge error code on InvalidTokenError Co-Authored-By: Larry Osakwe --- packages/oauth/src/keycardai/oauth/client.py | 8 +++++-- .../oauth/src/keycardai/oauth/exceptions.py | 13 ++++++++++-- .../keycardai/oauth/operations/_userinfo.py | 3 ++- .../oauth/operations/test_userinfo.py | 20 +++++++++++++++++- .../tests/keycardai/oauth/test_client.py | 21 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/oauth/src/keycardai/oauth/client.py b/packages/oauth/src/keycardai/oauth/client.py index 98c4e82b..8491387b 100644 --- a/packages/oauth/src/keycardai/oauth/client.py +++ b/packages/oauth/src/keycardai/oauth/client.py @@ -652,7 +652,9 @@ async def userinfo( Zone access tokens are authorization-only, so identity claims such as ``email`` live behind the issuer's ``userinfo_endpoint`` rather than in the token. The endpoint is resolved from server metadata: metadata the - client already discovered is reused, otherwise discovery runs first. + client already discovered is reused, otherwise discovery runs first -- + including when ``ClientConfig.enable_metadata_discovery`` is False. Pass + ``metadata`` to avoid the discovery request entirely. Simple usage: async with AsyncClient("https://zone.keycard.cloud") as client: @@ -1380,7 +1382,9 @@ def userinfo( Zone access tokens are authorization-only, so identity claims such as ``email`` live behind the issuer's ``userinfo_endpoint`` rather than in the token. The endpoint is resolved from server metadata: metadata the - client already discovered is reused, otherwise discovery runs first. + client already discovered is reused, otherwise discovery runs first -- + including when ``ClientConfig.enable_metadata_discovery`` is False. Pass + ``metadata`` to avoid the discovery request entirely. Simple usage: with Client("https://zone.keycard.cloud") as client: diff --git a/packages/oauth/src/keycardai/oauth/exceptions.py b/packages/oauth/src/keycardai/oauth/exceptions.py index 1ad4a8f1..8fb392f6 100644 --- a/packages/oauth/src/keycardai/oauth/exceptions.py +++ b/packages/oauth/src/keycardai/oauth/exceptions.py @@ -182,13 +182,22 @@ class JWKSKeyNotFoundError(JWKSError): class InvalidTokenError(OAuthError): - """A presented token failed verification. + """A presented token failed verification or was rejected by a server. Raised by the verify surface for any token-validity failure: an unsupported algorithm, a missing ``kid`` header, an untrusted or missing issuer, an expired token, an audience or scope mismatch, or a bad - signature. Carries the RFC 6750 ``invalid_token`` error code. + signature. Also raised when a resource server rejects the token with + HTTP 401, as the UserInfo endpoint does. + + Carries the RFC 6750 error code, ``invalid_token`` unless a server + challenge named a different one. """ error_code = "invalid_token" + def __init__(self, message: str, *, error_code: str | None = None): + super().__init__(message) + if error_code is not None: + self.error_code = error_code + diff --git a/packages/oauth/src/keycardai/oauth/operations/_userinfo.py b/packages/oauth/src/keycardai/oauth/operations/_userinfo.py index adf4b88c..c0c9f895 100644 --- a/packages/oauth/src/keycardai/oauth/operations/_userinfo.py +++ b/packages/oauth/src/keycardai/oauth/operations/_userinfo.py @@ -114,7 +114,8 @@ def parse_userinfo_http_response(res: HttpResponse) -> UserInfoResponse: error = _challenge_error(_header(res, "WWW-Authenticate")) raise InvalidTokenError( f"UserInfo request rejected with '{error}': the access token is " - "expired, revoked, or not accepted at the UserInfo endpoint." + "expired, revoked, or not accepted at the UserInfo endpoint.", + error_code=error, ) if res.status >= 400: diff --git a/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py b/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py index d30012ab..6a187084 100644 --- a/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py +++ b/packages/oauth/tests/keycardai/oauth/operations/test_userinfo.py @@ -131,11 +131,29 @@ def test_invalid_token_challenge_is_an_authorization_error(self): assert exc_info.value.error_code == "invalid_token" def test_401_without_challenge_is_still_an_invalid_token_error(self): - with pytest.raises(InvalidTokenError): + with pytest.raises(InvalidTokenError) as exc_info: parse_userinfo_http_response( HttpResponse(status=401, headers={}, body=b"") ) + assert exc_info.value.error_code == "invalid_token" + + def test_challenge_error_code_is_carried_on_the_exception(self): + """A challenge naming another RFC 6750 code is reported, not flattened.""" + with pytest.raises(InvalidTokenError) as exc_info: + parse_userinfo_http_response( + HttpResponse( + status=401, + headers={ + "WWW-Authenticate": 'Bearer error="insufficient_scope", ' + 'scope="openid profile"' + }, + body=b"", + ) + ) + + assert exc_info.value.error_code == "insufficient_scope" + def test_signed_response_is_a_protocol_error(self): with pytest.raises(OAuthProtocolError, match="application/jwt"): parse_userinfo_http_response( diff --git a/packages/oauth/tests/keycardai/oauth/test_client.py b/packages/oauth/tests/keycardai/oauth/test_client.py index 81f9e256..3458d09e 100644 --- a/packages/oauth/tests/keycardai/oauth/test_client.py +++ b/packages/oauth/tests/keycardai/oauth/test_client.py @@ -780,6 +780,27 @@ def test_sync_userinfo_reuses_provided_metadata(self): mock_discover.assert_not_called() assert result.sub == "user-123" + def test_sync_userinfo_discovers_even_when_auto_discovery_disabled(self): + """userinfo() needs the endpoint, so it discovers regardless of the config flag.""" + transport = Mock() + transport.request_raw.return_value = self._claims_response() + + client = Client( + "https://test.example.com", + transport=transport, + config=ClientConfig(enable_metadata_discovery=False), + ) + + with patch.object( + client, + "discover_server_metadata", + return_value=self._metadata("https://test.example.com/userinfo"), + ) as mock_discover: + result = client.userinfo("user-access-token") + + mock_discover.assert_called_once() + assert result.sub == "user-123" + def test_sync_userinfo_without_endpoint_makes_no_request(self): transport = Mock()