Validate ID tokens against discovered issuer and refreshed tokens
CI / Build Pip package (push) Successful in 8m49s

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.
This commit is contained in:
2026-10-01 23:22:00 +08:00
parent fa8bb0487d
commit e73c4b2c18
4 changed files with 421 additions and 28 deletions
+32
View File
@@ -164,6 +164,9 @@ authentication methods (`private_key_jwt`, mTLS) is not supported.
- Generic OIDC discovery
- Authorization Code Flow with PKCE (S256)
- ID token signature validation with JWKS
- Issuer validation against discovery metadata, including support for
Microsoft Entra ID `{tenantid}` issuer templates
- Refreshed ID token validation on token refresh
- `state` and `nonce` protection
- Session fixation defense via `regenerate_id()` after login
- Optional userinfo endpoint fetch
@@ -174,8 +177,37 @@ authentication methods (`private_key_jwt`, mTLS) is not supported.
- `single-identity` (default) or `multi-identity` session strategy
- Composable with any other `KayaMixin` (RSGI, MCP, etc.)
## Microsoft Entra ID
Entra ID v2 ID tokens carry an `iss` claim of the form
`https://login.microsoftonline.com/<tenant-id>/v2.0`, while the discovery
metadata for the multi-tenant endpoints advertises the templated issuer
`https://login.microsoftonline.com/{tenantid}/v2.0`. `kaya-oidc` validates the
`iss` claim against the discovered metadata and substitutes the token's `tid`
claim into `{tenantid}`, so both tenant-specific and multi-tenant issuers work:
```python
OIDCConfig(
issuer='https://login.microsoftonline.com/<tenant-id-or-domain>/v2.0',
client_id=os.environ['OIDC_CLIENT_ID'],
client_secret=os.environ['OIDC_CLIENT_SECRET'],
redirect_uri='https://example.com/auth/callback',
)
```
Use a tenant-specific issuer (the tenant GUID or a verified domain name) to
restrict logins to that tenant. The `common` and `organizations` aliases are
multi-tenant: any tenant in that cloud can obtain an ID token that passes
validation. If you build a multi-tenant application, check the `tid` claim
against the tenants you trust.
## Security notes
- The `iss` claim is validated against the `issuer` value of the provider's
discovery document, not against `OIDCConfig.issuer` (they can differ, for
example for Entra ID multi-tenant endpoints).
- Refreshed ID tokens are validated before being stored; the `nonce` is also
checked when the refreshed token carries one.
- The `none` signing algorithm is rejected by default.
- Only algorithms listed in `OIDCConfig.allowed_id_token_algorithms` are accepted.
- Always use HTTPS for `redirect_uri` in production.
+60 -4
View File
@@ -1,3 +1,4 @@
import re
from typing import Any, Mapping, Optional, Sequence, cast
from urllib.parse import urlencode
@@ -8,6 +9,8 @@ from jwt import PyJWK
from ._config import PARPolicy, OIDCConfig
from ._utils import generate_nonce, generate_pkce, generate_state
_TENANT_ID_PLACEHOLDER = re.compile(r'\{tenantid\}', re.IGNORECASE)
class OIDCClient:
"""Low-level OIDC client implementing discovery, PKCE, and token validation."""
@@ -164,6 +167,29 @@ class OIDCClient:
return cast(Mapping[str, Any], resp.json())
async def validate_id_token(self, id_token: str, nonce: str) -> Mapping[str, Any]:
payload = await self._decode_id_token(id_token)
if payload.get('nonce') != nonce:
raise ValueError('ID token nonce mismatch')
return payload
async def validate_refreshed_id_token(
self,
id_token: str,
expected_nonce: Optional[str] = None,
) -> Mapping[str, Any]:
"""Validate an ID token returned by the refresh token grant.
Unlike the authorization code flow, the provider may omit the ``nonce``
claim; when it is present and the original nonce is known, the two must
match (OIDC Core 12.2).
"""
payload = await self._decode_id_token(id_token)
token_nonce = payload.get('nonce')
if token_nonce is not None and expected_nonce is not None and token_nonce != expected_nonce:
raise ValueError('ID token nonce mismatch')
return payload
async def _decode_id_token(self, id_token: str) -> Mapping[str, Any]:
header = jwt.get_unverified_header(id_token)
alg = header.get('alg')
if not isinstance(alg, str):
@@ -172,29 +198,59 @@ class OIDCClient:
if alg == 'none':
if not self._config.allow_unsigned_id_tokens:
raise ValueError('Unsigned ID tokens are not allowed')
metadata_issuer = await self._get_metadata_issuer()
payload = jwt.decode(
id_token,
options={'verify_signature': False},
audience=self._config.client_id,
options={
'verify_signature': False,
'verify_aud': True,
'verify_exp': True,
'verify_iat': True,
'verify_nbf': True,
},
)
else:
if alg not in self._config.allowed_id_token_algorithms:
raise ValueError(f'ID token algorithm {alg} is not allowed')
metadata_issuer = await self._get_metadata_issuer()
jwks = await self._ensure_jwks()
signing_key = self._get_signing_key(jwks, header.get('kid'), alg)
payload = jwt.decode(
id_token,
signing_key.key,
algorithms=[alg],
issuer=self._config.issuer,
audience=self._config.client_id,
)
if not isinstance(payload, dict):
raise ValueError('ID token payload is not a JSON object')
if payload.get('nonce') != nonce:
raise ValueError('ID token nonce mismatch')
self._validate_issuer(payload, metadata_issuer)
return payload
async def _get_metadata_issuer(self) -> str:
metadata = await self._ensure_metadata()
issuer = metadata.get('issuer')
if not isinstance(issuer, str):
raise RuntimeError('OIDC provider does not advertise an issuer')
return issuer
@staticmethod
def _validate_issuer(payload: Mapping[str, Any], metadata_issuer: str) -> None:
iss = payload.get('iss')
if not isinstance(iss, str):
raise jwt.MissingRequiredClaimError('iss')
expected = metadata_issuer
if _TENANT_ID_PLACEHOLDER.search(expected):
tenant_id = payload.get('tid')
if not isinstance(tenant_id, str):
raise jwt.InvalidIssuerError('Invalid issuer')
expected = _TENANT_ID_PLACEHOLDER.sub(tenant_id, expected, count=1)
if iss != expected:
raise jwt.InvalidIssuerError('Invalid issuer')
def _get_signing_key(self, jwks: Mapping[str, Any], kid: Optional[str], alg: str) -> PyJWK:
keys = jwks.get('keys')
if not isinstance(keys, Sequence):
+18 -4
View File
@@ -1,5 +1,6 @@
from typing import Any, Awaitable, Callable, Literal, Mapping, Optional, Sequence, cast
import jwt
from kaya.core import HttpContext, KayaApp, KayaMixin
from kaya.session import Session, SessionMixin
from urllib.parse import parse_qs
@@ -175,8 +176,9 @@ class OIDCMixin(KayaMixin):
user.update(userinfo)
session.pop(self._key('state'), None)
session.pop(self._key('nonce'), None)
session.pop(self._key('code_verifier'), None)
# Keep this provider's nonce so refreshed ID tokens can be
# checked for nonce continuity (OIDC Core 12.2).
session[self._key('user')] = user
session[self._key('id_token')] = id_token
@@ -190,7 +192,7 @@ class OIDCMixin(KayaMixin):
session.regenerate_id()
await ctx.send_empty(302, {'Location': self._config.post_login_redirect})
except ValueError as exc:
except (ValueError, jwt.PyJWTError) as exc:
await ctx.send_str(400, f'Authentication failed: {exc}')
@app.GET(self._config.logout_path)
@@ -279,10 +281,22 @@ class OIDCMixin(KayaMixin):
try:
token_response = await self._client.refresh_token(refresh_token)
access_token = token_response.get('access_token')
id_token = token_response.get('id_token')
if isinstance(id_token, str):
stored_nonce = session.get(self._key('nonce'))
claims = await self._client.validate_refreshed_id_token(
id_token,
stored_nonce if isinstance(stored_nonce, str) else None,
)
user = session.get(self._key('user'))
original_sub = user.get('sub') if isinstance(user, Mapping) else None
if isinstance(original_sub, str) and claims.get('sub') != original_sub:
raise ValueError('ID token subject mismatch')
if isinstance(access_token, str):
session[self._key('access_token')] = access_token
if 'id_token' in token_response:
session[self._key('id_token')] = token_response['id_token']
if isinstance(id_token, str):
session[self._key('id_token')] = id_token
if 'refresh_token' in token_response:
session[self._key('refresh_token')] = token_response['refresh_token']
return access_token if isinstance(access_token, str) else None
+311 -20
View File
@@ -2,7 +2,7 @@ import base64
import unittest
from dataclasses import replace
from time import time
from typing import Mapping, Optional
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs, urlparse
import httpx
@@ -24,6 +24,7 @@ class MockOIDCProvider:
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,
@@ -32,14 +33,19 @@ class MockOIDCProvider:
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.issuer,
'issuer': self.metadata_issuer,
'authorization_endpoint': f'{self.issuer}/auth',
'token_endpoint': f'{self.issuer}/token',
'userinfo_endpoint': f'{self.issuer}/userinfo',
@@ -64,17 +70,29 @@ class MockOIDCProvider:
'alg': 'RS256',
}
def issue_id_token(self, nonce: str, audience: str, expired: bool = False, wrong_nonce: bool = False) -> str:
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 = {
'sub': self.sub,
'iss': self.issuer,
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,
'nonce': 'wrong-nonce' if wrong_nonce else nonce,
}
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,
@@ -82,13 +100,39 @@ class MockOIDCProvider:
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 == '/.well-known/openid-configuration':
if path.endswith('/.well-known/openid-configuration'):
return httpx.Response(200, json=self.discovery)
if path == '/jwks':
if path.endswith('/jwks'):
return httpx.Response(200, json={'keys': [self._public_key_to_jwk()]})
if path == '/par':
if request.method != 'POST':
@@ -111,13 +155,13 @@ class MockOIDCProvider:
return httpx.Response(200, json={
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'id_token': self.issue_id_token('refreshed-nonce', data.get('client_id', 'client')),
'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_id_token(self.nonce, data.get('client_id', 'client')),
'id_token': self._issue_configured_token(data.get('client_id', 'client')),
'token_type': 'Bearer',
})
if path == '/userinfo':
@@ -185,17 +229,110 @@ class OIDCClientTest(unittest.TestCase):
@async_test
async def test_validate_id_token_wrong_issuer(self) -> None:
token = self.provider.issue_id_token('nonce-value', 'client')
provider = MockOIDCProvider(metadata_issuer='https://other-issuer.local')
token = provider.issue_id_token('nonce-value', 'client')
config = OIDCConfig(
issuer='https://wrong-issuer.local',
issuer=provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
http_client=self.http_client,
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')
@@ -211,6 +348,55 @@ class OIDCClientTest(unittest.TestCase):
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')
@@ -498,6 +684,24 @@ class OIDCMixinTest(unittest.TestCase):
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()
@@ -543,6 +747,42 @@ class OIDCMixinTest(unittest.TestCase):
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()
@@ -580,17 +820,68 @@ class OIDCMixinTest(unittest.TestCase):
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)
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.