From 5c85333d88860385146002e1e66b29982528f68f Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 18:49:16 +0000 Subject: [PATCH 1/6] feat(keycardai-oauth): add stateless web-app authorization-code flow (spec #46) Co-Authored-By: Larry Osakwe --- packages/oauth/README.md | 32 ++ .../web_authorization_code_flow/README.md | 25 ++ .../web_authorization_code_flow/main.py | 50 +++ .../pyproject.toml | 12 + .../oauth/src/keycardai/oauth/__init__.py | 4 + .../oauth/src/keycardai/oauth/exceptions.py | 25 +- .../src/keycardai/oauth/pkce/__init__.py | 36 +- .../oauth/src/keycardai/oauth/pkce/client.py | 49 ++- .../oauth/src/keycardai/oauth/pkce/web.py | 154 +++++++++ .../tests/keycardai/oauth/pkce/test_web.py | 326 ++++++++++++++++++ 10 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 packages/oauth/examples/web_authorization_code_flow/README.md create mode 100644 packages/oauth/examples/web_authorization_code_flow/main.py create mode 100644 packages/oauth/examples/web_authorization_code_flow/pyproject.toml create mode 100644 packages/oauth/src/keycardai/oauth/pkce/web.py create mode 100644 packages/oauth/tests/keycardai/oauth/pkce/test_web.py diff --git a/packages/oauth/README.md b/packages/oauth/README.md index 374098e6..11b0e976 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -95,6 +95,37 @@ 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 + +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, +} +# 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-web-app", + issuer="https://oauth.example.com", + redirect_uri="https://app.example.com/oauth/callback", +) +``` + ## Features - **Token Exchange (RFC 8693)** - Exchange tokens for different audiences, scopes, or token types @@ -102,6 +133,7 @@ asyncio.run(main()) - **Authorization Server Metadata (RFC 8414)** - Auto-discover server endpoints and capabilities - **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 diff --git a/packages/oauth/examples/web_authorization_code_flow/README.md b/packages/oauth/examples/web_authorization_code_flow/README.md new file mode 100644 index 00000000..57bf03de --- /dev/null +++ b/packages/oauth/examples/web_authorization_code_flow/README.md @@ -0,0 +1,25 @@ +# 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. + +## Requirements + +- Python 3.10+ +- keycardai-oauth package +- Access to a Keycard authorization server diff --git a/packages/oauth/examples/web_authorization_code_flow/main.py b/packages/oauth/examples/web_authorization_code_flow/main.py new file mode 100644 index 00000000..1f50f76f --- /dev/null +++ b/packages/oauth/examples/web_authorization_code_flow/main.py @@ -0,0 +1,50 @@ +"""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 + + +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", + issuer="https://oauth.example.com", + redirect_uri="https://app.example.com/oauth/callback", + ) + + +session: dict[str, dict[str, str]] = {} + + +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() diff --git a/packages/oauth/examples/web_authorization_code_flow/pyproject.toml b/packages/oauth/examples/web_authorization_code_flow/pyproject.toml new file mode 100644 index 00000000..8b4dff58 --- /dev/null +++ b/packages/oauth/examples/web_authorization_code_flow/pyproject.toml @@ -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 } diff --git a/packages/oauth/src/keycardai/oauth/__init__.py b/packages/oauth/src/keycardai/oauth/__init__.py index 94896e91..62137df6 100644 --- a/packages/oauth/src/keycardai/oauth/__init__.py +++ b/packages/oauth/src/keycardai/oauth/__init__.py @@ -33,6 +33,7 @@ from .client import AsyncClient, Client from .exceptions import ( AuthenticationError, + AuthorizationDeniedError, ConfigError, InvalidTokenError, JWKSError, @@ -42,6 +43,7 @@ OAuthError, OAuthHttpError, OAuthProtocolError, + StateMismatchError, TokenExchangeError, ) from .http.auth import AuthStrategy, BasicAuth, BearerAuth, MultiZoneBasicAuth, NoneAuth @@ -79,6 +81,8 @@ "NetworkError", "ConfigError", "AuthenticationError", + "AuthorizationDeniedError", + "StateMismatchError", "TokenExchangeError", "JWKSError", "JWKSFetchError", diff --git a/packages/oauth/src/keycardai/oauth/exceptions.py b/packages/oauth/src/keycardai/oauth/exceptions.py index 1ad4a8f1..7b4a03c6 100644 --- a/packages/oauth/src/keycardai/oauth/exceptions.py +++ b/packages/oauth/src/keycardai/oauth/exceptions.py @@ -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. @@ -191,4 +215,3 @@ class InvalidTokenError(OAuthError): """ error_code = "invalid_token" - diff --git a/packages/oauth/src/keycardai/oauth/pkce/__init__.py b/packages/oauth/src/keycardai/oauth/pkce/__init__.py index ea0725d0..a21a522e 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/__init__.py +++ b/packages/oauth/src/keycardai/oauth/pkce/__init__.py @@ -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 @@ -24,9 +24,39 @@ 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 .callback import OAuthCallbackServer from .client import authenticate, resolve_issuer_from_challenge - -__all__ = ["OAuthCallbackServer", "authenticate", "resolve_issuer_from_challenge"] +from .web import AuthorizationRedirect, begin_authorization, complete_authorization + +__all__ = [ + "AuthorizationRedirect", + "OAuthCallbackServer", + "authenticate", + "begin_authorization", + "complete_authorization", + "resolve_issuer_from_challenge", +] diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index 015d8922..adadae7d 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -116,25 +116,16 @@ async def authenticate( RuntimeError: If the authorization redirect carried an OAuth ``error`` parameter. """ - if (issuer is None) == (www_authenticate_header is None): - raise ConfigError( - "Provide exactly one of 'issuer' or 'www_authenticate_header' " - "to authenticate()" - ) - if issuer is not None: logger.info("PKCE flow starting against issuer %s", issuer) - auth_server_url = issuer.rstrip("/") else: - if resource_url is None: - raise ConfigError( - "'resource_url' is required when authenticating from a " - "WWW-Authenticate challenge" - ) logger.info("PKCE flow starting for resource %s", resource_url) - auth_server_url = await resolve_issuer_from_challenge( - www_authenticate_header, http_client=http_client - ) + auth_server_url = await _resolve_auth_server_url( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + resource_url=resource_url, + http_client=http_client, + ) auth_strategy = ( BasicAuth(client_id, client_secret) if client_secret else NoneAuth() @@ -182,6 +173,34 @@ async def authenticate( ) +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 None) == (www_authenticate_header is None): + raise ConfigError( + "Provide exactly one of 'issuer' or 'www_authenticate_header' " + "to authenticate()" + ) + + if issuer is not None: + return issuer.rstrip("/") + + if resource_url is None: + raise ConfigError( + "'resource_url' is required when authenticating from a " + "WWW-Authenticate challenge" + ) + assert www_authenticate_header is not None + return await resolve_issuer_from_challenge( + www_authenticate_header, http_client=http_client + ) + + async def resolve_issuer_from_challenge( www_authenticate_header: str, *, diff --git a/packages/oauth/src/keycardai/oauth/pkce/web.py b/packages/oauth/src/keycardai/oauth/pkce/web.py new file mode 100644 index 00000000..8661a21a --- /dev/null +++ b/packages/oauth/src/keycardai/oauth/pkce/web.py @@ -0,0 +1,154 @@ +"""Stateless web-application authorization-code flow with PKCE. + +The web-app flow separates authorization into a begin step and a complete +step around the application's own redirect route. The application stores the +returned ``state`` and ``code_verifier`` between those calls. +""" + +import secrets +from collections.abc import Mapping + +import httpx +from pydantic import BaseModel + +from ..client import AsyncClient +from ..exceptions import ( + AuthorizationDeniedError, + OAuthProtocolError, + StateMismatchError, +) +from ..http.auth import BasicAuth, NoneAuth +from ..operations._authorize import build_authorize_url +from ..types.models import ClientConfig, TokenResponse +from ..utils.pkce import PKCEGenerator +from .client import _resolve_auth_server_url + + +class AuthorizationRedirect(BaseModel): + """Authorization redirect URL and values the application must retain.""" + + url: str + state: str + code_verifier: str + + +async def begin_authorization( + *, + client_id: str, + redirect_uri: str, + resource_url: str | None = None, + www_authenticate_header: str | None = None, + issuer: str | None = None, + scopes: list[str] | None = None, + http_client: httpx.AsyncClient | None = None, +) -> AuthorizationRedirect: + """Begin a web-app authorization-code-with-PKCE flow. + + Returns the URL to which the application should redirect the user's + browser, together with the ``state`` and ``code_verifier`` to store until + the callback route is reached. + """ + auth_server_url = await _resolve_auth_server_url( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + resource_url=resource_url, + http_client=http_client, + ) + auth_strategy = NoneAuth() + config = ClientConfig(enable_metadata_discovery=True, auto_register_client=False) + + async with AsyncClient( + issuer=auth_server_url, auth=auth_strategy, config=config + ) as oauth_client: + endpoints = await oauth_client.get_endpoints() + if not endpoints.authorize or not endpoints.token: + raise ValueError( + "Authorization server metadata is missing authorization_endpoint " + "or token_endpoint" + ) + + pkce = PKCEGenerator().generate_pkce_pair() + state = secrets.token_urlsafe(32) + url = build_authorize_url( + endpoints.authorize, + client_id=client_id, + redirect_uri=redirect_uri, + pkce=pkce, + resources=[resource_url] if resource_url else None, + scope=" ".join(scopes) if scopes else None, + state=state, + ) + + return AuthorizationRedirect( + url=url, + state=state, + code_verifier=pkce.code_verifier, + ) + + +async def complete_authorization( + *, + callback_params: Mapping[str, str], + state: str, + code_verifier: str, + client_id: str, + redirect_uri: str, + resource_url: str | None = None, + www_authenticate_header: str | None = None, + issuer: str | None = None, + client_secret: str | None = None, + http_client: httpx.AsyncClient | None = None, +) -> TokenResponse: + """Complete a web-app authorization-code-with-PKCE flow. + + Callback validation occurs before issuer discovery or any token request. + The application supplies the ``state`` and ``code_verifier`` retained + from :func:`begin_authorization`. + """ + error = callback_params.get("error") + if error is not None: + raise AuthorizationDeniedError( + error=error, + error_description=callback_params.get("error_description"), + operation="authorization callback", + ) + + callback_state = callback_params.get("state") + if callback_state is None or not secrets.compare_digest(callback_state, state): + raise StateMismatchError() + + code = callback_params.get("code") + if code is None: + raise OAuthProtocolError( + error="invalid_request", + error_description="Authorization callback is missing 'code'", + operation="authorization callback", + ) + + auth_server_url = await _resolve_auth_server_url( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + resource_url=resource_url, + http_client=http_client, + ) + auth_strategy = ( + BasicAuth(client_id, client_secret) if client_secret else NoneAuth() + ) + config = ClientConfig(enable_metadata_discovery=True, auto_register_client=False) + + async with AsyncClient( + issuer=auth_server_url, auth=auth_strategy, config=config + ) as oauth_client: + endpoints = await oauth_client.get_endpoints() + if not endpoints.authorize or not endpoints.token: + raise ValueError( + "Authorization server metadata is missing authorization_endpoint " + "or token_endpoint" + ) + return await oauth_client.exchange_authorization_code( + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + client_id=client_id, + resource=resource_url, + ) diff --git a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py new file mode 100644 index 00000000..4748e6e6 --- /dev/null +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py @@ -0,0 +1,326 @@ +"""Tests for the stateless web-app authorization-code flow.""" + +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest + +from keycardai.oauth.exceptions import ( + AuthorizationDeniedError, + ConfigError, + OAuthProtocolError, + StateMismatchError, +) +from keycardai.oauth.http.auth import BasicAuth, NoneAuth +from keycardai.oauth.pkce import ( + AuthorizationRedirect, + begin_authorization, + complete_authorization, +) +from keycardai.oauth.types.models import TokenResponse +from keycardai.oauth.utils.pkce import PKCEGenerator + +WWW_AUTHENTICATE = ( + 'Bearer resource_metadata="https://api.example.com/' + '.well-known/oauth-protected-resource"' +) + + +@pytest.mark.asyncio +async def test_begin_returns_redirect_and_pkce_values(monkeypatch): + captured = {} + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(captured=captured), + ) + + result = await begin_authorization( + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + scopes=["openid", "profile"], + resource_url="https://api.example.com", + ) + + assert isinstance(result, AuthorizationRedirect) + assert result.state + assert result.code_verifier + params = parse_qs(urlsplit(result.url).query) + assert params["state"] == [result.state] + assert params["code_challenge"] == [ + PKCEGenerator.generate_code_challenge(result.code_verifier) + ] + assert params["code_challenge_method"] == ["S256"] + assert params["scope"] == ["openid profile"] + assert params["resource"] == ["https://api.example.com"] + assert captured["issuer"] == "https://auth.example.com" + + +@pytest.mark.asyncio +async def test_complete_exchanges_matching_state(monkeypatch): + captured = {} + token = TokenResponse(access_token="token") + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(captured=captured, exchange_response=token), + ) + + result = await complete_authorization( + callback_params={"code": "auth-code", "state": "stored-state"}, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + resource_url="https://api.example.com", + ) + + assert result is token + assert captured["exchange_kwargs"] == { + "code": "auth-code", + "redirect_uri": "https://app.example.com/callback", + "code_verifier": "stored-verifier", + "client_id": "my-app", + "resource": "https://api.example.com", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "callback_params", + [{"code": "auth-code", "state": "wrong-state"}, {"code": "auth-code"}], +) +async def test_complete_rejects_wrong_or_missing_state_without_exchange( + monkeypatch, callback_params +): + exchange = AsyncMock() + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(exchange=exchange), + ) + + with pytest.raises(StateMismatchError): + await complete_authorization( + callback_params=callback_params, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + exchange.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complete_surfaces_authorization_denial_without_exchange(monkeypatch): + exchange = AsyncMock() + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(exchange=exchange), + ) + + with pytest.raises(AuthorizationDeniedError) as error: + await complete_authorization( + callback_params={ + "error": "access_denied", + "error_description": "The user declined", + }, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + assert error.value.error == "access_denied" + assert error.value.error_description == "The user declined" + exchange.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complete_rejects_missing_code_without_exchange(monkeypatch): + exchange = AsyncMock() + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(exchange=exchange), + ) + + with pytest.raises(OAuthProtocolError) as error: + await complete_authorization( + callback_params={"state": "stored-state"}, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + assert error.value.error == "invalid_request" + exchange.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_complete_uses_basic_auth_for_confidential_client(monkeypatch): + captured = {} + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory( + captured=captured, exchange_response=TokenResponse(access_token="token") + ), + ) + + await complete_authorization( + callback_params={"code": "code", "state": "state"}, + state="state", + code_verifier="verifier", + client_id="my-app", + client_secret="secret", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + assert isinstance(captured["auth"], BasicAuth) + assert captured["auth"].client_id == "my-app" + assert captured["auth"].client_secret == "secret" + + +@pytest.mark.asyncio +async def test_complete_uses_none_auth_for_public_client(monkeypatch): + captured = {} + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory( + captured=captured, exchange_response=TokenResponse(access_token="token") + ), + ) + + await complete_authorization( + callback_params={"code": "code", "state": "state"}, + state="state", + code_verifier="verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + assert isinstance(captured["auth"], NoneAuth) + + +@pytest.mark.asyncio +async def test_begin_resolves_issuer_from_challenge(monkeypatch): + captured = {} + http_client = _http_client_mock( + [{"authorization_servers": ["https://auth.example.com/"]}] + ) + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(captured=captured), + ) + + await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + resource_url="https://api.example.com", + www_authenticate_header=WWW_AUTHENTICATE, + http_client=http_client, + ) + + assert captured["issuer"] == "https://auth.example.com" + http_client.get.assert_awaited_once_with( + "https://api.example.com/.well-known/oauth-protected-resource" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "function,kwargs", + [ + (begin_authorization, {"client_id": "my-app", "redirect_uri": "https://app.example.com/callback"}), + ( + complete_authorization, + { + "callback_params": {"code": "code", "state": "state"}, + "state": "state", + "code_verifier": "verifier", + "client_id": "my-app", + "redirect_uri": "https://app.example.com/callback", + }, + ), + ], +) +@pytest.mark.asyncio +async def test_flow_requires_exactly_one_issuer_entry(function, kwargs): + with pytest.raises(ConfigError, match="exactly one"): + await function(**kwargs) + + with pytest.raises(ConfigError, match="exactly one"): + await function( + **kwargs, + issuer="https://auth.example.com", + www_authenticate_header=WWW_AUTHENTICATE, + ) + + +@pytest.mark.asyncio +async def test_begin_challenge_mode_requires_resource_url(): + with pytest.raises(ConfigError, match="resource_url"): + await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + www_authenticate_header=WWW_AUTHENTICATE, + ) + + +def _async_client_factory( + *, + captured: dict | None = None, + exchange_response: TokenResponse | None = None, + exchange: AsyncMock | None = None, +): + def factory(issuer=None, *, auth, config): + if captured is not None: + captured["issuer"] = issuer + captured["auth"] = auth + captured["config"] = config + instance = MagicMock() + instance.get_endpoints = AsyncMock( + return_value=MagicMock( + authorize="https://auth.example.com/authorize", + token="https://auth.example.com/token", + ) + ) + instance.exchange_authorization_code = ( + exchange + if exchange is not None + else AsyncMock(return_value=exchange_response) + ) + if captured is not None: + original_exchange = instance.exchange_authorization_code + + async def capture_exchange(**kwargs): + captured["exchange_kwargs"] = kwargs + return await original_exchange(**kwargs) + + instance.exchange_authorization_code = capture_exchange + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=None) + return instance + + return factory + + +def _mock_json_response(body: dict) -> MagicMock: + response = MagicMock(spec=httpx.Response) + response.json.return_value = body + response.raise_for_status.return_value = None + return response + + +def _http_client_mock(json_bodies: list[dict]) -> MagicMock: + mock = MagicMock() + mock.get = AsyncMock( + side_effect=[_mock_json_response(body) for body in json_bodies] + ) + return mock From ae95c24c4068599d1d52aa95a9c912bcfea9c278 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 18:52:07 +0000 Subject: [PATCH 2/6] fix(keycardai-oauth): harden stateless web authorization flow Co-Authored-By: Larry Osakwe --- packages/oauth/README.md | 44 ++++----- .../web_authorization_code_flow/main.py | 5 +- .../oauth/src/keycardai/oauth/pkce/_issuer.py | 39 ++++++++ .../oauth/src/keycardai/oauth/pkce/client.py | 32 +------ .../oauth/src/keycardai/oauth/pkce/web.py | 89 ++++++++++++++++++- .../tests/keycardai/oauth/pkce/test_web.py | 30 ++++++- 6 files changed, 180 insertions(+), 59 deletions(-) create mode 100644 packages/oauth/src/keycardai/oauth/pkce/_issuer.py diff --git a/packages/oauth/README.md b/packages/oauth/README.md index 11b0e976..af9e3908 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -104,26 +104,30 @@ state between requests: ```python from keycardai.oauth.pkce import begin_authorization, complete_authorization -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, -} -# 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-web-app", - issuer="https://oauth.example.com", - redirect_uri="https://app.example.com/oauth/callback", -) +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", + issuer="https://oauth.example.com", + redirect_uri="https://app.example.com/oauth/callback", + ) ``` ## Features diff --git a/packages/oauth/examples/web_authorization_code_flow/main.py b/packages/oauth/examples/web_authorization_code_flow/main.py index 1f50f76f..b3796f65 100644 --- a/packages/oauth/examples/web_authorization_code_flow/main.py +++ b/packages/oauth/examples/web_authorization_code_flow/main.py @@ -9,6 +9,8 @@ ) 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.""" @@ -38,9 +40,6 @@ async def oauth_callback(callback_params: Mapping[str, str]) -> TokenResponse: ) -session: dict[str, dict[str, str]] = {} - - 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.") diff --git a/packages/oauth/src/keycardai/oauth/pkce/_issuer.py b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py new file mode 100644 index 00000000..076bb5e5 --- /dev/null +++ b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py @@ -0,0 +1,39 @@ +"""Shared authorization-server issuer resolution for PKCE flows.""" + +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" + ) + + from .client import resolve_issuer_from_challenge + + return await resolve_issuer_from_challenge( + www_authenticate_header, http_client=http_client + ) diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index adadae7d..c49591f7 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -32,11 +32,11 @@ import httpx from ..client import AsyncClient -from ..exceptions import ConfigError from ..http.auth import BasicAuth, NoneAuth from ..operations._authorize import build_authorize_url from ..types.models import ClientConfig, TokenResponse from ..utils.pkce import PKCEGenerator +from ._issuer import _resolve_auth_server_url from .callback import OAuthCallbackServer logger = logging.getLogger(__name__) @@ -171,36 +171,6 @@ async def authenticate( client_id=client_id, resource=resource_url, ) - - -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 None) == (www_authenticate_header is None): - raise ConfigError( - "Provide exactly one of 'issuer' or 'www_authenticate_header' " - "to authenticate()" - ) - - if issuer is not None: - return issuer.rstrip("/") - - if resource_url is None: - raise ConfigError( - "'resource_url' is required when authenticating from a " - "WWW-Authenticate challenge" - ) - assert www_authenticate_header is not None - return await resolve_issuer_from_challenge( - www_authenticate_header, http_client=http_client - ) - - async def resolve_issuer_from_challenge( www_authenticate_header: str, *, diff --git a/packages/oauth/src/keycardai/oauth/pkce/web.py b/packages/oauth/src/keycardai/oauth/pkce/web.py index 8661a21a..01c540d7 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/web.py +++ b/packages/oauth/src/keycardai/oauth/pkce/web.py @@ -21,11 +21,18 @@ from ..operations._authorize import build_authorize_url from ..types.models import ClientConfig, TokenResponse from ..utils.pkce import PKCEGenerator -from .client import _resolve_auth_server_url +from ._issuer import _resolve_auth_server_url class AuthorizationRedirect(BaseModel): - """Authorization redirect URL and values the application must retain.""" + """Authorization redirect URL and values the application must retain. + + Attributes: + url: The authorization URL to which the browser should be redirected. + state: The generated CSRF value to store until the callback. + code_verifier: The PKCE verifier to store until the callback. This + value must never be sent to the browser. + """ url: str state: str @@ -47,6 +54,38 @@ async def begin_authorization( Returns the URL to which the application should redirect the user's browser, together with the ``state`` and ``code_verifier`` to store until the callback route is reached. + + Args: + client_id: OAuth client ID. + redirect_uri: Registered redirect URI handled by the web application. + resource_url: The protected resource the caller is targeting. Passed + as the RFC 8707 ``resource`` parameter when provided. + www_authenticate_header: The ``WWW-Authenticate`` challenge from the + protected resource. Must contain a ``resource_metadata`` URL per + RFC 9728. Mutually exclusive with ``issuer``. + issuer: Authorization server issuer URL to use directly. Mutually + exclusive with ``www_authenticate_header``. + scopes: Optional list of OAuth scopes to request. + http_client: Optional ``httpx.AsyncClient`` used to fetch protected + resource metadata in challenge-driven mode. When omitted, a + short-lived client is created internally. + + Returns: + ``AuthorizationRedirect`` containing the authorization URL, generated + state, and PKCE code verifier. Store the state and verifier in + application-controlled session state. + + Raises: + keycardai.oauth.ConfigError: If both or neither issuer entry modes are + provided, or challenge mode omits ``resource_url``. + ValueError: If discovery fails because required authorization server + endpoints are missing, or because challenge discovery metadata is + incomplete. + httpx.HTTPStatusError: If fetching protected resource metadata fails. + keycardai.oauth.OAuthHttpError: If authorization server discovery + returns an HTTP error. + keycardai.oauth.OAuthProtocolError: If authorization server discovery + returns an OAuth protocol error. """ auth_server_url = await _resolve_auth_server_url( issuer=issuer, @@ -104,6 +143,48 @@ async def complete_authorization( Callback validation occurs before issuer discovery or any token request. The application supplies the ``state`` and ``code_verifier`` retained from :func:`begin_authorization`. + + Args: + callback_params: Query parameters received by the application's + callback route, including ``code`` and ``state`` or an OAuth + ``error`` and optional ``error_description``. + state: The state value stored from the begin step. + code_verifier: The PKCE verifier stored from the begin step. This + value must never be sent to the browser. + client_id: OAuth client ID. + redirect_uri: The same registered redirect URI used in the begin step. + resource_url: The protected resource the caller is targeting. Passed + as the RFC 8707 ``resource`` parameter when provided. + www_authenticate_header: The ``WWW-Authenticate`` challenge from the + protected resource. Must contain a ``resource_metadata`` URL per + RFC 9728. Mutually exclusive with ``issuer``. + issuer: Authorization server issuer URL to use directly. Mutually + exclusive with ``www_authenticate_header``. + client_secret: Optional client secret for confidential clients. + Public clients omit this and use no token-endpoint auth. + http_client: Optional ``httpx.AsyncClient`` used to fetch protected + resource metadata in challenge-driven mode. When omitted, a + short-lived client is created internally. + + Returns: + ``TokenResponse`` returned by the authorization server's token + endpoint. + + Raises: + keycardai.oauth.ConfigError: If both or neither issuer entry modes are + provided, or challenge mode omits ``resource_url``. + ValueError: If discovery fails because required authorization server + endpoints are missing, or because challenge discovery metadata is + incomplete. + AuthorizationDeniedError: If the callback carries an OAuth + authorization error. No token request is made. + StateMismatchError: If the callback state is missing or does not match + the stored state. No token request is made. + OAuthProtocolError: If the callback has no authorization code, or if + the token endpoint returns an OAuth protocol error. + httpx.HTTPStatusError: If fetching protected resource metadata fails. + keycardai.oauth.OAuthHttpError: If authorization server discovery or + the token endpoint returns an HTTP error. """ error = callback_params.get("error") if error is not None: @@ -114,7 +195,9 @@ async def complete_authorization( ) callback_state = callback_params.get("state") - if callback_state is None or not secrets.compare_digest(callback_state, state): + if callback_state is None or not secrets.compare_digest( + callback_state.encode("utf-8"), state.encode("utf-8") + ): raise StateMismatchError() code = callback_params.get("code") diff --git a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py index 4748e6e6..fdbc6db0 100644 --- a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py @@ -113,6 +113,27 @@ async def test_complete_rejects_wrong_or_missing_state_without_exchange( exchange.assert_not_awaited() +@pytest.mark.asyncio +async def test_complete_rejects_non_ascii_callback_state_without_exchange(monkeypatch): + exchange = AsyncMock() + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(exchange=exchange), + ) + + with pytest.raises(StateMismatchError): + await complete_authorization( + callback_params={"code": "auth-code", "state": "attacker-☃"}, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + issuer="https://auth.example.com", + redirect_uri="https://app.example.com/callback", + ) + + exchange.assert_not_awaited() + + @pytest.mark.asyncio async def test_complete_surfaces_authorization_denial_without_exchange(monkeypatch): exchange = AsyncMock() @@ -237,7 +258,13 @@ async def test_begin_resolves_issuer_from_challenge(monkeypatch): @pytest.mark.parametrize( "function,kwargs", [ - (begin_authorization, {"client_id": "my-app", "redirect_uri": "https://app.example.com/callback"}), + ( + begin_authorization, + { + "client_id": "my-app", + "redirect_uri": "https://app.example.com/callback", + }, + ), ( complete_authorization, { @@ -250,7 +277,6 @@ async def test_begin_resolves_issuer_from_challenge(monkeypatch): ), ], ) -@pytest.mark.asyncio async def test_flow_requires_exactly_one_issuer_entry(function, kwargs): with pytest.raises(ConfigError, match="exactly one"): await function(**kwargs) From 5b06e0e5c30e739b95c422ce9bbb31e59491eec2 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 18:54:08 +0000 Subject: [PATCH 3/6] refactor(keycardai-oauth): centralize issuer resolution Co-Authored-By: Larry Osakwe --- .../oauth/src/keycardai/oauth/pkce/_issuer.py | 78 +++++++++++++++++- .../oauth/src/keycardai/oauth/pkce/client.py | 81 ++++--------------- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/packages/oauth/src/keycardai/oauth/pkce/_issuer.py b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py index 076bb5e5..da190389 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/_issuer.py +++ b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py @@ -1,9 +1,19 @@ """Shared authorization-server issuer resolution for PKCE flows.""" +import re +from typing import Any + import httpx from ..exceptions import ConfigError +__all__ = [ + "resolve_issuer_from_challenge", + "_resolve_auth_server_url", + "_fetch_resource_metadata", + "_extract_resource_metadata_url", +] + async def _resolve_auth_server_url( *, @@ -32,8 +42,72 @@ async def _resolve_auth_server_url( "WWW-Authenticate challenge" ) - from .client import resolve_issuer_from_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 diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index c49591f7..eaa64fe8 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -24,10 +24,8 @@ """ import logging -import re import secrets import webbrowser -from typing import Any import httpx @@ -36,11 +34,24 @@ from ..operations._authorize import build_authorize_url from ..types.models import ClientConfig, TokenResponse from ..utils.pkce import PKCEGenerator -from ._issuer import _resolve_auth_server_url +from ._issuer import ( + _extract_resource_metadata_url, + _fetch_resource_metadata, + _resolve_auth_server_url, + resolve_issuer_from_challenge, +) from .callback import OAuthCallbackServer logger = logging.getLogger(__name__) +__all__ = [ + "authenticate", + "resolve_issuer_from_challenge", + "_resolve_auth_server_url", + "_fetch_resource_metadata", + "_extract_resource_metadata_url", +] + async def authenticate( *, @@ -171,67 +182,3 @@ async def authenticate( client_id=client_id, resource=resource_url, ) -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 From 398fe8a4983a49ce0f008dec47600f6c7c6d0284 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 18:55:41 +0000 Subject: [PATCH 4/6] refactor(keycardai-oauth): remove issuer compatibility shims Co-Authored-By: Larry Osakwe --- .../oauth/src/keycardai/oauth/pkce/__init__.py | 3 ++- .../oauth/src/keycardai/oauth/pkce/_issuer.py | 7 ------- packages/oauth/src/keycardai/oauth/pkce/client.py | 15 +-------------- .../tests/keycardai/oauth/pkce/test_client.py | 2 +- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/oauth/src/keycardai/oauth/pkce/__init__.py b/packages/oauth/src/keycardai/oauth/pkce/__init__.py index a21a522e..e83a443b 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/__init__.py +++ b/packages/oauth/src/keycardai/oauth/pkce/__init__.py @@ -48,8 +48,9 @@ ) """ +from ._issuer import resolve_issuer_from_challenge from .callback import OAuthCallbackServer -from .client import authenticate, resolve_issuer_from_challenge +from .client import authenticate from .web import AuthorizationRedirect, begin_authorization, complete_authorization __all__ = [ diff --git a/packages/oauth/src/keycardai/oauth/pkce/_issuer.py b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py index da190389..89b3e46f 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/_issuer.py +++ b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py @@ -7,13 +7,6 @@ from ..exceptions import ConfigError -__all__ = [ - "resolve_issuer_from_challenge", - "_resolve_auth_server_url", - "_fetch_resource_metadata", - "_extract_resource_metadata_url", -] - async def _resolve_auth_server_url( *, diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index eaa64fe8..b0d8af37 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -34,24 +34,11 @@ from ..operations._authorize import build_authorize_url from ..types.models import ClientConfig, TokenResponse from ..utils.pkce import PKCEGenerator -from ._issuer import ( - _extract_resource_metadata_url, - _fetch_resource_metadata, - _resolve_auth_server_url, - resolve_issuer_from_challenge, -) +from ._issuer import _resolve_auth_server_url from .callback import OAuthCallbackServer logger = logging.getLogger(__name__) -__all__ = [ - "authenticate", - "resolve_issuer_from_challenge", - "_resolve_auth_server_url", - "_fetch_resource_metadata", - "_extract_resource_metadata_url", -] - async def authenticate( *, diff --git a/packages/oauth/tests/keycardai/oauth/pkce/test_client.py b/packages/oauth/tests/keycardai/oauth/pkce/test_client.py index 6bb0af2e..8ef63272 100644 --- a/packages/oauth/tests/keycardai/oauth/pkce/test_client.py +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_client.py @@ -16,7 +16,7 @@ authenticate, resolve_issuer_from_challenge, ) -from keycardai.oauth.pkce.client import _extract_resource_metadata_url +from keycardai.oauth.pkce._issuer import _extract_resource_metadata_url from keycardai.oauth.types.models import TokenResponse WWW_AUTHENTICATE = ( From c906d2d0f5c1320945f23e6e51703628f1edb777 Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 20:15:30 +0000 Subject: [PATCH 5/6] feat(keycardai-oauth): support cached metadata in web authorization flow Co-Authored-By: Larry Osakwe --- packages/oauth/README.md | 16 +- .../web_authorization_code_flow/README.md | 8 +- .../web_authorization_code_flow/main.py | 11 +- .../oauth/src/keycardai/oauth/pkce/client.py | 8 +- .../oauth/src/keycardai/oauth/pkce/web.py | 150 +++++++++++----- .../tests/keycardai/oauth/pkce/test_web.py | 167 +++++++++++++++++- 6 files changed, 301 insertions(+), 59 deletions(-) diff --git a/packages/oauth/README.md b/packages/oauth/README.md index af9e3908..afb08f92 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -99,16 +99,26 @@ asyncio.run(main()) 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: +state between requests. If the application already caches authorization server +metadata, pass it to both route handlers to skip discovery: ```python +from keycardai.oauth import AuthorizationServerMetadata from keycardai.oauth.pkce import begin_authorization, complete_authorization +# Load once at startup and refresh according to the application's cache policy. +metadata = AuthorizationServerMetadata( + issuer="https://oauth.example.com", + authorization_endpoint="https://oauth.example.com/authorize", + token_endpoint="https://oauth.example.com/token", +) + + 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", + metadata=metadata, scopes=["openid"], ) session["oauth_flow"] = { @@ -125,8 +135,8 @@ async def callback_route(request, session): state=flow["state"], code_verifier=flow["code_verifier"], client_id="my-web-app", - issuer="https://oauth.example.com", redirect_uri="https://app.example.com/oauth/callback", + metadata=metadata, ) ``` diff --git a/packages/oauth/examples/web_authorization_code_flow/README.md b/packages/oauth/examples/web_authorization_code_flow/README.md index 57bf03de..79e759b3 100644 --- a/packages/oauth/examples/web_authorization_code_flow/README.md +++ b/packages/oauth/examples/web_authorization_code_flow/README.md @@ -1,8 +1,9 @@ # 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`. +application caches authorization server metadata, 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 @@ -16,7 +17,8 @@ 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. +with your authorization server before connecting the handlers to routes. Load +and refresh the cached metadata according to your application's policy. ## Requirements diff --git a/packages/oauth/examples/web_authorization_code_flow/main.py b/packages/oauth/examples/web_authorization_code_flow/main.py index b3796f65..c23e6f7c 100644 --- a/packages/oauth/examples/web_authorization_code_flow/main.py +++ b/packages/oauth/examples/web_authorization_code_flow/main.py @@ -7,17 +7,22 @@ begin_authorization, complete_authorization, ) -from keycardai.oauth.types.models import TokenResponse +from keycardai.oauth.types.models import AuthorizationServerMetadata, TokenResponse session: dict[str, dict[str, str]] = {} +cached_metadata = AuthorizationServerMetadata( + issuer="https://oauth.example.com", + authorization_endpoint="https://oauth.example.com/authorize", + token_endpoint="https://oauth.example.com/token", +) 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", + metadata=cached_metadata, scopes=["openid", "profile"], ) session["oauth_flow"] = { @@ -35,8 +40,8 @@ async def oauth_callback(callback_params: Mapping[str, str]) -> TokenResponse: state=flow["state"], code_verifier=flow["code_verifier"], client_id="my-web-app", - issuer="https://oauth.example.com", redirect_uri="https://app.example.com/oauth/callback", + metadata=cached_metadata, ) diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index b0d8af37..484e438b 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -114,16 +114,16 @@ async def authenticate( RuntimeError: If the authorization redirect carried an OAuth ``error`` parameter. """ - if issuer is not None: - logger.info("PKCE flow starting against issuer %s", issuer) - else: - logger.info("PKCE flow starting for resource %s", resource_url) auth_server_url = await _resolve_auth_server_url( issuer=issuer, www_authenticate_header=www_authenticate_header, resource_url=resource_url, http_client=http_client, ) + if issuer is not None: + logger.info("PKCE flow starting against issuer %s", issuer) + else: + logger.info("PKCE flow starting for resource %s", resource_url) auth_strategy = ( BasicAuth(client_id, client_secret) if client_secret else NoneAuth() diff --git a/packages/oauth/src/keycardai/oauth/pkce/web.py b/packages/oauth/src/keycardai/oauth/pkce/web.py index 01c540d7..3fd6c3d8 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/web.py +++ b/packages/oauth/src/keycardai/oauth/pkce/web.py @@ -14,12 +14,18 @@ from ..client import AsyncClient from ..exceptions import ( AuthorizationDeniedError, + ConfigError, OAuthProtocolError, StateMismatchError, ) from ..http.auth import BasicAuth, NoneAuth from ..operations._authorize import build_authorize_url -from ..types.models import ClientConfig, TokenResponse +from ..types.models import ( + AuthorizationServerMetadata, + ClientConfig, + Endpoints, + TokenResponse, +) from ..utils.pkce import PKCEGenerator from ._issuer import _resolve_auth_server_url @@ -46,6 +52,7 @@ async def begin_authorization( resource_url: str | None = None, www_authenticate_header: str | None = None, issuer: str | None = None, + metadata: AuthorizationServerMetadata | None = None, scopes: list[str] | None = None, http_client: httpx.AsyncClient | None = None, ) -> AuthorizationRedirect: @@ -65,6 +72,9 @@ async def begin_authorization( RFC 9728. Mutually exclusive with ``issuer``. issuer: Authorization server issuer URL to use directly. Mutually exclusive with ``www_authenticate_header``. + metadata: Optional pre-discovered authorization server metadata. + Discovery is skipped when provided, and the application owns + caching and refreshing this metadata. scopes: Optional list of OAuth scopes to request. http_client: Optional ``httpx.AsyncClient`` used to fetch protected resource metadata in challenge-driven mode. When omitted, a @@ -76,47 +86,62 @@ async def begin_authorization( application-controlled session state. Raises: - keycardai.oauth.ConfigError: If both or neither issuer entry modes are + keycardai.oauth.ConfigError: If anything other than exactly one of + ``issuer``, ``www_authenticate_header``, or ``metadata`` is provided, or challenge mode omits ``resource_url``. - ValueError: If discovery fails because required authorization server - endpoints are missing, or because challenge discovery metadata is - incomplete. + ValueError: If the authorization endpoint is missing from the supplied + metadata or discovered server metadata, or if challenge discovery + metadata is incomplete. httpx.HTTPStatusError: If fetching protected resource metadata fails. keycardai.oauth.OAuthHttpError: If authorization server discovery returns an HTTP error. keycardai.oauth.OAuthProtocolError: If authorization server discovery returns an OAuth protocol error. """ - auth_server_url = await _resolve_auth_server_url( + _validate_entry_mode( issuer=issuer, www_authenticate_header=www_authenticate_header, - resource_url=resource_url, - http_client=http_client, + metadata=metadata, ) - auth_strategy = NoneAuth() - config = ClientConfig(enable_metadata_discovery=True, auto_register_client=False) - async with AsyncClient( - issuer=auth_server_url, auth=auth_strategy, config=config - ) as oauth_client: - endpoints = await oauth_client.get_endpoints() - if not endpoints.authorize or not endpoints.token: + if metadata is not None: + if metadata.authorization_endpoint is None: raise ValueError( - "Authorization server metadata is missing authorization_endpoint " - "or token_endpoint" + "Authorization server metadata is missing authorization_endpoint" ) - - pkce = PKCEGenerator().generate_pkce_pair() - state = secrets.token_urlsafe(32) - url = build_authorize_url( - endpoints.authorize, - client_id=client_id, - redirect_uri=redirect_uri, - pkce=pkce, - resources=[resource_url] if resource_url else None, - scope=" ".join(scopes) if scopes else None, - state=state, + authorization_endpoint = metadata.authorization_endpoint + else: + auth_server_url = await _resolve_auth_server_url( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + resource_url=resource_url, + http_client=http_client, ) + config = ClientConfig( + enable_metadata_discovery=True, auto_register_client=False + ) + + async with AsyncClient( + issuer=auth_server_url, auth=NoneAuth(), config=config + ) as oauth_client: + endpoints = await oauth_client.get_endpoints() + if not endpoints.authorize: + raise ValueError( + "Authorization server metadata is missing authorization_endpoint" + ) + authorization_endpoint = endpoints.authorize + + pkce = PKCEGenerator().generate_pkce_pair() + state = secrets.token_urlsafe(32) + url = build_authorize_url( + authorization_endpoint, + client_id=client_id, + redirect_uri=redirect_uri, + pkce=pkce, + resources=[resource_url] if resource_url else None, + scope=" ".join(scopes) if scopes else None, + state=state, + ) return AuthorizationRedirect( url=url, @@ -135,6 +160,7 @@ async def complete_authorization( resource_url: str | None = None, www_authenticate_header: str | None = None, issuer: str | None = None, + metadata: AuthorizationServerMetadata | None = None, client_secret: str | None = None, http_client: httpx.AsyncClient | None = None, ) -> TokenResponse: @@ -160,6 +186,9 @@ async def complete_authorization( RFC 9728. Mutually exclusive with ``issuer``. issuer: Authorization server issuer URL to use directly. Mutually exclusive with ``www_authenticate_header``. + metadata: Optional pre-discovered authorization server metadata. + Discovery is skipped when provided, and the application owns + caching and refreshing this metadata. client_secret: Optional client secret for confidential clients. Public clients omit this and use no token-endpoint auth. http_client: Optional ``httpx.AsyncClient`` used to fetch protected @@ -171,11 +200,12 @@ async def complete_authorization( endpoint. Raises: - keycardai.oauth.ConfigError: If both or neither issuer entry modes are + keycardai.oauth.ConfigError: If anything other than exactly one of + ``issuer``, ``www_authenticate_header``, or ``metadata`` is provided, or challenge mode omits ``resource_url``. - ValueError: If discovery fails because required authorization server - endpoints are missing, or because challenge discovery metadata is - incomplete. + ValueError: If the token endpoint is missing from the supplied + metadata or discovered server metadata, or if challenge discovery + metadata is incomplete. AuthorizationDeniedError: If the callback carries an OAuth authorization error. No token request is made. StateMismatchError: If the callback state is missing or does not match @@ -208,26 +238,44 @@ async def complete_authorization( operation="authorization callback", ) - auth_server_url = await _resolve_auth_server_url( + _validate_entry_mode( issuer=issuer, www_authenticate_header=www_authenticate_header, - resource_url=resource_url, - http_client=http_client, + metadata=metadata, ) + if metadata is not None: + if metadata.token_endpoint is None: + raise ValueError( + "Authorization server metadata is missing token_endpoint" + ) + auth_server_url = metadata.issuer + else: + auth_server_url = await _resolve_auth_server_url( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + resource_url=resource_url, + http_client=http_client, + ) + auth_strategy = ( BasicAuth(client_id, client_secret) if client_secret else NoneAuth() ) - config = ClientConfig(enable_metadata_discovery=True, auto_register_client=False) + config = ClientConfig( + enable_metadata_discovery=metadata is None, auto_register_client=False + ) async with AsyncClient( - issuer=auth_server_url, auth=auth_strategy, config=config + issuer=auth_server_url, + auth=auth_strategy, + config=config, + endpoints=Endpoints(token=metadata.token_endpoint) if metadata else None, ) as oauth_client: - endpoints = await oauth_client.get_endpoints() - if not endpoints.authorize or not endpoints.token: - raise ValueError( - "Authorization server metadata is missing authorization_endpoint " - "or token_endpoint" - ) + if metadata is None: + endpoints = await oauth_client.get_endpoints() + if not endpoints.token: + raise ValueError( + "Authorization server metadata is missing token_endpoint" + ) return await oauth_client.exchange_authorization_code( code=code, redirect_uri=redirect_uri, @@ -235,3 +283,19 @@ async def complete_authorization( client_id=client_id, resource=resource_url, ) + + +def _validate_entry_mode( + *, + issuer: str | None, + www_authenticate_header: str | None, + metadata: AuthorizationServerMetadata | None, +) -> None: + if sum( + value is not None + for value in (issuer, www_authenticate_header, metadata) + ) != 1: + raise ConfigError( + "Provide exactly one of 'issuer', 'www_authenticate_header', " + "or 'metadata'" + ) diff --git a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py index fdbc6db0..da8b3448 100644 --- a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py @@ -18,7 +18,11 @@ begin_authorization, complete_authorization, ) -from keycardai.oauth.types.models import TokenResponse +from keycardai.oauth.types.models import ( + AuthorizationServerMetadata, + Endpoints, + TokenResponse, +) from keycardai.oauth.utils.pkce import PKCEGenerator WWW_AUTHENTICATE = ( @@ -57,6 +61,29 @@ async def test_begin_returns_redirect_and_pkce_values(monkeypatch): assert captured["issuer"] == "https://auth.example.com" +@pytest.mark.asyncio +async def test_begin_uses_metadata_without_constructing_client(monkeypatch): + async_client = MagicMock() + monkeypatch.setattr("keycardai.oauth.pkce.web.AsyncClient", async_client) + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint="https://auth.example.com/token", + ) + + result = await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + metadata=metadata, + scopes=["openid"], + ) + + assert urlsplit(result.url).netloc == "auth.example.com" + assert urlsplit(result.url).path == "/authorize" + assert parse_qs(urlsplit(result.url).query)["scope"] == ["openid"] + async_client.assert_not_called() + + @pytest.mark.asyncio async def test_complete_exchanges_matching_state(monkeypatch): captured = {} @@ -86,6 +113,35 @@ async def test_complete_exchanges_matching_state(monkeypatch): } +@pytest.mark.asyncio +async def test_complete_uses_metadata_without_discovery(monkeypatch): + captured = {} + token = TokenResponse(access_token="token") + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory(captured=captured, exchange_response=token), + ) + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint="https://auth.example.com/token", + ) + + result = await complete_authorization( + callback_params={"code": "auth-code", "state": "stored-state"}, + state="stored-state", + code_verifier="stored-verifier", + client_id="my-app", + redirect_uri="https://app.example.com/callback", + metadata=metadata, + ) + + assert result is token + assert captured["config"].enable_metadata_discovery is False + assert captured["endpoints"] == Endpoints(token=metadata.token_endpoint) + captured["get_endpoints"].assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize( "callback_params", @@ -299,24 +355,129 @@ async def test_begin_challenge_mode_requires_resource_url(): ) +@pytest.mark.asyncio +async def test_begin_rejects_missing_authorization_endpoint(monkeypatch): + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory( + endpoint_result=MagicMock( + authorize=None, token="https://auth.example.com/token" + ) + ), + ) + + with pytest.raises(ValueError, match="authorization_endpoint"): + await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + issuer="https://auth.example.com", + ) + + +@pytest.mark.asyncio +async def test_complete_rejects_missing_token_endpoint(monkeypatch): + monkeypatch.setattr( + "keycardai.oauth.pkce.web.AsyncClient", + _async_client_factory( + endpoint_result=MagicMock( + authorize="https://auth.example.com/authorize", token=None + ) + ), + ) + + with pytest.raises(ValueError, match="token_endpoint"): + await complete_authorization( + callback_params={"code": "code", "state": "state"}, + state="state", + code_verifier="verifier", + client_id="my-app", + redirect_uri="https://app.example.com/callback", + issuer="https://auth.example.com", + ) + + +@pytest.mark.asyncio +async def test_begin_rejects_metadata_without_authorization_endpoint(): + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint=None, + token_endpoint="https://auth.example.com/token", + ) + + with pytest.raises(ValueError, match="authorization_endpoint"): + await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + metadata=metadata, + ) + + +@pytest.mark.asyncio +async def test_complete_rejects_metadata_without_token_endpoint(): + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint=None, + ) + + with pytest.raises(ValueError, match="token_endpoint"): + await complete_authorization( + callback_params={"code": "code", "state": "state"}, + state="state", + code_verifier="verifier", + client_id="my-app", + redirect_uri="https://app.example.com/callback", + metadata=metadata, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "entry_kwargs", + [ + {"issuer": "https://auth.example.com"}, + {"www_authenticate_header": WWW_AUTHENTICATE}, + ], +) +async def test_metadata_cannot_be_combined_with_other_entry_modes(entry_kwargs): + metadata = AuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint="https://auth.example.com/token", + ) + + with pytest.raises(ConfigError, match="exactly one"): + await begin_authorization( + client_id="my-app", + redirect_uri="https://app.example.com/callback", + metadata=metadata, + **entry_kwargs, + ) + + def _async_client_factory( *, captured: dict | None = None, exchange_response: TokenResponse | None = None, exchange: AsyncMock | None = None, + endpoint_result: MagicMock | None = None, ): - def factory(issuer=None, *, auth, config): + def factory(issuer=None, *, auth, config, endpoints=None): if captured is not None: captured["issuer"] = issuer captured["auth"] = auth captured["config"] = config + captured["endpoints"] = endpoints instance = MagicMock() instance.get_endpoints = AsyncMock( - return_value=MagicMock( + return_value=endpoint_result + or MagicMock( authorize="https://auth.example.com/authorize", token="https://auth.example.com/token", ) ) + if captured is not None: + captured["get_endpoints"] = instance.get_endpoints instance.exchange_authorization_code = ( exchange if exchange is not None From f61a6d6c0542bb45263fad5691029f2def16bddc Mon Sep 17 00:00:00 2001 From: devin-ai-keycard Date: Mon, 24 Aug 2026 20:17:29 +0000 Subject: [PATCH 6/6] fix(keycardai-oauth): restore issuer-first web-flow docs Co-Authored-By: Larry Osakwe --- packages/oauth/README.md | 35 ++++++++++++------- .../web_authorization_code_flow/README.md | 13 ++++--- .../web_authorization_code_flow/main.py | 11 ++---- .../tests/keycardai/oauth/pkce/test_web.py | 31 +++++++++++++--- 4 files changed, 60 insertions(+), 30 deletions(-) diff --git a/packages/oauth/README.md b/packages/oauth/README.md index afb08f92..09731716 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -99,26 +99,16 @@ asyncio.run(main()) 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. If the application already caches authorization server -metadata, pass it to both route handlers to skip discovery: +state between requests: ```python -from keycardai.oauth import AuthorizationServerMetadata from keycardai.oauth.pkce import begin_authorization, complete_authorization -# Load once at startup and refresh according to the application's cache policy. -metadata = AuthorizationServerMetadata( - issuer="https://oauth.example.com", - authorization_endpoint="https://oauth.example.com/authorize", - token_endpoint="https://oauth.example.com/token", -) - - 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", - metadata=metadata, scopes=["openid"], ) session["oauth_flow"] = { @@ -136,10 +126,29 @@ async def callback_route(request, session): code_verifier=flow["code_verifier"], client_id="my-web-app", redirect_uri="https://app.example.com/oauth/callback", - metadata=metadata, + 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 diff --git a/packages/oauth/examples/web_authorization_code_flow/README.md b/packages/oauth/examples/web_authorization_code_flow/README.md index 79e759b3..4c04c869 100644 --- a/packages/oauth/examples/web_authorization_code_flow/README.md +++ b/packages/oauth/examples/web_authorization_code_flow/README.md @@ -1,9 +1,8 @@ # Web-App Authorization Code Flow Example Demonstrates the stateless web-app authorization-code flow with PKCE. A web -application caches authorization server metadata, stores `state` and -`code_verifier` in session state between its login and callback routes, then -passes them to `complete_authorization`. +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 @@ -17,8 +16,12 @@ 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. Load -and refresh the cached metadata according to your application's policy. +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 diff --git a/packages/oauth/examples/web_authorization_code_flow/main.py b/packages/oauth/examples/web_authorization_code_flow/main.py index c23e6f7c..5749a598 100644 --- a/packages/oauth/examples/web_authorization_code_flow/main.py +++ b/packages/oauth/examples/web_authorization_code_flow/main.py @@ -7,22 +7,17 @@ begin_authorization, complete_authorization, ) -from keycardai.oauth.types.models import AuthorizationServerMetadata, TokenResponse +from keycardai.oauth.types.models import TokenResponse session: dict[str, dict[str, str]] = {} -cached_metadata = AuthorizationServerMetadata( - issuer="https://oauth.example.com", - authorization_endpoint="https://oauth.example.com/authorize", - token_endpoint="https://oauth.example.com/token", -) 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", - metadata=cached_metadata, scopes=["openid", "profile"], ) session["oauth_flow"] = { @@ -41,7 +36,7 @@ async def oauth_callback(callback_params: Mapping[str, str]) -> TokenResponse: code_verifier=flow["code_verifier"], client_id="my-web-app", redirect_uri="https://app.example.com/oauth/callback", - metadata=cached_metadata, + issuer="https://oauth.example.com", ) diff --git a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py index da8b3448..5a2b8aaf 100644 --- a/packages/oauth/tests/keycardai/oauth/pkce/test_web.py +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py @@ -432,6 +432,28 @@ async def test_complete_rejects_metadata_without_token_endpoint(): @pytest.mark.asyncio +@pytest.mark.parametrize( + "function, base_kwargs", + [ + ( + begin_authorization, + { + "client_id": "my-app", + "redirect_uri": "https://app.example.com/callback", + }, + ), + ( + complete_authorization, + { + "callback_params": {"code": "code", "state": "state"}, + "state": "state", + "code_verifier": "verifier", + "client_id": "my-app", + "redirect_uri": "https://app.example.com/callback", + }, + ), + ], +) @pytest.mark.parametrize( "entry_kwargs", [ @@ -439,7 +461,9 @@ async def test_complete_rejects_metadata_without_token_endpoint(): {"www_authenticate_header": WWW_AUTHENTICATE}, ], ) -async def test_metadata_cannot_be_combined_with_other_entry_modes(entry_kwargs): +async def test_metadata_cannot_be_combined_with_other_entry_modes( + function, base_kwargs, entry_kwargs +): metadata = AuthorizationServerMetadata( issuer="https://auth.example.com", authorization_endpoint="https://auth.example.com/authorize", @@ -447,9 +471,8 @@ async def test_metadata_cannot_be_combined_with_other_entry_modes(entry_kwargs): ) with pytest.raises(ConfigError, match="exactly one"): - await begin_authorization( - client_id="my-app", - redirect_uri="https://app.example.com/callback", + await function( + **base_kwargs, metadata=metadata, **entry_kwargs, )