Admin auth: Authentik JWT validation (#156)

This commit was merged in pull request #156.
This commit is contained in:
2026-09-23 00:37:30 -04:00
parent d4a55b6b40
commit ca1165de3f
10 changed files with 1628 additions and 49 deletions
+15 -2
View File
@@ -12,6 +12,11 @@ exposes the two endpoints every later task depends on:
(service-token protected; see :mod:`api.routes_panel`, whose router
carries the shared :func:`api.dependencies.require_panel_token`
check).
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
router carries the shared :func:`api.auth.require_admin_jwt`
dependency; the JWKS document it verifies against is cached on
``app.state.jwks_cache``).
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default.
@@ -37,9 +42,11 @@ from collections.abc import AsyncIterator
from fastapi import FastAPI
from api.auth import JwksCache
from api.config import ApiConfig
from api.database import DatabaseState, make_database
from api.events import TransitionPublisher
from api.routes_admin import router as admin_router
from api.routes_panel import router as panel_router
@@ -90,10 +97,16 @@ def create_app(config: ApiConfig | None = None) -> FastAPI:
)
# The resolved config rides on app.state so the reusable route
# dependencies (the panel service-token check) read the configured
# values from the app rather than module state.
# dependencies (the panel service-token check, the admin JWT check)
# read the configured values from the app rather than module state.
app.state.config = cfg
# The admin plane's ONE per-app JWKS cache: fetched from the
# configured provider on first use, then reused until its TTL
# lapses (see api.auth.JwksCache). Tests replace it with a
# stub-backed cache so no request ever leaves the process.
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
app.include_router(panel_router)
app.include_router(admin_router)
@app.get("/health", summary="Service health")
async def health() -> dict[str, str]:
+406
View File
@@ -0,0 +1,406 @@
"""Admin-plane authentication for the NestQuest API service.
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
a Bearer credential and :func:`require_admin_jwt` — the ONE
implementation of that check — verifies it before any admin route runs.
Verification enforces, without exception:
- the signature, against the RSA signing key published in the
provider's JWKS document (fetched from the configured
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
- the issuer (``iss`` must equal the configured
``NESTQUEST_OIDC_ISSUER``);
- the audience (``aud`` must carry the configured
``NESTQUEST_OIDC_AUDIENCE``);
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
when the token carries it).
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
the same detail — nothing in the response distinguishes which check
failed. The check FAILS CLOSED: a missing app config, a malformed or
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
``kid``, and any PyJWT verification error all yield the one 401.
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
JWKS cache is consulted, so structurally invalid tokens can never
amplify load on the provider. An unknown ``kid`` forces ONE
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
key is picked up without waiting out the TTL, and a JWKS entry that
matches the ``kid`` but carries corrupt key material maps to the same
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
provider being unreachable is a server-side fault (an HTTP 500
surfaces) and never poisons the cache, so the plane stays closed while
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).
"""
from __future__ import annotations
import asyncio
import binascii
import json
import time
from collections.abc import Awaitable, Callable
from typing import Annotated
import httpx
import jwt
from fastapi import Header, HTTPException, Request
#: The authorization scheme the admin plane expects. Matched
#: case-insensitively, as RFC 9110 requires for scheme names.
_BEARER_SCHEME = "bearer"
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
#: nor any JWKS key can talk the verifier into a weaker or symmetric
#: scheme; PyJWT re-enforces it at decode time.
_ALLOWED_ALGORITHM = "RS256"
#: The 401 detail shared by every rejection — deliberately uniform so
#: the response never reveals which part of the check failed.
_REJECTION_DETAIL = "Missing or invalid admin credentials"
#: Challenge header returned with a 401, per the Bearer auth scheme.
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
#: 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.
_JWKS_TTL_SECONDS = 300.0
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
#: this window after a failure the cache fails fast — it re-raises the
#: recorded fault instead of touching the provider — so an outage is
#: never amplified into one fetch per request.
_JWKS_BACKOFF_SECONDS = 5.0
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
#: cannot turn each request into a provider fetch either.
_JWKS_KID_REFETCH_SECONDS = 5.0
#: Seconds before the real JWKS fetch gives up on the provider.
_JWKS_TIMEOUT_SECONDS = 5.0
#: An async callable that fetches the JWKS document from a URL. The
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
#: the suite stays hermetic (no network access).
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
async def _fetch_jwks(url: str) -> dict[str, object]:
"""Fetch the provider's JWKS document over HTTP (the real fetch).
Uses httpx with a short timeout; a non-2xx response or a body that
is not a ``{"keys": [...]}`` object raises — the cache never stores
a failed or malformed fetch.
"""
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
response = await client.get(url)
response.raise_for_status()
document = response.json()
if not isinstance(document, dict) or not isinstance(
document.get("keys"), list
):
raise ValueError("JWKS document must be an object with a keys list")
return document
class JwksCache:
"""One app's cache of the OIDC provider's JWKS document.
The document is fetched through ``fetcher`` (the real one uses
httpx against the configured JWKS URL) and held for ``ttl_seconds``
— the dependency NEVER fetches per request. A single fetch is
shared between concurrent requests via an :class:`asyncio.Lock`
(double-checked around it), and a failed fetch is never cached, so
the next request retries once the backoff window lapses rather than
being locked out until the TTL does.
Two bounds keep the provider out of an amplification loop:
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
for this long — re-raising the recorded fault WITHOUT touching
the fetcher or even the lock — instead of retrying per request.
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
unmatched-``kid`` path — may run at most this often; a forced
refetch that is bounded out (or one inside the backoff window)
is served from the cached document as-is.
A successful fetch always clears the backoff; the document itself
is still trusted only for ``ttl_seconds`` — nothing is cached
indefinitely.
One instance lives on ``app.state.jwks_cache`` (installed by
:func:`api.app.create_app`); tests replace it with one built around
a stub fetcher, keeping the suite hermetic.
"""
def __init__(
self,
url: str,
*,
ttl_seconds: float = _JWKS_TTL_SECONDS,
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
fetcher: JwksFetcher | None = None,
) -> None:
self._url = url
self._ttl_seconds = ttl_seconds
self._backoff_seconds = backoff_seconds
self._kid_refetch_seconds = kid_refetch_seconds
self._fetcher: JwksFetcher = (
fetcher if fetcher is not None else _fetch_jwks
)
self._document: dict[str, object] | None = None
self._fetched_at: float | None = None
self._failed_until: float | None = None
self._last_error: BaseException | None = None
self._forced_at: float | None = None
self._lock = asyncio.Lock()
async def get(self, *, force: bool = False) -> dict[str, object]:
"""Return the JWKS document, refetching it if the TTL lapsed.
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
can pick up a rotated key without waiting it out; the forced
refetch is itself bounded (see the class docstring): inside the
backoff window or the ``kid_refetch_seconds`` interval the
cached document is served as-is instead. A failed fetch is
re-raised to the caller (a provider outage is a server fault,
never a 401) and starts the backoff window, during which every
caller — including forced ones with nothing cached — fails fast
on the recorded fault.
"""
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
# Fail fast OUTSIDE the lock: a request inside the backoff
# window must not queue behind an in-flight fetch.
if force:
document = self._document
if document is not None:
return document
raise error
async with self._lock:
if not force:
fresh = self._fresh_document()
if fresh is not None:
return fresh
error = self._backoff_error()
if error is not None:
if force:
document = self._document
if document is not None:
return document
raise error
if force and self._refetch_throttled():
document = self._document
if document is not None:
return document
return await self._fetch_locked(force=force)
def _fresh_document(self) -> dict[str, object] | None:
"""The cached document if one exists and is inside its TTL."""
document = self._document
fetched_at = self._fetched_at
if (
document is not None
and fetched_at is not None
and (time.monotonic() - fetched_at) < self._ttl_seconds
):
return document
return None
def _backoff_error(self) -> BaseException | None:
"""The recorded fetch fault while the backoff window is open."""
failed_until = self._failed_until
last_error = self._last_error
if (
failed_until is not None
and last_error is not None
and time.monotonic() < failed_until
):
return last_error
return None
def _refetch_throttled(self) -> bool:
"""Whether a forced refetch ran too recently to run another."""
forced_at = self._forced_at
return forced_at is not None and (
time.monotonic() - forced_at
) < self._kid_refetch_seconds
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
"""Run ONE fetch attempt (caller holds the lock) and record it.
A failed fetch starts the ``backoff_seconds`` window and is
re-raised; a successful one stores the document, restarts the
TTL and clears the backoff. A forced attempt stamps
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
how often forced refetches may run back-to-back.
"""
if force:
self._forced_at = time.monotonic()
try:
document = await self._fetcher(self._url)
except Exception as error:
self._failed_until = time.monotonic() + self._backoff_seconds
self._last_error = error
raise
self._document = document
self._fetched_at = time.monotonic()
self._failed_until = None
self._last_error = None
return document
def _reject() -> HTTPException:
"""Build the single 401 the dependency raises for every failure."""
return HTTPException(
status_code=401,
detail=_REJECTION_DETAIL,
headers=_WWW_AUTHENTICATE,
)
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
"""Return the RSA signing key named ``kid`` from the JWKS.
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
is asymmetric RSA); anything else — a missing ``keys`` list, no
entry with that ``kid`` — returns ``None`` rather than falling back
to "some" key. The key is built through PyJWT's JWKS decoder, and
an entry that MATCHES the ``kid`` but carries corrupt key material
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
mapped to the one 401 like every other rejection: the caller must
never see a half-built key.
"""
keys = jwks.get("keys")
if not isinstance(keys, list):
raise _reject()
for entry in keys:
if not isinstance(entry, dict):
continue
if entry.get("kid") != kid or entry.get("kty") != "RSA":
continue
try:
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
except (
jwt.PyJWTError,
binascii.Error,
ValueError,
TypeError,
KeyError,
):
raise _reject() from None
return None
async def verify_admin_token(
request: Request, token: str
) -> dict[str, object]:
"""Verify ONE admin bearer token and return its verified claims.
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
off ``app.state.jwks_cache`` (both installed by
:func:`api.app.create_app`) so the check carries no module state.
A missing config or cache, an EMPTY configured issuer/audience/JWKS
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
``kid``, and any PyJWT verification failure (bad signature,
expired, wrong issuer, wrong audience, not-yet-valid, missing
``exp``) raise the SAME 401 — the failure mode is never leaked.
``exp`` is REQUIRED (a token that never expires is never accepted)
and PyJWT enforces ``nbf`` when the token carries it.
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
the JWKS cache is consulted, so a structurally invalid token costs
no fetch, no lock, no provider load. When the cached document has
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
refetch is made and the match retried once — a rotated key is
picked up without waiting out the TTL — before the 401.
A JWKS fetch failure propagates (see the module docstring): the
provider being down is a server fault, not a bad credential.
"""
config = getattr(request.app.state, "config", None)
if (
config is None
or not config.oidc_issuer
or not config.oidc_audience
or not config.oidc_jwks_url
):
raise _reject()
cache = getattr(request.app.state, "jwks_cache", None)
if cache is None:
raise _reject()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
raise _reject() from None
if header.get("alg") != _ALLOWED_ALGORITHM:
raise _reject()
kid = header.get("kid")
if not isinstance(kid, str) or not kid:
raise _reject()
jwks = await cache.get()
try:
key = _matching_key(jwks, kid)
if key is None:
# The kid is not in the cached document: force ONE
# TTL-bypassing refetch (bounded by the cache) and retry
# the match once before rejecting.
jwks = await cache.get(force=True)
key = _matching_key(jwks, kid)
if key is None:
raise _reject()
claims = jwt.decode(
token,
key=key,
algorithms=[_ALLOWED_ALGORITHM],
issuer=config.oidc_issuer,
audience=config.oidc_audience,
options={"require": ["exp"]},
)
except jwt.PyJWTError:
raise _reject() from None
return claims
async def require_admin_jwt(
request: Request,
authorization: Annotated[str | None, Header()] = None,
) -> dict[str, object]:
"""Reject the request with 401 unless it carries a valid admin JWT.
Extracts the Bearer credential — a missing or malformed
``Authorization`` header (wrong scheme, empty credential) raises
the one 401 — and delegates the verification to
: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.
"""
if authorization is None:
raise _reject()
scheme, _, credential = authorization.partition(" ")
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
raise _reject()
return await verify_admin_token(request, credential.strip())
__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"]
+112 -36
View File
@@ -8,15 +8,25 @@ The API reads its configuration knobs from environment variables:
(D-012): the credential ``/api/v1/panel/*`` routes require via
:func:`api.dependencies.require_panel_token`. It is stored outside
the repo and handed to the service through the environment.
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
``iss`` claim admin tokens must carry (D-012 admin plane).
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
must carry as their ``aud`` claim (the API service's client id).
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
signing keys (the JWKS document) the admin tokens' signatures are
verified against. Cached per app (see :class:`api.auth.JwksCache`).
Unlike a dev convenience default, a missing or empty value is a
configuration ERROR, not a silent fallback: a default database path
would silently drop data at reboot, and a default (or empty) service
token would either authenticate everyone or lock the panel plane out
for good. ``ApiConfig.from_env`` therefore RAISES when either variable
is unset or empty. This mirrors the integration's "explicit settings
object" discipline (Feature 15's ``core.settings``): the API never
reads HA config; callers pass the resolved values into the wiring.
would silently drop data at reboot, a default (or empty) service token
would either authenticate everyone or lock the panel plane out for
good, and a default (or empty) OIDC setting would silently leave the
admin plane trusting an issuer that was never configured. All five
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
any of them is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's ``core.settings``):
the API never reads HA config; callers pass the resolved values into
the wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
@@ -41,6 +51,28 @@ DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: lock the panel plane out permanently.
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
#: Environment variable holding the Authentik OIDC issuer URL (D-012
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
#: issuer would silently make the admin plane trust tokens from an
#: issuer nobody configured.
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
#: Environment variable holding the OIDC audience (the API service's
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
#: Environment variable holding the Authentik JWKS URL: the endpoint
#: serving the signing keys admin JWT signatures are verified against.
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
#: without the provider's keys no token can be verified, so a silent
#: default would only move the failure to request time.
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
class ConfigError(RuntimeError):
"""Raised when required API configuration is missing or invalid."""
@@ -50,18 +82,25 @@ class ConfigError(RuntimeError):
class ApiConfig:
"""Resolved API configuration.
Both fields are required (no defaults): ``from_env`` rejects an
unset or empty value for either, and a caller building the dataclass
directly must pass an explicit token — the token check itself fails
closed on an empty configured token, but expecting a caller to
discover that at request time is a wiring mistake, so the field is
mandatory. Kept as a dataclass so later tasks (admin plane, SSE,
settings persistence) can extend it without touching the call sites
that read ``db_path``.
Every field is required (no defaults): ``from_env`` rejects an
unset or empty value for each, and a caller building the dataclass
directly must pass explicit values — the token check and the JWT
checks themselves fail closed on empty configured values, but
expecting a caller to discover that at request time is a wiring
mistake, so every field is mandatory. Kept as a dataclass so later
tasks (SSE, settings persistence) can extend it without touching
the call sites that read ``db_path``.
"""
db_path: str
panel_token: str
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
oidc_issuer: str
#: The OIDC audience (the API service's client id) admin tokens'
#: ``aud`` must carry.
oidc_audience: str
#: The Authentik JWKS URL the signing keys are fetched from.
oidc_jwks_url: str
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
@@ -70,29 +109,66 @@ class ApiConfig:
``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
or empty/whitespace-only — there is no silent default, because a
default in the temp directory would silently lose data at reboot
— and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty
(a default token is never invented: the panel plane is closed
until a real one is configured).
Raises :class:`ConfigError` when ANY of the five variables is
unset or empty/whitespace-only — there is no silent default:
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
silently lose data at reboot;
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
the panel plane is closed until a real one is configured;
- the three OIDC variables: an unconfigured issuer/audience/JWKS
URL must not silently leave the admin plane open (a default
JWKS URL or issuer would point at a provider nobody set up).
"""
source = env if env is not None else os.environ
raw_db_path = source.get(DB_PATH_ENV, "").strip()
if not raw_db_path:
raise ConfigError(
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
f"(it is unset or empty — the API will not fall back to a "
f"temp directory and silently lose data)"
)
raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip()
if not raw_panel_token:
raise ConfigError(
f"{PANEL_TOKEN_ENV} must be set to the panel service token "
f"(it is unset or empty — the API will not start without "
f"one, and no default token is invented)"
)
return cls(db_path=raw_db_path, panel_token=raw_panel_token)
def require(env_var: str, reason: str) -> str:
"""Return the stripped env value or raise ConfigError."""
value = source.get(env_var, "").strip()
if not value:
raise ConfigError(f"{env_var} {reason}")
return value
return cls(
db_path=require(
DB_PATH_ENV,
"must be set to a writable SQLite file path (it is unset "
"or empty — the API will not fall back to a temp directory "
"and silently lose data)",
),
panel_token=require(
PANEL_TOKEN_ENV,
"must be set to the panel service token (it is unset or "
"empty — the API will not start without one, and no "
"default token is invented)",
),
oidc_issuer=require(
OIDC_ISSUER_ENV,
"must be set to the Authentik OIDC issuer URL (it is "
"unset or empty — the admin plane will not trust tokens "
"from an issuer nobody configured)",
),
oidc_audience=require(
OIDC_AUDIENCE_ENV,
"must be set to the OIDC audience the admin tokens carry "
"(it is unset or empty — the admin plane will not accept "
"tokens without a configured audience)",
),
oidc_jwks_url=require(
OIDC_JWKS_URL_ENV,
"must be set to the Authentik JWKS URL (it is unset or "
"empty — admin token signatures cannot be verified "
"without the provider's signing keys)",
),
)
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"]
__all__ = [
"ApiConfig",
"ConfigError",
"DB_PATH_ENV",
"OIDC_AUDIENCE_ENV",
"OIDC_ISSUER_ENV",
"OIDC_JWKS_URL_ENV",
"PANEL_TOKEN_ENV",
]
+63
View File
@@ -0,0 +1,63 @@
"""Admin-plane routes for the NestQuest API service.
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).
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.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from api.auth import require_admin_jwt
#: All admin-plane routes share this router; the JWT check is attached
#: HERE so every admin route (current and later) requires it.
router = APIRouter(
prefix="/api/v1/admin",
dependencies=[Depends(require_admin_jwt)],
)
class AdminPingResponse(BaseModel):
"""The probe body: the status plus the verified token's subject."""
status: str
#: The JWT's ``sub`` claim — the subject the admin PWA authenticated
#: as. ``None`` only for a verified token that carries no ``sub``.
sub: str | None
@router.get("/ping", summary="Admin plane probe")
async def admin_ping(
claims: Annotated[dict[str, object], Depends(require_admin_jwt)],
) -> AdminPingResponse:
"""Return 200 for a verified admin JWT (the plane's smoke probe).
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
per-request dependency cache), so the token is verified exactly
once per request.
"""
sub = claims.get("sub")
return AdminPingResponse(
status="ok",
sub=sub if isinstance(sub, str) else None,
)
__all__ = ["router"]
+1
View File
@@ -17,6 +17,7 @@ dev = [
"fastapi>=0.115",
"httpx>=0.28",
"jinja2>=3",
"pyjwt[crypto]>=2.9",
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
+791
View File
@@ -0,0 +1,791 @@
"""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 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
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
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 _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
)
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,
}
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,
backoff_seconds: float | None = None,
kid_refetch_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
if backoff_seconds is not None:
cache_kwargs["backoff_seconds"] = backoff_seconds
if kid_refetch_seconds is not None:
cache_kwargs["kid_refetch_seconds"] = kid_refetch_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
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)
+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")
+23 -2
View File
@@ -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/",
}
)
+11 -1
View File
@@ -100,7 +100,17 @@ class _Server:
def factory():
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/"
),
)
)
self._server_loop = asyncio.get_running_loop()
self.app = app
Generated
+179
View File
@@ -42,6 +42,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -60,6 +158,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@@ -227,6 +381,7 @@ dev = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
@@ -241,6 +396,7 @@ dev = [
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" },
{ name = "jinja2", specifier = ">=3" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
@@ -266,6 +422,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
@@ -392,6 +557,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyjwt"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.1.1"