Files
saas_backend/tests/test_module_identity.py
T
2026-08-31 20:39:41 -04:00

205 lines
7.4 KiB
Python

"""Tokens a module can actually verify.
Module tokens are signed RS256 with a `kid` header. That header exists so a
receiver can look the key up — and there was nowhere to look. The platform signed
tokens no module could verify, which makes the asymmetry pointless: the reason to
use RS256 rather than a shared secret is that the module never needs the signing
key, only a way to fetch the public one.
`SAAS_PRIVATE_KEY` is also unset in every environment file, so the grant exchange
raised an unhandled `ValueError` and returned a 500 to a server-to-server caller.
"""
from __future__ import annotations
import base64
import pytest
from app.core.tenant_context import unscoped
from app.services.auth import module_identity
from .conftest import requires_db
@pytest.fixture
def signing_key(monkeypatch, module_signing_key):
"""Configure the platform with an ephemeral key, and clear the cache.
The public numbers are memoised, so a test that changed the key without
clearing would verify against the previous one and pass for the wrong
reason.
"""
module_identity._public_numbers.cache_clear()
yield module_signing_key
module_identity._public_numbers.cache_clear()
@pytest.fixture
def unconfigured(monkeypatch):
from app.config.settings import settings
monkeypatch.setattr(settings, "SAAS_PRIVATE_KEY", None, raising=False)
module_identity._public_numbers.cache_clear()
yield
module_identity._public_numbers.cache_clear()
def test_the_published_key_verifies_a_real_token(signing_key):
"""The end-to-end claim: what the platform signs, the document verifies.
Anything less is a JWKS that exists and does not work, which is worse than
none — a module author would build against it and find out in production.
"""
import jwt
from jwt import PyJWKClient # noqa: F401 (import proves the shape is standard)
from app.config.security import security
token = security.generate_module_token({"sub": "abc"}, "reporting")
document = module_identity.jwks()
key = document["keys"][0]
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
def _int(value: str) -> int:
padded = value + "=" * (-len(value) % 4)
return int.from_bytes(base64.urlsafe_b64decode(padded), "big")
public = RSAPublicNumbers(_int(key["e"]), _int(key["n"])).public_key()
claims = jwt.decode(token, public, algorithms=["RS256"], audience="reporting")
assert claims["sub"] == "abc"
assert claims["type"] == "module_access"
def test_the_document_names_the_key_the_tokens_carry(signing_key):
"""`kid` is what makes rotation possible: publish both, sign with the new
one, retire the old once nothing carries it. A mismatch means a receiver
looks up a key that is not there."""
import jwt
from app.config.security import security
from app.config.settings import settings
token = security.generate_module_token({"sub": "abc"}, "reporting")
assert jwt.get_unverified_header(token)["kid"] == settings.SAAS_KEY_ID
assert module_identity.jwks()["keys"][0]["kid"] == settings.SAAS_KEY_ID
def test_the_document_is_the_shape_receivers_expect(signing_key):
key = module_identity.jwks()["keys"][0]
assert key["kty"] == "RSA"
assert key["alg"] == "RS256"
assert key["use"] == "sig"
assert "=" not in key["n"] and "=" not in key["e"]
assert "+" not in key["n"] and "/" not in key["n"]
def test_the_private_key_is_not_in_the_document(signing_key):
"""The one thing that must never appear. A JWKS is public by definition and
is served unauthenticated."""
import json
from app.config.settings import settings
published = json.dumps(module_identity.jwks())
assert "PRIVATE" not in published
assert settings.SAAS_PRIVATE_KEY not in published
for field in ("d", "p", "q", "dp", "dq", "qi"):
assert field not in module_identity.jwks()["keys"][0]
def test_with_no_key_the_document_is_empty_rather_than_an_error(unconfigured):
"""A module polling this should see "no keys published" — true and
actionable — not a 500 that reads as "the platform is down"."""
assert module_identity.jwks() == {"keys": []}
assert module_identity.is_configured() is False
def test_a_malformed_key_does_not_become_a_500(monkeypatch, caplog):
"""A bad key is a deployment error. It should be reported once, not raised
on whichever endpoint is called first."""
import logging
from app.config.settings import settings
monkeypatch.setattr(settings, "SAAS_PRIVATE_KEY", "not a pem", raising=False)
module_identity._public_numbers.cache_clear()
with caplog.at_level(logging.ERROR):
assert module_identity.jwks() == {"keys": []}
assert any("could not be parsed" in r.getMessage() for r in caplog.records)
module_identity._public_numbers.cache_clear()
@requires_db
def test_the_document_is_served_without_a_credential(client, signing_key):
"""Deliberately unauthenticated: a module needs this before it can trust
anything the platform says, including whatever credential it would present.
"""
response = client.get("/.well-known/jwks.json")
assert response.status_code == 200
assert len(response.json()["keys"]) == 1
assert "max-age" in response.headers.get("cache-control", "")
@requires_db
def test_the_route_works_before_the_key_is_configured(client, unconfigured):
response = client.get("/.well-known/jwks.json")
assert response.status_code == 200
assert response.json() == {"keys": []}
@requires_db
def test_the_health_check_reports_whether_it_is_configured(client, unconfigured):
"""Otherwise the state is only discoverable by tripping over it."""
body = client.get("/health").json()
assert body["module_identity"] == "not configured"
@requires_db
def test_the_health_check_reports_a_configured_key(client, signing_key):
body = client.get("/health").json()
assert body["module_identity"] == "configured"
@requires_db
def test_an_exchange_without_a_key_refuses_clearly(db, unconfigured, fake_redis,
tenant_factory, plan_factory,
role_factory, user_factory,
module_factory,
environment_factory,
tenant_module_factory):
"""It used to raise an unhandled ValueError — a 500 with a stack trace, on a
server-to-server call, telling the module nothing it could act on. 503 says
"not me, and not now", which is what a retrying client needs.
"""
from fastapi import HTTPException
from app.services.auth.sso_service import SSOService
plan = plan_factory()
tenant = tenant_factory(plan_id=plan.id)
role = role_factory(tenant=tenant, name="Member")
user = user_factory(tenant=tenant, role=role)
module = module_factory()
environment_factory(module, slug="prod", is_default=True)
tenant_module_factory(tenant, module)
with unscoped():
grant = SSOService.generate_grant(
db, user.id, module.module_id, tenant.id
)
with pytest.raises(HTTPException) as exc:
SSOService.exchange_grant(
db, grant["grant_code"], module.module_id, "prod"
)
assert exc.value.status_code == 503
assert "not configured" in str(exc.value.detail)