# tests/test_demo_token.py from __future__ import annotations import json import tempfile import unittest from pathlib import Path from uuid import uuid4 from services.control_api.auth.errors import InvalidToken from services.control_api.auth.models import PrincipalKind from services.control_api.persistence.demo_client import ( DEMO_CLIENT_ID, DEMO_CLIENT_ISSUER, DEMO_CLIENT_SUBJECT, ) from services.runtime.demo_token import ( MAX_TTL_HOURS, MIN_TTL_HOURS, DemoTokenAuthenticator, load_demo_revocations, mint_demo_token, verify_demo_token, ) KEY = b"x" * 32 OTHER_KEY = b"y" * 32 class DemoTokenCoreTests(unittest.TestCase): def test_mint_and_verify_round_trip(self) -> None: token, jti = mint_demo_token(KEY, ttl_hours=48, now=1_000_000) claims = verify_demo_token(token, KEY, now=1_000_100) self.assertEqual(claims.jti, jti) self.assertEqual(claims.issued_at, 1_000_000) self.assertEqual(claims.expires_at, 1_000_000 + 48 * 3600) def test_ttl_bounds_enforced(self) -> None: with self.assertRaises(ValueError): mint_demo_token(KEY, ttl_hours=MIN_TTL_HOURS - 1) with self.assertRaises(ValueError): mint_demo_token(KEY, ttl_hours=MAX_TTL_HOURS + 1) mint_demo_token(KEY, ttl_hours=MIN_TTL_HOURS) mint_demo_token(KEY, ttl_hours=MAX_TTL_HOURS) def test_expired_token_rejected(self) -> None: token, _ = mint_demo_token(KEY, ttl_hours=1, now=1_000_000) with self.assertRaises(InvalidToken): verify_demo_token(token, KEY, now=1_000_000 + 3601) def test_wrong_key_rejected(self) -> None: token, _ = mint_demo_token(KEY, ttl_hours=1, now=1_000_000) with self.assertRaises(InvalidToken): verify_demo_token(token, OTHER_KEY, now=1_000_100) def test_tampered_payload_rejected(self) -> None: token, _ = mint_demo_token(KEY, ttl_hours=1, now=1_000_000) prefix, rest = token.split("demo_", 1) payload_b64, signature_b64 = rest.split(".") tampered = f"{prefix}demo_{payload_b64}A.{signature_b64}" with self.assertRaises(InvalidToken): verify_demo_token(tampered, KEY, now=1_000_100) def test_non_demo_token_rejected(self) -> None: with self.assertRaises(InvalidToken): verify_demo_token("not-a-demo-token", KEY) def test_malformed_shape_rejected(self) -> None: with self.assertRaises(InvalidToken): verify_demo_token("demo_onlyonepart", KEY) with self.assertRaises(InvalidToken): verify_demo_token("demo_a.b.c", KEY) class DemoTokenAuthenticatorTests(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.revocations_path = Path(self.temp_dir.name) / "revocations.json" def tearDown(self) -> None: self.temp_dir.cleanup() def test_missing_revocations_file_means_nothing_revoked(self) -> None: self.assertEqual(load_demo_revocations(self.revocations_path), frozenset()) def test_revocations_file_is_read(self) -> None: self.revocations_path.write_text( json.dumps({"schemaVersion": "1.0", "revokedJtis": ["abc", "def"]}), encoding="utf-8", ) self.assertEqual(load_demo_revocations(self.revocations_path), frozenset({"abc", "def"})) def test_revocations_path_that_is_a_directory_fails_closed(self) -> None: """Docker creates a directory at a bind-mount target whose host source is missing. That must raise, not read as 'nothing revoked'.""" self.revocations_path.mkdir() with self.assertRaises(ValueError): load_demo_revocations(self.revocations_path) def test_invalid_revocations_file_raises(self) -> None: self.revocations_path.write_text("not json", encoding="utf-8") with self.assertRaises(ValueError): load_demo_revocations(self.revocations_path) def test_valid_token_authenticates_as_fixed_demo_principal(self) -> None: # DemoTokenAuthenticator has no test-clock hook by design (its # __call__ signature is fixed to authorization_header only), so it # always verifies against the real wall clock -- mint against real # "now" here rather than a fixed past epoch that would always read # as already-expired. token, _ = mint_demo_token(KEY, ttl_hours=1) authenticator = DemoTokenAuthenticator(KEY, self.revocations_path) principal = authenticator(f"Bearer {token}") self.assertEqual(principal.client_id, DEMO_CLIENT_ID) self.assertEqual(principal.issuer, DEMO_CLIENT_ISSUER) self.assertEqual(principal.subject, DEMO_CLIENT_SUBJECT) self.assertEqual(principal.kind, PrincipalKind.MACHINE_CLIENT) def test_key_loader_supports_secret_rotation_without_router_restart(self) -> None: first = b"a" * 32 second = b"b" * 32 current = [first] authenticator = DemoTokenAuthenticator( None, self.revocations_path, key_loader=lambda: current[0] ) first_token, _ = mint_demo_token(first, ttl_hours=1) authenticator(f"Bearer {first_token}") current[0] = second second_token, _ = mint_demo_token(second, ttl_hours=1) authenticator(f"Bearer {second_token}") with self.assertRaises(InvalidToken): authenticator(f"Bearer {first_token}") def test_authenticator_requires_exactly_one_key_source(self) -> None: with self.assertRaises(ValueError): DemoTokenAuthenticator(None, self.revocations_path) with self.assertRaises(ValueError): DemoTokenAuthenticator(KEY, self.revocations_path, key_loader=lambda: KEY) def test_revoked_jti_rejected(self) -> None: # Real "now" so the InvalidToken raised below is actually caused by # the revocation check, not masked by an unrelated expiry failure. token, jti = mint_demo_token(KEY, ttl_hours=1) self.revocations_path.write_text( json.dumps({"schemaVersion": "1.0", "revokedJtis": [jti]}), encoding="utf-8" ) authenticator = DemoTokenAuthenticator(KEY, self.revocations_path) with self.assertRaises(InvalidToken): authenticator(f"Bearer {token}") def test_missing_or_malformed_header_rejected(self) -> None: authenticator = DemoTokenAuthenticator(KEY, self.revocations_path) with self.assertRaises(InvalidToken): authenticator(None) with self.assertRaises(InvalidToken): authenticator("not-bearer-shaped") def test_tampered_payload_can_never_resolve_to_a_different_client(self) -> None: """Defense in depth: a hand-crafted payload that injects an extra "project" field still resolves to the one fixed demo client, because the authenticator never reads project/client identity from the token payload at all -- only jti/iat/exp are ever consulted.""" import hashlib import hmac as hmac_module import time from services.runtime.demo_token import _b64url_encode now = int(time.time()) forged_payload = json.dumps( { "jti": "forged-jti", "iat": now, "exp": now + 3600, "project": "some-other-project", "client_id": str(uuid4()), }, sort_keys=True, separators=(",", ":"), ).encode("utf-8") payload_b64 = _b64url_encode(forged_payload) signature = hmac_module.new(KEY, payload_b64.encode("ascii"), hashlib.sha256).digest() forged_token = f"demo_{payload_b64}.{_b64url_encode(signature)}" authenticator = DemoTokenAuthenticator(KEY, self.revocations_path) principal = authenticator(f"Bearer {forged_token}") self.assertEqual(principal.client_id, DEMO_CLIENT_ID) self.assertEqual(principal.issuer, DEMO_CLIENT_ISSUER) self.assertEqual(principal.subject, DEMO_CLIENT_SUBJECT) def test_expired_demo_principal_is_rejected_by_the_existing_authorizer(self) -> None: """Defense in depth at the OTHER layer: even if a demo principal's own expiry check were ever bypassed, the existing, unmodified AliasAuthorizer.authorize() still independently rejects it, because DemoTokenAuthenticator always sets expires_at from the token's real exp claim, never a far-future placeholder.""" from datetime import timedelta from pydantic import AnyHttpUrl, TypeAdapter from services.dataplane_router.authz import AliasAuthorizer from services.dataplane_router.errors import RouterError from services.dataplane_router.models import AliasRecord, ClientBinding token, _ = mint_demo_token(KEY, ttl_hours=1) authenticator = DemoTokenAuthenticator(KEY, self.revocations_path) principal = authenticator(f"Bearer {token}") record = AliasRecord( project_slug="mobilityops", environment="prod", alias="/mcp/mobilityops/prod", upstream_url=TypeAdapter(AnyHttpUrl).validate_python("https://gateway.invalid/x"), enabled=True, bindings=( ClientBinding( client_id=DEMO_CLIENT_ID, issuer=DEMO_CLIENT_ISSUER, subject=DEMO_CLIENT_SUBJECT, enabled=True, ), ), ) # principal.expires_at reflects the token's real exp claim; evaluate # "now" as later than that to prove the authorizer's own check fires. with self.assertRaises(RouterError): AliasAuthorizer().authorize( principal, record, now=principal.expires_at + timedelta(seconds=100) )