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:
+23
-2
@@ -236,7 +236,15 @@ class _PanelRunner:
|
||||
self, db_path: str, panel_token: str = PANEL_TOKEN
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(db_path=db_path, panel_token=panel_token)
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=panel_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) -> tuple["_PanelRunner", httpx.AsyncClient]:
|
||||
@@ -311,6 +319,9 @@ def test_from_env_reads_panel_token() -> 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.panel_token == "panel-token"
|
||||
@@ -319,7 +330,14 @@ def test_from_env_reads_panel_token() -> None:
|
||||
def test_from_env_requires_panel_token_when_missing() -> None:
|
||||
"""A missing panel token raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"})
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_empty() -> None:
|
||||
@@ -329,6 +347,9 @@ def test_from_env_requires_panel_token_when_empty() -> None:
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": " ",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user