Files
intervalsicu-mcp/tests/test_auth.py
T
Chris Farhood 43bbbb6bbb
build-image / test (push) Failing after 53s
build-image / build (push) Has been skipped
test: raise coverage 64% -> 90% with behavior-focused tests + enforced gate
New suites assert real behavior, not just that code runs:
- test_types: workout serialization round-trips (recursive steps, camelCase
  keys, enum conversion) + __str__ formatting.
- test_api_client: request construction (URL/method/auth/body) and the full
  HTTP status-code -> message mapping.
- test_auth: RS256 JWT verification — valid -> AccessToken; expired/wrong-aud/
  wrong-issuer/wrong-key/missing-claim -> None; audience slash variants.
- test_server_setup: transport selection + start_server dispatch.
- test_events / test_activities / test_custom_items: request payloads
  (create vs update, POST/PUT/DELETE), delete accounting, JSON-content parsing,
  and error/empty branches.

Enforce >=90 via pytest --cov-fail-under=90; CI test job now gates the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:39:20 -04:00

156 lines
5.3 KiB
Python

"""
Tests for intervals_mcp_server.auth — native OAuth token verification.
Covers the real security logic: a valid RS256 JWT yields an AccessToken with the
right claims; tampered / expired / wrong-audience / wrong-key tokens yield None;
the audience accepts both trailing-slash forms; and build_auth only enables auth
when the environment is configured.
"""
import asyncio
import time
import types
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from intervals_mcp_server import auth as auth_mod
from intervals_mcp_server.auth import AuthentikTokenVerifier, _audience_variants, build_auth
ISSUER = "https://auth.example/application/o/x/"
RESOURCE = "https://res.example"
def _keypair():
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return priv, priv.public_key()
def _verifier(pub, audience):
v = AuthentikTokenVerifier("https://jwks.invalid", ISSUER, audience)
# avoid network: hand the verifier a fake JWKS client returning our public key
v._jwks = types.SimpleNamespace( # noqa: SLF001
get_signing_key_from_jwt=lambda _t: types.SimpleNamespace(key=pub)
)
return v
def _token(priv, **overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": RESOURCE,
"exp": now + 3600,
"iat": now,
"sub": "user-123",
"scope": "read write",
"azp": "client-abc",
}
claims.update(overrides)
return jwt.encode(claims, priv, algorithm="RS256")
# --------------------------------------------------------------------------- #
# verify_token
# --------------------------------------------------------------------------- #
def test_valid_token_returns_access_token_with_claims():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE, "client-abc"])
tok = _token(priv)
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.subject == "user-123"
assert result.client_id == "client-abc"
assert result.scopes == ["read", "write"]
assert result.resource == RESOURCE
assert result.token == tok
def test_expired_token_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, exp=int(time.time()) - 10)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_audience_rejected():
priv, pub = _keypair()
v = _verifier(pub, ["https://someone-else"]) # verifier expects a different aud
tok = _token(priv, aud=RESOURCE)
assert asyncio.run(v.verify_token(tok)) is None
def test_wrong_issuer_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, iss="https://evil")
assert asyncio.run(v.verify_token(tok)) is None
def test_signature_from_other_key_rejected():
priv, _ = _keypair()
_, other_pub = _keypair() # verifier gets a key that did NOT sign the token
v = _verifier(other_pub, [RESOURCE])
tok = _token(priv)
assert asyncio.run(v.verify_token(tok)) is None
def test_missing_required_claim_rejected():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
# drop exp -> options require ["exp",...] -> rejected
now = int(time.time())
tok = jwt.encode({"iss": ISSUER, "aud": RESOURCE, "iat": now}, priv, algorithm="RS256")
assert asyncio.run(v.verify_token(tok)) is None
def test_client_id_falls_back_to_resource_when_no_azp():
priv, pub = _keypair()
v = _verifier(pub, [RESOURCE])
tok = _token(priv, azp=None)
del_claims = jwt.decode(tok, options={"verify_signature": False})
assert "azp" in del_claims # azp present but None
result = asyncio.run(v.verify_token(tok))
assert result is not None
assert result.client_id == RESOURCE # falls back to the aud/resource
# --------------------------------------------------------------------------- #
# _audience_variants
# --------------------------------------------------------------------------- #
def test_audience_variants_include_both_slash_forms_and_client_id():
variants = _audience_variants("https://res.example", "cid")
assert "https://res.example" in variants
assert "https://res.example/" in variants
assert "cid" in variants
def test_audience_variants_dedupe_and_no_client_id():
variants = _audience_variants("https://res.example/", None)
assert variants == ["https://res.example", "https://res.example/"]
# --------------------------------------------------------------------------- #
# build_auth
# --------------------------------------------------------------------------- #
def test_build_auth_disabled_without_env(monkeypatch):
for var in ("MCP_ISSUER", "MCP_RESOURCE", "MCP_JWKS_URI", "MCP_CLIENT_ID"):
monkeypatch.delenv(var, raising=False)
assert build_auth() == (None, None)
def test_build_auth_enabled_with_env(monkeypatch):
monkeypatch.setenv("MCP_ISSUER", ISSUER)
monkeypatch.setenv("MCP_RESOURCE", RESOURCE)
monkeypatch.setenv("MCP_JWKS_URI", "https://auth.example/jwks/")
monkeypatch.setenv("MCP_CLIENT_ID", "client-abc")
settings, verifier = build_auth()
assert settings is not None
assert isinstance(verifier, AuthentikTokenVerifier)
assert str(settings.issuer_url) == ISSUER
# audience carries both slash forms + client id
assert "client-abc" in verifier._audience # noqa: SLF001