Validate ID tokens against discovered issuer and refreshed tokens
CI / Build Pip package (push) Successful in 8m49s
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:
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user