The admin plane had no authentication: any caller could reach its routes once they existed. This adds the plane's ONE reusable FastAPI dependency (require_admin_jwt in api/auth.py) that verifies an Authentik OIDC JWT — Bearer extraction, RS256-only signature check against the provider's JWKS, issuer, audience, required exp, and enforced nbf — and raises the SAME 401 for every failure so the response never leaks which check failed. The JWKS is fetched over httpx once per app (JwksCache, 5-minute TTL, asyncio-lock-guarded, failed fetches never cached) and tests stub the fetcher, so the suite proves signature/expiry/audience enforcement with locally generated RSA key pairs and zero network access. ApiConfig grows the three required OIDC settings (NESTQUEST_OIDC_ISSUER/AUDIENCE/JWKS_URL) under the existing fail-fast discipline: an unconfigured issuer must not silently leave the admin plane open, so from_env raises rather than inventing a default. This is authentication only — the verified claims are the dependency's return value, so the nestquest-admins group check is the next task's addition, not a rewrite. PyJWT[crypto] joins the dev dependency group (the API deployable's runtime manifest lands in Feature 17). Maestro task: e70e6bfb-085a-4781-a3f4-276355bbbd19
473 lines
16 KiB
Python
473 lines
16 KiB
Python
"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb).
|
|
|
|
These tests exercise the NestQuest API's admin plane against the ASGI
|
|
app with httpx, using a locally generated RSA key pair and a stubbed
|
|
JWKS fetcher — NO network access:
|
|
|
|
- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a
|
|
request with NO ``Authorization`` header and a MALFORMED header
|
|
return 401; a garbage, expired, wrong-issuer, wrong-audience,
|
|
not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature
|
|
token returns 401; a token without ``exp`` is rejected; a VALID
|
|
token returns 200 with the verified subject.
|
|
- Every 401 carries the SAME detail — nothing in the response
|
|
distinguishes which check failed.
|
|
- The JWKS document is fetched ONCE through the app's
|
|
:class:`api.auth.JwksCache` and then reused across requests (the
|
|
stub fetcher counts calls); a zero TTL refetches.
|
|
|
|
``ApiConfig.from_env`` requires the three OIDC variables
|
|
(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``,
|
|
``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as
|
|
``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app
|
|
with explicit ApiConfig values so they stay hermetic.
|
|
|
|
The signature/expiry/audience proof is real, not stubbed: tokens are
|
|
signed with a locally generated RSA private key, the app verifies
|
|
against the JWKS built from its public key, and the wrong-signature
|
|
case signs with a SECOND locally generated key pair.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import jwt
|
|
import pytest
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
from api.app import create_app
|
|
from api.auth import JwksCache
|
|
from api.config import ApiConfig, ConfigError
|
|
|
|
#: The configured OIDC settings the test apps run with (values a real
|
|
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
|
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
|
AUDIENCE = "nestquest-api"
|
|
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
|
|
|
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
|
KID = "admin-test-key-1"
|
|
|
|
#: The subject a valid token authenticates as.
|
|
SUBJECT = "admin-user-1"
|
|
|
|
#: The uniform 401 detail every rejection must carry.
|
|
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
|
|
|
|
|
def _generate_key() -> rsa.RSAPrivateKey:
|
|
"""Generate one local RSA key pair (no network, no fixtures)."""
|
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
|
|
|
|
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
|
#: signed with it verify; the second pair below proves they don't when
|
|
#: signed otherwise.
|
|
ADMIN_KEY = _generate_key()
|
|
OTHER_KEY = _generate_key()
|
|
|
|
|
|
def _b64url_uint(value: int) -> str:
|
|
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
|
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
|
"""Build a JWKS document publishing ``private_key``'s public half."""
|
|
numbers = private_key.public_key().public_numbers()
|
|
return {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"kid": kid,
|
|
"n": _b64url_uint(numbers.n),
|
|
"e": _b64url_uint(numbers.e),
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
class _StubbedFetch:
|
|
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
|
|
|
def __init__(self, document: dict[str, object]) -> None:
|
|
self.document = document
|
|
self.calls = 0
|
|
|
|
async def __call__(self, url: str) -> dict[str, object]:
|
|
self.calls += 1
|
|
return self.document
|
|
|
|
|
|
def _make_token(
|
|
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
|
*,
|
|
kid: str | None = KID,
|
|
iss: str = ISSUER,
|
|
aud: object = AUDIENCE,
|
|
sub: str = SUBJECT,
|
|
expires_in: int | None = 300,
|
|
nbf_in: int | None = -1,
|
|
) -> str:
|
|
"""Mint one RS256 JWT the way Authentik would (its claims, our key)."""
|
|
now = int(time.time())
|
|
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
|
if nbf_in is not None:
|
|
claims["nbf"] = now + nbf_in
|
|
if expires_in is not None:
|
|
claims["exp"] = now + expires_in
|
|
headers: dict[str, str] = {}
|
|
if kid is not None:
|
|
headers["kid"] = kid
|
|
return jwt.encode(
|
|
claims, signing_key, algorithm="RS256", headers=headers
|
|
)
|
|
|
|
|
|
class _AdminRunner:
|
|
"""Run the app's lifespan around an httpx AsyncClient.
|
|
|
|
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
|
transport does not run the lifespan, so it is driven explicitly and
|
|
shut down in ``__aexit__``'s finally block even on failure.
|
|
|
|
The app's JWKS cache is replaced (before the lifespan runs) with
|
|
one built around a counting stub fetcher, so verification goes
|
|
through the REAL cache logic but never the network.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
db_path: str,
|
|
jwks_document: dict[str, object],
|
|
*,
|
|
ttl_seconds: float | None = None,
|
|
) -> None:
|
|
self.app = create_app(
|
|
ApiConfig(
|
|
db_path=db_path,
|
|
panel_token="admin-test-panel-token",
|
|
oidc_issuer=ISSUER,
|
|
oidc_audience=AUDIENCE,
|
|
oidc_jwks_url=JWKS_URL,
|
|
)
|
|
)
|
|
self.fetch = _StubbedFetch(jwks_document)
|
|
cache_kwargs: dict[str, float] = {}
|
|
if ttl_seconds is not None:
|
|
cache_kwargs["ttl_seconds"] = ttl_seconds
|
|
self.app.state.jwks_cache = JwksCache(
|
|
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
|
)
|
|
|
|
async def __aenter__(self) -> httpx.AsyncClient:
|
|
self._lifespan = self.app.router.lifespan_context(self.app)
|
|
await self._lifespan.__aenter__()
|
|
self._client = httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=self.app),
|
|
base_url="http://testserver",
|
|
)
|
|
return await self._client.__aenter__()
|
|
|
|
async def __aexit__(self, *exc) -> None:
|
|
try:
|
|
await self._client.__aexit__(*exc)
|
|
finally:
|
|
await self._lifespan.__aexit__(*exc)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db_path(tmp_path: Path) -> str:
|
|
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
|
return str(tmp_path / "nestquest.db")
|
|
|
|
|
|
@pytest.fixture
|
|
async def admin_client(
|
|
temp_db_path: str,
|
|
) -> httpx.AsyncClient:
|
|
"""An app client whose provider publishes the admin test key."""
|
|
async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client:
|
|
yield client
|
|
|
|
|
|
# --- the happy path -------------------------------------------------------
|
|
|
|
|
|
async def test_valid_token_returns_200_with_subject(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A valid token returns 200 with the status and verified subject."""
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {_make_token()}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
|
|
|
|
|
async def test_bearer_scheme_is_case_insensitive(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""``bearer`` (lowercase) is accepted, as RFC 9110 requires."""
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"bearer {_make_token()}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
# --- 401s: missing / malformed credentials --------------------------------
|
|
|
|
|
|
async def test_missing_token_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A request with no Authorization header returns 401."""
|
|
response = await admin_client.get("/api/v1/admin/ping")
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
assert "www-authenticate" in {
|
|
key.lower() for key in response.headers
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"header",
|
|
[
|
|
"Basic cGFuZWw6dG9rZW4=", # wrong scheme
|
|
"Bearer", # scheme, no credential
|
|
"Bearer ", # blank credential
|
|
"BearerNotAToken", # no scheme separator at all
|
|
],
|
|
)
|
|
async def test_malformed_header_is_unauthorized(
|
|
admin_client: httpx.AsyncClient, header: str
|
|
) -> None:
|
|
"""A malformed Authorization header returns 401."""
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping", headers={"Authorization": header}
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_garbage_token_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A Bearer credential that is not a JWT at all returns 401."""
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": "Bearer not-a-jwt"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
# --- 401s: token content the verifier must reject ---------------------------
|
|
|
|
|
|
async def test_expired_token_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token whose exp has passed returns 401."""
|
|
token = _make_token(expires_in=-60)
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_wrong_issuer_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token issued by another issuer returns 401."""
|
|
token = _make_token(iss="https://evil.example.com/application/o/nestquest/")
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_wrong_audience_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token minted for another audience returns 401."""
|
|
token = _make_token(aud="some-other-client")
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_future_nbf_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token that is not yet valid (future nbf) returns 401."""
|
|
token = _make_token(nbf_in=3600)
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_token_without_exp_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token with no exp claim returns 401 (expiry is REQUIRED)."""
|
|
token = _make_token(expires_in=None)
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_unknown_kid_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token naming a kid the JWKS does not publish returns 401."""
|
|
token = _make_token(kid="no-such-key")
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_bad_signature_is_unauthorized(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A token signed by a key the provider never published returns 401.
|
|
|
|
Signed with the SECOND local key pair (header kid still points at
|
|
the published key), so the signature check itself must fail.
|
|
"""
|
|
token = _make_token(signing_key=OTHER_KEY)
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
# --- the uniform 401 contract -----------------------------------------------
|
|
|
|
|
|
async def test_every_rejection_carries_the_same_detail(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""No failure mode is distinguishable in the response body.
|
|
|
|
Collects the (status, detail) pair for every rejection — missing
|
|
and malformed headers, garbage, expired, wrong issuer, wrong
|
|
audience, future nbf, missing exp, unknown kid, bad signature —
|
|
and asserts they are ALL the same one 401.
|
|
"""
|
|
cases: list[dict[str, str]] = [
|
|
{},
|
|
{"Authorization": "Basic cGFuZWw6dG9rZW4="},
|
|
{"Authorization": "Bearer"},
|
|
{"Authorization": "Bearer not-a-jwt"},
|
|
{"Authorization": f"Bearer {_make_token(expires_in=-60)}"},
|
|
{"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"},
|
|
{"Authorization": f"Bearer {_make_token(aud='other-client')}"},
|
|
{"Authorization": f"Bearer {_make_token(nbf_in=3600)}"},
|
|
{"Authorization": f"Bearer {_make_token(expires_in=None)}"},
|
|
{"Authorization": f"Bearer {_make_token(kid='no-such-key')}"},
|
|
{"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"},
|
|
]
|
|
observed = set()
|
|
for headers in cases:
|
|
response = await admin_client.get("/api/v1/admin/ping", headers=headers)
|
|
observed.add((response.status_code, response.json()["detail"]))
|
|
assert observed == {(401, REJECTION_DETAIL)}
|
|
|
|
|
|
# --- the JWKS cache ----------------------------------------------------------
|
|
|
|
|
|
async def test_jwks_is_fetched_once_across_requests(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""Multiple requests reuse ONE cached JWKS document.
|
|
|
|
Three separately authenticated requests trigger exactly ONE stub
|
|
fetch — the cache is per app and never per request.
|
|
"""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
token = _make_token()
|
|
async with runner as client:
|
|
for _ in range(3):
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert runner.fetch.calls == 1
|
|
|
|
|
|
async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None:
|
|
"""A lapsed TTL triggers a fresh fetch instead of serving stale keys."""
|
|
runner = _AdminRunner(
|
|
temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0
|
|
)
|
|
token = _make_token()
|
|
async with runner as client:
|
|
for _ in range(2):
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert runner.fetch.calls == 2
|
|
|
|
|
|
# --- ApiConfig.from_env: the OIDC settings -----------------------------------
|
|
|
|
|
|
def _full_env() -> dict[str, str]:
|
|
"""A from_env dict with every required variable set to a valid value."""
|
|
return {
|
|
"NESTQUEST_DB_PATH": "/data/nq.db",
|
|
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
|
"NESTQUEST_OIDC_ISSUER": ISSUER,
|
|
"NESTQUEST_OIDC_AUDIENCE": AUDIENCE,
|
|
"NESTQUEST_OIDC_JWKS_URL": JWKS_URL,
|
|
}
|
|
|
|
|
|
def test_from_env_reads_oidc_settings() -> None:
|
|
"""Set, non-empty OIDC env values are returned verbatim."""
|
|
cfg = ApiConfig.from_env(_full_env())
|
|
assert cfg.oidc_issuer == ISSUER
|
|
assert cfg.oidc_audience == AUDIENCE
|
|
assert cfg.oidc_jwks_url == JWKS_URL
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"env_var",
|
|
[
|
|
"NESTQUEST_OIDC_ISSUER",
|
|
"NESTQUEST_OIDC_AUDIENCE",
|
|
"NESTQUEST_OIDC_JWKS_URL",
|
|
],
|
|
)
|
|
def test_from_env_requires_oidc_setting(env_var: str) -> None:
|
|
"""A missing OR empty OIDC variable raises ConfigError (fail fast)."""
|
|
env = _full_env()
|
|
del env[env_var]
|
|
with pytest.raises(ConfigError):
|
|
ApiConfig.from_env(env)
|
|
env[env_var] = " "
|
|
with pytest.raises(ConfigError):
|
|
ApiConfig.from_env(env)
|