205 lines
7.4 KiB
Python
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)
|