Admin auth: nestquest-admins group gate and service-token rejection (#157)
This commit was merged in pull request #157.
This commit is contained in:
+92
-8
@@ -35,16 +35,20 @@ the real cause is visible in the server log instead of disguised as a
|
||||
bad credential; a short backoff window after a failed fetch fails fast
|
||||
(re-raising the recorded fault) rather than fetching once per request.
|
||||
|
||||
This module validates the TOKEN only — it is authentication, not
|
||||
authorization. Membership in the ``nestquest-admins`` group is the
|
||||
NEXT task, which extends :func:`require_admin_jwt` (the verified
|
||||
claims are already its return value, so no rewrite is needed).
|
||||
Beyond authentication, :func:`require_admin` is the ONE dependency
|
||||
every admin route uses: it REJECTS the panel service token outright —
|
||||
that credential authenticates the panel plane only, so treating it as
|
||||
a JWT candidate would blur two credential types into one check — and
|
||||
then requires the verified JWT's ``groups`` claim to name the
|
||||
``nestquest-admins`` group (authorization, 403, distinct from the
|
||||
uniform authentication 401 so the two failure classes stay apart).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
@@ -70,6 +74,11 @@ _REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
#: The authorization group a verified admin JWT must name in its
|
||||
#: ``groups`` claim. A module constant (the value Authentik's
|
||||
#: nestquest-admins group maps to) so it is clearly named in one place.
|
||||
ADMIN_GROUP = "nestquest-admins"
|
||||
|
||||
#: Seconds a fetched JWKS document stays trusted before the next
|
||||
#: fetch. Five minutes bounds how long a rotated-away key keeps
|
||||
#: verifying while staying far above a per-request fetch.
|
||||
@@ -266,6 +275,19 @@ class JwksCache:
|
||||
return document
|
||||
|
||||
|
||||
#: 403 detail for the panel service token on an admin route. Deliberately
|
||||
#: non-leaking: it names the credential TYPE, not its value or where it came
|
||||
#: from. 403 (not 401) because this is a REFUSED credential type, not an
|
||||
#: absent/invalid one — challenging re-authentication would suggest the same
|
||||
#: credential could work with a retry, which it never can here.
|
||||
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
|
||||
|
||||
#: 403 detail for a valid JWT that is not a member of the admin group.
|
||||
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
|
||||
#: the caller is not AUTHORIZED, so the response may say so.
|
||||
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
@@ -391,9 +413,9 @@ async def require_admin_jwt(
|
||||
:func:`verify_admin_token`, returning the verified claims for the
|
||||
route to use (the probe route surfaces the token's ``sub``).
|
||||
|
||||
The nestquest-admins GROUP check is the NEXT task: it extends this
|
||||
dependency (e.g. by inspecting the returned claims) so every admin
|
||||
route inherits it — the 401 contract stays exactly this uniform.
|
||||
The nestquest-admins GROUP check lives in :func:`require_admin`,
|
||||
which wraps this dependency; ``require_admin_jwt`` remains the plain
|
||||
JWT-validation contract — the 401 path stays exactly this uniform.
|
||||
"""
|
||||
if authorization is None:
|
||||
raise _reject()
|
||||
@@ -403,4 +425,66 @@ async def require_admin_jwt(
|
||||
return await verify_admin_token(request, credential.strip())
|
||||
|
||||
|
||||
__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"]
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""The ONE admin-plane dependency: service-token refusal + group gate.
|
||||
|
||||
Order matters. FIRST the Bearer credential is compared against the
|
||||
configured panel service token (``app.state.config.panel_token``):
|
||||
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
|
||||
the panel credential is never even treated as a JWT candidate, let
|
||||
alone a non-admin JWT. (403 rather than 401 because this is a
|
||||
refused credential TYPE, not an invalid one: challenging
|
||||
re-authentication would imply retrying could ever succeed; the
|
||||
detail names the type only, never the value.) A config with an
|
||||
EMPTY panel token never matches anything, so it cannot lock every
|
||||
Bearer request out.
|
||||
|
||||
Otherwise the credential goes through the existing JWT path
|
||||
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
|
||||
or malformed header and any invalid JWT still yield the SAME
|
||||
uniform 401 as before. A VALID JWT is then checked for
|
||||
authorization: its ``groups`` claim must be a list containing
|
||||
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
|
||||
one without the group raises 403 (distinct from the 401 on
|
||||
purpose: the token authenticated, the caller did not authorize).
|
||||
|
||||
Returns the verified claims for the route.
|
||||
"""
|
||||
if authorization is not None:
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() == _BEARER_SCHEME and credential.strip():
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is not None
|
||||
and config.panel_token
|
||||
# compare_digest on bytes (as require_panel_token does)
|
||||
# so the refusal does not short-circuit on the first
|
||||
# mismatching byte — a constant-time comparison keeps a
|
||||
# timing side channel on the credential off the table.
|
||||
and secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"),
|
||||
config.panel_token.encode("utf-8"),
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_PANEL_TOKEN_REJECTION_DETAIL,
|
||||
)
|
||||
claims = await require_admin_jwt(request, authorization)
|
||||
groups = claims.get("groups")
|
||||
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
|
||||
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
|
||||
return claims
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_GROUP",
|
||||
"JwksCache",
|
||||
"JwksFetcher",
|
||||
"require_admin",
|
||||
"require_admin_jwt",
|
||||
"verify_admin_token",
|
||||
]
|
||||
|
||||
+18
-16
@@ -2,17 +2,17 @@
|
||||
|
||||
The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA.
|
||||
Every route on :data:`router` requires a valid Authentik OIDC JWT
|
||||
through the ONE reusable dependency :func:`api.auth.require_admin_jwt`
|
||||
(declared once at the router level, so each later admin route added to
|
||||
this router inherits the check).
|
||||
whose ``groups`` claim names ``nestquest-admins``, through the ONE
|
||||
reusable dependency :func:`api.auth.require_admin` (declared once at
|
||||
the router level, so each later admin route added to this router
|
||||
inherits the check). The panel service token is refused outright on
|
||||
this plane (403), never treated as a JWT.
|
||||
|
||||
This task is the AUTH probe only: the ping route is a thin adapter
|
||||
that performs NO business logic and NO auth of its own — it reuses the
|
||||
router's already-verified claims (FastAPI caches a dependency's result
|
||||
per request, so the check runs exactly once) and returns the status
|
||||
plus the token's subject. Authorization — membership in the
|
||||
``nestquest-admins`` group — is the NEXT task, layered onto
|
||||
:func:`api.auth.require_admin_jwt` so every admin route inherits it.
|
||||
plus the token's subject.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,13 +21,15 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.auth import require_admin_jwt
|
||||
from api.auth import require_admin
|
||||
|
||||
#: All admin-plane routes share this router; the JWT check is attached
|
||||
#: HERE so every admin route (current and later) requires it.
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
#: (service-token refusal + JWT verification + nestquest-admins group
|
||||
#: gate) is attached HERE so every admin route (current and later)
|
||||
#: requires it.
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin",
|
||||
dependencies=[Depends(require_admin_jwt)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
@@ -42,14 +44,14 @@ class AdminPingResponse(BaseModel):
|
||||
|
||||
@router.get("/ping", summary="Admin plane probe")
|
||||
async def admin_ping(
|
||||
claims: Annotated[dict[str, object], Depends(require_admin_jwt)],
|
||||
claims: Annotated[dict[str, object], Depends(require_admin)],
|
||||
) -> AdminPingResponse:
|
||||
"""Return 200 for a verified admin JWT (the plane's smoke probe).
|
||||
"""Return 200 for a verified JWT naming the nestquest-admins group.
|
||||
|
||||
Requires a valid Authentik JWT (checked by the router's shared
|
||||
:func:`~api.auth.require_admin_jwt` dependency — the ONE token
|
||||
check; this handler performs NO auth of its own). ``claims`` is
|
||||
the SAME verified-claims dict that dependency produced (FastAPI's
|
||||
Requires a valid Authentik JWT with the admin group (checked by the
|
||||
router's shared :func:`~api.auth.require_admin` dependency — the
|
||||
ONE check; this handler performs NO auth of its own). ``claims``
|
||||
is the SAME verified-claims dict that dependency produced (FastAPI's
|
||||
per-request dependency cache), so the token is verified exactly
|
||||
once per request.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user