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
25 changes: 25 additions & 0 deletions packages/oauth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/oauth/src/keycardai/oauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +57,8 @@
Endpoints,
TokenExchangeRequest,
TokenResponse,
UserInfoRequest,
UserInfoResponse,
)
from .types.oauth import (
GrantType,
Expand Down Expand Up @@ -94,6 +97,8 @@
"ClientCredentialsRequest",
"TokenExchangeRequest",
"AuthorizationServerMetadata",
"UserInfoRequest",
"UserInfoResponse",
# === Authorization ===
"build_authorize_url",
# === OAuth Enums ===
Expand Down
129 changes: 129 additions & 0 deletions packages/oauth/src/keycardai/oauth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +54,8 @@
ServerMetadataRequest,
TokenExchangeRequest,
TokenResponse,
UserInfoRequest,
UserInfoResponse,
)
from .types.oauth import (
GrantType,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -631,6 +640,65 @@ 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 --
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:
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()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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,
Expand Down Expand Up @@ -1019,6 +1087,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:
Expand Down Expand Up @@ -1048,6 +1117,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,
Expand Down Expand Up @@ -1300,6 +1370,65 @@ 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 --
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:
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,
Expand Down
13 changes: 11 additions & 2 deletions packages/oauth/src/keycardai/oauth/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 2 additions & 0 deletions packages/oauth/src/keycardai/oauth/operations/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading