Add Pushed Authorization Request (RFC 9126) support
CI / Build Pip package (push) Successful in 14m8s

OIDCConfig.par selects never (default), auto, or always. When enabled, build_authorization_url pushes the authorization parameters to the provider's PAR endpoint and redirects the browser with only client_id and request_uri. Providers that require PAR raise a clear error when configured with par='never'. Client authentication follows the token endpoint (HTTP Basic or public client).
This commit is contained in:
woggioni committed 2026-10-01 08:23:40 +08:00
1 parent 15dc4b6dad
commit fa8bb0487d
6 files changed
+379 -16

No files matched your search

+32
View File
@@ -128,6 +128,37 @@ app = KayaApp(mixins=[session, google, github])
> default provider is `default`). Existing logged-in users are signed out once > default provider is `default`). Existing logged-in users are signed out once
> and simply re-authenticate. > 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 ## Features
- Generic OIDC discovery - Generic OIDC discovery
@@ -138,6 +169,7 @@ app = KayaApp(mixins=[session, google, github])
- Optional userinfo endpoint fetch - Optional userinfo endpoint fetch
- Refresh token support - Refresh token support
- RP-initiated logout (when provider advertises `end_session_endpoint`) - 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) - Multiple providers in one app via `OIDCProviders` (or several `OIDCMixin` instances)
- `single-identity` (default) or `multi-identity` session strategy - `single-identity` (default) or `multi-identity` session strategy
- Composable with any other `KayaMixin` (RSGI, MCP, etc.) - Composable with any other `KayaMixin` (RSGI, MCP, etc.)
+2 -1
View File
@@ -1,5 +1,5 @@
from ._client import OIDCClient from ._client import OIDCClient
from ._config import OIDCConfig from ._config import OIDCConfig, PARPolicy
from ._mixin import IdentityStrategy, OIDCMixin, OIDCUser from ._mixin import IdentityStrategy, OIDCMixin, OIDCUser
from ._providers import OIDCProviders from ._providers import OIDCProviders
@@ -11,4 +11,5 @@ __all__ = [
'OIDCMixin', 'OIDCMixin',
'OIDCProviders', 'OIDCProviders',
'OIDCUser', 'OIDCUser',
'PARPolicy',
] ]
+70 -13
View File
@@ -5,7 +5,7 @@ import httpx
import jwt import jwt
from jwt import PyJWK from jwt import PyJWK
from ._config import OIDCConfig from ._config import PARPolicy, OIDCConfig
from ._utils import generate_nonce, generate_pkce, generate_state from ._utils import generate_nonce, generate_pkce, generate_state
@@ -42,8 +42,14 @@ class OIDCClient:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
return await client.request(method, url, **kwargs) return await client.request(method, url, **kwargs)
async def build_authorization_url(self) -> tuple[str, str, str, str]: async def build_authorization_url(self, par: PARPolicy = 'never') -> tuple[str, str, str, str]:
"""Return (authorization_url, state, nonce, code_verifier).""" """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() metadata = await self._ensure_metadata()
authorization_endpoint = metadata.get('authorization_endpoint') authorization_endpoint = metadata.get('authorization_endpoint')
if not isinstance(authorization_endpoint, str): if not isinstance(authorization_endpoint, str):
@@ -54,7 +60,6 @@ class OIDCClient:
code_verifier, code_challenge = generate_pkce() code_verifier, code_challenge = generate_pkce()
params = { params = {
'client_id': self._config.client_id,
'response_type': 'code', 'response_type': 'code',
'scope': ' '.join(self._config.scopes), 'scope': ' '.join(self._config.scopes),
'redirect_uri': self._config.redirect_uri, 'redirect_uri': self._config.redirect_uri,
@@ -63,9 +68,67 @@ class OIDCClient:
'code_challenge': code_challenge, 'code_challenge': code_challenge,
'code_challenge_method': 'S256', '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 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]: async def fetch_token(self, code: str, code_verifier: str) -> Mapping[str, Any]:
metadata = await self._ensure_metadata() metadata = await self._ensure_metadata()
token_endpoint = metadata.get('token_endpoint') token_endpoint = metadata.get('token_endpoint')
@@ -79,11 +142,8 @@ class OIDCClient:
'client_id': self._config.client_id, 'client_id': self._config.client_id,
'code_verifier': code_verifier, '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() resp.raise_for_status()
return cast(Mapping[str, Any], resp.json()) return cast(Mapping[str, Any], resp.json())
@@ -98,11 +158,8 @@ class OIDCClient:
'refresh_token': refresh_token, 'refresh_token': refresh_token,
'client_id': self._config.client_id, '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() resp.raise_for_status()
return cast(Mapping[str, Any], resp.json()) return cast(Mapping[str, Any], resp.json())
+18 -1
View File
@@ -1,11 +1,15 @@
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Optional, Sequence from typing import Any, Literal, Optional, Sequence
import httpx import httpx
_PROVIDER_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$') _PROVIDER_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
type PARPolicy = Literal['never', 'auto', 'always']
_PAR_POLICIES: tuple[PARPolicy, ...] = ('never', 'auto', 'always')
@dataclass @dataclass
class OIDCConfig: class OIDCConfig:
@@ -15,6 +19,13 @@ class OIDCConfig:
session state (``oidc:{provider}:...``) so several providers can be used in session state (``oidc:{provider}:...``) so several providers can be used in
the same application without interfering with each other. 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. ``client_secret`` is optional; omit it for public clients.
``fetch_userinfo`` controls whether the userinfo endpoint is queried after ``fetch_userinfo`` controls whether the userinfo endpoint is queried after
token exchange. token exchange.
@@ -43,6 +54,7 @@ class OIDCConfig:
)) ))
http_client: Optional[httpx.AsyncClient] = None http_client: Optional[httpx.AsyncClient] = None
provider: str = 'default' provider: str = 'default'
par: PARPolicy = 'never'
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.provider or _PROVIDER_PATTERN.match(self.provider) is None: if not self.provider or _PROVIDER_PATTERN.match(self.provider) is None:
@@ -51,6 +63,11 @@ class OIDCConfig:
"underscores or hyphens (got " "underscores or hyphens (got "
f'{self.provider!r})' 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) scopes_list = list(self.scopes)
if 'openid' not in scopes_list: if 'openid' not in scopes_list:
self.scopes = ('openid', *scopes_list) self.scopes = ('openid', *scopes_list)
+3 -1
View File
@@ -122,7 +122,9 @@ class OIDCMixin(KayaMixin):
def apply(self, app: KayaApp) -> None: def apply(self, app: KayaApp) -> None:
@app.GET(self._config.login_path) @app.GET(self._config.login_path)
async def login(ctx: HttpContext) -> None: 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._session_of(ctx)
session[self._key('state')] = state session[self._key('state')] = state
session[self._key('nonce')] = nonce session[self._key('nonce')] = nonce
+254
View File
@@ -24,6 +24,9 @@ class MockOIDCProvider:
issuer: Optional[str] = None, issuer: Optional[str] = None,
sub: str = 'user123', sub: str = 'user123',
email: str = 'user@example.com', email: str = 'user@example.com',
supports_par: bool = False,
require_par: bool = False,
par_error: bool = False,
) -> None: ) -> None:
self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
self.public_key = self.private_key.public_key() self.public_key = self.private_key.public_key()
@@ -32,6 +35,9 @@ class MockOIDCProvider:
self.sub = sub self.sub = sub
self.email = email self.email = email
self.nonce = f'{name}-nonce-value' 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 = { self.discovery = {
'issuer': self.issuer, 'issuer': self.issuer,
'authorization_endpoint': f'{self.issuer}/auth', 'authorization_endpoint': f'{self.issuer}/auth',
@@ -40,6 +46,10 @@ class MockOIDCProvider:
'end_session_endpoint': f'{self.issuer}/logout', 'end_session_endpoint': f'{self.issuer}/logout',
'jwks_uri': f'{self.issuer}/jwks', '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]: def _public_key_to_jwk(self) -> dict[str, str]:
numbers = self.public_key.public_numbers() numbers = self.public_key.public_numbers()
@@ -80,6 +90,20 @@ class MockOIDCProvider:
return httpx.Response(200, json=self.discovery) return httpx.Response(200, json=self.discovery)
if path == '/jwks': if path == '/jwks':
return httpx.Response(200, json={'keys': [self._public_key_to_jwk()]}) 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': if path == '/token' and request.method == 'POST':
body = request.content.decode() body = request.content.decode()
data = dict(part.split('=') for part in body.split('&')) if body else {} 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(['id-token'], query['id_token_hint'])
self.assertEqual(['/'], query['post_logout_redirect_uri']) 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): class OIDCMixinTest(unittest.TestCase):