"""The validator must reject every category of bad token, not just expired ones. Without these, `get_current_principal` could be accepting anything and the endpoint tests -- which override authentication -- would never notice. """ from __future__ import annotations import datetime as dt from collections.abc import Callable import pytest from cryptography.hazmat.primitives.asymmetric import rsa from v2x_server.auth.keycloak import OIDCProvider, TokenError from v2x_server.auth.principal import Principal async def test_accepts_a_valid_token( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: claims = await provider.decode(make_token()) assert claims["sub"] == "11111111-1111-1111-1111-111111111111" assert claims["preferred_username"] == "vera" async def test_rejects_expired_token( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: # Beyond the 30s leeway. with pytest.raises(TokenError): await provider.decode(make_token(expires_in=-120)) async def test_rejects_wrong_issuer(provider: OIDCProvider, make_token: Callable[..., str]) -> None: with pytest.raises(TokenError): await provider.decode(make_token(iss="https://evil.test/realms/v2x")) async def test_rejects_wrong_audience( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: # This is the failure Keycloak produces by default: without an audience # mapper the token's aud is "account", not the API's client id. with pytest.raises(TokenError): await provider.decode(make_token(aud="account")) async def test_rejects_unknown_key_id( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: with pytest.raises(TokenError): await provider.decode(make_token(kid="rotated-away")) async def test_rejects_missing_key_id( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: with pytest.raises(TokenError, match="no kid"): await provider.decode(make_token(kid=None)) async def test_rejects_alg_none(provider: OIDCProvider, unsigned_token: str) -> None: with pytest.raises(TokenError): await provider.decode(unsigned_token) async def test_rejects_token_signed_by_another_key( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) with pytest.raises(TokenError): await provider.decode(make_token(key=attacker_key)) async def test_rejects_tampered_payload( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: header, payload, signature = make_token().split(".") # Flip a character in the payload; the signature no longer covers it. mutated = payload[:-2] + ("A" if payload[-2] != "A" else "B") + payload[-1] with pytest.raises(TokenError): await provider.decode(f"{header}.{mutated}.{signature}") async def test_rejects_garbage(provider: OIDCProvider) -> None: with pytest.raises(TokenError): await provider.decode("not-a-jwt") @pytest.mark.parametrize("claim", ["sub", "exp", "iat", "iss", "aud"]) async def test_rejects_token_missing_a_required_claim( provider: OIDCProvider, make_token: Callable[..., str], claim: str ) -> None: with pytest.raises(TokenError): await provider.decode(make_token(drop=(claim,))) class TestPrincipalMapping: """Claim shape -> Principal. The one place that knows Keycloak's layout.""" def test_maps_realm_and_client_roles(self) -> None: principal = Principal.from_claims( { "sub": "abc", "preferred_username": "vera", "email": "vera@example.com", "realm_access": {"roles": ["viewer", "operator"]}, "resource_access": {"v2x-api": {"roles": ["device.read"]}}, "scope": "openid profile", }, client_id="v2x-api", ) assert principal.realm_roles == frozenset({"viewer", "operator"}) assert principal.client_roles == frozenset({"device.read"}) assert principal.roles == frozenset({"viewer", "operator", "device.read"}) assert principal.has_any_role("operator", "admin") assert not principal.has_all_roles("operator", "admin") def test_tolerates_absent_role_blocks(self) -> None: principal = Principal.from_claims({"sub": "abc"}, client_id="v2x-api") assert principal.roles == frozenset() assert principal.username is None def test_ignores_roles_for_other_clients(self) -> None: principal = Principal.from_claims( { "sub": "abc", "resource_access": {"some-other-client": {"roles": ["admin"]}}, }, client_id="v2x-api", ) assert principal.client_roles == frozenset() async def test_unknown_kid_triggers_a_refresh_once_the_floor_has_passed( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: """Key rotation must be survivable without restarting the process.""" await provider.decode(make_token()) # warm the cache before = provider._jwks_fetched_at # Age the cache past the minimum refresh interval. provider._jwks_fetched_at -= 3600 with pytest.raises(TokenError): await provider.decode(make_token(kid="unknown-kid")) # A refetch happened: the timestamp moved forward again. assert provider._jwks_fetched_at > before - 3600 async def test_unknown_kid_refresh_is_rate_limited( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: """A flood of junk tokens must not become load amplification on Keycloak.""" await provider.decode(make_token()) with pytest.raises(TokenError, match="rate-limited"): await provider.decode(make_token(kid="unknown-kid")) async def test_clock_skew_within_leeway_is_accepted( provider: OIDCProvider, make_token: Callable[..., str] ) -> None: """A token issued a few seconds in the future should still pass.""" future = dt.datetime.now(tz=dt.UTC) + dt.timedelta(seconds=10) claims = await provider.decode(make_token(iat=future, nbf=future)) assert claims["sub"]