diff --git a/packages/kaya-oidc/README.md b/packages/kaya-oidc/README.md index a5256e0..3fad4fa 100644 --- a/packages/kaya-oidc/README.md +++ b/packages/kaya-oidc/README.md @@ -128,6 +128,37 @@ app = KayaApp(mixins=[session, google, github]) > default provider is `default`). Existing logged-in users are signed out once > and simply re-authenticate. +## Pushed authorization requests (PAR) + +`OIDCConfig.par` controls RFC 9126 Pushed Authorization Requests: + +- `'never'` (default): the authorization request travels as query parameters, + as before. +- `'auto'`: push the request whenever the provider advertises a + `pushed_authorization_request_endpoint`; otherwise fall back to the plain + redirect. +- `'always'`: always push; login raises a `RuntimeError` if the provider does + not support PAR. + +```python +OIDCConfig(..., par='auto') +``` + +A provider that advertises `require_pushed_authorization_requests: true` must be +configured with `par='auto'` or `par='always'`. With `par='never'` the login +fails fast with a clear error instead of issuing a redirect the provider would +reject. + +The same parameters as the plain authorization request (PKCE challenge, +`state`, `nonce`, scopes, ...) are POSTed directly to the provider, and the +browser is redirected with only `client_id` and the single-use `request_uri`. +Request parameters therefore no longer travel through the user agent. PKCE, +`state`, and `nonce` are still used and protect against request URI swapping. + +Client authentication follows the token endpoint: HTTP Basic when +`client_secret` is set, or none for public clients. PAR with other client +authentication methods (`private_key_jwt`, mTLS) is not supported. + ## Features - Generic OIDC discovery @@ -138,6 +169,7 @@ app = KayaApp(mixins=[session, google, github]) - Optional userinfo endpoint fetch - Refresh token support - RP-initiated logout (when provider advertises `end_session_endpoint`) +- Pushed Authorization Requests (RFC 9126) via `OIDCConfig.par` - Multiple providers in one app via `OIDCProviders` (or several `OIDCMixin` instances) - `single-identity` (default) or `multi-identity` session strategy - Composable with any other `KayaMixin` (RSGI, MCP, etc.) diff --git a/packages/kaya-oidc/src/kaya/oidc/__init__.py b/packages/kaya-oidc/src/kaya/oidc/__init__.py index 0a3f4c9..3d45c52 100644 --- a/packages/kaya-oidc/src/kaya/oidc/__init__.py +++ b/packages/kaya-oidc/src/kaya/oidc/__init__.py @@ -1,5 +1,5 @@ from ._client import OIDCClient -from ._config import OIDCConfig +from ._config import OIDCConfig, PARPolicy from ._mixin import IdentityStrategy, OIDCMixin, OIDCUser from ._providers import OIDCProviders @@ -11,4 +11,5 @@ __all__ = [ 'OIDCMixin', 'OIDCProviders', 'OIDCUser', + 'PARPolicy', ] diff --git a/packages/kaya-oidc/src/kaya/oidc/_client.py b/packages/kaya-oidc/src/kaya/oidc/_client.py index a0f94b7..9458d4c 100644 --- a/packages/kaya-oidc/src/kaya/oidc/_client.py +++ b/packages/kaya-oidc/src/kaya/oidc/_client.py @@ -5,7 +5,7 @@ import httpx import jwt from jwt import PyJWK -from ._config import OIDCConfig +from ._config import PARPolicy, OIDCConfig from ._utils import generate_nonce, generate_pkce, generate_state @@ -42,8 +42,14 @@ class OIDCClient: async with httpx.AsyncClient() as client: return await client.request(method, url, **kwargs) - async def build_authorization_url(self) -> tuple[str, str, str, str]: - """Return (authorization_url, state, nonce, code_verifier).""" + async def build_authorization_url(self, par: PARPolicy = 'never') -> tuple[str, str, str, str]: + """Return (authorization_url, state, nonce, code_verifier). + + When ``par`` selects Pushed Authorization Requests (RFC 9126), the + authorization request parameters are POSTed to the provider's PAR + endpoint first and the returned URL carries only ``client_id`` and + ``request_uri``. + """ metadata = await self._ensure_metadata() authorization_endpoint = metadata.get('authorization_endpoint') if not isinstance(authorization_endpoint, str): @@ -54,7 +60,6 @@ class OIDCClient: code_verifier, code_challenge = generate_pkce() params = { - 'client_id': self._config.client_id, 'response_type': 'code', 'scope': ' '.join(self._config.scopes), 'redirect_uri': self._config.redirect_uri, @@ -63,9 +68,67 @@ class OIDCClient: 'code_challenge': code_challenge, 'code_challenge_method': 'S256', } - url = authorization_endpoint + '?' + urlencode(params) + + if self._use_par(metadata, par): + request_uri = await self._push_authorization_request(metadata, params) + redirect_params = {'client_id': self._config.client_id, 'request_uri': request_uri} + else: + redirect_params = {'client_id': self._config.client_id, **params} + + url = authorization_endpoint + '?' + urlencode(redirect_params) return url, state, nonce, code_verifier + def _use_par(self, metadata: Mapping[str, Any], policy: PARPolicy) -> bool: + endpoint = metadata.get('pushed_authorization_request_endpoint') + required = metadata.get('require_pushed_authorization_requests') is True + + if required: + if not isinstance(endpoint, str): + raise RuntimeError( + 'OIDC provider requires pushed authorization requests but does not ' + 'advertise a pushed_authorization_request_endpoint' + ) + if policy == 'never': + raise RuntimeError( + "OIDC provider requires pushed authorization requests; set par to " + "'auto' or 'always'" + ) + return True + + if policy == 'always': + if not isinstance(endpoint, str): + raise RuntimeError( + 'OIDC provider does not advertise a pushed_authorization_request_endpoint' + ) + return True + + return policy == 'auto' and isinstance(endpoint, str) + + async def _push_authorization_request( + self, metadata: Mapping[str, Any], params: Mapping[str, str] + ) -> str: + endpoint = metadata.get('pushed_authorization_request_endpoint') + if not isinstance(endpoint, str): + raise RuntimeError( + 'OIDC provider does not advertise a pushed_authorization_request_endpoint' + ) + + # RFC 9126 section 2.1: client_id is required in the body even when the + # client authenticates with the Authorization header. + data = {'client_id': self._config.client_id, **params} + resp = await self._request('POST', endpoint, data=data, auth=self._client_auth()) + resp.raise_for_status() + body = resp.json() + request_uri = body.get('request_uri') if isinstance(body, Mapping) else None + if not isinstance(request_uri, str): + raise RuntimeError('PAR endpoint response is missing request_uri') + return request_uri + + def _client_auth(self) -> Optional[tuple[str, str]]: + if self._config.client_secret is None: + return None + return (self._config.client_id, self._config.client_secret) + async def fetch_token(self, code: str, code_verifier: str) -> Mapping[str, Any]: metadata = await self._ensure_metadata() token_endpoint = metadata.get('token_endpoint') @@ -79,11 +142,8 @@ class OIDCClient: 'client_id': self._config.client_id, 'code_verifier': code_verifier, } - auth: Optional[tuple[str, str]] = None - if self._config.client_secret is not None: - auth = (self._config.client_id, self._config.client_secret) - resp = await self._request('POST', token_endpoint, data=data, auth=auth) + resp = await self._request('POST', token_endpoint, data=data, auth=self._client_auth()) resp.raise_for_status() return cast(Mapping[str, Any], resp.json()) @@ -98,11 +158,8 @@ class OIDCClient: 'refresh_token': refresh_token, 'client_id': self._config.client_id, } - auth: Optional[tuple[str, str]] = None - if self._config.client_secret is not None: - auth = (self._config.client_id, self._config.client_secret) - resp = await self._request('POST', token_endpoint, data=data, auth=auth) + resp = await self._request('POST', token_endpoint, data=data, auth=self._client_auth()) resp.raise_for_status() return cast(Mapping[str, Any], resp.json()) diff --git a/packages/kaya-oidc/src/kaya/oidc/_config.py b/packages/kaya-oidc/src/kaya/oidc/_config.py index e8e1aa8..1715429 100644 --- a/packages/kaya-oidc/src/kaya/oidc/_config.py +++ b/packages/kaya-oidc/src/kaya/oidc/_config.py @@ -1,11 +1,15 @@ import re from dataclasses import dataclass, field -from typing import Any, Optional, Sequence +from typing import Any, Literal, Optional, Sequence import httpx _PROVIDER_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$') +type PARPolicy = Literal['never', 'auto', 'always'] + +_PAR_POLICIES: tuple[PARPolicy, ...] = ('never', 'auto', 'always') + @dataclass class OIDCConfig: @@ -15,6 +19,13 @@ class OIDCConfig: session state (``oidc:{provider}:...``) so several providers can be used in the same application without interfering with each other. + ``par`` selects when to use Pushed Authorization Requests (RFC 9126): + ``'never'`` uses a plain query-string authorization request, ``'auto'`` + pushes the request whenever the provider advertises a + ``pushed_authorization_request_endpoint``, and ``'always'`` requires PAR. + A provider that sets ``require_pushed_authorization_requests`` is always + treated as requiring PAR. + ``client_secret`` is optional; omit it for public clients. ``fetch_userinfo`` controls whether the userinfo endpoint is queried after token exchange. @@ -43,6 +54,7 @@ class OIDCConfig: )) http_client: Optional[httpx.AsyncClient] = None provider: str = 'default' + par: PARPolicy = 'never' def __post_init__(self) -> None: if not self.provider or _PROVIDER_PATTERN.match(self.provider) is None: @@ -51,6 +63,11 @@ class OIDCConfig: "underscores or hyphens (got " f'{self.provider!r})' ) + if self.par not in _PAR_POLICIES: + raise ValueError( + f'par must be one of {", ".join(map(repr, _PAR_POLICIES))} ' + f'(got {self.par!r})' + ) scopes_list = list(self.scopes) if 'openid' not in scopes_list: self.scopes = ('openid', *scopes_list) diff --git a/packages/kaya-oidc/src/kaya/oidc/_mixin.py b/packages/kaya-oidc/src/kaya/oidc/_mixin.py index f8c6470..85d2ae1 100644 --- a/packages/kaya-oidc/src/kaya/oidc/_mixin.py +++ b/packages/kaya-oidc/src/kaya/oidc/_mixin.py @@ -122,7 +122,9 @@ class OIDCMixin(KayaMixin): def apply(self, app: KayaApp) -> None: @app.GET(self._config.login_path) async def login(ctx: HttpContext) -> None: - auth_url, state, nonce, code_verifier = await self._client.build_authorization_url() + auth_url, state, nonce, code_verifier = await self._client.build_authorization_url( + par=self._config.par + ) session = self._session_of(ctx) session[self._key('state')] = state session[self._key('nonce')] = nonce diff --git a/packages/kaya-oidc/tests/test_oidc.py b/packages/kaya-oidc/tests/test_oidc.py index 7c7ae78..a2c22d1 100644 --- a/packages/kaya-oidc/tests/test_oidc.py +++ b/packages/kaya-oidc/tests/test_oidc.py @@ -24,6 +24,9 @@ class MockOIDCProvider: issuer: Optional[str] = None, sub: str = 'user123', email: str = 'user@example.com', + supports_par: bool = False, + require_par: bool = False, + par_error: bool = False, ) -> None: self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) self.public_key = self.private_key.public_key() @@ -32,6 +35,9 @@ class MockOIDCProvider: self.sub = sub self.email = email self.nonce = f'{name}-nonce-value' + self.par_error = par_error + self.par_requests: list[dict[str, list[str]]] = [] + self.par_auth_headers: list[Optional[str]] = [] self.discovery = { 'issuer': self.issuer, 'authorization_endpoint': f'{self.issuer}/auth', @@ -40,6 +46,10 @@ class MockOIDCProvider: 'end_session_endpoint': f'{self.issuer}/logout', 'jwks_uri': f'{self.issuer}/jwks', } + if supports_par or require_par: + self.discovery['pushed_authorization_request_endpoint'] = f'{self.issuer}/par' + if require_par: + self.discovery['require_pushed_authorization_requests'] = True def _public_key_to_jwk(self) -> dict[str, str]: numbers = self.public_key.public_numbers() @@ -80,6 +90,20 @@ class MockOIDCProvider: return httpx.Response(200, json=self.discovery) if path == '/jwks': return httpx.Response(200, json={'keys': [self._public_key_to_jwk()]}) + if path == '/par': + if request.method != 'POST': + return httpx.Response(405) + self.par_requests.append(parse_qs(request.content.decode())) + self.par_auth_headers.append(request.headers.get('Authorization')) + if self.par_error: + return httpx.Response(400, json={ + 'error': 'invalid_request', + 'error_description': 'mock PAR failure', + }) + return httpx.Response(201, json={ + 'request_uri': f'urn:ietf:params:oauth:request_uri:par-{len(self.par_requests)}', + 'expires_in': 60, + }) if path == '/token' and request.method == 'POST': body = request.content.decode() data = dict(part.split('=') for part in body.split('&')) if body else {} @@ -204,6 +228,236 @@ class OIDCClientTest(unittest.TestCase): self.assertEqual(['id-token'], query['id_token_hint']) self.assertEqual(['/'], query['post_logout_redirect_uri']) + @async_test + async def test_build_authorization_url_with_par(self) -> None: + provider = MockOIDCProvider('par', supports_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + client_secret='secret', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + par='always', + ) + client = OIDCClient(config) + + url, state, nonce, verifier = await client.build_authorization_url(par='always') + + query = parse_qs(urlparse(url).query) + self.assertEqual(['client'], query['client_id']) + self.assertEqual(['urn:ietf:params:oauth:request_uri:par-1'], query['request_uri']) + self.assertNotIn('state', query) + self.assertNotIn('nonce', query) + self.assertNotIn('code_challenge', query) + + self.assertEqual(1, len(provider.par_requests)) + data = provider.par_requests[0] + self.assertEqual(['client'], data['client_id']) + self.assertEqual(['code'], data['response_type']) + self.assertEqual([state], data['state']) + self.assertEqual([nonce], data['nonce']) + self.assertEqual(['S256'], data['code_challenge_method']) + self.assertIn('openid', data['scope'][0]) + self.assertIn('code_challenge', data) + self.assertTrue(len(verifier) > 0) + + auth = provider.par_auth_headers[0] + self.assertIsNotNone(auth) + assert auth is not None + self.assertTrue(auth.startswith('Basic ')) + + @async_test + async def test_par_auto_uses_par_only_when_advertised(self) -> None: + provider = MockOIDCProvider('par', supports_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + ) + client = OIDCClient(config) + + url, *_ = await client.build_authorization_url(par='auto') + query = parse_qs(urlparse(url).query) + self.assertIn('request_uri', query) + self.assertEqual(1, len(provider.par_requests)) + + # The default mock provider does not advertise a PAR endpoint. + url, *_ = await self.client.build_authorization_url(par='auto') + query = parse_qs(urlparse(url).query) + self.assertNotIn('request_uri', query) + self.assertIn('state', query) + + @async_test + async def test_par_always_without_endpoint_raises(self) -> None: + with self.assertRaises(RuntimeError): + await self.client.build_authorization_url(par='always') + + @async_test + async def test_required_par_with_never_raises(self) -> None: + provider = MockOIDCProvider('req', require_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + ) + client = OIDCClient(config) + with self.assertRaises(RuntimeError) as ctx: + await client.build_authorization_url(par='never') + self.assertIn('set par', str(ctx.exception)) + self.assertEqual(0, len(provider.par_requests)) + + @async_test + async def test_required_par_with_auto_uses_par(self) -> None: + provider = MockOIDCProvider('req', require_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + ) + client = OIDCClient(config) + url, *_ = await client.build_authorization_url(par='auto') + self.assertIn('request_uri', parse_qs(urlparse(url).query)) + self.assertEqual(1, len(provider.par_requests)) + + @async_test + async def test_par_error_response_propagates(self) -> None: + provider = MockOIDCProvider('par', supports_par=True, par_error=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + par='always', + ) + client = OIDCClient(config) + with self.assertRaises(httpx.HTTPStatusError): + await client.build_authorization_url(par='always') + self.assertEqual(1, len(provider.par_requests)) + + @async_test + async def test_par_public_client_uses_no_basic_auth(self) -> None: + provider = MockOIDCProvider('par', supports_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + par='always', + ) + client = OIDCClient(config) + await client.build_authorization_url(par='always') + self.assertIsNone(provider.par_auth_headers[0]) + self.assertEqual(['client'], provider.par_requests[0]['client_id']) + + def test_config_par_defaults_to_never_and_validates(self) -> None: + config = OIDCConfig( + issuer='https://issuer.local', + client_id='client', + redirect_uri='http://localhost/callback', + ) + self.assertEqual('never', config.par) + with self.assertRaises(ValueError): + OIDCConfig( + issuer='https://issuer.local', + client_id='client', + redirect_uri='http://localhost/callback', + par='bogus', + ) + + +class OIDCParTest(unittest.TestCase): + + @async_test + async def test_login_and_callback_with_par(self) -> None: + provider = MockOIDCProvider('par', email='par@example.com', supports_par=True) + http_client = httpx.AsyncClient(transport=MockTransport(provider)) + store = InMemorySessionStore() + session = SessionMixin(store) + config = OIDCConfig( + issuer=provider.issuer, + client_id='client', + client_secret='secret', + redirect_uri='http://localhost:8000/auth/callback', + http_client=http_client, + par='auto', + fetch_userinfo=True, + ) + oidc = OIDCMixin(config, session=session) + app = KayaApp(mixins=[session, oidc]) + + @app.GET('/profile') + @oidc.require_auth + async def profile(ctx: HttpContext) -> None: + user = oidc.get_user(ctx) + await ctx.send_str(200, user.email if user and user.email else 'none') + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/auth/login', follow_redirects=False) + self.assertEqual(302, r.status_code) + query = parse_qs(urlparse(r.headers['Location']).query) + self.assertIn('request_uri', query) + self.assertNotIn('state', query) + self.assertEqual(1, len(provider.par_requests)) + + state = provider.par_requests[0]['state'][0] + provider.nonce = provider.par_requests[0]['nonce'][0] + + r = await client.get('/auth/callback', params={'code': 'mock-code', 'state': state}, follow_redirects=False) + self.assertEqual(302, r.status_code) + + r = await client.get('/profile') + self.assertEqual('par@example.com', r.text) + + @async_test + async def test_container_applies_policy_per_provider(self) -> None: + par_provider = MockOIDCProvider('par', supports_par=True) + plain_provider = MockOIDCProvider('plain') + par_http = httpx.AsyncClient(transport=MockTransport(par_provider)) + plain_http = httpx.AsyncClient(transport=MockTransport(plain_provider)) + + par_config = OIDCConfig( + issuer=par_provider.issuer, + client_id='par-client', + redirect_uri='http://localhost:8000/auth/withpar/callback', + http_client=par_http, + provider='withpar', + par='always', + ) + plain_config = OIDCConfig( + issuer=plain_provider.issuer, + client_id='plain-client', + redirect_uri='http://localhost:8000/auth/plain/callback', + http_client=plain_http, + provider='plain', + par='never', + ) + session = SessionMixin(InMemorySessionStore()) + oidc = OIDCProviders([par_config, plain_config], session=session) + app = KayaApp(mixins=[session, oidc]) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/auth/withpar/login', follow_redirects=False) + self.assertEqual(302, r.status_code) + self.assertIn('request_uri', parse_qs(urlparse(r.headers['Location']).query)) + + r = await client.get('/auth/plain/login', follow_redirects=False) + self.assertEqual(302, r.status_code) + plain_query = parse_qs(urlparse(r.headers['Location']).query) + self.assertNotIn('request_uri', plain_query) + self.assertIn('state', plain_query) + + class OIDCMixinTest(unittest.TestCase):