Admin plane: validate Authentik OIDC JWTs on /api/v1/admin/*

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
This commit is contained in:
2026-09-22 23:55:02 -04:00
parent d4a55b6b40
commit 3cdedb37c8
10 changed files with 1164 additions and 49 deletions
+27 -8
View File
@@ -69,11 +69,21 @@ class _AppRunner:
"""
def __init__(self, db_path: str) -> None:
# The token value is irrelevant to these tests (the health
# route is unauthenticated); it just satisfies the required
# field — the panel token's own tests live in test_api_panel.py.
# The token and OIDC values are irrelevant to these tests (the
# health route is unauthenticated); they just satisfy the
# required fields — the panel token's own tests live in
# test_api_panel.py and the OIDC settings' in
# test_api_admin_auth.py.
self.app = create_app(
ApiConfig(db_path=db_path, panel_token="health-runner-token")
ApiConfig(
db_path=db_path,
panel_token="health-runner-token",
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
oidc_audience="nestquest-api",
oidc_jwks_url=(
"https://authentik.example.com/application/o/nestquest/jwks/"
),
)
)
async def __aenter__(self) -> httpx.AsyncClient:
@@ -107,6 +117,9 @@ def test_from_env_returns_set_value() -> None:
{
"NESTQUEST_DB_PATH": "/data/nq.db",
"NESTQUEST_PANEL_TOKEN": "panel-token",
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
}
)
assert cfg.db_path == "/data/nq.db"
@@ -295,10 +308,16 @@ _NO_HA_SCRIPT = textwrap.dedent(
# Build the app (the lifespan is NOT run here, so the path is never
# opened — it just needs to be a non-empty string to satisfy the
# fail-fast config check). Use a temp path so nothing is created;
# the panel token value is irrelevant to this import-only check.
app = create_app(ApiConfig(db_path=str(
Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"
), panel_token="noha-import-token"))
# the token and OIDC values are irrelevant to this import-only check.
app = create_app(ApiConfig(
db_path=str(Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"),
panel_token="noha-import-token",
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
oidc_audience="nestquest-api",
oidc_jwks_url=(
"https://authentik.example.com/application/o/nestquest/jwks/"
),
))
assert app is not None
print("API NO-HA IMPORT OK")