diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f896484..a33e3d5 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -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 @@ -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 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 0000000..4c04c86 --- /dev/null +++ b/packages/oauth/examples/web_authorization_code_flow/README.md @@ -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 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 0000000..5749a59 --- /dev/null +++ b/packages/oauth/examples/web_authorization_code_flow/main.py @@ -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() 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 0000000..8b4dff5 --- /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 5e00cde..fb94e89 100644 --- a/packages/oauth/src/keycardai/oauth/__init__.py +++ b/packages/oauth/src/keycardai/oauth/__init__.py @@ -34,6 +34,7 @@ from .client import AsyncClient, Client from .exceptions import ( AuthenticationError, + AuthorizationDeniedError, ConfigError, InvalidTokenError, JWKSError, @@ -43,6 +44,7 @@ OAuthError, OAuthHttpError, OAuthProtocolError, + StateMismatchError, TokenExchangeError, ) from .http.auth import AuthStrategy, BasicAuth, BearerAuth, MultiZoneBasicAuth, NoneAuth @@ -82,6 +84,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 8fb392f..817e909 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. @@ -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 - diff --git a/packages/oauth/src/keycardai/oauth/pkce/__init__.py b/packages/oauth/src/keycardai/oauth/pkce/__init__.py index ea0725d..e83a443 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,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", +] 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 0000000..89b3e46 --- /dev/null +++ b/packages/oauth/src/keycardai/oauth/pkce/_issuer.py @@ -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 diff --git a/packages/oauth/src/keycardai/oauth/pkce/client.py b/packages/oauth/src/keycardai/oauth/pkce/client.py index 015d892..484e438 100644 --- a/packages/oauth/src/keycardai/oauth/pkce/client.py +++ b/packages/oauth/src/keycardai/oauth/pkce/client.py @@ -24,19 +24,17 @@ """ import logging -import re import secrets import webbrowser -from typing import Any 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__) @@ -116,25 +114,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()" - ) - + 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) - 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_strategy = ( BasicAuth(client_id, client_secret) if client_secret else NoneAuth() @@ -180,69 +169,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 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 0000000..3fd6c3d --- /dev/null +++ b/packages/oauth/src/keycardai/oauth/pkce/web.py @@ -0,0 +1,301 @@ +"""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, + ConfigError, + OAuthProtocolError, + StateMismatchError, +) +from ..http.auth import BasicAuth, NoneAuth +from ..operations._authorize import build_authorize_url +from ..types.models import ( + AuthorizationServerMetadata, + ClientConfig, + Endpoints, + TokenResponse, +) +from ..utils.pkce import PKCEGenerator +from ._issuer import _resolve_auth_server_url + + +class AuthorizationRedirect(BaseModel): + """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 + 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, + metadata: AuthorizationServerMetadata | 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. + + 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``. + 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 + 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 anything other than exactly one of + ``issuer``, ``www_authenticate_header``, or ``metadata`` is + provided, or challenge mode omits ``resource_url``. + 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. + """ + _validate_entry_mode( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + metadata=metadata, + ) + + if metadata is not None: + if metadata.authorization_endpoint is None: + raise ValueError( + "Authorization server metadata is missing authorization_endpoint" + ) + 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, + 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, + metadata: AuthorizationServerMetadata | 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`. + + 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``. + 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 + 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 anything other than exactly one of + ``issuer``, ``www_authenticate_header``, or ``metadata`` is + provided, or challenge mode omits ``resource_url``. + 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 + 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: + 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.encode("utf-8"), state.encode("utf-8") + ): + 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", + ) + + _validate_entry_mode( + issuer=issuer, + www_authenticate_header=www_authenticate_header, + 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=metadata is None, auto_register_client=False + ) + + async with AsyncClient( + issuer=auth_server_url, + auth=auth_strategy, + config=config, + endpoints=Endpoints(token=metadata.token_endpoint) if metadata else None, + ) as oauth_client: + 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, + code_verifier=code_verifier, + 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_client.py b/packages/oauth/tests/keycardai/oauth/pkce/test_client.py index 6bb0af2..8ef6327 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 = ( 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 0000000..5a2b8aa --- /dev/null +++ b/packages/oauth/tests/keycardai/oauth/pkce/test_web.py @@ -0,0 +1,536 @@ +"""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 ( + AuthorizationServerMetadata, + Endpoints, + 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_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 = {} + 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 +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", + [{"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_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() + 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", + }, + ), + ], +) +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, + ) + + +@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( + "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", + [ + {"issuer": "https://auth.example.com"}, + {"www_authenticate_header": WWW_AUTHENTICATE}, + ], +) +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", + token_endpoint="https://auth.example.com/token", + ) + + with pytest.raises(ConfigError, match="exactly one"): + await function( + **base_kwargs, + 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, 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=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 + 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