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:
2026-10-01 08:23:40 +08:00
parent 15dc4b6dad
commit fa8bb0487d
6 changed files with 379 additions and 16 deletions
+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
> 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.)
+2 -1
View File
@@ -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',
]
+70 -13
View File
@@ -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())
+18 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+254
View File
@@ -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):