Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
761 lines
26 KiB
Python
761 lines
26 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. That harness (the key pairs, the
|
|
JWKS builder, the token minting and the app runner) is shared with the
|
|
other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this
|
|
module imports it and proves the auth CONTRACT itself:
|
|
|
|
- ``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 token's HEADER is validated BEFORE the JWKS cache is touched: a
|
|
garbage or non-RS256 token triggers NO fetch at all.
|
|
- A JWKS entry that matches the ``kid`` but carries corrupt key
|
|
material maps to the same uniform 401.
|
|
- 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.
|
|
- A JWKS OUTAGE fails fast: after one failed fetch the cache re-raises
|
|
the fault for the whole backoff window without further fetches, even
|
|
under a burst of concurrent requests; an unknown ``kid`` forces one
|
|
TTL-bypassing refetch, throttled to at most one per interval.
|
|
|
|
``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 asyncio
|
|
import base64
|
|
import json
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from api.auth import JwksCache
|
|
from api.config import ApiConfig, ConfigError
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
AUDIENCE,
|
|
ISSUER,
|
|
JWKS_URL,
|
|
KID,
|
|
OTHER_KEY,
|
|
SUBJECT,
|
|
AdminRunner as _AdminRunner,
|
|
jwks_for as _jwks_for,
|
|
make_token as _make_token,
|
|
)
|
|
|
|
#: The uniform 401 detail every rejection must carry.
|
|
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
|
|
|
#: The 403 detail for the panel service token on an admin route.
|
|
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
"This credential type is not accepted for admin access"
|
|
)
|
|
|
|
#: The 403 detail for a valid JWT without the admin group.
|
|
FORBIDDEN_DETAIL = (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
|
|
|
|
class _FailingFetch:
|
|
"""A JWKS fetcher stub that always fails, counting the attempts."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
|
|
async def __call__(self, url: str) -> dict[str, object]:
|
|
self.calls += 1
|
|
raise httpx.ConnectError("provider unreachable")
|
|
|
|
|
|
class _FlakyFetch:
|
|
"""A fetcher that succeeds ONCE, then fails on every later call."""
|
|
|
|
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
|
|
if self.calls == 1:
|
|
return self.document
|
|
raise httpx.ConnectError("provider unreachable")
|
|
|
|
|
|
class _FailThenServe:
|
|
"""A fetcher that fails the FIRST call, then serves the document."""
|
|
|
|
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
|
|
if self.calls == 1:
|
|
raise httpx.ConnectError("provider unreachable")
|
|
return self.document
|
|
|
|
|
|
def _token_with_header(
|
|
header: dict[str, object], payload: dict[str, object]
|
|
) -> str:
|
|
"""Build a JWT with an EXACT header (bypassing jwt.encode's rules).
|
|
|
|
Used to prove header checks (``alg``, ``kid``) happen before the
|
|
JWKS cache is consulted; the signature segment is never reached.
|
|
"""
|
|
def part(obj: dict[str, object]) -> str:
|
|
raw = json.dumps(obj).encode("utf-8")
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
return f"{part(header)}.{part(payload)}.c2lnbmF0dXJl"
|
|
|
|
|
|
def _verified_payload() -> dict[str, object]:
|
|
"""Claims that would verify — the header checks fire before them."""
|
|
now = int(time.time())
|
|
return {
|
|
"iss": ISSUER,
|
|
"aud": AUDIENCE,
|
|
"sub": SUBJECT,
|
|
"exp": now + 300,
|
|
"nbf": now - 1,
|
|
}
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
async def test_corrupt_jwks_entry_matching_kid_is_unauthorized(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A JWKS entry matching the kid but with corrupt key data → 401.
|
|
|
|
``RSAAlgorithm.from_jwk`` raises ValueError/TypeError (NOT a
|
|
PyJWTError) on such an entry; it must map to the SAME uniform 401,
|
|
not surface as a server error.
|
|
"""
|
|
corrupt_jwks: dict[str, object] = {
|
|
"keys": [
|
|
{"kty": "RSA", "kid": KID, "n": "AQ", "e": "AQAB"},
|
|
]
|
|
}
|
|
runner = _AdminRunner(temp_db_path, corrupt_jwks)
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {_make_token()}"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
|
|
|
|
# --- the header is checked BEFORE the JWKS cache -----------------------------
|
|
|
|
|
|
async def test_garbage_token_triggers_no_jwks_fetch(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A token that is not a JWT costs NO fetch, lock or provider load."""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": "Bearer not-a-jwt"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
assert runner.fetch.calls == 0
|
|
|
|
|
|
async def test_non_rs256_token_triggers_no_jwks_fetch(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A structurally valid JWT with a non-RS256 alg is 401 pre-cache.
|
|
|
|
The header carries a kid pointing at the published key, so ONLY the
|
|
early alg check can reject it — the fetcher must never be called.
|
|
"""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
token = _token_with_header(
|
|
{"alg": "HS256", "typ": "JWT", "kid": KID}, _verified_payload()
|
|
)
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
assert runner.fetch.calls == 0
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- the JWKS cache under an outage: fail fast, don't amplify ---------------
|
|
|
|
|
|
async def test_jwks_outage_fails_fast_during_backoff() -> None:
|
|
"""After one failed fetch, later requests fail fast for the window."""
|
|
fetch = _FailingFetch()
|
|
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
|
for _ in range(4):
|
|
with pytest.raises(httpx.ConnectError):
|
|
await cache.get()
|
|
assert fetch.calls == 1
|
|
|
|
|
|
async def test_jwks_outage_burst_calls_the_fetcher_once() -> None:
|
|
"""A burst of concurrent requests costs ONE fetch, not one each.
|
|
|
|
The first caller fetches and fails; every other caller — queued on
|
|
the lock or arriving inside the backoff window — fails fast on the
|
|
recorded fault without touching the fetcher.
|
|
"""
|
|
fetch = _FailingFetch()
|
|
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
|
results = await asyncio.gather(
|
|
*(cache.get() for _ in range(6)), return_exceptions=True
|
|
)
|
|
assert fetch.calls == 1
|
|
assert all(
|
|
isinstance(result, httpx.ConnectError) for result in results
|
|
)
|
|
|
|
|
|
async def test_jwks_backoff_lapses_and_recovers() -> None:
|
|
"""A backoff window is not a lockout: retries resume after it.
|
|
|
|
With a zero-length window the very next request retries (and the
|
|
successful document is then served from the cache), proving a
|
|
failed state is never cached for good.
|
|
"""
|
|
document = _jwks_for(ADMIN_KEY, KID)
|
|
fetch = _FailThenServe(document)
|
|
cache = JwksCache(JWKS_URL, fetcher=fetch, backoff_seconds=0.0)
|
|
with pytest.raises(httpx.ConnectError):
|
|
await cache.get()
|
|
assert await cache.get() == document
|
|
assert await cache.get() == document
|
|
assert fetch.calls == 2
|
|
|
|
|
|
async def test_jwks_outage_is_a_server_fault_not_a_401(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A provider outage propagates as a fault, never as a 401."""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
runner.app.state.jwks_cache = JwksCache(JWKS_URL, fetcher=_FailingFetch())
|
|
async with runner as client:
|
|
with pytest.raises(httpx.ConnectError):
|
|
await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {_make_token()}"},
|
|
)
|
|
|
|
|
|
# --- the unknown-kid refetch: one forced, bounded attempt --------------------
|
|
|
|
|
|
async def test_unknown_kid_refetch_picks_up_a_rotated_key(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""An unmatched kid forces ONE refetch, and the retry can verify.
|
|
|
|
The JWKS first publishes a DIFFERENT kid: the token is 401 after
|
|
exactly two fetches (normal + forced). When the rotated key lands
|
|
in the provider's document, the NEXT token forces another refetch
|
|
that picks it up — the same token verifies WITHOUT waiting out the
|
|
TTL.
|
|
"""
|
|
runner = _AdminRunner(
|
|
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
|
)
|
|
token = _make_token()
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
assert runner.fetch.calls == 2
|
|
|
|
# The rotation lands at the provider (same stub fetcher).
|
|
runner.fetch.document = _jwks_for(ADMIN_KEY, KID)
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert runner.fetch.calls == 3
|
|
|
|
|
|
async def test_unknown_kid_refetch_is_throttled(temp_db_path: str) -> None:
|
|
"""Back-to-back unknown-kid tokens force at most one refetch.
|
|
|
|
The interval bounds forced refetches: the second unknown-kid token
|
|
inside it is served from the cached document (still unmatched →
|
|
401) with no further fetch.
|
|
"""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, "other-key"))
|
|
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 == 401
|
|
assert runner.fetch.calls == 2
|
|
|
|
|
|
async def test_unknown_kid_refetch_resumes_after_the_interval(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A lapsed interval allows the next forced refetch again.
|
|
|
|
With a zero-length interval every request may force its refetch:
|
|
the first costs one normal fetch plus one forced (two), and the
|
|
second — its cached document still fresh — costs just the forced
|
|
one (three in total), proving the throttle is a bounded interval
|
|
rather than a permanent lockout.
|
|
"""
|
|
runner = _AdminRunner(
|
|
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_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 == 401
|
|
assert runner.fetch.calls == 3
|
|
|
|
|
|
async def test_failed_forced_refetch_propagates_then_fails_fast(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""A failed forced refetch is a fault, and its backoff bounds the rest.
|
|
|
|
First request: cache fills (1 fetch), the kid is unmatched, the
|
|
forced refetch FAILS (2 fetches) and propagates as the server
|
|
fault. Second request inside the backoff window: the cached
|
|
document is served, the match fails, and the token is 401 — with
|
|
NO third fetch.
|
|
"""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
runner.app.state.jwks_cache = JwksCache(
|
|
JWKS_URL, fetcher=_FlakyFetch(_jwks_for(ADMIN_KEY, "other-key"))
|
|
)
|
|
token = _make_token()
|
|
async with runner as client:
|
|
with pytest.raises(httpx.ConnectError):
|
|
await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == REJECTION_DETAIL
|
|
|
|
|
|
# --- 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)
|
|
|
|
|
|
# --- the nestquest-admins group gate (task 0f2afb29) -------------------------
|
|
|
|
|
|
async def test_admin_jwt_with_group_returns_200(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A valid JWT whose groups claim names nestquest-admins returns 200."""
|
|
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_non_admin_jwt_is_forbidden(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A valid JWT WITHOUT nestquest-admins in groups returns 403."""
|
|
token = _make_token(groups=("some-other-group",))
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
|
|
|
|
async def test_empty_groups_is_forbidden(
|
|
admin_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A valid JWT with an EMPTY groups list returns 403."""
|
|
token = _make_token(groups=())
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
@pytest.mark.parametrize("groups", ["nestquest-admins", None])
|
|
async def test_malformed_groups_is_forbidden(
|
|
admin_client: httpx.AsyncClient, groups: object
|
|
) -> None:
|
|
"""A valid JWT with a non-list (or missing) groups claim returns 403.
|
|
|
|
Even a plain STRING naming the group is not enough — the claim must
|
|
be a list, so a token crafted with a scalar ``groups`` cannot pass.
|
|
"""
|
|
token = _make_token(groups=groups)
|
|
response = await admin_client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
|
|
|
|
async def test_panel_service_token_is_refused_outright(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""The panel service token on an admin route is 403, never a JWT.
|
|
|
|
403 (not the uniform 401) proves the credential was refused as a
|
|
TYPE, not run through JWT verification — and the JWKS fetcher is
|
|
never called, proving it was not treated as a token to verify.
|
|
"""
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": "Bearer admin-test-panel-token"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
assert runner.fetch.calls == 0
|
|
|
|
|
|
async def test_admin_router_is_wired_through_require_admin(
|
|
temp_db_path: str,
|
|
) -> None:
|
|
"""The admin router's dependency IS the one require_admin dependency.
|
|
|
|
The router-level dependency list carries
|
|
:func:`api.auth.require_admin`, so every admin route (current and
|
|
later) inherits the service-token refusal and the group gate.
|
|
"""
|
|
from api.auth import require_admin
|
|
from api.routes_admin import router as admin_router
|
|
|
|
dependencies = [d.dependency for d in admin_router.dependencies]
|
|
assert require_admin in dependencies
|
|
|
|
# Behavioral proof: the probe returns 200 for an admin JWT through
|
|
# the same dependency.
|
|
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
response = await client.get(
|
|
"/api/v1/admin/ping",
|
|
headers={"Authorization": f"Bearer {_make_token()}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|