diff --git a/.env.example b/.env.example index c56b1c1..36def80 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,20 @@ LLM_MODEL= # RESULT_CACHE_MAX_LIFETIME_MINUTES=720 # RESULT_CACHE_MAX_ENTRIES=100 +# --- Sign-in via the organisation's OIDC provider (optional) ----------------- +# Off by default; the app expects to sit behind your own auth proxy. Switch it +# on to make the app require a sign-in itself. Setup: docs/operations/sso.md +# OIDC_ENABLED=true +# OIDC_ISSUER= # e.g. https://keycloak.klinik.de/realms/intranet +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_SESSION_SECRET= # openssl rand -hex 32 +# APP_PUBLIC_URL= # e.g. https://deid.klinik.de (also needed for the redirect URI) +# OIDC_SCOPES=openid profile email +# OIDC_SESSION_MINUTES=480 +# OIDC_END_SESSION=false # also sign out at the provider +# OIDC_HTTP_TIMEOUT_SECONDS=10 + # --- Deployment banner ------------------------------------------------------ # BANNER_ENABLED=true # BANNER_TEXT='Research Use Only!' diff --git a/AGENTS.md b/AGENTS.md index a5355eb..d7140e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,11 @@ be read as a guarantee must be corrected, not softened. **Tech stack:** Vue 3 + Vite + TypeScript + TailwindCSS v4 (frontend), FastAPI + Pydantic v2 (backend), `uv` for Python, the `openai` SDK for every -OpenAI-compatible endpoint. **No database, no Celery/Redis, no S3, no auth, no -Alembic** — the app runs behind the hospital's own auth proxy and persists -nothing. +OpenAI-compatible endpoint. **No database, no Celery/Redis, no S3, no user +accounts, no Alembic** — the app runs behind the hospital's own auth proxy and +persists nothing. The one exception is an *optional* OIDC sign-in gate +(`OIDC_ENABLED`, off by default) for deployments without such a proxy: it +gates the API on a signed cookie and holds no accounts, roles or permissions. The sibling project **llmaixweb** (https://github.com/KatherLab/llmaixweb) is the convention source: layout, config pattern, service style, frontend @@ -102,8 +104,9 @@ deidentifier/ ``` Deliberately **absent** (and not to be reintroduced without a design change): -`models/`, `db/`, `alembic/`, `celery/`, dynamic settings, auth/SSO, S3, -WebSockets. +`models/`, `db/`, `alembic/`, `celery/`, dynamic settings, user accounts / +roles / permissions, S3, WebSockets. The OIDC gate is authentication only — +"who may enter", never "who may do what". --- @@ -282,7 +285,10 @@ Retention rules that are security properties, not tuning knobs: ### API surface -All under `/api/v1` (`routers/v1/api.py`), no auth: +All under `/api/v1` (`routers/v1/api.py`). No auth by default; with +`OIDC_ENABLED` everything except `/api/v1/auth/*` and `/health/*` requires the +session cookie (`middleware/auth_gate.py` — gating in middleware, so a new +route is protected by default rather than by remembering a dependency): | Route | Purpose | |---|---| @@ -294,6 +300,9 @@ All under `/api/v1` (`routers/v1/api.py`), no auth: | `POST /api/v1/export/pdf/pages` | Renders pages as PNGs for the area-redaction editor, with embedded-image boxes as one-click suggestions. | | `GET /api/v1/status` | Configured detectors + OCR engine, endpoint **hosts** and their locality, limits. Never returns paths, keys, or full URLs. | | `GET /health/live`, `GET /health/ready` | Liveness/readiness. | +| `GET /api/v1/auth/session` | Whether a gate exists and who is signed in. The frontend's first call; with the gate off it answers `enabled=false, authenticated=true` and nothing else in the UI changes. | +| `GET /api/v1/auth/login` → `GET /api/v1/auth/callback` | Authorization Code + PKCE. The state token is signed and carried in *both* the URL and a cookie; the callback requires them to match. Failures redirect to `{APP_PUBLIC_URL}/?auth_error=` so the reviewer reads a sentence, not JSON. | +| `POST /api/v1/auth/logout` | Drops the session cookie; returns the provider's `end_session` URL when `OIDC_END_SESSION` is on. | Limits: `APP_MAX_UPLOAD_MB` (413 before buffering), `APP_MAX_TEXT_CHARS`, extensions `.txt/.docx/.pdf`. `Cache-Control: no-store` on content routes @@ -318,6 +327,9 @@ extensions `.txt/.docx/.pdf`. `Cache-Control: no-store` on content routes | `utils/pdf_export.py` | Native-PDF true redaction, rasterized fallback, scanned-PDF reconstruction, page rendering. **Fails closed**: an export that cannot be verified is refused. | | `utils/notices.py` | Stable codes + English text for every non-fatal message (the translation contract). | | `utils/policy.py` | Default policy + the replacement placeholders of every output language. | +| `utils/auth.py` | Session + login-state tokens for the OIDC gate (HS256, PKCE helpers). No session store: the signed cookie *is* the session. | +| `services/oidc_client.py` | OIDC discovery, authorize URL, code exchange, id_token verification against the provider's JWKS. Own `OidcError` with a stable `code` the UI translates. | +| `middleware/auth_gate.py` | The gate. Fail-closed by path, exempting only `/api/v1/auth/*`. | | `utils/safe_logging.py` | `get_safe_logger()` — the only logger application code may use. | | `utils/concurrency.py` | Process-wide named semaphores (global LLM/OCR request budgets across concurrent documents). | | `services/docling_serve_client.py` | docling-serve HTTP client. | @@ -374,6 +386,12 @@ hints, expert-mode popover, dark-mode toggle) and switches between and is batch-wide on purpose: `resultsExpireAt` counts down to whichever document expires first and `extendResults()` extends them all, so the header can state one number and one button for the work in front of the reviewer. +- **`auth.ts`** — the optional sign-in gate: `enabled`/`authenticated`/`user` + from `/auth/session`, `blocked` (the one flag `App.vue` reads), and the + sign-in/sign-out navigations. With no gate configured it settles on + `enabled=false` and nothing else in the UI changes. It also registers the + 401 handler on `services/api.ts` — that module never imports a store, so + there is no cycle. - **`settings.ts`** — expert mode + keep-original-filenames. The *only* store that touches `localStorage`. - **`toast.ts`** — global toast queue. @@ -384,8 +402,9 @@ hints, expert-mode popover, dark-mode toggle) and switches between ### Services (`services/`) -`api.ts` holds the shared axios instance; **components never import it**. -Call `anonymizeApi` / `statusApi`, or the streaming helpers in +`api.ts` holds the shared axios instance (`withCredentials` for the gate's +cookie, plus a 401 interceptor); **components never import it**. +Call `anonymizeApi` / `statusApi` / `authApi`, or the streaming helpers in `anonymizeStream.ts` (which speak `fetch` + NDJSON because axios cannot stream a response body in the browser). Add a function to the matching module rather than reaching for `api` directly. diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd50f8..a09f315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ documentation, and CI work are left out. ## [Unreleased] +### Added + +- Optional sign-in at the organisation's OpenID Connect provider, for + deployments without an authenticating proxy in front: `OIDC_ENABLED` plus + the client credentials and `APP_PUBLIC_URL` gate every API route on a + signed session cookie. No accounts, no roles — see + [Single sign-on](docs/operations/sso.md). + ## [0.2.1] — 2026-08-19 ### Added diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index db7f80f..4b0e627 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,12 +22,14 @@ is excluded because it is not distributed. | sniffio | 1.3.1 | Apache Software License; MIT License | https://github.com/python-trio/sniffio | | uvloop | 0.22.1 | Apache Software License; MIT License | UNKNOWN | | python-multipart | 0.0.32 | Apache-2.0 | https://github.com/Kludex/python-multipart | +| cryptography | 50.0.0 | Apache-2.0 OR BSD-3-Clause | https://github.com/pyca/cryptography | | httpx | 0.28.1 | BSD License | https://github.com/encode/httpx | | reportlab | 5.0.0 | BSD License | https://www.reportlab.com/ | | click | 8.4.2 | BSD-3-Clause | https://github.com/pallets/click/ | | httpcore | 1.0.9 | BSD-3-Clause | https://www.encode.io/httpcore/ | | idna | 3.18 | BSD-3-Clause | https://github.com/kjd/idna | | lxml | 6.1.1 | BSD-3-Clause | https://lxml.de/ | +| pycparser | 3.0 | BSD-3-Clause | https://github.com/eliben/pycparser | | pypdf | 6.15.0 | BSD-3-Clause | https://github.com/py-pdf/pypdf | | python-dotenv | 1.2.2 | BSD-3-Clause | https://github.com/theskumar/python-dotenv | | starlette | 1.4.0 | BSD-3-Clause | https://github.com/Kludex/starlette | @@ -35,6 +37,7 @@ is excluded because it is not distributed. | websockets | 17.0.1 | BSD-3-Clause | https://github.com/python-websockets/websockets | | pypdfium2 | 5.12.1 | BSD-3-Clause, Apache-2.0, dependency licenses | https://github.com/pypdfium2-team/pypdfium2 | | pymupdf | 1.28.2 | Dual Licensed - GNU AFFERO GPL 3.0 or Artifex Commercial License | https://github.com/pymupdf/pymupdf | +| PyJWT | 2.13.0 | MIT | https://github.com/jpadilla/pyjwt | | annotated-doc | 0.0.5 | MIT | https://github.com/fastapi/annotated-doc | | annotated-types | 0.8.0 | MIT | https://github.com/annotated-types/annotated-types | | anyio | 4.14.2 | MIT | https://anyio.readthedocs.io/en/stable/versionhistory.html | @@ -50,6 +53,7 @@ is excluded because it is not distributed. | h11 | 0.16.0 | MIT License | https://github.com/python-hyper/h11 | | python-docx | 1.2.0 | MIT License | https://github.com/python-openxml/python-docx | | watchfiles | 1.2.0 | MIT License | https://github.com/samuelcolvin/watchfiles | +| cffi | 2.1.1 | MIT-0 | https://cffi.readthedocs.io/en/latest/whatsnew.html | | pillow | 12.3.0 | MIT-CMU | https://python-pillow.github.io | | tqdm | 4.70.0 | MPL-2.0 AND MIT | https://tqdm.github.io | | certifi | 2026.7.22 | Mozilla Public License 2.0 (MPL 2.0) | https://github.com/certifi/python-certifi | diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 6beb8e0..29c02f4 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -70,6 +70,32 @@ class Settings(BaseSettings): BANNER_TEXT: str = "" BANNER_COLOR: str = "amber" # amber | red | blue | green | gray + # ── Access control (optional) ────────────────────────────────────────── + # Off by default: the app is designed to run behind the hospital's own + # auth proxy. Switching it on makes the app itself require a sign-in at + # the organisation's OpenID Connect provider. It is a *gate*, not an + # authorisation model — everyone who can sign in gets the same, whole app. + OIDC_ENABLED: bool = False + # Provider base URL; the app reads {issuer}/.well-known/openid-configuration. + OIDC_ISSUER: str = "" + OIDC_CLIENT_ID: str = "" + OIDC_CLIENT_SECRET: str = "" + OIDC_SCOPES: str = "openid profile email" + # Signing key for the session cookie (and the short-lived login state). + # Rotating it signs everyone out; sharing it is equivalent to sharing + # every session. Generate with: openssl rand -hex 32 + OIDC_SESSION_SECRET: str = "" + OIDC_SESSION_MINUTES: int = Field(default=480, ge=5) + # Also end the session at the provider on sign-out (RP-initiated logout), + # when the provider advertises an end_session_endpoint. Off by default: + # it signs the user out of every application, not only this one. + OIDC_END_SESSION: bool = False + OIDC_HTTP_TIMEOUT_SECONDS: int = Field(default=10, ge=1) + # The public origin browsers reach the app at, e.g. https://deid.klinik.de. + # The redirect URI registered with the provider is derived from it, so it + # must match what the browser actually uses — not the container's address. + APP_PUBLIC_URL: str = "" + # Detectors: comma-separated (mock | rules | llm) DETECTORS: str = "rules" @@ -154,6 +180,75 @@ def banner_active(self) -> bool: """Enabled *and* non-empty — an empty banner would be a blank bar.""" return self.BANNER_ENABLED and bool(self.banner_text) + @property + def public_url(self) -> str: + return self.APP_PUBLIC_URL.strip().rstrip("/") + + @property + def oidc_issuer(self) -> str: + return self.OIDC_ISSUER.strip().rstrip("/") + + @property + def oidc_scopes(self) -> str: + """The requested scopes, always including `openid` — without it the + provider runs a plain OAuth flow and returns no id_token.""" + scopes = self.OIDC_SCOPES.split() + if "openid" not in scopes: + scopes.insert(0, "openid") + return " ".join(scopes) + + @property + def oidc_redirect_uri(self) -> str: + """The callback URL that must be registered with the provider.""" + return f"{self.public_url}/api/v1/auth/callback" + + @property + def cookies_secure(self) -> bool: + """`Secure` on the session cookie whenever the app is served over TLS. + An http:// deployment cannot set it without breaking sign-in.""" + return self.public_url.lower().startswith("https://") + + +#: A shorter key than this makes the session cookie's signature guessable. +MIN_SESSION_SECRET_CHARS = 32 + + +def validate_auth_settings(settings: Settings) -> None: + """Refuse to start with a half-configured OIDC gate. + + Checked in every environment, not only production: an access gate that + silently does not gate is worse than one that never came up. The counterpart + — the app running with no gate at all — is the documented default, so an + operator cannot reach this state by accident. + """ + if not settings.OIDC_ENABLED: + return + problems: list[str] = [] + required = { + "OIDC_ISSUER": settings.oidc_issuer, + "OIDC_CLIENT_ID": settings.OIDC_CLIENT_ID.strip(), + "OIDC_CLIENT_SECRET": settings.OIDC_CLIENT_SECRET.strip(), + "APP_PUBLIC_URL": settings.public_url, + "OIDC_SESSION_SECRET": settings.OIDC_SESSION_SECRET.strip(), + } + missing = [name for name, value in required.items() if not value] + if missing: + problems.append(f"OIDC_ENABLED is true but {', '.join(missing)} are not set") + if settings.public_url and not settings.public_url.lower().startswith(("http://", "https://")): + problems.append("APP_PUBLIC_URL must be an absolute http(s) URL") + if settings.oidc_issuer and not settings.oidc_issuer.lower().startswith( + ("http://", "https://") + ): + problems.append("OIDC_ISSUER must be an absolute http(s) URL") + secret = settings.OIDC_SESSION_SECRET.strip() + if secret and len(secret) < MIN_SESSION_SECRET_CHARS: + problems.append( + f"OIDC_SESSION_SECRET must be at least {MIN_SESSION_SECRET_CHARS} characters " + "(openssl rand -hex 32)" + ) + if problems: + raise RuntimeError("Refusing to start: " + "; ".join(problems)) + def validate_production_settings(settings: Settings) -> None: """Refuse unsafe configurations in production mode.""" diff --git a/backend/src/main.py b/backend/src/main.py index e058007..2cdb9e4 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -10,7 +10,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .core.config import get_settings, validate_production_settings +from .core.config import get_settings, validate_auth_settings, validate_production_settings +from .middleware.auth_gate import AuthGateMiddleware from .middleware.error_handlers import register_error_handlers from .middleware.security_headers import SecurityHeadersMiddleware from .routers.v1.api import api_router @@ -39,6 +40,12 @@ async def _sweep_cache_periodically() -> None: async def lifespan(app: FastAPI): settings = get_settings() validate_production_settings(settings) + validate_auth_settings(settings) + if settings.OIDC_ENABLED and not settings.cookies_secure: + logger.warning( + "session_cookie_not_secure", + note="APP_PUBLIC_URL is not https - the session cookie travels unencrypted", + ) if settings.APP_ALLOW_INSECURE_CONTENT_LOGGING: logger.warning( "insecure_content_logging_enabled", @@ -49,6 +56,7 @@ async def lifespan(app: FastAPI): "startup", env=settings.APP_ENV, detectors=settings.DETECTORS, + auth="oidc" if settings.OIDC_ENABLED else "none", result_ttl_minutes=settings.RESULT_CACHE_TTL_MINUTES, result_max_lifetime_minutes=settings.RESULT_CACHE_MAX_LIFETIME_MINUTES, ) @@ -74,12 +82,20 @@ async def lifespan(app: FastAPI): openapi_url="/openapi.json" if _docs_enabled else None, ) +# Order matters: the last one added is the outermost. CORS therefore wraps the +# gate (so a 401 still carries CORS headers, and a preflight never needs a +# session), and the gate wraps everything that touches a document. app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(AuthGateMiddleware) app.add_middleware( CORSMiddleware, allow_origins=_settings.cors_origins, allow_methods=["*"], allow_headers=["*"], + # The session lives in a cookie, so the dev setup (Vite on :5173 calling + # the backend on :8000) needs credentialed cross-origin requests. Safe + # only because the origins are an explicit list, never "*". + allow_credentials=True, ) register_error_handlers(app) app.include_router(api_router) diff --git a/backend/src/middleware/auth_gate.py b/backend/src/middleware/auth_gate.py new file mode 100644 index 0000000..c98e281 --- /dev/null +++ b/backend/src/middleware/auth_gate.py @@ -0,0 +1,41 @@ +"""The access gate, when one is configured (`OIDC_ENABLED`). + +Enforced in middleware rather than as a per-route dependency on purpose: a +route added next month is then gated because it is under `/api/`, not because +somebody remembered to declare it. The only way to *lose* protection is to add +a path to `EXEMPT_PREFIXES` below, which is a visible edit. + +`/health/*` stays open — a readiness probe has no browser and no cookie — and +so do the sign-in routes themselves, which are what an unauthenticated caller +is supposed to reach. +""" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from ..core.config import get_settings +from ..utils.auth import SESSION_COOKIE, read_session + +#: Everything under /api/ is gated except these. +EXEMPT_PREFIXES = ("/api/v1/auth/",) + + +class AuthGateMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + settings = get_settings() + path = request.url.path + gated = ( + settings.OIDC_ENABLED + and path.startswith("/api/") + and not path.startswith(EXEMPT_PREFIXES) + ) + if gated and read_session(settings, request.cookies.get(SESSION_COOKIE)) is None: + # Short-circuits before SecurityHeadersMiddleware, so the no-store + # header this response needs is set here. + return JSONResponse( + status_code=401, + content={"detail": "Authentication required"}, + headers={"Cache-Control": "no-store"}, + ) + return await call_next(request) diff --git a/backend/src/routers/v1/api.py b/backend/src/routers/v1/api.py index 1f61d5f..a10aba1 100644 --- a/backend/src/routers/v1/api.py +++ b/backend/src/routers/v1/api.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from .endpoints import anonymize, export, status +from .endpoints import anonymize, auth, export, status api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router, tags=["auth"]) api_router.include_router(anonymize.router, tags=["anonymize"]) api_router.include_router(export.router, tags=["export"]) api_router.include_router(status.router, tags=["status"]) diff --git a/backend/src/routers/v1/endpoints/auth.py b/backend/src/routers/v1/endpoints/auth.py new file mode 100644 index 0000000..aecf006 --- /dev/null +++ b/backend/src/routers/v1/endpoints/auth.py @@ -0,0 +1,220 @@ +"""The optional OpenID Connect sign-in gate. + +Four routes, all exempt from the gate itself (`middleware/auth_gate.py`): + +| Route | Purpose | +|---|---| +| `GET /api/v1/auth/session` | Who is signed in — and whether a gate exists at all. The frontend's first call. | +| `GET /api/v1/auth/login` | Top-level redirect to the provider (PKCE + signed state cookie). | +| `GET /api/v1/auth/callback` | The provider returns here; verifies, then sets the session cookie. | +| `POST /api/v1/auth/logout` | Drops the session cookie, optionally ending the provider's session too. | + +A failed sign-in redirects back to the app with `?auth_error=` instead of +rendering an API error page: the person in front of it is a clinician who +pressed a button, not a caller reading JSON. The codes are stable and +translated in the frontend catalogs. +""" + +import secrets + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import RedirectResponse + +from ....core.config import Settings, get_settings +from ....schemas.auth import AuthUser, LogoutResponse, SessionResponse +from ....services import oidc_client +from ....services.oidc_client import OidcError +from ....utils.auth import ( + SESSION_COOKIE, + STATE_COOKIE, + STATE_MAX_AGE_SECONDS, + AuthenticatedUser, + issue_session, + issue_state, + pkce_pair, + read_session, + read_state, +) +from ....utils.safe_logging import get_safe_logger, log_reference + +router = APIRouter(prefix="/auth") +logger = get_safe_logger(__name__) + +#: The session cookie is only ever sent to the API, so it is scoped to it. +_SESSION_COOKIE_PATH = "/api" +#: The state cookie is needed by exactly one route. +_STATE_COOKIE_PATH = "/api/v1/auth" + + +def _require_enabled(settings: Settings) -> None: + if not settings.OIDC_ENABLED: + raise HTTPException(status_code=404, detail="Sign-in is not configured on this server") + + +def _app_redirect(settings: Settings, *, auth_error: str | None = None) -> RedirectResponse: + """Back to the app itself. There is one screen, so there is one target — + which also means there is no redirect parameter to tamper with.""" + target = f"{settings.public_url}/" + if auth_error: + target = f"{target}?auth_error={auth_error}" + return RedirectResponse(url=target, status_code=302) + + +def _set_session_cookie(response: Response, settings: Settings, token: str) -> None: + response.set_cookie( + SESSION_COOKIE, + token, + max_age=settings.OIDC_SESSION_MINUTES * 60, + httponly=True, + samesite="lax", + secure=settings.cookies_secure, + path=_SESSION_COOKIE_PATH, + ) + + +@router.get("/session", response_model=SessionResponse) +async def read_current_session( + request: Request, settings: Settings = Depends(get_settings) +) -> SessionResponse: + """The frontend's first call. With no gate configured this reports + `enabled=false` and the app runs exactly as it did before.""" + if not settings.OIDC_ENABLED: + return SessionResponse(enabled=False, authenticated=True) + user = read_session(settings, request.cookies.get(SESSION_COOKIE)) + return SessionResponse( + enabled=True, + authenticated=user is not None, + user=AuthUser(name=user.name, email=user.email) if user else None, + login_url=f"{settings.public_url}/api/v1/auth/login", + ) + + +@router.get("/login") +async def login(settings: Settings = Depends(get_settings)) -> Response: + """Begin the Authorization Code flow.""" + _require_enabled(settings) + verifier, challenge = pkce_pair() + nonce = secrets.token_urlsafe(32) + # The very same token goes into the URL and into the cookie: the callback + # requires both, so a login started elsewhere cannot be completed here. + state = issue_state(settings, code_verifier=verifier, nonce=nonce) + try: + discovery = await oidc_client.discover(settings) + url = oidc_client.authorization_url( + settings, discovery, state=state, nonce=nonce, code_challenge=challenge + ) + except OidcError as exc: + logger.warning("oidc_login_start_failed", reason=exc.code) + return _app_redirect(settings, auth_error=exc.code) + + response = RedirectResponse(url=url, status_code=302) + response.set_cookie( + STATE_COOKIE, + state, + max_age=STATE_MAX_AGE_SECONDS, + httponly=True, + samesite="lax", + secure=settings.cookies_secure, + path=_STATE_COOKIE_PATH, + ) + return response + + +@router.get("/callback") +async def callback( + request: Request, + code: str | None = None, + state: str | None = None, + error: str | None = None, + settings: Settings = Depends(get_settings), +) -> Response: + """Where the provider sends the browser back.""" + _require_enabled(settings) + cookie_state = request.cookies.get(STATE_COOKIE) + + def finish(response: Response) -> Response: + response.delete_cookie(STATE_COOKIE, path=_STATE_COOKIE_PATH) + return response + + if error: + # The provider declined — typically the user cancelled at the login + # screen, so this is a normal outcome, not a fault. + logger.info("oidc_login_declined", reason=error[:64]) + return finish(_app_redirect(settings, auth_error="denied")) + + if not code or not state or not cookie_state or not secrets.compare_digest(state, cookie_state): + logger.warning("oidc_callback_state_mismatch") + return finish(_app_redirect(settings, auth_error="state")) + login_state = read_state(settings, state) + if login_state is None: + logger.warning("oidc_callback_state_invalid") + return finish(_app_redirect(settings, auth_error="state")) + + try: + discovery = await oidc_client.discover(settings) + tokens = await oidc_client.exchange_code( + settings, discovery, code=code, verifier=login_state.code_verifier + ) + claims = await oidc_client.verify_id_token( + settings, discovery, id_token=tokens["id_token"], nonce=login_state.nonce + ) + user = await _user_from_claims(settings, discovery, claims, tokens) + except OidcError as exc: + logger.warning("oidc_login_failed", reason=exc.code) + return finish(_app_redirect(settings, auth_error=exc.code)) + + # The subject identifies a person; it is logged only as a correlation + # handle, like the request id. + logger.info("oidc_login", subject_ref=log_reference(user.subject)) + response = finish(_app_redirect(settings)) + _set_session_cookie(response, settings, issue_session(settings, user)) + return response + + +async def _user_from_claims( + settings: Settings, discovery: dict, claims: dict, tokens: dict +) -> AuthenticatedUser: + """The display name and email, from the id_token where the provider put + them there and from userinfo otherwise.""" + name = _first_string(claims, ("name", "preferred_username", "given_name")) + email = _first_string(claims, ("email",)) + access_token = tokens.get("access_token") + if not (name and email) and isinstance(access_token, str) and access_token: + info = await oidc_client.fetch_userinfo(settings, discovery, access_token=access_token) + name = name or _first_string(info, ("name", "preferred_username", "given_name")) + email = email or _first_string(info, ("email",)) + return AuthenticatedUser( + subject=str(claims["sub"]), + name=name or email, + email=email, + ) + + +def _first_string(source: dict, keys: tuple[str, ...]) -> str: + for key in keys: + value = source.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +@router.post("/logout", response_model=LogoutResponse) +async def logout(settings: Settings = Depends(get_settings)) -> Response: + """Drop the session. Always succeeds, whether or not one existed.""" + _require_enabled(settings) + redirect_url: str | None = None + if settings.OIDC_END_SESSION: + try: + redirect_url = oidc_client.end_session_url( + settings, await oidc_client.discover(settings) + ) + except OidcError: + # The local session is gone either way; that is the part this app + # is responsible for. + logger.warning("oidc_end_session_unavailable") + response = Response( + content=LogoutResponse(redirect_url=redirect_url).model_dump_json(), + media_type="application/json", + ) + response.delete_cookie(SESSION_COOKIE, path=_SESSION_COOKIE_PATH) + return response diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py new file mode 100644 index 0000000..1ba0779 --- /dev/null +++ b/backend/src/schemas/auth.py @@ -0,0 +1,28 @@ +"""Contracts for the optional sign-in gate. + +Deliberately thin: the app has no user records, no roles and no permissions. +`AuthUser` exists so the header can say who is signed in, and for nothing else. +""" + +from pydantic import BaseModel, Field + + +class AuthUser(BaseModel): + name: str = "" + email: str = "" + + +class SessionResponse(BaseModel): + #: False when no gate is configured — then `authenticated` is always true + #: and the frontend behaves exactly as it did before OIDC existed. + enabled: bool + authenticated: bool + user: AuthUser | None = None + #: Where the browser goes to start a sign-in; empty when the gate is off. + login_url: str = "" + + +class LogoutResponse(BaseModel): + #: Set when the provider should end its own session too + #: (OIDC_END_SESSION); the browser navigates there after signing out here. + redirect_url: str | None = Field(default=None) diff --git a/backend/src/services/oidc_client.py b/backend/src/services/oidc_client.py new file mode 100644 index 0000000..4fb11cb --- /dev/null +++ b/backend/src/services/oidc_client.py @@ -0,0 +1,287 @@ +"""OpenID Connect client for the optional sign-in gate. + +Authorization Code flow with PKCE against the operator-configured provider: +discovery, the authorize URL, the code exchange, id_token verification against +the provider's JWKS, and the optional userinfo lookup. + +The issuer is **operator configuration, not user input** — nobody can point +this at an address of their choosing through the API — so the endpoints named +in the discovery document are taken at face value beyond a scheme check. +Everything else follows the repo's outbound-HTTP rules: explicit timeouts, no +redirect following, and errors that never echo the provider's response body. +""" + +from urllib.parse import urlencode + +import httpx +import jwt + +from ..core.config import Settings +from ..utils.safe_logging import get_safe_logger + +logger = get_safe_logger(__name__) + +#: The signature algorithms an id_token may use. Symmetric algorithms are +#: absent on purpose: accepting HS256 here would let anyone who knows the +#: client secret mint tokens, and `none` needs no explanation. +_ID_TOKEN_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"] + +#: Per-process caches. Discovery documents and signing keys are effectively +#: static; a restart re-reads them, and a key rotation is picked up by the +#: unknown-`kid` refresh in `_signing_key`. +_discovery_cache: dict[str, dict] = {} +_jwks_cache: dict[str, dict] = {} + + +class OidcError(Exception): + """A sign-in could not be completed. `code` is a stable, translatable + reason the frontend shows; `message` is for the log, not the browser.""" + + def __init__(self, message: str, *, code: str = "provider", status_code: int = 502): + self.code = code + self.status_code = status_code + super().__init__(message) + + +def reset_caches() -> None: + """Forget the cached discovery document and keys (tests, config reload).""" + _discovery_cache.clear() + _jwks_cache.clear() + + +def _client(settings: Settings) -> httpx.AsyncClient: + return httpx.AsyncClient( + timeout=settings.OIDC_HTTP_TIMEOUT_SECONDS, + follow_redirects=False, + ) + + +def _require_absolute(url: object, field: str) -> str: + if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")): + raise OidcError(f"discovery document has no usable {field}", code="provider") + return url + + +async def discover(settings: Settings) -> dict: + """The provider's discovery document, fetched once per process.""" + issuer = settings.oidc_issuer + cached = _discovery_cache.get(issuer) + if cached is not None: + return cached + + url = f"{issuer}/.well-known/openid-configuration" + try: + async with _client(settings) as client: + response = await client.get(url) + except httpx.HTTPError as exc: + # The raw error can name the internal address the issuer resolved to. + logger.warning("oidc_discovery_unreachable", issuer=issuer, error=type(exc).__name__) + raise OidcError("discovery endpoint unreachable", code="provider") from exc + if response.status_code != 200: + logger.warning("oidc_discovery_failed", issuer=issuer, status=response.status_code) + raise OidcError("discovery endpoint returned an error", code="provider") + try: + document = response.json() + except ValueError as exc: + raise OidcError("discovery endpoint returned no JSON", code="provider") from exc + if not isinstance(document, dict): + raise OidcError("discovery endpoint returned no JSON object", code="provider") + + _require_absolute(document.get("authorization_endpoint"), "authorization_endpoint") + _require_absolute(document.get("token_endpoint"), "token_endpoint") + _discovery_cache[issuer] = document + return document + + +def authorization_url( + settings: Settings, + discovery: dict, + *, + state: str, + nonce: str, + code_challenge: str, +) -> str: + endpoint = _require_absolute(discovery.get("authorization_endpoint"), "authorization_endpoint") + params = { + "response_type": "code", + "client_id": settings.OIDC_CLIENT_ID, + "redirect_uri": settings.oidc_redirect_uri, + "scope": settings.oidc_scopes, + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + separator = "&" if "?" in endpoint else "?" + return f"{endpoint}{separator}{urlencode(params)}" + + +async def exchange_code(settings: Settings, discovery: dict, *, code: str, verifier: str) -> dict: + """Trade the authorization code for the token response.""" + endpoint = _require_absolute(discovery.get("token_endpoint"), "token_endpoint") + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": settings.oidc_redirect_uri, + "client_id": settings.OIDC_CLIENT_ID, + "code_verifier": verifier, + } + auth: tuple[str, str] | None = None + if _prefers_basic_auth(discovery): + auth = (settings.OIDC_CLIENT_ID, settings.OIDC_CLIENT_SECRET) + else: + data["client_secret"] = settings.OIDC_CLIENT_SECRET + + try: + async with _client(settings) as client: + response = await client.post(endpoint, data=data, auth=auth) + except httpx.HTTPError as exc: + logger.warning("oidc_token_unreachable", error=type(exc).__name__) + raise OidcError("token endpoint unreachable", code="token") from exc + if response.status_code != 200: + # The body can carry the client secret back at us in an error echo. + logger.warning("oidc_token_exchange_failed", status=response.status_code) + raise OidcError("token exchange failed", code="token", status_code=400) + try: + payload = response.json() + except ValueError as exc: + raise OidcError("token endpoint returned no JSON", code="token") from exc + if not isinstance(payload, dict) or not payload.get("id_token"): + raise OidcError("token response carried no id_token", code="token") + return payload + + +def _prefers_basic_auth(discovery: dict) -> bool: + """HTTP Basic when the provider advertises it *and* not the POST form. + + Both are mandatory-to-implement in the spec but real providers implement + one or the other, and picking the wrong one fails with an opaque 401. + """ + methods = discovery.get("token_endpoint_auth_methods_supported") + if not isinstance(methods, list): + return False + return "client_secret_basic" in methods and "client_secret_post" not in methods + + +def _match_key(jwks: dict, kid: str | None) -> object | None: + try: + key_set = jwt.PyJWKSet.from_dict(jwks) + except jwt.exceptions.PyJWKSetError as exc: + raise OidcError("provider returned an unusable JWKS", code="identity") from exc + for key in key_set.keys: + if key.public_key_use not in (None, "sig"): + continue + if kid is None or key.key_id == kid: + return key.key + return None + + +async def _signing_key(settings: Settings, discovery: dict, id_token: str) -> object: + jwks_uri = _require_absolute(discovery.get("jwks_uri"), "jwks_uri") + try: + kid = jwt.get_unverified_header(id_token).get("kid") + except jwt.PyJWTError as exc: + raise OidcError("id_token has no readable header", code="identity") from exc + + cached = _jwks_cache.get(jwks_uri) + if cached is not None: + key = _match_key(cached, kid) + if key is not None: + return key + # An unknown `kid` is what a key rotation looks like. Refusing every + # sign-in until the next restart is not an acceptable answer to it. + jwks = await _fetch_jwks(settings, jwks_uri) + _jwks_cache[jwks_uri] = jwks + key = _match_key(jwks, kid) + if key is None: + raise OidcError("no signing key matches the id_token", code="identity") + return key + + +async def _fetch_jwks(settings: Settings, jwks_uri: str) -> dict: + try: + async with _client(settings) as client: + response = await client.get(jwks_uri) + except httpx.HTTPError as exc: + logger.warning("oidc_jwks_unreachable", error=type(exc).__name__) + raise OidcError("jwks endpoint unreachable", code="identity") from exc + if response.status_code != 200: + raise OidcError("jwks endpoint returned an error", code="identity") + try: + jwks = response.json() + except ValueError as exc: + raise OidcError("jwks endpoint returned no JSON", code="identity") from exc + if not isinstance(jwks, dict): + raise OidcError("jwks endpoint returned no JSON object", code="identity") + return jwks + + +async def verify_id_token( + settings: Settings, discovery: dict, *, id_token: str, nonce: str +) -> dict: + """Verify signature, issuer, audience, expiry and the nonce binding. + + The nonce check is what makes a stolen or replayed id_token useless: it was + minted for one login attempt whose state cookie this process signed. + """ + key = await _signing_key(settings, discovery, id_token) + issuer = discovery.get("issuer") or settings.oidc_issuer + try: + claims = jwt.decode( + id_token, + key, + algorithms=_ID_TOKEN_ALGORITHMS, + audience=settings.OIDC_CLIENT_ID, + issuer=issuer, + options={"require": ["exp", "iat", "iss", "aud", "sub"]}, + ) + except jwt.PyJWTError as exc: + logger.warning("oidc_id_token_rejected", error=type(exc).__name__) + raise OidcError("id_token verification failed", code="identity") from exc + if claims.get("nonce") != nonce: + raise OidcError("id_token nonce does not match this login", code="identity") + return claims + + +async def fetch_userinfo(settings: Settings, discovery: dict, *, access_token: str) -> dict: + """Best-effort display details. A provider that omits `name`/`email` from + the id_token usually serves them here; a failure is not a failed sign-in.""" + endpoint = discovery.get("userinfo_endpoint") + if not isinstance(endpoint, str) or not endpoint.lower().startswith(("http://", "https://")): + return {} + try: + async with _client(settings) as client: + response = await client.get( + endpoint, headers={"Authorization": f"Bearer {access_token}"} + ) + except httpx.HTTPError: + logger.warning("oidc_userinfo_unreachable") + return {} + if response.status_code != 200: + logger.warning("oidc_userinfo_failed", status=response.status_code) + return {} + try: + info = response.json() + except ValueError: + return {} + return info if isinstance(info, dict) else {} + + +def end_session_url(settings: Settings, discovery: dict) -> str | None: + """The provider's RP-initiated logout URL, or None when it has none. + + No `id_token_hint`: keeping the id_token around only to hand it back at + sign-out would mean carrying it in the session cookie for the whole shift. + `client_id` + `post_logout_redirect_uri` is the spec's alternative. + """ + endpoint = discovery.get("end_session_endpoint") + if not isinstance(endpoint, str) or not endpoint.lower().startswith(("http://", "https://")): + return None + params = urlencode( + { + "client_id": settings.OIDC_CLIENT_ID, + "post_logout_redirect_uri": settings.public_url or "/", + } + ) + separator = "&" if "?" in endpoint else "?" + return f"{endpoint}{separator}{params}" diff --git a/backend/src/utils/auth.py b/backend/src/utils/auth.py new file mode 100644 index 0000000..0fbcb4e --- /dev/null +++ b/backend/src/utils/auth.py @@ -0,0 +1,143 @@ +"""Session and login-state tokens for the optional OIDC gate. + +There is no database and no server-side session store, so the signed cookie +*is* the session. Two token kinds, both HS256 over ``OIDC_SESSION_SECRET``: + +``session`` + Issued after a successful sign-in. Carries only what the header displays + (a name, an email) plus the subject the provider issued — never anything + derived from a document. + +``state`` + Issued at the start of the login redirect and valid for ten minutes. It + carries the PKCE ``code_verifier`` and the ``nonce`` so the flow needs no + server-side storage, which keeps it correct across restarts and workers. + +Both are opaque to the browser in the sense that matters: they are signed, so +a tampered cookie is rejected rather than believed. +""" + +import base64 +import hashlib +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +import jwt + +from ..core.config import Settings + +#: Cookie holding the signed session. HttpOnly + SameSite=Lax: the API is only +#: ever called from the app's own origin, and Lax still sends the cookie on the +#: provider's top-level redirect back to the callback. +SESSION_COOKIE = "deid_session" +#: Cookie holding the login state, cleared as soon as the callback runs. Paired +#: with the `state` query parameter so a forged callback fails the comparison. +STATE_COOKIE = "deid_login_state" +#: How long a started login may take to come back. Ten minutes is long enough +#: for a password + second factor and short enough to be worthless later. +STATE_MAX_AGE_SECONDS = 600 + +_ALGORITHM = "HS256" +_SESSION_ISSUER = "deidentifier-session" +_STATE_ISSUER = "deidentifier-login" + + +@dataclass(frozen=True) +class AuthenticatedUser: + """The whole of what this app knows about who is signed in.""" + + subject: str + name: str + email: str + + +@dataclass(frozen=True) +class LoginState: + code_verifier: str + nonce: str + + +def _now() -> datetime: + return datetime.now(UTC) + + +def pkce_pair() -> tuple[str, str]: + """Return ``(code_verifier, code_challenge)`` for the S256 method.""" + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + return verifier, challenge + + +def issue_state(settings: Settings, *, code_verifier: str, nonce: str) -> str: + payload = { + "iss": _STATE_ISSUER, + "iat": _now(), + "exp": _now() + timedelta(seconds=STATE_MAX_AGE_SECONDS), + "verifier": code_verifier, + "nonce": nonce, + } + return jwt.encode(payload, settings.OIDC_SESSION_SECRET, algorithm=_ALGORITHM) + + +def read_state(settings: Settings, token: str | None) -> LoginState | None: + """Verify a login-state token. ``None`` for anything not currently valid.""" + if not token: + return None + try: + payload = jwt.decode( + token, + settings.OIDC_SESSION_SECRET, + algorithms=[_ALGORITHM], + issuer=_STATE_ISSUER, + options={"require": ["exp", "iss"]}, + ) + except jwt.PyJWTError: + return None + verifier = payload.get("verifier") + nonce = payload.get("nonce") + if not isinstance(verifier, str) or not isinstance(nonce, str): + return None + return LoginState(code_verifier=verifier, nonce=nonce) + + +def issue_session(settings: Settings, user: AuthenticatedUser) -> str: + payload = { + "iss": _SESSION_ISSUER, + "sub": user.subject, + "iat": _now(), + # Absolute, like the result cache's TTL: a session that renewed itself + # on every request would never end for someone who leaves the tab open. + "exp": _now() + timedelta(minutes=settings.OIDC_SESSION_MINUTES), + "name": user.name, + "email": user.email, + } + return jwt.encode(payload, settings.OIDC_SESSION_SECRET, algorithm=_ALGORITHM) + + +def read_session(settings: Settings, token: str | None) -> AuthenticatedUser | None: + """Verify a session cookie. ``None`` means "not signed in" — expired, + tampered with, or signed under a rotated secret are all the same answer.""" + if not token or not settings.OIDC_SESSION_SECRET: + return None + try: + payload = jwt.decode( + token, + settings.OIDC_SESSION_SECRET, + algorithms=[_ALGORITHM], + issuer=_SESSION_ISSUER, + options={"require": ["exp", "iss", "sub"]}, + ) + except jwt.PyJWTError: + return None + subject = payload.get("sub") + if not isinstance(subject, str) or not subject: + return None + name = payload.get("name") + email = payload.get("email") + return AuthenticatedUser( + subject=subject, + name=name if isinstance(name, str) else "", + email=email if isinstance(email, str) else "", + ) diff --git a/backend/tests/integration/test_auth_gate.py b/backend/tests/integration/test_auth_gate.py new file mode 100644 index 0000000..277b126 --- /dev/null +++ b/backend/tests/integration/test_auth_gate.py @@ -0,0 +1,210 @@ +"""The sign-in gate through the real app. + +Two things have to hold: with no gate configured the app is exactly what it +was before (principle: the default deployment is unchanged), and with one +configured *nothing* that touches a document answers without a session. +""" + +import pytest +from fastapi.testclient import TestClient + +from backend.src.core.config import get_settings +from backend.src.services import oidc_client +from backend.src.utils.auth import SESSION_COOKIE, AuthenticatedUser + +ISSUER = "https://idp.example.org" +PUBLIC_URL = "http://deid.example.org" +SESSION_SECRET = "0" * 64 + +DISCOVERY = { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "jwks_uri": f"{ISSUER}/jwks", + "end_session_endpoint": f"{ISSUER}/logout", +} + +USER = AuthenticatedUser(subject="idp|42", name="Dr. Müller", email="mueller@example.org") + + +@pytest.fixture() +def gated_client(monkeypatch): + """A TestClient for an app with the OIDC gate switched on.""" + monkeypatch.setenv("OIDC_ENABLED", "true") + monkeypatch.setenv("OIDC_ISSUER", ISSUER) + monkeypatch.setenv("OIDC_CLIENT_ID", "deidentifier") + monkeypatch.setenv("OIDC_CLIENT_SECRET", "s3cret") + monkeypatch.setenv("OIDC_SESSION_SECRET", SESSION_SECRET) + monkeypatch.setenv("APP_PUBLIC_URL", PUBLIC_URL) + get_settings.cache_clear() + oidc_client.reset_caches() + + async def fake_discover(_settings): + return DISCOVERY + + monkeypatch.setattr(oidc_client, "discover", fake_discover) + + from backend.src.main import app + + with TestClient(app, follow_redirects=False) as client: + yield client + get_settings.cache_clear() + oidc_client.reset_caches() + + +def sign_in(client: TestClient, monkeypatch) -> None: + """Walk the real login → callback flow, with only the provider faked. + + Seeding the cookie by hand would be shorter, but then nothing would check + that the callback issues a cookie the gate actually accepts. + """ + + async def fake_exchange(_settings, _discovery, *, code, verifier): + assert code == "the-code" + assert verifier # the PKCE verifier travelled in the signed state + return {"id_token": "id", "access_token": "at"} + + async def fake_verify(_settings, _discovery, *, id_token, nonce): + assert nonce # bound to this login + return {"sub": USER.subject, "name": USER.name, "email": USER.email} + + monkeypatch.setattr(oidc_client, "exchange_code", fake_exchange) + monkeypatch.setattr(oidc_client, "verify_id_token", fake_verify) + + state = client.get("/api/v1/auth/login").cookies["deid_login_state"] + response = client.get("/api/v1/auth/callback", params={"code": "the-code", "state": state}) + assert response.status_code == 302 + assert response.headers["location"] == f"{PUBLIC_URL}/" + + +# ── No gate configured: nothing changes ─────────────────────────────────── + + +def test_without_a_gate_the_api_is_open(client): + assert client.get("/api/v1/status").status_code == 200 + + +def test_without_a_gate_the_session_route_says_so(client): + body = client.get("/api/v1/auth/session").json() + assert body == {"enabled": False, "authenticated": True, "user": None, "login_url": ""} + + +def test_without_a_gate_there_is_nothing_to_sign_in_to(client): + assert client.get("/api/v1/auth/login").status_code == 404 + assert client.post("/api/v1/auth/logout").status_code == 404 + + +# ── Gate configured ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("get", "/api/v1/status"), + ("post", "/api/v1/anonymize"), + ("post", "/api/v1/anonymize/stream"), + ("post", "/api/v1/export/pdf"), + ("delete", "/api/v1/anonymize/some-request-id"), + ], +) +def test_every_document_route_needs_a_session(gated_client, method, path): + response = getattr(gated_client, method)(path) + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" + assert response.headers["cache-control"] == "no-store" + + +def test_health_probes_stay_open(gated_client): + """A readiness probe has no browser and no cookie.""" + assert gated_client.get("/health/live").status_code == 200 + assert gated_client.get("/health/ready").status_code in (200, 503) + + +def test_a_signed_in_request_passes_the_gate(gated_client, monkeypatch): + sign_in(gated_client, monkeypatch) + assert gated_client.get("/api/v1/status").status_code == 200 + + +def test_a_forged_session_cookie_does_not_pass_the_gate(gated_client): + gated_client.cookies.set(SESSION_COOKIE, "clearly.not.signed") + assert gated_client.get("/api/v1/status").status_code == 401 + + +def test_the_session_route_reports_who_is_signed_in(gated_client, monkeypatch): + anonymous = gated_client.get("/api/v1/auth/session").json() + assert anonymous["enabled"] is True + assert anonymous["authenticated"] is False + assert anonymous["login_url"] == f"{PUBLIC_URL}/api/v1/auth/login" + + sign_in(gated_client, monkeypatch) + signed_in = gated_client.get("/api/v1/auth/session").json() + assert signed_in["authenticated"] is True + assert signed_in["user"] == {"name": "Dr. Müller", "email": "mueller@example.org"} + + +# ── The sign-in flow ────────────────────────────────────────────────────── + + +def test_login_redirects_to_the_provider_and_remembers_the_state(gated_client): + response = gated_client.get("/api/v1/auth/login") + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith(f"{ISSUER}/authorize?") + assert "code_challenge_method=S256" in location + + state_cookie = response.cookies.get("deid_login_state") + assert state_cookie is not None + # The state in the URL and the state in the cookie must be the same token: + # that pairing is what makes a callback from elsewhere unusable. + assert f"state={state_cookie}" in location.replace("%2E", ".") + + +def test_a_callback_without_the_state_cookie_is_refused(gated_client): + response = gated_client.get("/api/v1/auth/callback", params={"code": "c", "state": "s"}) + assert response.status_code == 302 + assert response.headers["location"] == f"{PUBLIC_URL}/?auth_error=state" + assert SESSION_COOKIE not in response.cookies + + +def test_a_cancelled_sign_in_comes_back_as_denied(gated_client): + response = gated_client.get("/api/v1/auth/callback", params={"error": "access_denied"}) + assert response.headers["location"] == f"{PUBLIC_URL}/?auth_error=denied" + + +def test_a_complete_sign_in_issues_a_session(gated_client, monkeypatch): + sign_in(gated_client, monkeypatch) + # The gate now lets the document routes through, and the header can say who. + assert gated_client.get("/api/v1/status").status_code == 200 + assert gated_client.get("/api/v1/auth/session").json()["user"]["name"] == USER.name + + +def test_a_callback_whose_state_was_not_issued_here_is_refused(gated_client, monkeypatch): + """A login started against another deployment must not complete here.""" + import jwt + + foreign_state = jwt.encode( + {"iss": "deidentifier-login", "exp": 4102444800, "verifier": "v", "nonce": "n"}, + "1" * 64, + algorithm="HS256", + ) + gated_client.cookies.set("deid_login_state", foreign_state) + response = gated_client.get( + "/api/v1/auth/callback", params={"code": "c", "state": foreign_state} + ) + assert response.headers["location"] == f"{PUBLIC_URL}/?auth_error=state" + + +def test_logout_drops_the_session(gated_client, monkeypatch): + sign_in(gated_client, monkeypatch) + response = gated_client.post("/api/v1/auth/logout") + assert response.status_code == 200 + assert response.json() == {"redirect_url": None} + assert gated_client.get("/api/v1/status").status_code == 401 + + +def test_logout_can_end_the_provider_session_too(gated_client, monkeypatch): + monkeypatch.setenv("OIDC_END_SESSION", "true") + get_settings.cache_clear() + sign_in(gated_client, monkeypatch) + redirect_url = gated_client.post("/api/v1/auth/logout").json()["redirect_url"] + assert redirect_url.startswith(f"{ISSUER}/logout?") diff --git a/backend/tests/unit/test_auth_tokens.py b/backend/tests/unit/test_auth_tokens.py new file mode 100644 index 0000000..f7558ad --- /dev/null +++ b/backend/tests/unit/test_auth_tokens.py @@ -0,0 +1,152 @@ +"""Session and login-state cookies: the whole of the gate's server-side state. + +There is no session store to fall back on, so these tokens have to be right on +their own — a forged or stale one must read as "not signed in", never as a +default user. +""" + +import base64 +import hashlib +from datetime import UTC, datetime, timedelta + +import jwt +import pytest + +from backend.src.core.config import Settings, validate_auth_settings +from backend.src.utils.auth import ( + AuthenticatedUser, + issue_session, + issue_state, + pkce_pair, + read_session, + read_state, +) + +SECRET = "0" * 64 + + +def make_settings(**overrides) -> Settings: + base = { + "OIDC_ENABLED": True, + "OIDC_ISSUER": "https://idp.example.org", + "OIDC_CLIENT_ID": "deidentifier", + "OIDC_CLIENT_SECRET": "s3cret", + "OIDC_SESSION_SECRET": SECRET, + "APP_PUBLIC_URL": "https://deid.example.org", + } + base.update(overrides) + return Settings(**base) + + +USER = AuthenticatedUser(subject="idp|42", name="Dr. Müller", email="mueller@example.org") + + +def test_session_round_trip_preserves_the_identity(): + settings = make_settings() + user = read_session(settings, issue_session(settings, USER)) + assert user == USER + + +def test_session_signed_with_another_secret_is_not_a_session(): + token = issue_session(make_settings(), USER) + assert read_session(make_settings(OIDC_SESSION_SECRET="1" * 64), token) is None + + +def test_tampered_session_is_rejected(): + token = issue_session(make_settings(), USER) + header, payload, signature = token.split(".") + # Swap in a payload claiming a different subject, keeping the signature. + forged = jwt.encode({"sub": "attacker"}, "1" * 64, algorithm="HS256").split(".")[1] + assert read_session(make_settings(), f"{header}.{forged}.{signature}") is None + + +def test_expired_session_is_rejected(): + settings = make_settings() + expired = jwt.encode( + { + "iss": "deidentifier-session", + "sub": USER.subject, + "exp": datetime.now(UTC) - timedelta(minutes=1), + }, + SECRET, + algorithm="HS256", + ) + assert read_session(settings, expired) is None + + +def test_session_lifetime_follows_the_setting(): + settings = make_settings(OIDC_SESSION_MINUTES=30) + claims = jwt.decode( + issue_session(settings, USER), + SECRET, + algorithms=["HS256"], + issuer="deidentifier-session", + audience=None, + ) + remaining = claims["exp"] - claims["iat"] + assert remaining == 30 * 60 + + +@pytest.mark.parametrize("token", ["", None, "not-a-jwt", "a.b.c"]) +def test_garbage_is_not_a_session(token): + assert read_session(make_settings(), token) is None + + +def test_a_state_token_carries_the_verifier_and_nonce(): + settings = make_settings() + state = issue_state(settings, code_verifier="v" * 43, nonce="n0nce") + parsed = read_state(settings, state) + assert parsed is not None + assert parsed.code_verifier == "v" * 43 + assert parsed.nonce == "n0nce" + + +def test_a_session_token_is_not_accepted_as_login_state(): + """Distinct issuers: neither token kind may be replayed as the other.""" + settings = make_settings() + assert read_state(settings, issue_session(settings, USER)) is None + assert read_session(settings, issue_state(settings, code_verifier="v", nonce="n")) is None + + +def test_pkce_challenge_is_the_s256_hash_of_the_verifier(): + verifier, challenge = pkce_pair() + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + assert challenge == expected.decode().rstrip("=") + assert pkce_pair()[0] != verifier # a fresh verifier per login + + +def test_redirect_uri_is_derived_from_the_public_url(): + settings = make_settings(APP_PUBLIC_URL="https://deid.example.org/") + assert settings.oidc_redirect_uri == "https://deid.example.org/api/v1/auth/callback" + assert settings.cookies_secure is True + assert make_settings(APP_PUBLIC_URL="http://localhost:8080").cookies_secure is False + + +def test_openid_scope_is_always_requested(): + assert "openid" in make_settings(OIDC_SCOPES="profile email").oidc_scopes.split() + + +# ── Startup validation ──────────────────────────────────────────────────── + + +def test_a_disabled_gate_needs_no_configuration(): + validate_auth_settings(Settings()) + + +def test_half_configured_gate_refuses_to_start(): + with pytest.raises(RuntimeError, match="OIDC_CLIENT_SECRET"): + validate_auth_settings(make_settings(OIDC_CLIENT_SECRET="")) + + +def test_a_short_session_secret_refuses_to_start(): + with pytest.raises(RuntimeError, match="OIDC_SESSION_SECRET"): + validate_auth_settings(make_settings(OIDC_SESSION_SECRET="short")) + + +def test_a_relative_public_url_refuses_to_start(): + with pytest.raises(RuntimeError, match="APP_PUBLIC_URL"): + validate_auth_settings(make_settings(APP_PUBLIC_URL="deid.example.org")) + + +def test_a_fully_configured_gate_starts(): + validate_auth_settings(make_settings()) diff --git a/backend/tests/unit/test_oidc_client.py b/backend/tests/unit/test_oidc_client.py new file mode 100644 index 0000000..1a1ae05 --- /dev/null +++ b/backend/tests/unit/test_oidc_client.py @@ -0,0 +1,207 @@ +"""id_token verification against a provider's JWKS. + +This is the one step that decides *who* is signed in, so it is exercised with +a real RSA key pair and real signatures rather than a stubbed decoder. +""" + +from datetime import UTC, datetime, timedelta + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from backend.src.core.config import Settings +from backend.src.services import oidc_client +from backend.src.services.oidc_client import OidcError + +ISSUER = "https://idp.example.org" +CLIENT_ID = "deidentifier" +NONCE = "the-one-nonce" + +DISCOVERY = { + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/authorize", + "token_endpoint": f"{ISSUER}/token", + "jwks_uri": f"{ISSUER}/jwks", + "userinfo_endpoint": f"{ISSUER}/userinfo", +} + + +@pytest.fixture() +def settings() -> Settings: + return Settings( + OIDC_ENABLED=True, + OIDC_ISSUER=ISSUER, + OIDC_CLIENT_ID=CLIENT_ID, + OIDC_CLIENT_SECRET="s3cret", + OIDC_SESSION_SECRET="0" * 64, + APP_PUBLIC_URL="https://deid.example.org", + ) + + +@pytest.fixture() +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(autouse=True) +def _clear_caches(): + oidc_client.reset_caches() + yield + oidc_client.reset_caches() + + +@pytest.fixture() +def jwks(monkeypatch, signing_key): + """Serve the public half of `signing_key` as the provider's key set.""" + public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key(), as_dict=True) + public_jwk.update({"kid": "key-1", "use": "sig", "alg": "RS256"}) + document = {"keys": [public_jwk]} + + async def fake_fetch(_settings, _uri): + fake_fetch.calls += 1 + return document + + fake_fetch.calls = 0 + monkeypatch.setattr(oidc_client, "_fetch_jwks", fake_fetch) + return fake_fetch + + +def make_id_token(signing_key, *, kid="key-1", **overrides) -> str: + claims = { + "iss": ISSUER, + "sub": "idp|42", + "aud": CLIENT_ID, + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(minutes=5), + "nonce": NONCE, + "name": "Dr. Müller", + "email": "mueller@example.org", + } + claims.update(overrides) + return jwt.encode(claims, signing_key, algorithm="RS256", headers={"kid": kid}) + + +async def test_a_valid_id_token_yields_its_claims(settings, signing_key, jwks): + claims = await oidc_client.verify_id_token( + settings, DISCOVERY, id_token=make_id_token(signing_key), nonce=NONCE + ) + assert claims["sub"] == "idp|42" + assert claims["email"] == "mueller@example.org" + + +async def test_a_token_for_another_client_is_rejected(settings, signing_key, jwks): + token = make_id_token(signing_key, aud="some-other-app") + with pytest.raises(OidcError, match="verification failed"): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_a_token_from_another_issuer_is_rejected(settings, signing_key, jwks): + token = make_id_token(signing_key, iss="https://evil.example.org") + with pytest.raises(OidcError, match="verification failed"): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_an_expired_token_is_rejected(settings, signing_key, jwks): + token = make_id_token(signing_key, exp=datetime.now(UTC) - timedelta(seconds=1)) + with pytest.raises(OidcError, match="verification failed"): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_a_token_signed_by_a_stranger_is_rejected(settings, signing_key, jwks): + other = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = make_id_token(other) + with pytest.raises(OidcError, match="verification failed"): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_a_token_from_a_different_login_is_rejected(settings, signing_key, jwks): + """The nonce binds the token to the login this process started.""" + token = make_id_token(signing_key, nonce="replayed-from-elsewhere") + with pytest.raises(OidcError, match="nonce"): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_an_unsigned_token_is_rejected(settings, signing_key, jwks): + """`alg: none` must not be an accepted way to sign in.""" + token = jwt.encode({"iss": ISSUER, "sub": "x", "aud": CLIENT_ID}, None, algorithm="none") + with pytest.raises(OidcError): + await oidc_client.verify_id_token(settings, DISCOVERY, id_token=token, nonce=NONCE) + + +async def test_the_key_set_is_fetched_once_then_cached(settings, signing_key, jwks): + for _ in range(3): + await oidc_client.verify_id_token( + settings, DISCOVERY, id_token=make_id_token(signing_key), nonce=NONCE + ) + assert jwks.calls == 1 + + +async def test_an_unknown_key_id_refetches_the_key_set(settings, signing_key, jwks): + """What a key rotation looks like — it must not need a restart.""" + await oidc_client.verify_id_token( + settings, DISCOVERY, id_token=make_id_token(signing_key), nonce=NONCE + ) + with pytest.raises(OidcError, match="no signing key"): + await oidc_client.verify_id_token( + settings, DISCOVERY, id_token=make_id_token(signing_key, kid="key-2"), nonce=NONCE + ) + assert jwks.calls == 2 + + +# ── Authorization request ───────────────────────────────────────────────── + + +def test_the_authorize_url_carries_pkce_and_the_registered_redirect(settings): + url = oidc_client.authorization_url( + settings, DISCOVERY, state="the-state", nonce=NONCE, code_challenge="chal" + ) + assert url.startswith(f"{ISSUER}/authorize?") + for expected in ( + "response_type=code", + "code_challenge=chal", + "code_challenge_method=S256", + "state=the-state", + "redirect_uri=https%3A%2F%2Fdeid.example.org%2Fapi%2Fv1%2Fauth%2Fcallback", + "scope=openid+profile+email", + ): + assert expected in url + + +def test_an_authorize_endpoint_with_a_query_string_is_extended_not_broken(settings): + discovery = DISCOVERY | {"authorization_endpoint": f"{ISSUER}/authorize?tenant=klinik"} + url = oidc_client.authorization_url( + settings, discovery, state="s", nonce=NONCE, code_challenge="c" + ) + assert "?tenant=klinik&response_type=code" in url + + +def test_a_discovery_document_without_an_authorize_endpoint_is_refused(settings): + with pytest.raises(OidcError, match="authorization_endpoint"): + oidc_client.authorization_url(settings, {}, state="s", nonce=NONCE, code_challenge="c") + + +def test_basic_auth_is_used_only_when_the_provider_offers_nothing_else(): + assert oidc_client._prefers_basic_auth({}) is False + assert ( + oidc_client._prefers_basic_auth( + {"token_endpoint_auth_methods_supported": ["client_secret_basic"]} + ) + is True + ) + assert ( + oidc_client._prefers_basic_auth( + {"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"]} + ) + is False + ) + + +def test_end_session_url_is_none_when_the_provider_has_no_such_endpoint(settings): + assert oidc_client.end_session_url(settings, DISCOVERY) is None + url = oidc_client.end_session_url( + settings, DISCOVERY | {"end_session_endpoint": f"{ISSUER}/out"} + ) + assert url is not None + assert url.startswith(f"{ISSUER}/out?") + assert "post_logout_redirect_uri=https%3A%2F%2Fdeid.example.org" in url diff --git a/docs/DATA_FLOW.md b/docs/DATA_FLOW.md index 5e1ec76..b7fa8d2 100644 --- a/docs/DATA_FLOW.md +++ b/docs/DATA_FLOW.md @@ -36,6 +36,13 @@ no telemetry endpoint, no CDN, and no analytics. | `DELETE /api/v1/anonymize/{id}` | A request id | Nothing (204 either way) | No — it only forgets the cached detection | | `GET /api/v1/status` | — | Detector states, OCR engine, endpoint **hosts** + locality, limits | No | | `GET /health/live`, `/health/ready` | — | Status only | Readiness may probe configured endpoints | +| `GET /api/v1/auth/session` | — | Whether a sign-in gate exists, and the signed-in name/email | No | +| `GET /api/v1/auth/login`, `/auth/callback` | An authorization code from the provider | A redirect + the session cookie | To the identity provider — client id, redirect URI, scopes, PKCE challenge and nonce. **Never document content.** | +| `POST /api/v1/auth/logout` | — | Whether to visit the provider's sign-out | No | + +The three `/auth` routes exist only when +[the OIDC gate](operations/sso.md) is configured; with it on, every other row +in this table requires a valid session cookie. `/api/v1/status` returns hosts, never full URLs, keys, or filesystem paths. It is what the UI uses to warn that content will leave the machine, so it has to @@ -47,7 +54,8 @@ stay safe to expose. |---|---|---| | Document text, results, corrections | Pinia store — **memory only** | Cleared on reload. Never `localStorage`/`sessionStorage`. | | Object URLs for previews (original PDF, redacted PDF, rendered pages) | Memory, revoked on reset | Needed to display a PDF. | -| `darkMode`, `expertMode`, `keepFilenames` | `localStorage` | UI preferences only. | +| `darkMode`, `expertMode`, `keepFilenames`, `locale` | `localStorage` | UI preferences only. | +| The sign-in session (OIDC gate only) | `HttpOnly` cookie, scoped to `/api` | Signed, absolute expiry, no document content. JavaScript cannot read it. | That split is a hard rule in the codebase: nothing derived from a document is ever persisted client-side. @@ -73,6 +81,7 @@ refused in production mode. |---|---| | Browser memory | Until reload or **Neues Dokument** | | Browser `localStorage` | UI preferences only, indefinitely | +| Sign-in cookie (OIDC gate only) | `OIDC_SESSION_MINUTES` (8 h by default) from sign-in, absolute; gone on sign-out | | Backend memory (request) | The request | | Backend cache | 15 minutes from creation, extendable by the reviewer in 1 h steps up to 12 h (all configurable), or until the UI drops it, eviction (100 entries), or restart | | Backend disk | **Nothing.** Read-only filesystem, `tmpfs` for `/tmp`, no volumes | diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md index 93019db..8810b50 100644 --- a/docs/RISK_REGISTER.md +++ b/docs/RISK_REGISTER.md @@ -23,7 +23,7 @@ listed controls. | # | Risk | Controls | Residual | Owner | |---|---|---|---|---| -| S1 | The app is exposed without the auth proxy | Backend publishes no port; checklist; docs state the requirement repeatedly | **Low**, if reviewed at deployment | Operator | +| S1 | The app is exposed without the auth proxy | Backend publishes no port; checklist; docs state the requirement repeatedly; optional built-in OIDC gate (`OIDC_ENABLED`) for deployments with no proxy, which refuses to start half-configured | **Low**, if reviewed at deployment | Operator | | S2 | Prompt injection from document content | Fenced document markers, untrusted-data system prompt, strings-only model output, deterministic grounding, independent validation | **Low** for integrity; contributes to P1 | Developers | | S3 | Parser vulnerability in a document library | Extension allow-list, size caps, read-only non-root container, no persistence, Dependabot + CI scanning | **Low** | Developers | | S4 | A redacted PDF that is not actually redacted | Text removal + box coverage, post-export verification, fail-closed refusal, reconstruction for scans | **Low** | Developers | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 29d2ed8..a430570 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -16,7 +16,7 @@ vulnerability, see | Decision | Consequence | |---|---| | **No persistence.** No database, no object storage, no volumes; the backend container runs read-only. | There is no data at rest to protect, breach, or subpoena. A restart drops everything. | -| **No authentication.** The app is deployed behind the institution's auth proxy. | One fewer credential store and session mechanism to secure — and a hard requirement that the proxy is actually there. | +| **No authentication by default.** The app is deployed behind the institution's auth proxy; optionally it can require an [OIDC sign-in](operations/sso.md) itself. | One fewer credential store and session mechanism to secure — and a hard requirement that the proxy (or the gate) is actually there. The gate holds no accounts and no passwords: the session is a signed cookie, the provider owns the identity. | | **Content-refusing logger.** Fields whose names carry document content are dropped before a log line is written. | Logs can be shipped to normal infrastructure without becoming a PHI store. | | **Configured endpoints only.** Model and OCR base URLs are deployment configuration, not user input. | The set of destinations document content may reach is fixed at deploy time and visible in the UI. | | **Fail closed.** An unavailable detector fails the request; an unverifiable PDF export is refused. | The system never reports a document as processed when it was not fully checked. | @@ -68,8 +68,11 @@ no external fonts or scripts. The app needs no internet access at runtime. ## Deployment checklist - [ ] An authenticating reverse proxy is in front of the frontend, and port - 8080 is not reachable around it. -- [ ] TLS terminates at that proxy. + 8080 is not reachable around it — or `OIDC_ENABLED=true` with the + provider configured ([Single sign-on](operations/sso.md)). +- [ ] TLS terminates at that proxy. With the OIDC gate on, `APP_PUBLIC_URL` + is an `https://` URL, so the session cookie is `Secure` (the backend + warns at startup when it is not). - [ ] `APP_ENV=production`. - [ ] `APP_ALLOW_INSECURE_CONTENT_LOGGING=false` (production refuses otherwise). - [ ] `DETECTORS` contains no `mock` (production refuses otherwise). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 6ada5ca..6c90899 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -101,11 +101,18 @@ containment. *Vector:* the app is exposed without the auth proxy. *Controls:* the backend publishes no port; only the frontend port is published; -the deployment checklist leads with the proxy requirement. - -*Residual:* if the proxy is missing, anyone reachable can process documents and -consume the model endpoint. There is still no stored data to exfiltrate — every -request only returns what the caller submitted. +the deployment checklist leads with the proxy requirement. Where no proxy +exists, `OIDC_ENABLED=true` gates every `/api/` route on a signed session +cookie issued after an authorization-code sign-in (PKCE, verified id_token) at +the institution's provider — enforced in middleware, so a route added later is +covered by default. A half-configured gate refuses to start rather than +running open. + +*Residual:* if neither the proxy nor the gate is in place, anyone reachable can +process documents and consume the model endpoint. There is still no stored data +to exfiltrate — every request only returns what the caller submitted. With the +gate on, the static frontend bundle remains readable without a session; it +contains no patient data. ### T7 — Unverifiable redacted export diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 77a6d27..dca30ae 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -13,9 +13,11 @@ Browser ── nginx (SPA + /api proxy) ── FastAPI ── LLM / OCR endpoint └───── in-memory cache ``` -Deliberately absent: database, migrations, task queue, object storage, -authentication, WebSockets. The app is a stateless transformer of a document -into an anonymized document. +Deliberately absent: database, migrations, task queue, object storage, user +accounts, WebSockets. The app is a stateless transformer of a document into an +anonymized document. The optional [OIDC sign-in](../operations/sso.md) is the +one thing that resembles auth, and it keeps the property: the session is a +signed cookie, so there is still nothing stored server-side. Conventions come from the sibling project [llmaixweb](https://github.com/KatherLab/llmaixweb) — layout, config pattern, diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 5c0f66c..dc791fb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -67,11 +67,15 @@ automatically — see [OCR engines](../operations/ocr-engines.md). ### Behind a reverse proxy -The app has **no authentication**: it is designed to sit behind the +The app has **no authentication by default**: it is designed to sit behind the institution's existing auth proxy. Put your proxy in front of the `frontend` container, terminate TLS there, and do not publish port 8080 beyond it. `FRONTEND_PORT` changes the published port. +If you have no such proxy, the app can require a sign-in at your organisation's +OpenID Connect provider instead — see +[Single sign-on](../operations/sso.md). You still need TLS in front of it. + ## Local development setup Prerequisites: Python 3.13 or 3.14 (`requires-python = ">=3.13,<3.15"`), diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index dbdcf88..6e1090c 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -64,6 +64,37 @@ Tightening also applies to what is already in memory. The bounds are read once at startup, so a restart with stricter values immediately drops whatever no longer fits. +## Sign-in (OIDC) + +Off by default: the app assumes it sits behind your own authenticating proxy. +Turning it on makes the app itself require a sign-in at your identity provider +before any route that touches a document answers. It is a gate, not an +authorisation model — everyone who can sign in gets the same, whole +application. Full setup, including what to register at the provider: +[Single sign-on](sso.md). + +| Variable | Default | Notes | +|---|---|---| +| `OIDC_ENABLED` | `false` | Turns the gate on. With it on, the five values below are required — the backend refuses to start without them, in every environment. | +| `OIDC_ISSUER` | *(empty)* | The provider's base URL. The app reads `{issuer}/.well-known/openid-configuration`; the value must match the `iss` the provider puts in its tokens. | +| `OIDC_CLIENT_ID` | *(empty)* | From the client you register at the provider. | +| `OIDC_CLIENT_SECRET` | *(empty)* | Confidential client: the secret never reaches the browser. | +| `OIDC_SESSION_SECRET` | *(empty)* | Signs the session cookie. At least 32 characters — `openssl rand -hex 32`. Rotating it signs everyone out; treat it like a private key. | +| `APP_PUBLIC_URL` | *(empty)* | The origin browsers actually use, e.g. `https://deid.klinik.de`. The redirect URI is derived from it, and an `https://` value is what lets the session cookie be `Secure`. | +| `OIDC_SCOPES` | `openid profile email` | Space-separated. `openid` is always requested even if you leave it out. | +| `OIDC_SESSION_MINUTES` | `480` (8 h) | How long a sign-in lasts. **Absolute** — it does not renew on activity, so the session ends on schedule whether or not the tab stayed open. | +| `OIDC_END_SESSION` | `false` | Also end the session at the provider on sign-out, when it advertises an `end_session_endpoint`. Off because it signs the user out of every application, not just this one. | +| `OIDC_HTTP_TIMEOUT_SECONDS` | `10` | Timeout for the calls to the provider (discovery, token exchange, keys). | + +```bash +OIDC_ENABLED=true +OIDC_ISSUER=https://keycloak.klinik.de/realms/intranet +OIDC_CLIENT_ID=deidentifier +OIDC_CLIENT_SECRET=… +OIDC_SESSION_SECRET=… # openssl rand -hex 32 +APP_PUBLIC_URL=https://deid.klinik.de +``` + ## Deployment banner A bar above the header for a deployment-wide notice — "Research Use Only!", diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index c56bea9..c732f58 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -43,7 +43,7 @@ A backend that exits immediately after `docker compose up` almost always logs ## Authentication -There is none, by design: the app is meant to run behind the institution's +None by default, by design: the app is meant to run behind the institution's existing authenticating reverse proxy. Terminate TLS there, enforce authentication and authorization there, and do not expose port 8080 beyond it. @@ -51,6 +51,12 @@ Everything a user can reach is a stateless endpoint that processes the document they submitted — there are no accounts, no stored documents, and nothing to enumerate. That is only true as long as the proxy is actually in front of it. +Where that proxy does not exist, the app can require a sign-in at your +organisation's OpenID Connect provider itself — one setting plus the client +credentials, no accounts and no roles. See +[Single sign-on](sso.md). It replaces the *authentication* the proxy would +provide, not the TLS termination. + ## Configuration Read from `.env` in the repo root at runtime, never baked into an image: diff --git a/docs/operations/sso.md b/docs/operations/sso.md new file mode 100644 index 0000000..7969190 --- /dev/null +++ b/docs/operations/sso.md @@ -0,0 +1,162 @@ +# Single sign-on (OIDC) + +The anonymizer ships with **no sign-in at all**. That is the intended default: +it is built to run inside the hospital network behind whatever authenticating +proxy you already operate, and it stores nothing, so there is no account to +protect. + +Where that proxy does not exist — a departmental server, a pilot on a VM, a +deployment your identity team would rather see integrated directly — the app +can require a sign-in at your **OpenID Connect** provider itself. Keycloak, +Entra ID (Azure AD), Authentik, Okta, Google Workspace and anything else that +speaks OIDC discovery all work. + +!!! note "A gate, not an authorisation model" + Everyone who can sign in gets the same, whole application. There are no + roles, no per-user settings and no user records — signing in decides + *whether* you get in and nothing else. Restricting *who* may sign in is + done at the provider, by only assigning the application to the group that + should have it. + +## What it protects + +With `OIDC_ENABLED=true`, every route under `/api/` requires a valid session — +anonymizing, exporting, page rendering, status, the cached-result routes, all +of it. The exceptions are deliberate and short: + +- `/api/v1/auth/*` — the sign-in routes themselves. +- `/health/live` and `/health/ready` — a readiness probe has no browser and no + cookie. +- The static frontend (HTML, JS, CSS). It is served by nginx and contains no + patient data; someone who loads it without a session sees the sign-in screen + and nothing else. + +## Setting it up + +### 1. Register a client at your provider + +Create a **confidential** client (one with a secret) using the **authorization +code** flow with **PKCE**, and register exactly one redirect URI: + +``` +https:///api/v1/auth/callback +``` + +A mismatch here is the single most common cause of a failed sign-in — the URL +must be the one the browser actually uses, including scheme and any port. + +If your provider also wants a post-logout redirect URI (only relevant with +`OIDC_END_SESSION=true`), register `APP_PUBLIC_URL` itself. + +### 2. Configure the app + +In `.env`: + +```bash +OIDC_ENABLED=true +OIDC_ISSUER=https://keycloak.klinik.de/realms/intranet +OIDC_CLIENT_ID=deidentifier +OIDC_CLIENT_SECRET=… +OIDC_SESSION_SECRET=… # openssl rand -hex 32 +APP_PUBLIC_URL=https://deid.klinik.de +``` + +`OIDC_ISSUER` is the base URL, **not** the discovery URL: the app appends +`/.well-known/openid-configuration` itself. Use the value the provider reports +as its `issuer` — for Keycloak that is +`https://host/realms/`, for Entra ID +`https://login.microsoftonline.com//v2.0`. + +Every optional knob (`OIDC_SCOPES`, `OIDC_SESSION_MINUTES`, +`OIDC_END_SESSION`, `OIDC_HTTP_TIMEOUT_SECONDS`) is in the +[configuration reference](configuration.md#sign-in-oidc). + +### 3. Restart and check the log + +``` +docker compose up -d +docker compose logs backend | head +``` + +A half-configured gate **refuses to start** and says what is missing: + +``` +Refusing to start: OIDC_ENABLED is true but OIDC_CLIENT_SECRET, +APP_PUBLIC_URL are not set +``` + +That is on purpose. An access gate that silently does not gate is worse than +one that never came up. + +You should also see `startup … auth=oidc` in the log. If `APP_PUBLIC_URL` is +not `https://`, a warning follows it: the session cookie cannot be marked +`Secure` over plain HTTP, so anything on the path can read it. Terminate TLS in +front of the app. + +### Trying it in development + +The Vite dev server proxies `/api` to the backend, so point `APP_PUBLIC_URL` at +the dev server rather than at the backend port: + +```bash +APP_PUBLIC_URL=http://localhost:3000 +``` + +and register `http://localhost:3000/api/v1/auth/callback` at the provider. The +startup warning about a non-`https` cookie is expected here. + +## What signing in looks like + +1. The app shows a sign-in screen instead of the drop zone. +2. **Anmelden** sends the browser to your provider (a full page navigation — + your provider owns the tab, including any second factor). +3. The provider returns to `/api/v1/auth/callback`; the app verifies the + response and sets a session cookie. +4. The header shows the signed-in name, with **Abmelden** behind it. + +The name and email in the header come from the provider's `name`/`email` +claims and are used for that display only. Nothing is stored — refresh the +page and they are read from the cookie again. + +### When something goes wrong + +A failed sign-in returns to the app with a message rather than an API error +page. What the messages mean: + +| On screen | Cause | Where to look | +|---|---|---| +| The sign-in was cancelled | The user declined at the provider | Nothing to fix | +| The sign-in expired or could not be matched | More than 10 minutes between starting and finishing, or the login was started in a different browser/tab-session | Just sign in again | +| The sign-in service cannot be reached | Discovery failed | `OIDC_ISSUER`, network path from the backend container to the provider | +| The sign-in could not be completed | The token exchange was rejected | `OIDC_CLIENT_SECRET`, and whether the redirect URI matches exactly | +| The response could not be verified | The id_token failed signature, issuer, audience or nonce checks | `OIDC_ISSUER` vs. the provider's actual `iss`, and `OIDC_CLIENT_ID` | + +The backend log carries the matching `oidc_login_failed reason=…` line. It +never contains the provider's raw response — those bodies can echo the client +secret back. + +## How the session works + +- The session is a **signed cookie**, `HttpOnly` + `SameSite=Lax` + `Secure` + (over https), scoped to `/api`. There is no server-side session store, so + restarts and multiple workers are not a problem. +- Its lifetime is **absolute** (`OIDC_SESSION_MINUTES`, 8 hours by default) and + does not renew on activity — the same rule the + [result cache](../DATA_RETENTION.md) follows. +- When it runs out mid-review, the next request returns 401 and the sign-in + screen comes back with "Your session has expired". The result cache is + unaffected; after signing in again the document is re-submitted from the + browser as usual. +- Rotating `OIDC_SESSION_SECRET` invalidates every session immediately. That is + the way to sign everybody out. +- **Sign-out** clears the cookie and reloads the page, which is also what + clears the document currently open from browser memory. With + `OIDC_END_SESSION=true` the browser then continues to the provider's own + sign-out. + +## What the app sends to the provider + +Only what OIDC requires: the client id, the redirect URI, the requested scopes, +a PKCE challenge and a nonce. **No document content, no file name and no +result ever reaches the identity provider** — it is contacted only during +sign-in, never during processing. See [Data flow](../DATA_FLOW.md). diff --git a/frontend/App.vue b/frontend/App.vue index 00b45f4..37e996f 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -115,6 +115,41 @@ + +
+ + +
+ @@ -132,7 +167,14 @@
- + +
+ +
+ +
@@ -153,21 +195,30 @@ diff --git a/frontend/locales/de.json b/frontend/locales/de.json index 7e58bc1..7499207 100644 --- a/frontend/locales/de.json +++ b/frontend/locales/de.json @@ -39,6 +39,30 @@ "to_dark": "Zum dunklen Design wechseln" } }, + "auth": { + "gate": { + "title": "Anmeldung erforderlich", + "description": "Dieser Zugang ist durch die Anmeldung Ihrer Einrichtung geschützt. Melden Sie sich an, um Dokumente zu anonymisieren.", + "sign_in": "Anmelden", + "checking": "Anmeldung wird geprüft", + "note": "Sie werden zur Anmeldeseite Ihrer Einrichtung weitergeleitet. Dokumente werden erst danach verarbeitet." + }, + "user": { + "label": "Angemeldet als {name}", + "menu_label": "Konto", + "signed_in": "Angemeldet", + "sign_out": "Abmelden" + }, + "errors": { + "denied": "Die Anmeldung wurde abgebrochen.", + "state": "Die Anmeldung ist abgelaufen oder konnte nicht zugeordnet werden. Bitte melden Sie sich erneut an.", + "provider": "Der Anmeldedienst ist nicht erreichbar. Bitte später erneut versuchen oder die IT informieren.", + "token": "Die Anmeldung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + "identity": "Die Antwort des Anmeldedienstes konnte nicht überprüft werden. Bitte informieren Sie die IT.", + "expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "unknown": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut." + } + }, "input": { "dropzone_label": "Dateien auswählen oder hierher ziehen", "dropzone_prompt": "Dateien hierher ziehen oder klicken, um auszuwählen", @@ -379,7 +403,8 @@ "llm_unreachable": "Der KI-Erkennungsdienst ist nicht erreichbar. Das Dokument wurde NICHT anonymisiert.", "backend_unreachable": "Server nicht erreichbar. Bitte prüfen Sie, ob das Backend läuft.", "pdf_export_failed": "PDF-Export fehlgeschlagen. Bitte versuchen Sie es erneut.", - "pdf_export_detail": "PDF-Export fehlgeschlagen: {detail}" + "pdf_export_detail": "PDF-Export fehlgeschlagen: {detail}", + "unauthorized": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." }, "toast": { "copy_success": "Anonymisierter Text in die Zwischenablage kopiert.", diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 2f60538..f8800b8 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -39,6 +39,30 @@ "to_dark": "Switch to dark theme" } }, + "auth": { + "gate": { + "title": "Sign-in required", + "description": "This installation is protected by your organisation's sign-in. Sign in to anonymize documents.", + "sign_in": "Sign in", + "checking": "Checking sign-in", + "note": "You will be sent to your organisation's sign-in page. No document is processed before that." + }, + "user": { + "label": "Signed in as {name}", + "menu_label": "Account", + "signed_in": "Signed in", + "sign_out": "Sign out" + }, + "errors": { + "denied": "The sign-in was cancelled.", + "state": "The sign-in expired or could not be matched. Please sign in again.", + "provider": "The sign-in service cannot be reached. Try again later or contact IT.", + "token": "The sign-in could not be completed. Please try again.", + "identity": "The sign-in service's response could not be verified. Please contact IT.", + "expired": "Your session has expired. Please sign in again.", + "unknown": "The sign-in failed. Please try again." + } + }, "input": { "dropzone_label": "Select files or drag them here", "dropzone_prompt": "Drag files here or click to select", @@ -379,7 +403,8 @@ "llm_unreachable": "The AI detection service is unreachable. The document was NOT anonymized.", "backend_unreachable": "Server unreachable. Please check whether the backend is running.", "pdf_export_failed": "PDF export failed. Please try again.", - "pdf_export_detail": "PDF export failed: {detail}" + "pdf_export_detail": "PDF export failed: {detail}", + "unauthorized": "Your session has expired. Please sign in again." }, "toast": { "copy_success": "Anonymized text copied to the clipboard.", diff --git a/frontend/locales/es.json b/frontend/locales/es.json index fedad2e..7182f08 100644 --- a/frontend/locales/es.json +++ b/frontend/locales/es.json @@ -39,6 +39,30 @@ "to_dark": "Cambiar al tema oscuro" } }, + "auth": { + "gate": { + "title": "Se requiere iniciar sesión", + "description": "Este acceso está protegido por el inicio de sesión de su institución. Inicie sesión para anonimizar documentos.", + "sign_in": "Iniciar sesión", + "checking": "Comprobando el inicio de sesión", + "note": "Se le redirigirá a la página de inicio de sesión de su institución. Ningún documento se procesa antes de eso." + }, + "user": { + "label": "Sesión iniciada como {name}", + "menu_label": "Cuenta", + "signed_in": "Sesión iniciada", + "sign_out": "Cerrar sesión" + }, + "errors": { + "denied": "El inicio de sesión se canceló.", + "state": "El inicio de sesión caducó o no se pudo asociar. Vuelva a iniciar sesión.", + "provider": "No se puede contactar con el servicio de inicio de sesión. Inténtelo más tarde o avise a informática.", + "token": "No se pudo completar el inicio de sesión. Inténtelo de nuevo.", + "identity": "No se pudo verificar la respuesta del servicio de inicio de sesión. Avise a informática.", + "expired": "Su sesión ha caducado. Vuelva a iniciar sesión.", + "unknown": "El inicio de sesión falló. Inténtelo de nuevo." + } + }, "input": { "dropzone_label": "Seleccionar archivos o arrastrarlos aquí", "dropzone_prompt": "Arrastre archivos aquí o haga clic para seleccionarlos", @@ -379,7 +403,8 @@ "llm_unreachable": "El servicio de detección por IA no está accesible. El documento NO se ha anonimizado.", "backend_unreachable": "Servidor no accesible. Compruebe si el backend está en marcha.", "pdf_export_failed": "La exportación a PDF ha fallado. Inténtelo de nuevo.", - "pdf_export_detail": "La exportación a PDF ha fallado: {detail}" + "pdf_export_detail": "La exportación a PDF ha fallado: {detail}", + "unauthorized": "Su sesión ha caducado. Vuelva a iniciar sesión." }, "toast": { "copy_success": "Texto anonimizado copiado al portapapeles.", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 3b1e0d5..7d25f9d 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -39,6 +39,30 @@ "to_dark": "Passer au thème sombre" } }, + "auth": { + "gate": { + "title": "Connexion requise", + "description": "Cet accès est protégé par l'authentification de votre établissement. Connectez-vous pour anonymiser des documents.", + "sign_in": "Se connecter", + "checking": "Vérification de la connexion", + "note": "Vous serez redirigé vers la page de connexion de votre établissement. Aucun document n'est traité avant cela." + }, + "user": { + "label": "Connecté en tant que {name}", + "menu_label": "Compte", + "signed_in": "Connecté", + "sign_out": "Se déconnecter" + }, + "errors": { + "denied": "La connexion a été annulée.", + "state": "La connexion a expiré ou n'a pas pu être associée. Veuillez vous reconnecter.", + "provider": "Le service d'authentification est injoignable. Réessayez plus tard ou contactez le service informatique.", + "token": "La connexion n'a pas pu être finalisée. Veuillez réessayer.", + "identity": "La réponse du service d'authentification n'a pas pu être vérifiée. Contactez le service informatique.", + "expired": "Votre session a expiré. Veuillez vous reconnecter.", + "unknown": "La connexion a échoué. Veuillez réessayer." + } + }, "input": { "dropzone_label": "Sélectionner des fichiers ou les glisser ici", "dropzone_prompt": "Glissez des fichiers ici ou cliquez pour les sélectionner", @@ -379,7 +403,8 @@ "llm_unreachable": "Le service de détection par IA est injoignable. Le document n’a PAS été anonymisé.", "backend_unreachable": "Serveur injoignable. Veuillez vérifier que le backend fonctionne.", "pdf_export_failed": "L’export PDF a échoué. Veuillez réessayer.", - "pdf_export_detail": "L’export PDF a échoué : {detail}" + "pdf_export_detail": "L’export PDF a échoué : {detail}", + "unauthorized": "Votre session a expiré. Veuillez vous reconnecter." }, "toast": { "copy_success": "Texte anonymisé copié dans le presse-papiers.", diff --git a/frontend/services/anonymizeStream.ts b/frontend/services/anonymizeStream.ts index db44716..da1fa25 100644 --- a/frontend/services/anonymizeStream.ts +++ b/frontend/services/anonymizeStream.ts @@ -17,7 +17,7 @@ * the existing mapping in `utils/errors.ts` (extractApiErrorMessage) keeps * working unchanged. */ -import { api } from '@/services/api' +import { api, notifyUnauthorized } from '@/services/api' import { appendCustomRules, hasPolicyEntries } from '@/services/anonymizeApi' import type { AnonymizeResponse, @@ -78,6 +78,9 @@ async function streamAnonymize( headers, body, signal, + // fetch does not inherit the axios instance's `withCredentials`, so the + // sign-in gate's session cookie has to be asked for explicitly. + credentials: 'include', }) } catch (err) { // Deliberate aborts (reset while streaming) keep their AbortError shape so @@ -89,6 +92,8 @@ async function streamAnonymize( } if (!response.ok) { + // No axios interceptor on this path, so the gate is told by hand. + if (response.status === 401) notifyUnauthorized() // Request rejected before streaming — a normal HTTP error with a JSON // {"detail": ...} body (or none). let data: unknown diff --git a/frontend/services/api.ts b/frontend/services/api.ts index ee523b1..0dce219 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import axios, { isAxiosError } from 'axios' // In dev mode (Vite dev server), use absolute URL to backend. // In production (nginx serves SPA), use relative path — nginx proxies /api/ to backend. @@ -10,10 +10,38 @@ const getBaseURL = () => { } /** - * Shared axios instance (llmaixweb pattern, minus auth — the anonymizer has - * no login; a hospital proxy fronts the app). Components never import this + * Shared axios instance (llmaixweb pattern). Components never import this * directly: call the typed `services/*Api.ts` modules instead. + * + * `withCredentials` carries the session cookie of the optional OIDC gate. It + * matters only in dev, where Vite (:5173) and the backend (:8000) are + * different origins; in production nginx serves both from one origin. Safe + * because the backend's CORS origins are an explicit list, never `*`. */ export const api = axios.create({ baseURL: getBaseURL(), + withCredentials: true, +}) + +let unauthorizedHandler: (() => void) | null = null + +/** + * Register what happens when the backend says "not signed in". The auth store + * registers itself here rather than this module importing the store — that + * would be a cycle, since the store calls the API. + */ +export function setUnauthorizedHandler(handler: () => void): void { + unauthorizedHandler = handler +} + +/** A 401 arrived: the gate is on and this session is over. */ +export function notifyUnauthorized(): void { + unauthorizedHandler?.() +} + +api.interceptors.response.use(undefined, (error: unknown) => { + if (isAxiosError(error) && error.response?.status === 401) { + notifyUnauthorized() + } + return Promise.reject(error) }) diff --git a/frontend/services/authApi.ts b/frontend/services/authApi.ts new file mode 100644 index 0000000..a12f2be --- /dev/null +++ b/frontend/services/authApi.ts @@ -0,0 +1,16 @@ +import type { AxiosResponse } from 'axios' +import { api } from '@/services/api' +import type { AuthSession, LogoutResponse } from '@/types/auth' + +export const authApi = { + /** Who is signed in — and whether a sign-in gate exists at all. */ + getSession(): Promise> { + return api.get('/auth/session') + }, + + /** Ends the session here; the response says whether the provider wants a + * visit too (RP-initiated logout). */ + logout(): Promise> { + return api.post('/auth/logout') + }, +} diff --git a/frontend/stores/auth.test.ts b/frontend/stores/auth.test.ts new file mode 100644 index 0000000..96d37ce --- /dev/null +++ b/frontend/stores/auth.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { authApi } from '@/services/authApi' +import { notifyUnauthorized } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import type { AuthSession } from '@/types/auth' + +function sessionResponse(overrides: Partial = {}) { + return { + data: { + enabled: true, + authenticated: true, + user: { name: 'Dr. Müller', email: 'mueller@example.org' }, + login_url: 'https://deid.example.org/api/v1/auth/login', + ...overrides, + } as AuthSession, + } +} + +/** jsdom forbids assigning window.location; replace it wholesale instead. */ +function stubLocation(): { href: string; pathname: string; search: string } { + const location = { href: 'https://deid.example.org/', pathname: '/', search: '' } + Object.defineProperty(window, 'location', { value: location, writable: true }) + return location +} + +describe('auth store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + stubLocation() + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not block anything when no gate is configured', async () => { + vi.spyOn(authApi, 'getSession').mockResolvedValue( + sessionResponse({ enabled: false, user: null, login_url: '' }) as never, + ) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.enabled).toBe(false) + expect(auth.blocked).toBe(false) + expect(auth.ready).toBe(true) + }) + + it('blocks the app when a gate is configured and nobody is signed in', async () => { + vi.spyOn(authApi, 'getSession').mockResolvedValue( + sessionResponse({ authenticated: false, user: null }) as never, + ) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.blocked).toBe(true) + }) + + it('reports who is signed in', async () => { + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse() as never) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.blocked).toBe(false) + expect(auth.user?.name).toBe('Dr. Müller') + }) + + // An unreachable backend is not the same as being locked out — showing a + // sign-in screen then would hide the real "server not reachable" error. + it('stays out of the way when the session route cannot be reached', async () => { + vi.spyOn(authApi, 'getSession').mockRejectedValue(new Error('offline')) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.blocked).toBe(false) + expect(auth.ready).toBe(true) + }) + + it('shows the sign-in screen again when a 401 arrives mid-session', async () => { + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse() as never) + const auth = useAuthStore() + await auth.initialize() + + notifyUnauthorized() + + expect(auth.blocked).toBe(true) + expect(auth.problem).toBe('expired') + expect(auth.user).toBeNull() + }) + + it('ignores a 401 when there is no gate at all', async () => { + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse({ enabled: false }) as never) + const auth = useAuthStore() + await auth.initialize() + + notifyUnauthorized() + + expect(auth.blocked).toBe(false) + expect(auth.problem).toBeNull() + }) + + it('picks up a failed sign-in from the URL and clears it', async () => { + const location = stubLocation() + location.search = '?auth_error=denied' + location.href = 'https://deid.example.org/?auth_error=denied' + vi.spyOn(authApi, 'getSession').mockResolvedValue( + sessionResponse({ authenticated: false, user: null }) as never, + ) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.problem).toBe('denied') + // Cleared from the address bar, or a reload would replay a stale failure. + expect(window.history.replaceState).toHaveBeenCalledWith({}, '', 'https://deid.example.org/') + }) + + it('maps an unknown error code to the generic failure', async () => { + const location = stubLocation() + location.search = '?auth_error=something-new' + location.href = 'https://deid.example.org/?auth_error=something-new' + vi.spyOn(authApi, 'getSession').mockResolvedValue( + sessionResponse({ authenticated: false, user: null }) as never, + ) + const auth = useAuthStore() + await auth.initialize() + + expect(auth.problem).toBe('unknown') + }) + + it('sends the browser to the provider to sign in', async () => { + const location = stubLocation() + vi.spyOn(authApi, 'getSession').mockResolvedValue( + sessionResponse({ authenticated: false, user: null }) as never, + ) + const auth = useAuthStore() + await auth.initialize() + auth.signIn() + + expect(location.href).toBe('https://deid.example.org/api/v1/auth/login') + }) + + it('reloads after signing out so no document text survives it', async () => { + const location = stubLocation() + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse() as never) + vi.spyOn(authApi, 'logout').mockResolvedValue({ data: { redirect_url: null } } as never) + const auth = useAuthStore() + await auth.initialize() + await auth.signOut() + + expect(location.href).toBe('/') + }) + + it('follows the provider to its own sign-out page when it asks for one', async () => { + const location = stubLocation() + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse() as never) + vi.spyOn(authApi, 'logout').mockResolvedValue({ + data: { redirect_url: 'https://idp.example.org/logout' }, + } as never) + const auth = useAuthStore() + await auth.initialize() + await auth.signOut() + + expect(location.href).toBe('https://idp.example.org/logout') + }) + + // Signing out has to end locally even if the API call does not come back. + it('still leaves when the sign-out request fails', async () => { + const location = stubLocation() + vi.spyOn(authApi, 'getSession').mockResolvedValue(sessionResponse() as never) + vi.spyOn(authApi, 'logout').mockRejectedValue(new Error('offline')) + const auth = useAuthStore() + await auth.initialize() + await auth.signOut() + + expect(location.href).toBe('/') + }) +}) diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts new file mode 100644 index 0000000..e0fc865 --- /dev/null +++ b/frontend/stores/auth.ts @@ -0,0 +1,122 @@ +/** + * The optional sign-in gate. + * + * With no gate configured (`OIDC_ENABLED=false`, the default) this store + * settles on `enabled=false, authenticated=true` and the app behaves exactly + * as it did before OIDC existed — `blocked` is never true and nothing else in + * the UI changes. + * + * Nothing here is persisted. The session lives in an HttpOnly cookie the + * browser handles on its own, which is also why signing in is a full-page + * navigation rather than an XHR. + */ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { authApi } from '@/services/authApi' +import { setUnauthorizedHandler } from '@/services/api' +import type { AuthErrorCode, AuthUser } from '@/types/auth' + +const AUTH_ERROR_CODES: AuthErrorCode[] = ['denied', 'state', 'provider', 'token', 'identity'] + +/** + * Read (and clear) the `?auth_error=` the backend redirects back with after a + * failed sign-in. Clearing it matters: without that, a reload would show the + * same stale failure over a session that is by then perfectly fine. + */ +function takeAuthErrorFromUrl(): AuthErrorCode | 'unknown' | null { + const value = new URLSearchParams(window.location.search).get('auth_error') + if (value === null) return null + const url = new URL(window.location.href) + url.searchParams.delete('auth_error') + window.history.replaceState({}, '', url.toString()) + return (AUTH_ERROR_CODES as string[]).includes(value) ? (value as AuthErrorCode) : 'unknown' +} + +export const useAuthStore = defineStore('auth', () => { + /** Whether this deployment has a gate at all. */ + const enabled = ref(false) + const authenticated = ref(false) + const user = ref(null) + const loginUrl = ref('') + /** False until the first `/auth/session` answered — the app waits for it + * rather than flashing the input panel at someone who may not be let in. */ + const ready = ref(false) + const signingOut = ref(false) + /** The reason the last sign-in attempt failed, or 'expired' for a session + * that ran out while the tab was open. */ + const problem = ref(null) + + /** True exactly when the sign-in screen must replace the app. */ + const blocked = computed(() => enabled.value && !authenticated.value) + + async function fetchSession(): Promise { + try { + const { data } = await authApi.getSession() + enabled.value = data.enabled + authenticated.value = data.authenticated + user.value = data.user + loginUrl.value = data.login_url + } catch { + // The backend is unreachable, which is not the same as being locked out: + // stay out of the way and let the usual "server not reachable" errors + // surface where they always did. + enabled.value = false + authenticated.value = true + user.value = null + } finally { + ready.value = true + } + } + + /** A 401 came back mid-session: the cookie expired or was revoked. */ + function markSignedOut(): void { + if (!enabled.value || !authenticated.value) return + authenticated.value = false + user.value = null + problem.value = 'expired' + } + + function signIn(): void { + problem.value = null + // A full-page navigation, not an XHR: the provider needs to own the tab in + // order to show its own login screen (and any second factor). + window.location.href = loginUrl.value || '/api/v1/auth/login' + } + + async function signOut(): Promise { + if (signingOut.value) return + signingOut.value = true + let redirectUrl: string | null = null + try { + redirectUrl = (await authApi.logout()).data.redirect_url + } catch { + // The cookie may or may not be gone; reloading settles it either way. + } + // Reload rather than switching a flag: it is the only way to be sure no + // document text from the previous session is still in memory. + window.location.href = redirectUrl ?? window.location.pathname + } + + /** Called once at startup, before anything else talks to the API. */ + async function initialize(): Promise { + problem.value = takeAuthErrorFromUrl() + setUnauthorizedHandler(markSignedOut) + await fetchSession() + } + + return { + enabled, + authenticated, + user, + loginUrl, + ready, + signingOut, + problem, + blocked, + initialize, + fetchSession, + markSignedOut, + signIn, + signOut, + } +}) diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts new file mode 100644 index 0000000..a867a6d --- /dev/null +++ b/frontend/types/auth.ts @@ -0,0 +1,29 @@ +/** + * The optional sign-in gate (backend `schemas/auth.py`). + * + * The app has no user accounts — `AuthUser` exists so the header can say who + * is signed in, and for nothing else. Everyone who gets past the gate sees the + * same application. + */ + +export interface AuthUser { + name: string + email: string +} + +export interface AuthSession { + /** False when no gate is configured — then `authenticated` is always true. */ + enabled: boolean + authenticated: boolean + user: AuthUser | null + /** Where the browser goes to start a sign-in; empty when the gate is off. */ + login_url: string +} + +export interface LogoutResponse { + /** Set when the identity provider should end its session too. */ + redirect_url: string | null +} + +/** The `?auth_error=` codes the backend redirects back with. */ +export type AuthErrorCode = 'denied' | 'state' | 'provider' | 'token' | 'identity' diff --git a/frontend/utils/errors.ts b/frontend/utils/errors.ts index 42de562..23628e1 100644 --- a/frontend/utils/errors.ts +++ b/frontend/utils/errors.ts @@ -11,6 +11,8 @@ import { isAxiosError } from 'axios' import { t } from '@/i18n' const STATUS_MESSAGE_KEYS: Record = { + // Only reachable with the sign-in gate on: the session ran out mid-work. + 401: 'errors.unauthorized', 410: 'errors.expired', 413: 'errors.too_large', 415: 'errors.unsupported_type', diff --git a/mkdocs.yml b/mkdocs.yml index 4054ccd..83db36b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,7 @@ nav: - Requirements & sizing: operations/requirements.md - Deployment: operations/deployment.md - Configuration: operations/configuration.md + - Single sign-on (OIDC): operations/sso.md - LLM endpoints: operations/llm-endpoints.md - OCR engines: operations/ocr-engines.md - Troubleshooting: operations/troubleshooting.md diff --git a/pyproject.toml b/pyproject.toml index 07d06eb..08798e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ dependencies = [ "pydantic-settings==2.15.0", "python-multipart==0.0.32", "httpx==0.28.1", + # Only used by the optional OIDC gate: signs the session cookie and + # verifies the provider's id_token against its JWKS ([crypto] pulls in the + # RSA/EC support that needs). + "pyjwt[crypto]==2.13.0", "openai==2.53.0", "pypdf==6.15.0", "python-docx==1.2.0", diff --git a/uv.lock b/uv.lock index b02703c..08ace4d 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,55 @@ 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/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" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -195,6 +244,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + [[package]] name = "deidentifier" version = "0.2.1" @@ -206,6 +305,7 @@ dependencies = [ { name = "pillow" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pymupdf" }, { name = "pypdf" }, { name = "pypdfium2" }, @@ -236,6 +336,7 @@ requires-dist = [ { name = "pillow", specifier = "==12.3.0" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pydantic-settings", specifier = "==2.15.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pymupdf", specifier = "==1.28.2" }, { name = "pypdf", specifier = "==6.15.0" }, { name = "pypdfium2", specifier = "==5.12.1" }, @@ -760,6 +861,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.4" @@ -854,6 +964,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "11.0.1"