Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73c4b2c18
|
No files matched your search
@@ -164,6 +164,9 @@ authentication methods (`private_key_jwt`, mTLS) is not supported.
|
|||||||
- Generic OIDC discovery
|
- Generic OIDC discovery
|
||||||
- Authorization Code Flow with PKCE (S256)
|
- Authorization Code Flow with PKCE (S256)
|
||||||
- ID token signature validation with JWKS
|
- 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
|
- `state` and `nonce` protection
|
||||||
- Session fixation defense via `regenerate_id()` after login
|
- Session fixation defense via `regenerate_id()` after login
|
||||||
- Optional userinfo endpoint fetch
|
- 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
|
- `single-identity` (default) or `multi-identity` session strategy
|
||||||
- Composable with any other `KayaMixin` (RSGI, MCP, etc.)
|
- 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
|
## 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.
|
- The `none` signing algorithm is rejected by default.
|
||||||
- Only algorithms listed in `OIDCConfig.allowed_id_token_algorithms` are accepted.
|
- Only algorithms listed in `OIDCConfig.allowed_id_token_algorithms` are accepted.
|
||||||
- Always use HTTPS for `redirect_uri` in production.
|
- Always use HTTPS for `redirect_uri` in production.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from typing import Any, Mapping, Optional, Sequence, cast
|
from typing import Any, Mapping, Optional, Sequence, cast
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ from jwt import PyJWK
|
|||||||
from ._config import PARPolicy, OIDCConfig
|
from ._config import PARPolicy, OIDCConfig
|
||||||
from ._utils import generate_nonce, generate_pkce, generate_state
|
from ._utils import generate_nonce, generate_pkce, generate_state
|
||||||
|
|
||||||
|
_TENANT_ID_PLACEHOLDER = re.compile(r'\{tenantid\}', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
class OIDCClient:
|
class OIDCClient:
|
||||||
"""Low-level OIDC client implementing discovery, PKCE, and token validation."""
|
"""Low-level OIDC client implementing discovery, PKCE, and token validation."""
|
||||||
@@ -164,6 +167,29 @@ class OIDCClient:
|
|||||||
return cast(Mapping[str, Any], resp.json())
|
return cast(Mapping[str, Any], resp.json())
|
||||||
|
|
||||||
async def validate_id_token(self, id_token: str, nonce: str) -> Mapping[str, Any]:
|
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)
|
header = jwt.get_unverified_header(id_token)
|
||||||
alg = header.get('alg')
|
alg = header.get('alg')
|
||||||
if not isinstance(alg, str):
|
if not isinstance(alg, str):
|
||||||
@@ -172,29 +198,59 @@ class OIDCClient:
|
|||||||
if alg == 'none':
|
if alg == 'none':
|
||||||
if not self._config.allow_unsigned_id_tokens:
|
if not self._config.allow_unsigned_id_tokens:
|
||||||
raise ValueError('Unsigned ID tokens are not allowed')
|
raise ValueError('Unsigned ID tokens are not allowed')
|
||||||
|
metadata_issuer = await self._get_metadata_issuer()
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
id_token,
|
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:
|
else:
|
||||||
if alg not in self._config.allowed_id_token_algorithms:
|
if alg not in self._config.allowed_id_token_algorithms:
|
||||||
raise ValueError(f'ID token algorithm {alg} is not allowed')
|
raise ValueError(f'ID token algorithm {alg} is not allowed')
|
||||||
|
metadata_issuer = await self._get_metadata_issuer()
|
||||||
jwks = await self._ensure_jwks()
|
jwks = await self._ensure_jwks()
|
||||||
signing_key = self._get_signing_key(jwks, header.get('kid'), alg)
|
signing_key = self._get_signing_key(jwks, header.get('kid'), alg)
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
id_token,
|
id_token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=[alg],
|
algorithms=[alg],
|
||||||
issuer=self._config.issuer,
|
|
||||||
audience=self._config.client_id,
|
audience=self._config.client_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError('ID token payload is not a JSON object')
|
raise ValueError('ID token payload is not a JSON object')
|
||||||
if payload.get('nonce') != nonce:
|
self._validate_issuer(payload, metadata_issuer)
|
||||||
raise ValueError('ID token nonce mismatch')
|
|
||||||
return payload
|
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:
|
def _get_signing_key(self, jwks: Mapping[str, Any], kid: Optional[str], alg: str) -> PyJWK:
|
||||||
keys = jwks.get('keys')
|
keys = jwks.get('keys')
|
||||||
if not isinstance(keys, Sequence):
|
if not isinstance(keys, Sequence):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Any, Awaitable, Callable, Literal, Mapping, Optional, Sequence, cast
|
from typing import Any, Awaitable, Callable, Literal, Mapping, Optional, Sequence, cast
|
||||||
|
|
||||||
|
import jwt
|
||||||
from kaya.core import HttpContext, KayaApp, KayaMixin
|
from kaya.core import HttpContext, KayaApp, KayaMixin
|
||||||
from kaya.session import Session, SessionMixin
|
from kaya.session import Session, SessionMixin
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
@@ -175,8 +176,9 @@ class OIDCMixin(KayaMixin):
|
|||||||
user.update(userinfo)
|
user.update(userinfo)
|
||||||
|
|
||||||
session.pop(self._key('state'), None)
|
session.pop(self._key('state'), None)
|
||||||
session.pop(self._key('nonce'), None)
|
|
||||||
session.pop(self._key('code_verifier'), 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('user')] = user
|
||||||
session[self._key('id_token')] = id_token
|
session[self._key('id_token')] = id_token
|
||||||
@@ -190,7 +192,7 @@ class OIDCMixin(KayaMixin):
|
|||||||
|
|
||||||
session.regenerate_id()
|
session.regenerate_id()
|
||||||
await ctx.send_empty(302, {'Location': self._config.post_login_redirect})
|
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}')
|
await ctx.send_str(400, f'Authentication failed: {exc}')
|
||||||
|
|
||||||
@app.GET(self._config.logout_path)
|
@app.GET(self._config.logout_path)
|
||||||
@@ -279,10 +281,22 @@ class OIDCMixin(KayaMixin):
|
|||||||
try:
|
try:
|
||||||
token_response = await self._client.refresh_token(refresh_token)
|
token_response = await self._client.refresh_token(refresh_token)
|
||||||
access_token = token_response.get('access_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):
|
if isinstance(access_token, str):
|
||||||
session[self._key('access_token')] = access_token
|
session[self._key('access_token')] = access_token
|
||||||
if 'id_token' in token_response:
|
if isinstance(id_token, str):
|
||||||
session[self._key('id_token')] = token_response['id_token']
|
session[self._key('id_token')] = id_token
|
||||||
if 'refresh_token' in token_response:
|
if 'refresh_token' in token_response:
|
||||||
session[self._key('refresh_token')] = token_response['refresh_token']
|
session[self._key('refresh_token')] = token_response['refresh_token']
|
||||||
return access_token if isinstance(access_token, str) else None
|
return access_token if isinstance(access_token, str) else None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import base64
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Mapping, Optional
|
from typing import Any, Mapping, Optional
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,6 +24,7 @@ class MockOIDCProvider:
|
|||||||
issuer: Optional[str] = None,
|
issuer: Optional[str] = None,
|
||||||
sub: str = 'user123',
|
sub: str = 'user123',
|
||||||
email: str = 'user@example.com',
|
email: str = 'user@example.com',
|
||||||
|
metadata_issuer: Optional[str] = None,
|
||||||
supports_par: bool = False,
|
supports_par: bool = False,
|
||||||
require_par: bool = False,
|
require_par: bool = False,
|
||||||
par_error: bool = False,
|
par_error: bool = False,
|
||||||
@@ -32,14 +33,19 @@ class MockOIDCProvider:
|
|||||||
self.public_key = self.private_key.public_key()
|
self.public_key = self.private_key.public_key()
|
||||||
self.kid = 'mock-key'
|
self.kid = 'mock-key'
|
||||||
self.issuer = issuer or f'https://{name}-oidc.local'
|
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.sub = sub
|
||||||
self.email = email
|
self.email = email
|
||||||
self.nonce = f'{name}-nonce-value'
|
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_error = par_error
|
||||||
self.par_requests: list[dict[str, list[str]]] = []
|
self.par_requests: list[dict[str, list[str]]] = []
|
||||||
self.par_auth_headers: list[Optional[str]] = []
|
self.par_auth_headers: list[Optional[str]] = []
|
||||||
self.discovery = {
|
self.discovery = {
|
||||||
'issuer': self.issuer,
|
'issuer': self.metadata_issuer,
|
||||||
'authorization_endpoint': f'{self.issuer}/auth',
|
'authorization_endpoint': f'{self.issuer}/auth',
|
||||||
'token_endpoint': f'{self.issuer}/token',
|
'token_endpoint': f'{self.issuer}/token',
|
||||||
'userinfo_endpoint': f'{self.issuer}/userinfo',
|
'userinfo_endpoint': f'{self.issuer}/userinfo',
|
||||||
@@ -64,17 +70,29 @@ class MockOIDCProvider:
|
|||||||
'alg': 'RS256',
|
'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()
|
now = time()
|
||||||
exp = now - 3600 if expired else now + 3600
|
exp = now - 3600 if expired else now + 3600
|
||||||
payload = {
|
payload: dict[str, Any] = {
|
||||||
'sub': self.sub,
|
'sub': sub if sub is not None else self.sub,
|
||||||
'iss': self.issuer,
|
'iss': issuer if issuer is not None else self.issuer,
|
||||||
'aud': audience,
|
'aud': audience,
|
||||||
'iat': now,
|
'iat': now,
|
||||||
'exp': exp,
|
'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(
|
return jwt.encode(
|
||||||
payload,
|
payload,
|
||||||
self.private_key,
|
self.private_key,
|
||||||
@@ -82,13 +100,39 @@ class MockOIDCProvider:
|
|||||||
headers={'kid': self.kid},
|
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:
|
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
url = str(request.url)
|
url = str(request.url)
|
||||||
path = urlparse(url).path
|
path = urlparse(url).path
|
||||||
# Serve discovery and JWKS for any host so that issuer-mismatch tests can still fetch keys.
|
# 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)
|
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()]})
|
return httpx.Response(200, json={'keys': [self._public_key_to_jwk()]})
|
||||||
if path == '/par':
|
if path == '/par':
|
||||||
if request.method != 'POST':
|
if request.method != 'POST':
|
||||||
@@ -111,13 +155,13 @@ class MockOIDCProvider:
|
|||||||
return httpx.Response(200, json={
|
return httpx.Response(200, json={
|
||||||
'access_token': 'new-access-token',
|
'access_token': 'new-access-token',
|
||||||
'refresh_token': 'new-refresh-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',
|
'token_type': 'Bearer',
|
||||||
})
|
})
|
||||||
return httpx.Response(200, json={
|
return httpx.Response(200, json={
|
||||||
'access_token': 'mock-access-token',
|
'access_token': 'mock-access-token',
|
||||||
'refresh_token': 'mock-refresh-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',
|
'token_type': 'Bearer',
|
||||||
})
|
})
|
||||||
if path == '/userinfo':
|
if path == '/userinfo':
|
||||||
@@ -185,17 +229,110 @@ class OIDCClientTest(unittest.TestCase):
|
|||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_validate_id_token_wrong_issuer(self) -> None:
|
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(
|
config = OIDCConfig(
|
||||||
issuer='https://wrong-issuer.local',
|
issuer=provider.issuer,
|
||||||
client_id='client',
|
client_id='client',
|
||||||
redirect_uri='http://localhost:8000/auth/callback',
|
redirect_uri='http://localhost:8000/auth/callback',
|
||||||
http_client=self.http_client,
|
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
|
||||||
)
|
)
|
||||||
client = OIDCClient(config)
|
client = OIDCClient(config)
|
||||||
with self.assertRaises(jwt.InvalidIssuerError):
|
with self.assertRaises(jwt.InvalidIssuerError):
|
||||||
await client.validate_id_token(token, 'nonce-value')
|
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_test
|
||||||
async def test_validate_id_token_disallowed_algorithm(self) -> None:
|
async def test_validate_id_token_disallowed_algorithm(self) -> None:
|
||||||
token = self.provider.issue_id_token('nonce-value', 'client')
|
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')
|
await client.validate_id_token(token, 'nonce-value')
|
||||||
self.assertIn('RS256', str(ctx.exception))
|
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_test
|
||||||
async def test_fetch_userinfo(self) -> None:
|
async def test_fetch_userinfo(self) -> None:
|
||||||
userinfo = await self.client.fetch_userinfo('access-token')
|
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)
|
new_token = await oidc.refresh_access_token(ctx.session)
|
||||||
await ctx.send_str(200, new_token or 'no-token')
|
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_test
|
||||||
async def test_login_redirect(self) -> None:
|
async def test_login_redirect(self) -> None:
|
||||||
app, oidc, provider, store = self._build_app()
|
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)
|
r = await client.get('/auth/callback', params={'code': 'mock-code', 'state': 'wrong'}, follow_redirects=False)
|
||||||
self.assertEqual(400, r.status_code)
|
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_test
|
||||||
async def test_logout(self) -> None:
|
async def test_logout(self) -> None:
|
||||||
app, oidc, provider, store = self._build_app()
|
app, oidc, provider, store = self._build_app()
|
||||||
@@ -580,17 +820,68 @@ class OIDCMixinTest(unittest.TestCase):
|
|||||||
self._setup_routes(app, oidc)
|
self._setup_routes(app, oidc)
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
|
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)
|
await self._login(client, provider)
|
||||||
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)
|
|
||||||
|
|
||||||
r = await client.get('/refresh')
|
r = await client.get('/refresh')
|
||||||
self.assertEqual(200, r.status_code)
|
self.assertEqual(200, r.status_code)
|
||||||
self.assertEqual('new-access-token', r.text)
|
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_test
|
||||||
async def test_dependency_applied_automatically(self) -> None:
|
async def test_dependency_applied_automatically(self) -> None:
|
||||||
# Only pass oidc to KayaApp; SessionMixin should be applied via dependencies.
|
# Only pass oidc to KayaApp; SessionMixin should be applied via dependencies.
|
||||||
|
|||||||
Reference in new issue
Block a user