Files
kaya/packages/kaya-oidc/tests/test_oidc.py
T
woggioni e73c4b2c18
CI / Build Pip package (push) Successful in 8m49s
Validate ID tokens against discovered issuer and refreshed tokens
OIDCClient.validate_id_token compared the iss claim against
OIDCConfig.issuer (the discovery URL) instead of the issuer advertised by
the provider metadata. This broke Microsoft Entra ID, whose v2 metadata
advertises the templated issuer
https://login.microsoftonline.com/{tenantid}/v2.0 while tokens carry the
concrete tenant GUID, and it also affected tenant-domain issuers.

- Validate iss against the discovered metadata issuer and substitute
  {tenantid} with the token's tid claim.
- Catch jwt.PyJWTError in the OIDC callback so invalid tokens return 400
  instead of 500 (InvalidIssuerError is not a ValueError).
- Re-enable aud/exp/iat/nbf checks for unsigned (alg: none) ID tokens.
- Validate ID tokens returned by the refresh grant before storing them,
  including nonce continuity when present and sub continuity with the
  session user.
- Document Entra ID support and update tests.
2026-10-01 23:22:00 +08:00

1273 lines
54 KiB
Python

import base64
import unittest
from dataclasses import replace
from time import time
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs, urlparse
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from pwo import async_test
from kaya.core import HttpContext, KayaApp
from kaya.session import InMemorySessionStore, SessionMixin
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCProviders
from kaya.oidc._client import OIDCClient
class MockOIDCProvider:
def __init__(
self,
name: str = 'mock',
*,
issuer: Optional[str] = None,
sub: str = 'user123',
email: str = 'user@example.com',
metadata_issuer: Optional[str] = None,
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()
self.kid = 'mock-key'
self.issuer = issuer or f'https://{name}-oidc.local'
self.metadata_issuer = metadata_issuer if metadata_issuer is not None else self.issuer
self.sub = sub
self.email = email
self.nonce = f'{name}-nonce-value'
self.token_issuer: Optional[str] = None
self.token_tid: Optional[str] = None
self.token_sub: Optional[str] = None
self.token_expired = False
self.par_error = par_error
self.par_requests: list[dict[str, list[str]]] = []
self.par_auth_headers: list[Optional[str]] = []
self.discovery = {
'issuer': self.metadata_issuer,
'authorization_endpoint': f'{self.issuer}/auth',
'token_endpoint': f'{self.issuer}/token',
'userinfo_endpoint': f'{self.issuer}/userinfo',
'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()
e_bytes = numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, 'big')
n_bytes = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, 'big')
return {
'kty': 'RSA',
'kid': self.kid,
'use': 'sig',
'n': base64.urlsafe_b64encode(n_bytes).rstrip(b'=').decode(),
'e': base64.urlsafe_b64encode(e_bytes).rstrip(b'=').decode(),
'alg': 'RS256',
}
def issue_id_token(
self,
nonce: Optional[str],
audience: str,
expired: bool = False,
wrong_nonce: bool = False,
issuer: Optional[str] = None,
tid: Optional[str] = None,
sub: Optional[str] = None,
) -> str:
now = time()
exp = now - 3600 if expired else now + 3600
payload: dict[str, Any] = {
'sub': sub if sub is not None else self.sub,
'iss': issuer if issuer is not None else self.issuer,
'aud': audience,
'iat': now,
'exp': exp,
}
if nonce is not None:
payload['nonce'] = 'wrong-nonce' if wrong_nonce else nonce
if tid is not None:
payload['tid'] = tid
return jwt.encode(
payload,
self.private_key,
algorithm='RS256',
headers={'kid': self.kid},
)
def issue_unsigned_id_token(
self,
nonce: str,
audience: str,
issuer: Optional[str] = None,
) -> str:
payload: dict[str, Any] = {
'sub': self.sub,
'iss': issuer if issuer is not None else self.issuer,
'aud': audience,
'iat': time(),
'exp': time() + 3600,
'nonce': nonce,
}
return jwt.encode(payload, None, algorithm='none')
def _issue_configured_token(self, audience: str) -> str:
return self.issue_id_token(
self.nonce,
audience,
expired=self.token_expired,
issuer=self.token_issuer,
tid=self.token_tid,
sub=self.token_sub if self.token_sub is not None else self.sub,
)
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
path = urlparse(url).path
# Serve discovery and JWKS for any host so that issuer-mismatch tests can still fetch keys.
if path.endswith('/.well-known/openid-configuration'):
return httpx.Response(200, json=self.discovery)
if path.endswith('/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 {}
if data.get('grant_type') == 'refresh_token':
return httpx.Response(200, json={
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'id_token': self._issue_configured_token(data.get('client_id', 'client')),
'token_type': 'Bearer',
})
return httpx.Response(200, json={
'access_token': 'mock-access-token',
'refresh_token': 'mock-refresh-token',
'id_token': self._issue_configured_token(data.get('client_id', 'client')),
'token_type': 'Bearer',
})
if path == '/userinfo':
return httpx.Response(200, json={'sub': self.sub, 'email': self.email})
if path == '/logout':
return httpx.Response(200)
return httpx.Response(404)
class MockTransport(httpx.AsyncBaseTransport):
def __init__(self, provider: MockOIDCProvider) -> None:
self._provider = provider
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
return self._provider.handle_request(request)
class OIDCClientTest(unittest.TestCase):
def setUp(self) -> None:
self.provider = MockOIDCProvider()
self.http_client = httpx.AsyncClient(transport=MockTransport(self.provider))
self.config = OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
client_secret='secret',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
)
self.client = OIDCClient(self.config)
@async_test
async def test_build_authorization_url(self) -> None:
url, state, nonce, verifier = await self.client.build_authorization_url()
self.assertIn(self.provider.discovery['authorization_endpoint'], url)
parsed = urlparse(url)
query = parse_qs(parsed.query)
self.assertEqual(['client'], query['client_id'])
self.assertEqual(['code'], query['response_type'])
self.assertEqual(['S256'], query['code_challenge_method'])
self.assertIn('openid', query['scope'][0])
self.assertEqual([state], query['state'])
self.assertEqual([nonce], query['nonce'])
self.assertTrue(len(verifier) > 0)
@async_test
async def test_validate_id_token_success(self) -> None:
token = self.provider.issue_id_token('nonce-value', 'client')
claims = await self.client.validate_id_token(token, 'nonce-value')
self.assertEqual('user123', claims['sub'])
self.assertEqual('nonce-value', claims['nonce'])
@async_test
async def test_validate_id_token_wrong_nonce(self) -> None:
token = self.provider.issue_id_token('nonce-value', 'client', wrong_nonce=True)
with self.assertRaises(ValueError) as ctx:
await self.client.validate_id_token(token, 'nonce-value')
self.assertIn('nonce', str(ctx.exception))
@async_test
async def test_validate_id_token_expired(self) -> None:
token = self.provider.issue_id_token('nonce-value', 'client', expired=True)
with self.assertRaises(jwt.ExpiredSignatureError):
await self.client.validate_id_token(token, 'nonce-value')
@async_test
async def test_validate_id_token_wrong_issuer(self) -> None:
provider = MockOIDCProvider(metadata_issuer='https://other-issuer.local')
token = provider.issue_id_token('nonce-value', 'client')
config = OIDCConfig(
issuer=provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
client = OIDCClient(config)
with self.assertRaises(jwt.InvalidIssuerError):
await client.validate_id_token(token, 'nonce-value')
@async_test
async def test_validate_id_token_uses_discovered_issuer(self) -> None:
# The configured issuer is only used as the discovery URL; the ID token
# is validated against the issuer advertised by the discovery document.
provider = MockOIDCProvider(metadata_issuer='https://canonical-issuer.local')
token = provider.issue_id_token('nonce-value', 'client', issuer='https://canonical-issuer.local')
config = OIDCConfig(
issuer=provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
client = OIDCClient(config)
claims = await client.validate_id_token(token, 'nonce-value')
self.assertEqual('user123', claims['sub'])
@async_test
async def test_validate_id_token_entra_style_issuer_template(self) -> None:
# Microsoft Entra ID multi-tenant metadata advertises
# https://login.microsoftonline.com/{tenantid}/v2.0 and tokens carry
# the concrete tenant id in both iss and tid.
tenant = '11111111-2222-3333-4444-555555555555'
provider = MockOIDCProvider(metadata_issuer='https://mock-oidc.local/{tenantid}/v2.0')
token = provider.issue_id_token(
'nonce-value',
'client',
issuer=f'https://mock-oidc.local/{tenant}/v2.0',
tid=tenant,
)
config = OIDCConfig(
issuer=f'{provider.issuer}/common/v2.0',
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
client = OIDCClient(config)
claims = await client.validate_id_token(token, 'nonce-value')
self.assertEqual(tenant, claims['tid'])
@async_test
async def test_validate_id_token_template_with_wrong_tid(self) -> None:
tenant = '11111111-2222-3333-4444-555555555555'
other_tenant = '99999999-8888-7777-6666-555555555555'
provider = MockOIDCProvider(metadata_issuer='https://mock-oidc.local/{tenantid}/v2.0')
token = provider.issue_id_token(
'nonce-value',
'client',
issuer=f'https://mock-oidc.local/{tenant}/v2.0',
tid=other_tenant,
)
config = OIDCConfig(
issuer=f'{provider.issuer}/common/v2.0',
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
client = OIDCClient(config)
with self.assertRaises(jwt.InvalidIssuerError):
await client.validate_id_token(token, 'nonce-value')
@async_test
async def test_validate_id_token_template_without_tid(self) -> None:
tenant = '11111111-2222-3333-4444-555555555555'
provider = MockOIDCProvider(metadata_issuer='https://mock-oidc.local/{tenantid}/v2.0')
token = provider.issue_id_token(
'nonce-value',
'client',
issuer=f'https://mock-oidc.local/{tenant}/v2.0',
)
config = OIDCConfig(
issuer=f'{provider.issuer}/common/v2.0',
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
client = OIDCClient(config)
with self.assertRaises(jwt.InvalidIssuerError):
await client.validate_id_token(token, 'nonce-value')
@async_test
async def test_validate_refreshed_id_token_allows_missing_nonce(self) -> None:
token = self.provider.issue_id_token(None, 'client')
claims = await self.client.validate_refreshed_id_token(token, 'nonce-value')
self.assertEqual('user123', claims['sub'])
@async_test
async def test_validate_refreshed_id_token_wrong_nonce(self) -> None:
token = self.provider.issue_id_token('other-nonce', 'client')
with self.assertRaises(ValueError) as ctx:
await self.client.validate_refreshed_id_token(token, 'nonce-value')
self.assertIn('nonce', str(ctx.exception))
@async_test
async def test_validate_id_token_disallowed_algorithm(self) -> None:
token = self.provider.issue_id_token('nonce-value', 'client')
config = OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
allowed_id_token_algorithms=('ES256',),
)
client = OIDCClient(config)
with self.assertRaises(ValueError) as ctx:
await client.validate_id_token(token, 'nonce-value')
self.assertIn('RS256', str(ctx.exception))
@async_test
async def test_validate_id_token_unsigned_allowed(self) -> None:
token = self.provider.issue_unsigned_id_token('nonce-value', 'client')
config = OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
allow_unsigned_id_tokens=True,
)
client = OIDCClient(config)
claims = await client.validate_id_token(token, 'nonce-value')
self.assertEqual('user123', claims['sub'])
@async_test
async def test_validate_id_token_unsigned_rejected_by_default(self) -> None:
token = self.provider.issue_unsigned_id_token('nonce-value', 'client')
with self.assertRaises(ValueError) as ctx:
await self.client.validate_id_token(token, 'nonce-value')
self.assertIn('Unsigned', str(ctx.exception))
@async_test
async def test_validate_id_token_unsigned_wrong_audience(self) -> None:
token = self.provider.issue_unsigned_id_token('nonce-value', 'other-client')
config = OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
allow_unsigned_id_tokens=True,
)
client = OIDCClient(config)
with self.assertRaises(jwt.InvalidAudienceError):
await client.validate_id_token(token, 'nonce-value')
@async_test
async def test_validate_id_token_unsigned_wrong_issuer(self) -> None:
token = self.provider.issue_unsigned_id_token('nonce-value', 'client', issuer='https://evil-issuer.local')
config = OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
allow_unsigned_id_tokens=True,
)
client = OIDCClient(config)
with self.assertRaises(jwt.InvalidIssuerError):
await client.validate_id_token(token, 'nonce-value')
@async_test
async def test_fetch_userinfo(self) -> None:
userinfo = await self.client.fetch_userinfo('access-token')
self.assertEqual('user123', userinfo['sub'])
self.assertEqual('user@example.com', userinfo['email'])
@async_test
async def test_build_logout_url(self) -> None:
url = await self.client.build_logout_url('id-token')
self.assertIsNotNone(url)
assert url is not None
self.assertIn(self.provider.discovery['end_session_endpoint'], url)
parsed = urlparse(url)
query = parse_qs(parsed.query)
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):
def _build_app(self, fetch_userinfo: bool = False) -> tuple[KayaApp, OIDCMixin, MockOIDCProvider, InMemorySessionStore]:
provider = MockOIDCProvider()
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,
fetch_userinfo=fetch_userinfo,
)
oidc = OIDCMixin(config, session=session)
app = KayaApp(mixins=[session, oidc])
return app, oidc, provider, store
def _setup_routes(self, app: KayaApp, oidc: OIDCMixin) -> None:
@app.GET('/')
async def home(ctx: HttpContext) -> None:
await ctx.send_str(200, 'home')
@app.GET('/profile')
@oidc.require_auth
async def profile(ctx: HttpContext) -> None:
user = oidc.get_user(ctx)
if user is None:
await ctx.send_empty(401)
return
await ctx.send_str(200, f'Hello {user.email}')
@app.GET('/refresh')
@oidc.require_auth
async def refresh(ctx: HttpContext) -> None:
new_token = await oidc.refresh_access_token(ctx.session)
await ctx.send_str(200, new_token or 'no-token')
@app.GET('/id-token')
@oidc.require_auth
async def id_token(ctx: HttpContext) -> None:
stored = ctx.session.get(oidc._key('id_token'))
await ctx.send_str(200, stored if isinstance(stored, str) else 'none')
async def _login(self, client: httpx.AsyncClient, provider: MockOIDCProvider) -> None:
r = await client.get('/auth/login', follow_redirects=False)
self.assertEqual(302, r.status_code)
query = parse_qs(urlparse(r.headers['Location']).query)
provider.nonce = query['nonce'][0]
r = await client.get(
'/auth/callback',
params={'code': 'mock-code', 'state': query['state'][0]},
follow_redirects=False,
)
self.assertEqual(302, r.status_code)
@async_test
async def test_login_redirect(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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/login', follow_redirects=False)
self.assertEqual(302, r.status_code)
location = r.headers['Location']
self.assertIn(provider.discovery['authorization_endpoint'], location)
self.assertIn('Set-Cookie', r.headers)
@async_test
async def test_callback_success(self) -> None:
app, oidc, provider, store = self._build_app(fetch_userinfo=True)
self._setup_routes(app, 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/login', follow_redirects=False)
self.assertEqual(302, r.status_code)
parsed = urlparse(r.headers['Location'])
query = parse_qs(parsed.query)
state = query['state'][0]
nonce = query['nonce'][0]
provider.nonce = nonce
r = await client.get('/auth/callback', params={'code': 'mock-code', 'state': state}, follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertEqual('/', r.headers['Location'])
self.assertIn('Set-Cookie', r.headers)
r = await client.get('/profile')
self.assertEqual(200, r.status_code)
self.assertIn('user@example.com', r.text)
@async_test
async def test_callback_invalid_state(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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/callback', params={'code': 'mock-code', 'state': 'wrong'}, follow_redirects=False)
self.assertEqual(400, r.status_code)
@async_test
async def test_callback_invalid_issuer_returns_400(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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/login', follow_redirects=False)
query = parse_qs(urlparse(r.headers['Location']).query)
provider.nonce = query['nonce'][0]
provider.token_issuer = 'https://evil-issuer.local'
r = await client.get(
'/auth/callback',
params={'code': 'mock-code', 'state': query['state'][0]},
follow_redirects=False,
)
self.assertEqual(400, r.status_code)
@async_test
async def test_callback_expired_id_token_returns_400(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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/login', follow_redirects=False)
query = parse_qs(urlparse(r.headers['Location']).query)
provider.nonce = query['nonce'][0]
provider.token_expired = True
r = await client.get(
'/auth/callback',
params={'code': 'mock-code', 'state': query['state'][0]},
follow_redirects=False,
)
self.assertEqual(400, r.status_code)
@async_test
async def test_logout(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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/login', follow_redirects=False)
parsed = urlparse(r.headers['Location'])
query = parse_qs(parsed.query)
state = query['state'][0]
provider.nonce = query['nonce'][0]
await client.get('/auth/callback', params={'code': 'mock-code', 'state': state}, follow_redirects=False)
session_id = client.cookies['session_id']
self.assertIn(session_id, store._data)
r = await client.get('/auth/logout', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertIn(provider.discovery['end_session_endpoint'], r.headers['Location'])
self.assertNotIn(session_id, store._data)
@async_test
async def test_require_auth_redirect(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, 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('/profile', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertEqual('/auth/login', r.headers['Location'])
@async_test
async def test_refresh_access_token(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, provider)
r = await client.get('/refresh')
self.assertEqual(200, r.status_code)
self.assertEqual('new-access-token', r.text)
@async_test
async def test_refresh_access_token_rejects_invalid_issuer(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, provider)
original_id_token = (await client.get('/id-token')).text
provider.token_issuer = 'https://evil-issuer.local'
r = await client.get('/refresh')
self.assertEqual(200, r.status_code)
self.assertEqual('no-token', r.text)
# The refreshed ID token was not validated, so nothing was stored.
self.assertEqual(original_id_token, (await client.get('/id-token')).text)
@async_test
async def test_refresh_access_token_rejects_wrong_nonce(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, provider)
provider.nonce = 'tampered-nonce'
r = await client.get('/refresh')
self.assertEqual(200, r.status_code)
self.assertEqual('no-token', r.text)
@async_test
async def test_refresh_access_token_rejects_wrong_subject(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, provider)
provider.token_sub = 'other-user'
r = await client.get('/refresh')
self.assertEqual(200, r.status_code)
self.assertEqual('no-token', r.text)
@async_test
async def test_refresh_access_token_expired_id_token(self) -> None:
app, oidc, provider, store = self._build_app()
self._setup_routes(app, oidc)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, provider)
provider.token_expired = True
r = await client.get('/refresh')
self.assertEqual(200, r.status_code)
self.assertEqual('no-token', r.text)
@async_test
async def test_dependency_applied_automatically(self) -> None:
# Only pass oidc to KayaApp; SessionMixin should be applied via dependencies.
provider = MockOIDCProvider()
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,
)
oidc = OIDCMixin(config, session=session)
app = KayaApp(mixins=[oidc])
@app.GET('/')
async def home(ctx: HttpContext) -> None:
ctx.session['x'] = 1
await ctx.send_str(200, 'ok')
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('/')
self.assertEqual(200, r.status_code)
self.assertIn('Set-Cookie', r.headers)
class OIDCMultiProviderTest(unittest.TestCase):
def _provider_config(self, provider: MockOIDCProvider, name: str) -> tuple[OIDCConfig, httpx.AsyncClient]:
http_client = httpx.AsyncClient(transport=MockTransport(provider))
config = OIDCConfig(
issuer=provider.issuer,
client_id=f'{name}-client',
client_secret='secret',
provider=name,
redirect_uri=f'http://localhost:8000/auth/{name}/callback',
http_client=http_client,
fetch_userinfo=True,
)
return config, http_client
def _build(self, strategy: str = 'multi-identity') -> tuple[KayaApp, OIDCProviders, InMemorySessionStore]:
self.google = MockOIDCProvider('google', sub='g-sub', email='g@example.com')
self.github = MockOIDCProvider('github', sub='h-sub', email='h@example.com')
google_config, self.google_http = self._provider_config(self.google, 'google')
github_config, self.github_http = self._provider_config(self.github, 'github')
store = InMemorySessionStore()
session = SessionMixin(store)
oidc = OIDCProviders([google_config, github_config], session=session, strategy=strategy)
app = KayaApp(mixins=[session, oidc])
@app.GET('/who')
async def who(ctx: HttpContext) -> None:
users = oidc.get_users(ctx)
await ctx.send_str(200, ','.join(sorted(users)) or 'none')
@app.GET('/google-profile')
@oidc.require_auth('google')
async def google_profile(ctx: HttpContext) -> None:
user = oidc.get_user(ctx, 'google')
assert user is not None
await ctx.send_str(200, user.email or '')
@app.GET('/github-profile')
@oidc.require_auth('github')
async def github_profile(ctx: HttpContext) -> None:
user = oidc.get_user(ctx, 'github')
assert user is not None
await ctx.send_str(200, user.email or '')
@app.GET('/refresh-github')
@oidc.require_auth('github')
async def refresh_github(ctx: HttpContext) -> None:
token = await oidc.refresh_access_token(ctx.session, 'github')
await ctx.send_str(200, token or 'no-token')
return app, oidc, store
async def _complete_login(self, client: httpx.AsyncClient, name: str, state: str) -> None:
r = await client.get(
f'/auth/{name}/callback',
params={'code': 'mock-code', 'state': state},
follow_redirects=False,
)
self.assertEqual(302, r.status_code)
async def _login(self, client: httpx.AsyncClient, provider: MockOIDCProvider, name: str) -> None:
r = await client.get(f'/auth/{name}/login', follow_redirects=False)
self.assertEqual(302, r.status_code)
query = parse_qs(urlparse(r.headers['Location']).query)
provider.nonce = query['nonce'][0]
await self._complete_login(client, name, query['state'][0])
@async_test
async def test_independent_logins(self) -> None:
app, oidc, store = self._build()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, self.google, 'google')
r = await client.get('/google-profile')
self.assertEqual('g@example.com', r.text)
# The github provider is untouched by the google login.
r = await client.get('/github-profile', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertEqual('/auth/github/login', r.headers['Location'])
r = await client.get('/who')
self.assertEqual('google', r.text)
@async_test
async def test_interleaved_login_flows(self) -> None:
app, oidc, store = self._build()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
# Start both flows before completing either one.
r = await client.get('/auth/google/login', follow_redirects=False)
google_query = parse_qs(urlparse(r.headers['Location']).query)
r = await client.get('/auth/github/login', follow_redirects=False)
github_query = parse_qs(urlparse(r.headers['Location']).query)
self.google.nonce = google_query['nonce'][0]
self.github.nonce = github_query['nonce'][0]
# Completing google must not invalidate the pending github state.
await self._complete_login(client, 'google', google_query['state'][0])
await self._complete_login(client, 'github', github_query['state'][0])
r = await client.get('/who')
self.assertEqual('github,google', r.text)
@async_test
async def test_logout_one_provider_keeps_the_other(self) -> None:
app, oidc, store = self._build()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, self.google, 'google')
await self._login(client, self.github, 'github')
session_id = client.cookies['session_id']
self.assertIn(session_id, store._data)
r = await client.get('/auth/github/logout', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertIn(self.github.discovery['end_session_endpoint'], r.headers['Location'])
# Google is still logged in and the shared session survives.
r = await client.get('/google-profile')
self.assertEqual('g@example.com', r.text)
r = await client.get('/github-profile', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertIn(session_id, store._data)
# Logging out the last provider invalidates the session.
r = await client.get('/auth/google/logout', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertNotIn(session_id, store._data)
@async_test
async def test_provider_refresh_is_namespaced(self) -> None:
app, oidc, store = self._build()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, self.google, 'google')
await self._login(client, self.github, 'github')
# Refreshing github must not clobber google's session state.
r = await client.get('/refresh-github')
self.assertEqual('new-access-token', r.text)
r = await client.get('/google-profile')
self.assertEqual('g@example.com', r.text)
@async_test
async def test_multiple_mixin_instances_compose(self) -> None:
google = MockOIDCProvider('google', sub='g-sub', email='g@example.com')
github = MockOIDCProvider('github', sub='h-sub', email='h@example.com')
google_config, google_http = self._provider_config(google, 'google')
github_config, github_http = self._provider_config(github, 'github')
store = InMemorySessionStore()
session = SessionMixin(store)
google_mixin = OIDCMixin(
replace(google_config, login_path='/google/login', callback_path='/google/callback', logout_path='/google/logout'),
session=session,
)
github_mixin = OIDCMixin(
replace(github_config, login_path='/github/login', callback_path='/github/callback', logout_path='/github/logout'),
session=session,
)
app = KayaApp(mixins=[session, google_mixin, github_mixin])
@app.GET('/g')
@google_mixin.require_auth
async def g(ctx: HttpContext) -> None:
user = google_mixin.get_user(ctx)
assert user is not None
await ctx.send_str(200, user.email or '')
@app.GET('/h')
@github_mixin.require_auth
async def h(ctx: HttpContext) -> None:
user = github_mixin.get_user(ctx)
assert user is not None
await ctx.send_str(200, user.email or '')
@app.GET('/who')
async def who(ctx: HttpContext) -> None:
users = google_mixin.get_users(ctx)
providers = google_mixin.logged_in_providers(ctx)
summary = ','.join(f'{name}={users[name].email}' for name in sorted(users))
await ctx.send_str(200, f'{",".join(sorted(providers))}|{summary}')
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('/google/login', follow_redirects=False)
query = parse_qs(urlparse(r.headers['Location']).query)
google.nonce = query['nonce'][0]
r = await client.get('/google/callback', params={'code': 'c', 'state': query['state'][0]}, follow_redirects=False)
self.assertEqual(302, r.status_code)
r = await client.get('/g')
self.assertEqual('g@example.com', r.text)
r = await client.get('/h', follow_redirects=False)
self.assertEqual(302, r.status_code)
# Mixin-level helpers see the namespaced user across providers.
r = await client.get('/who')
self.assertEqual('google|google=g@example.com', r.text)
@async_test
async def test_single_identity_evicts_previous_provider(self) -> None:
app, oidc, store = self._build(strategy='single-identity')
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await self._login(client, self.google, 'google')
r = await client.get('/google-profile')
self.assertEqual('g@example.com', r.text)
# Logging in with github replaces the google identity.
await self._login(client, self.github, 'github')
r = await client.get('/who')
self.assertEqual('github', r.text)
r = await client.get('/google-profile', follow_redirects=False)
self.assertEqual(302, r.status_code)
self.assertEqual('/auth/google/login', r.headers['Location'])
r = await client.get('/github-profile')
self.assertEqual('h@example.com', r.text)
data = store._data[client.cookies['session_id']]
self.assertNotIn('oidc:google:user', data)
self.assertNotIn('oidc:google:id_token', data)
self.assertIn('oidc:github:user', data)
@async_test
async def test_single_identity_last_completed_login_wins(self) -> None:
app, oidc, store = self._build(strategy='single-identity')
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
# Start both flows before completing either one; pending flow state
# is not evicted, so both callbacks can complete.
r = await client.get('/auth/google/login', follow_redirects=False)
google_query = parse_qs(urlparse(r.headers['Location']).query)
r = await client.get('/auth/github/login', follow_redirects=False)
github_query = parse_qs(urlparse(r.headers['Location']).query)
self.google.nonce = google_query['nonce'][0]
self.github.nonce = github_query['nonce'][0]
await self._complete_login(client, 'google', google_query['state'][0])
await self._complete_login(client, 'github', github_query['state'][0])
r = await client.get('/who')
self.assertEqual('github', r.text)
@async_test
async def test_standalone_mixins_default_to_single_identity(self) -> None:
google = MockOIDCProvider('google', sub='g-sub', email='g@example.com')
github = MockOIDCProvider('github', sub='h-sub', email='h@example.com')
google_config, google_http = self._provider_config(google, 'google')
github_config, github_http = self._provider_config(github, 'github')
session = SessionMixin(InMemorySessionStore())
google_mixin = OIDCMixin(
replace(google_config, login_path='/google/login', callback_path='/google/callback', logout_path='/google/logout'),
session=session,
)
github_mixin = OIDCMixin(
replace(github_config, login_path='/github/login', callback_path='/github/callback', logout_path='/github/logout'),
session=session,
)
app = KayaApp(mixins=[session, google_mixin, github_mixin])
self.assertEqual('single-identity', google_mixin.strategy)
@app.GET('/g')
@google_mixin.require_auth
async def g(ctx: HttpContext) -> None:
await ctx.send_str(200, 'google')
@app.GET('/h')
@github_mixin.require_auth
async def h(ctx: HttpContext) -> None:
await ctx.send_str(200, 'github')
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('/google/login', follow_redirects=False)
query = parse_qs(urlparse(r.headers['Location']).query)
google.nonce = query['nonce'][0]
r = await client.get('/google/callback', params={'code': 'c', 'state': query['state'][0]}, follow_redirects=False)
self.assertEqual(302, r.status_code)
r = await client.get('/g')
self.assertEqual('google', r.text)
r = await client.get('/github/login', follow_redirects=False)
query = parse_qs(urlparse(r.headers['Location']).query)
github.nonce = query['nonce'][0]
r = await client.get('/github/callback', params={'code': 'c', 'state': query['state'][0]}, follow_redirects=False)
self.assertEqual(302, r.status_code)
r = await client.get('/g', follow_redirects=False)
self.assertEqual(302, r.status_code)
r = await client.get('/h')
self.assertEqual('github', r.text)
def test_default_strategy_is_single_identity(self) -> None:
provider = MockOIDCProvider()
config, http = self._provider_config(provider, 'google')
session = SessionMixin(InMemorySessionStore())
providers = OIDCProviders([config], session=session)
self.assertEqual('single-identity', providers.strategy)
mixin = OIDCMixin(config, session=session)
self.assertEqual('single-identity', mixin.strategy)
def test_invalid_strategy_rejected(self) -> None:
provider = MockOIDCProvider()
config, http = self._provider_config(provider, 'google')
session = SessionMixin(InMemorySessionStore())
with self.assertRaises(ValueError):
OIDCMixin(config, session=session, strategy='bogus')
with self.assertRaises(ValueError):
OIDCProviders([config], session=session, strategy='bogus')
def test_config_rejects_invalid_provider(self) -> None:
with self.assertRaises(ValueError):
OIDCConfig(
issuer='https://issuer.local',
client_id='client',
redirect_uri='http://localhost/callback',
provider='bad:name',
)
def test_providers_reject_duplicates(self) -> None:
config = OIDCConfig(
issuer='https://issuer.local',
client_id='client',
redirect_uri='http://localhost/callback',
)
session = SessionMixin(InMemorySessionStore())
with self.assertRaises(ValueError):
OIDCProviders([config, config], session=session)
def test_providers_require_at_least_one_config(self) -> None:
session = SessionMixin(InMemorySessionStore())
with self.assertRaises(ValueError):
OIDCProviders([], session=session)
def test_providers_reject_bad_base_path(self) -> None:
config = OIDCConfig(
issuer='https://issuer.local',
client_id='client',
redirect_uri='http://localhost/callback',
)
session = SessionMixin(InMemorySessionStore())
with self.assertRaises(ValueError):
OIDCProviders([config], session=session, base_path='auth')
if __name__ == '__main__':
unittest.main()