Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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!'
Expand Down
35 changes: 27 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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".

---

Expand Down Expand Up @@ -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 |
|---|---|
Expand All @@ -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=<code>` 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
Expand All @@ -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. |
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@ 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 |
| uvicorn | 0.52.1 | BSD-3-Clause | https://uvicorn.dev/ |
| 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 |
Expand All @@ -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 |
Expand Down
95 changes: 95 additions & 0 deletions backend/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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."""
Expand Down
18 changes: 17 additions & 1 deletion backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
)
Expand All @@ -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)
Expand Down
Loading
Loading