Skip to content
55 changes: 55 additions & 0 deletions packages/oauth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,60 @@ async def main():
asyncio.run(main())
```

### Web-App Authorization Code Flow

For applications that own their registered redirect route, use the stateless
web-app flow and store the returned `state` and `code_verifier` in session
state between requests:

```python
from keycardai.oauth.pkce import begin_authorization, complete_authorization

async def login_route(session):
redirect = await begin_authorization(
client_id="my-web-app",
issuer="https://oauth.example.com",
redirect_uri="https://app.example.com/oauth/callback",
scopes=["openid"],
)
session["oauth_flow"] = {
"state": redirect.state,
"code_verifier": redirect.code_verifier,
}
return redirect.url # Redirect the browser to this URL.


async def callback_route(request, session):
flow = session.pop("oauth_flow")
return await complete_authorization(
callback_params=request.query_params,
state=flow["state"],
code_verifier=flow["code_verifier"],
client_id="my-web-app",
redirect_uri="https://app.example.com/oauth/callback",
issuer="https://oauth.example.com",
)
```

If the application caches authorization server metadata, it can replace
`issuer=...` with `metadata=cached_metadata` in both handlers to skip discovery
on each sign-in:

```python
from keycardai.oauth import AuthorizationServerMetadata

cached_metadata = AuthorizationServerMetadata(
issuer="https://oauth.example.com",
authorization_endpoint="https://oauth.example.com/authorize",
token_endpoint="https://oauth.example.com/token",
)

