Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,228 @@
|
||||
# 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)
|
||||
)
|
||||
Reference in new issue
Block a user