# login_route: issuer="https://oauth.example.com"
# -> metadata=cached_metadata
# callback_route: issuer="https://oauth.example.com"
# -> metadata=cached_metadata
```

## Features

- **Token Exchange (RFC 8693)** - Exchange tokens for different audiences, scopes, or token types
Expand All @@ -103,6 +157,7 @@ asyncio.run(main())
- **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
- **Web-App Authorization Code Flow** - Stateless begin and complete helpers for applications with their own callback route
- **Multiple Auth Strategies** - BasicAuth, BearerAuth, and multi-zone authentication
- **Comprehensive Error Handling** - Structured exceptions with retry guidance
- **Sync and Async Clients** - Choose the right client for your application
Expand Down
30 changes: 30 additions & 0 deletions packages/oauth/examples/web_authorization_code_flow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Web-App Authorization Code Flow Example

Demonstrates the stateless web-app authorization-code flow with PKCE. A web
application stores `state` and `code_verifier` in session state between its
login and callback routes, then passes them to `complete_authorization`.

The example is framework-agnostic. Connect `login_redirect` and
`oauth_callback` to routes in your web framework and replace the in-memory
`session` mapping with the framework's session storage.

## Usage

```bash
uv sync
uv run python main.py
```

Replace the example issuer, client ID, and redirect URI with values registered
with your authorization server before connecting the handlers to routes.

If your application caches authorization server metadata, pass the cached
`AuthorizationServerMetadata` as `metadata=` to both helpers instead of
`issuer=`. This skips discovery on each sign-in; load and refresh the metadata
according to your application's policy.

## Requirements

- Python 3.10+
- keycardai-oauth package
- Access to a Keycard authorization server
49 changes: 49 additions & 0 deletions packages/oauth/examples/web_authorization_code_flow/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Framework-agnostic web-app authorization-code flow example."""

from collections.abc import Mapping

from keycardai.oauth.pkce import (
AuthorizationRedirect,
begin_authorization,
complete_authorization,
)
from keycardai.oauth.types.models import TokenResponse

session: dict[str, dict[str, str]] = {}


async def login_redirect() -> AuthorizationRedirect:
"""Start login and store the flow values in the user's session."""
redirect = await begin_authorization(
client_id="my-web-app",
issuer="https://oauth.example.com",
redirect_uri="https://app.example.com/oauth/callback",
scopes=["openid", "profile"],
)
session["oauth_flow"] = {
"state": redirect.state,
"code_verifier": redirect.code_verifier,
}
return redirect


async def oauth_callback(callback_params: Mapping[str, str]) -> TokenResponse:
"""Exchange the callback after retrieving the flow values from the session."""
flow = session.pop("oauth_flow")
return await complete_authorization(
callback_params=callback_params,
state=flow["state"],
code_verifier=flow["code_verifier"],
client_id="my-web-app",
redirect_uri="https://app.example.com/oauth/callback",
issuer="https://oauth.example.com",
)


def main() -> None:
"""Show where a web framework would connect the two route handlers."""
print("Connect login_redirect() and oauth_callback() to your web routes.")


if __name__ == "__main__":
main()
12 changes: 12 additions & 0 deletions packages/oauth/examples/web_authorization_code_flow/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "web-authorization-code-flow"
version = "0.1.0"
description = "Web-app authorization-code flow example"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"keycardai-oauth",
]

[tool.uv.sources]
keycardai-oauth = { path = "../../", editable = true }
4 changes: 4 additions & 0 deletions packages/oauth/src/keycardai/oauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .client import AsyncClient, Client
from .exceptions import (
AuthenticationError,
AuthorizationDeniedError,
ConfigError,
InvalidTokenError,
JWKSError,
Expand All @@ -43,6 +44,7 @@
OAuthError,
OAuthHttpError,
OAuthProtocolError,
StateMismatchError,
TokenExchangeError,
)
from .http.auth import AuthStrategy, BasicAuth, BearerAuth, MultiZoneBasicAuth, NoneAuth
Expand Down Expand Up @@ -82,6 +84,8 @@
"NetworkError",
"ConfigError",
"AuthenticationError",
"AuthorizationDeniedError",
"StateMismatchError",
"TokenExchangeError",
"JWKSError",
"JWKSFetchError",
Expand Down
25 changes: 24 additions & 1 deletion packages/oauth/src/keycardai/oauth/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ def __init__(
super().__init__(message)


class AuthorizationDeniedError(OAuthProtocolError):
"""Authorization endpoint denial returned in a redirect callback.

Carries the OAuth ``error`` and optional ``error_description`` values
returned by the authorization server.
"""

def __init__(
self,
error: str,
error_description: str | None = None,
error_uri: str | None = None,
operation: str = "",
):
super().__init__(error, error_description, error_uri, operation)


class StateMismatchError(OAuthError):
"""The authorization callback state is missing or does not match."""

def __init__(self, message: str = "Authorization callback state mismatch"):
super().__init__(message)


@dataclass
class NetworkError(OAuthError):
"""Transport/network failures with retry guidance.
Expand Down Expand Up @@ -200,4 +224,3 @@ def __init__(self, message: str, *, error_code: str | None = None):
super().__init__(message)
if error_code is not None:
self.error_code = error_code

39 changes: 35 additions & 4 deletions packages/oauth/src/keycardai/oauth/pkce/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""High-level PKCE flow for browser-based OAuth 2.0 user authentication.
"""High-level PKCE flows for browser-based OAuth 2.0 user authentication.

Builds on the lower-level PKCE primitives in :mod:`keycardai.oauth.utils.pkce`
and reuses :class:`keycardai.oauth.AsyncClient` for the OAuth-server-facing
Expand All @@ -24,9 +24,40 @@
client_id="my-app",
issuer="https://auth.example.com",
)

Example (web app)::

redirect = await begin_authorization(
client_id="my-app",
issuer="https://auth.example.com",
redirect_uri="https://app.example.com/oauth/callback",
)
session["oauth_flow"] = {
"state": redirect.state,
"code_verifier": redirect.code_verifier,
}
# Redirect the browser to ``redirect.url``. In the callback route:
flow = session.pop("oauth_flow")
token = await complete_authorization(
callback_params=request.query_params,
state=flow["state"],
code_verifier=flow["code_verifier"],
client_id="my-app",
issuer="https://auth.example.com",
redirect_uri="https://app.example.com/oauth/callback",
)
"""

from ._issuer import resolve_issuer_from_challenge
from .callback import OAuthCallbackServer
from .client import authenticate, resolve_issuer_from_challenge

__all__ = ["OAuthCallbackServer", "authenticate", "resolve_issuer_from_challenge"]
from .client import authenticate
from .web import AuthorizationRedirect, begin_authorization, complete_authorization

__all__ = [
"AuthorizationRedirect",
"OAuthCallbackServer",
"authenticate",
"begin_authorization",
"complete_authorization",
"resolve_issuer_from_challenge",
]
106 changes: 106 additions & 0 deletions packages/oauth/src/keycardai/oauth/pkce/_issuer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Shared authorization-server issuer resolution for PKCE flows."""

import re
from typing import Any

import httpx

from ..exceptions import ConfigError


async def _resolve_auth_server_url(
*,
issuer: str | None,
www_authenticate_header: str | None,
resource_url: str | None,
http_client: httpx.AsyncClient | None,
) -> str:
"""Resolve the authorization server from the supported flow entry modes."""
if issuer is not None:
if www_authenticate_header is not None:
raise ConfigError(
"Provide exactly one of 'issuer' or 'www_authenticate_header' "
"to resolve the authorization server"
)
return issuer.rstrip("/")

if www_authenticate_header is None:
raise ConfigError(
"Provide exactly one of 'issuer' or 'www_authenticate_header' "
"to resolve the authorization server"
)
if resource_url is None:
raise ConfigError(
"'resource_url' is required when authenticating from a "
"WWW-Authenticate challenge"
)

return await resolve_issuer_from_challenge(
www_authenticate_header, http_client=http_client
)


async def resolve_issuer_from_challenge(
www_authenticate_header: str,
*,
http_client: httpx.AsyncClient | None = None,
) -> str:
"""Resolve the authorization server issuer from a ``WWW-Authenticate`` challenge.

Parses the ``resource_metadata`` URL from the challenge (RFC 9728),
fetches the protected resource metadata document, and returns the first
entry of ``authorization_servers`` with any trailing slash removed.

Args:
www_authenticate_header: The ``WWW-Authenticate`` value from the
protected resource's 401 response.
http_client: Optional ``httpx.AsyncClient`` used to fetch the
protected resource metadata document. When not supplied, a
short-lived client is created internally.

Returns:
The issuer URL of the resource's first advertised authorization
server.

Raises:
ValueError: If the challenge has no ``resource_metadata`` URL or the
metadata document lists no ``authorization_servers``.
httpx.HTTPStatusError: If the resource metadata fetch fails.
"""
metadata_url = _extract_resource_metadata_url(www_authenticate_header)
if not metadata_url:
raise ValueError("No resource_metadata URL in WWW-Authenticate header")

resource_metadata = await _fetch_resource_metadata(metadata_url, http_client)
auth_servers = resource_metadata.get("authorization_servers") or []
if not auth_servers:
raise ValueError("No authorization_servers in resource metadata")

return str(auth_servers[0]).rstrip("/")


async def _fetch_resource_metadata(
metadata_url: str, http_client: httpx.AsyncClient | None
) -> dict[str, Any]:
"""Fetch the RFC 9728 protected resource metadata document.

This step is paired with the protected resource (not the OAuth server),
so it lives outside :class:`AsyncClient`.
"""
if http_client is not None:
response = await http_client.get(metadata_url)
response.raise_for_status()
return response.json()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(metadata_url)
response.raise_for_status()
return response.json()


def _extract_resource_metadata_url(www_authenticate: str) -> str | None:
"""Extract the ``resource_metadata`` URL from a ``WWW-Authenticate`` header.

See RFC 9728 §5.3 for the parameter definition.
"""
match = re.search(r'resource_metadata="([^"]+)"', www_authenticate)
return match.group(1) if match else None
Loading
Loading