# Schleuse — complete documentation > Every guide and reference page of https://schleuse.meowww.dev in one file. > Generated from the sources at build time; the index is at /llms.txt. # Getting started Schleuse runs as a single container. The minimum dependency is a database — SQLite lives on a volume, PostgreSQL is a second container. JWT keys are auto-generated on first start and persisted, so you don't need to pre-create secrets. The app image is published to the GitLab container registry at `registry.gitlab.com/meowww_dev/schleuse:latest` — you do not need the source tree to deploy. ## Docker (recommended) ```bash cp .env.example .env ``` Edit `.env` and fill in at least: | Variable | Example | |---------------------------|---------------------------------------------| | `NUXT_DATABASE_URL` | `postgresql://schleuse_user:schleuse_password@postgres:5432/schleuse` or `sqlite:/app/data/schleuse.db` | | `NUXT_APP_URL` | `https://auth.example.com` | | `NUXT_ISSUER` | `https://auth.example.com/oidc` | | `NUXT_RP_ID` | `auth.example.com` | | `NUXT_RP_NAME` | `My Product` | | `NUXT_RP_ORIGIN` | `https://auth.example.com` | | `NUXT_OIDC_CLIENTS_JSON` | see [OIDC configuration](./oidc#client-configuration) | Then: ```bash docker compose pull docker compose up -d ``` - App: [http://localhost:5000](http://localhost:5000) - Mailcatcher UI (dev): [http://localhost:1080](http://localhost:1080) On first start the container: 1. Auto-generates `jwt-secret.txt` and `jwt-private-key.pem` in `/app/data/` 2. Runs database migrations 3. Starts serving on port 3000 (published to 5000 by compose) **The first user to register is auto-promoted to admin.** Toggle with `NUXT_AUTO_ADMIN_FIRST_USER=false`. For an unattended install — or any instance with registration disabled — set `NUXT_BOOTSTRAP_ADMIN_EMAIL` instead and the admin is created on first start, with a generated password written to the log (`docker compose logs app | grep oneTimePassword`). See [Admin panel → First-admin bootstrap](./admin#first-admin-bootstrap). If you set `NUXT_REQUIRE_TWO_FACTOR=true` without SMTP, that first admin — bootstrapped or self-registered — must enrol an authenticator app or a passkey on `/setup-2fa` right after their first login before the panel unlocks — there's no admin exemption. See [Security → Mandatory two-factor](./security#mandatory-two-factor). **Tip: Building from source** Contributors working from a git checkout can still swap `docker compose up -d` for `docker compose up --build` — the bundled `docker-compose.yml` keeps a local `build:` stanza so iterations on the Dockerfile don't require a pushed image. ## Minimal self-hosted compose If you already run PostgreSQL elsewhere, or you want SQLite, this is the whole stack (no source tree required — the image is pulled from the registry): ```yaml services: schleuse: image: registry.gitlab.com/meowww_dev/schleuse:latest ports: - "3000:3000" volumes: - app_data:/app/data # auto-generated keys + SQLite DB (if used) environment: NODE_ENV: production NUXT_DATABASE_URL: sqlite:/app/data/schleuse.db NUXT_ISSUER: https://auth.example.com/oidc NUXT_APP_URL: https://auth.example.com NUXT_RP_ID: auth.example.com NUXT_RP_NAME: My Product NUXT_RP_ORIGIN: https://auth.example.com NUXT_OIDC_CLIENTS_JSON: > [{"clientId":"my-app", "redirectUris":["https://my-app.example.com/callback"], "grantTypes":["authorization_code","refresh_token"], "scopes":["openid","email","profile","offline_access"], "public":true}] volumes: app_data: ``` ## Local development ```bash pnpm install docker compose up postgres mailcatcher -d cp .env.example .env pnpm dev ``` Migrations run automatically at startup via a Nitro plugin. ## Next steps - Wire your OIDC clients → [OIDC provider](./oidc) - Enable 2FA, customise the password policy → [Configuration](./configuration) - Set up SMTP, rebrand emails → [Email templates](./email-templates) - Prep for production → [Deployment](./deployment) --- # Configuration Every setting lives in an environment variable using Nuxt's runtime-config convention: - `NUXT_` — server-only, never sent to the browser - `NUXT_PUBLIC_` — exposed to the client No rebuild is needed to change config — the app reads these at startup. **Tip: Configurator in the admin UI** Logged-in admins can view and export the running instance's env-var mapping at **`/admin/configurator`** — pre-filled values, one-click switch between `docker-compose.yml`, `.env`, and shell `export` formats, secrets redacted. See [Admin panel → Configurator](./admin#configurator-admin-configurator). ## Required | Variable | Description | |--------------------------|----------------------------------------------------------| | `NUXT_DATABASE_URL` | PostgreSQL: `postgresql://…` — SQLite: `sqlite:./data.db` or `:memory:` | | `NUXT_ISSUER` | OIDC issuer URL, must end with `/oidc` | | `NUXT_APP_URL` | Browser-reachable base URL | | `NUXT_OIDC_CLIENTS_JSON` | JSON array of OIDC client definitions ([schema](./oidc#client-configuration)) | | `NUXT_RP_ID` | WebAuthn relying party domain (no scheme, no port) | | `NUXT_RP_NAME` | WebAuthn RP display name | | `NUXT_RP_ORIGIN` | WebAuthn origin URL, full scheme + port | ## Optional — core | Variable | Default | Description | |--------------------------------|---------|-------------| | `NUXT_JWT_SECRET` | auto | ≥ 32 chars, HMAC for session JWTs. Auto-generated and persisted to `data/jwt-secret.txt` on first start. | | `NUXT_TOTP_ENCRYPTION_KEY` | auto | Encrypts authenticator-app (TOTP) secrets at rest. Auto-generated to `data/totp-encryption-key.txt` if unset; **back it up with the data dir**. Rotating it makes existing TOTP enrolments undecryptable (users must re-enrol). | | `NUXT_JWT_PRIVATE_KEY` | auto | RSA 2048 PKCS8 PEM. Auto-generated and persisted to `data/jwt-private-key.pem`. `\n`-escape for `.env`. | | `NUXT_JWT_PRIVATE_KEY_PREVIOUS`| _(empty)_ | Retired signing key kept in the JWKS (verify-only) during a [no-downtime key rotation](./deployment#key-rotation-without-downtime). Same PEM format. Empty = single-key. | | `NUXT_SESSION_MAX_AGE_MINUTES` | `15` | How long a session survives **without activity**. The session JWT and its cookie always get the same lifetime, and the server re-issues both once half of it has elapsed — so an active user is never signed out mid-work, while an abandoned tab stops working after this long. `1`–`1440`. | | `NUXT_SESSION_REMEMBER_ME_MAX_AGE_DAYS` | `7` | The same, for a login that ticked *Remember me*. `1`–`365`. | | `NUXT_TRUST_PROXY` | `false` | Set `true` behind a reverse proxy that terminates TLS and sets `X-Forwarded-*`. The client IP is then read from the **rightmost** `X-Forwarded-For` entry (the hop your proxy recorded) — assumes a single trusted proxy. Off, the header is ignored entirely. | | `NUXT_COOKIE_SECURE` | auto | Defaults to `true` when `NODE_ENV=production`. Set `false` over plain HTTP for local dev. | | `NUXT_REGISTRATION_ENABLED` | `true` | Set `false` to disable new user signups. | | `NUXT_AUTO_ADMIN_FIRST_USER` | `true` | First registered user is promoted to admin. A bootstrapped admin already fills that slot. | | `NUXT_BOOTSTRAP_ADMIN_EMAIL` | _(empty)_ | Create an admin with this address at startup if it has no account yet. The route to a first admin on a closed instance. See [Admin panel → First-admin bootstrap](./admin#first-admin-bootstrap). | | `NUXT_BOOTSTRAP_ADMIN_PASSWORD` | _(empty)_ | Password for that admin. Empty = one is generated and **logged once**. Must satisfy the password policy or startup fails. **Create-only**: changing it after the account exists does nothing. | | `NUXT_TWO_FACTOR_ENABLED` | `false` | Enables email-code 2FA after password login. Passkeys and authenticator-app (TOTP) work as 2FA too. TOTP is per-user opt-in and needs no env var (it works even with this off / no SMTP). With this **off**, the emailed code is never accepted as a second factor even when SMTP is configured — a TOTP account can't be downgraded to mailbox access; the suspicious-login step-up is the one exception that still uses email. | | `NUXT_MAGIC_LINK_ENABLED` | `true` | Offer **Email me a sign-in link** on the login page: a single-use link, valid for 15 minutes, that signs the user in without a password. Exists only when SMTP is configured — without `NUXT_SMTP_HOST` it is off whatever this says. Accounts with TOTP still enter their code after the link. Set `false` if a mailbox alone must not open an account. See [Security → Magic-link sign-in](./security#magic-link-sign-in). | | `NUXT_REQUIRE_TWO_FACTOR` | `false` | Force every account to **have** a second factor. A user with no usable factor is corralled to `/setup-2fa` (hard server-enforced gate) until they enrol TOTP or a passkey. Any factor satisfies it — including the emailed code — so forced enrolment only triggers when email-2FA isn't available. See [Security → Mandatory two-factor](./security#mandatory-two-factor). | | `NUXT_REQUIRE_TWO_FACTOR_SCOPE` | `all` | Who the mandatory-2FA gate applies to: `all` or `admins` (only admin accounts). Ignored when require-2FA is off. | | `NUXT_REQUIRE_TWO_FACTOR_GRACE_DAYS` | `0` | New-account grace (days) before the gate applies, measured from account creation. `0` = gate immediately. Accounts older than N days are gated regardless (no bypass window when the policy is enabled). | | `NUXT_SUSPICIOUS_LOGIN_ALERTS` | `false` | Email the user a "new sign-in" alert on a login from a new IP or device. Opt-in; requires SMTP. IP accuracy behind a proxy needs `NUXT_TRUST_PROXY`. See [Security → New-sign-in alerts](./security#new-sign-in-alerts). | | `NUXT_SUSPICIOUS_LOGIN_STEP_UP` | `false` | Force an email-code challenge before granting the session when a login is flagged suspicious (new IP/device). Opt-in; requires SMTP. Independent of the alert. See [Security → New-sign-in alerts](./security#new-sign-in-alerts). | | `NUXT_APP_ROLES_REQUIRE_GRANT` | `false` | Restrict the app-roles API (`GET` **and** `PUT` `/api/apps/roles`): only read/write for users who already have an OIDC grant with the calling client (else `404`, uniform with an unknown user) — same model as `NUXT_APP_METADATA_REQUIRE_GRANT`. See [OIDC → App-managed roles](./oidc#app-managed-roles-server-to-server). | | `NUXT_APP_METADATA_ENABLED` | `false` | Enable the per-user app metadata store (`GET`/`PATCH /api/apps/metadata`) — a small key→value store an app reads and writes with its own `client_secret`. When off both routes return `404`; the admin panel still shows any rows already stored. See [OIDC → Per-user app metadata](./oidc#per-user-app-metadata). | | `NUXT_APP_METADATA_REQUIRE_GRANT` | `false` | Restrict the metadata API to users who already have an OIDC grant with the calling client (else `404`, uniform with an unknown user). Applies to reads as well as writes. | | `NUXT_OIDC_RESOURCE_AUDIENCE` | _(empty)_ | Comma-separated audience URIs (RFC 8707). Empty = all access tokens stay **opaque**. When set, a client that lists the audience in its `allowedResources` and requests `resource=` gets a **JWT** access token; audiences are deny-by-default. See [OIDC → JWT access tokens](./oidc#jwt-access-tokens). | | `NUXT_OIDC_RESOURCE_SERVERS_JSON` | _(empty)_ | Richer JWT-AT resource config: JSON array of `{ audience, scopes?, accessTokenTTL?, gateClientId? }` giving a resource its own scope set, a shorter token TTL (seconds) and/or a [login role gate](./oidc#gating-an-audience-by-login-role-gateclientid). Clients must name an audience in their `allowedResources` to request it. `scopes` accepts an array (`["a","b"]`) or the space-delimited string OAuth uses on the wire (`"a b"`) — both mean the same. Merged with (overriding, per audience) `NUXT_OIDC_RESOURCE_AUDIENCE`. This is also how you bind a token to an [MCP server](./mcp) — the `audience` becomes the token's `aud`. See [OIDC → JWT access tokens](./oidc#jwt-access-tokens). | | `NUXT_BACKCHANNEL_LOGOUT_RETRY_DELAYS_MS` | `2000,15000` | Comma-separated backoff delays (ms) before each back-channel-logout re-attempt after the inline first attempt. Empty = single attempt. Bounded + in-memory (lost on restart). | | `NUXT_LOG_LEVEL` | `info` | pino level: `fatal` `error` `warn` `info` `debug` `trace`. | ## Connection pool PostgreSQL only — SQLite is one file and always uses a single connection. | Variable | Default | Description | |----------|---------|-------------| | `NUXT_DATABASE_POOL_MIN` | `2` | Connections kept open while the instance is idle. `0` releases them all, so a quiet instance costs the server nothing. | | `NUXT_DATABASE_POOL_MAX` | `10` | Connections this instance may take at peak. | `max_connections` on a PostgreSQL server is a **shared budget**, and the default is 100. Ten simultaneously busy instances at the default maximum exhaust it, and Postgres then refuses new connections *server-wide* — one instance's traffic spike becomes everyone's outage. If staging, production and another application point at the same cluster, give each a smaller ceiling: ```bash NUXT_DATABASE_POOL_MIN=0 NUXT_DATABASE_POOL_MAX=3 ``` Both accept `0`–`1000` (`1`–`1000` for the maximum); anything else — a word, a decimal, a negative — is rejected at [startup](#startup-validation) with the variable named. An empty value (`NUXT_DATABASE_POOL_MAX=`) counts as unset and takes the default. That upper bound is a typo guard of ours, not a PostgreSQL limit: raise the maximum if a tuned cluster and a single dedicated instance call for it. A maximum below the minimum is refused by the pool itself: the process exits with `Tarn: opt.max is smaller than opt.min` before it serves a request. Defaults are unchanged from earlier releases, so an instance that sets neither variable behaves exactly as it did before they existed. ## SMTP (optional — email features degrade when absent) If `NUXT_SMTP_HOST` is empty, email verification and 2FA are disabled, users are auto-verified on registration, and password reset via email is unavailable. Useful for local dev without Mailcatcher. | Variable | Example | |--------------------|----------------------------------| | `NUXT_SMTP_HOST` | `smtp.sendgrid.net` / `mailcatcher` | | `NUXT_SMTP_PORT` | `587` / `1025` | | `NUXT_SMTP_SECURE` | `true` for port 465 | | `NUXT_SMTP_USER` | `apikey` | | `NUXT_SMTP_PASS` | `` | | `NUXT_SMTP_FROM` | `Schleuse ` | | `NUXT_SMTP_REQUIRE_TLS` | `true` (default) / `auto` / `false` | | `NUXT_SMTP_TLS_REJECT_UNAUTHORIZED` | `true` (default) | | `NUXT_EMAIL_RETRY_DELAYS_MS` | `30000,120000` (default) | **Set `NUXT_SMTP_FROM`.** Left unset it falls back to `noreply@localhost`, a domain receivers cannot route or verify — so verification, password-reset and invite messages are likely to be rejected or spam-filed. Schleuse warns about it at startup. Both `noreply@example.com` and `Schleuse ` are accepted. ### Transport security `NUXT_SMTP_REQUIRE_TLS` decides what happens on a non-465 port: | Value | Behaviour | |-------|-----------| | `true` *(default)* | Require STARTTLS **and** a valid certificate. No upgrade means no delivery. | | `auto` | Upgrade to STARTTLS whenever the relay advertises it. If the relay doesn't offer it — or rejects the command — delivery continues unencrypted. Never blocks mail, with one exception: when the relay advertises STARTTLS and then rejects it, the SMTP credentials are not sent over the plaintext session, so a relay that requires `NUXT_SMTP_USER` login refuses the mail. | | `false` | Never attempt STARTTLS, even when advertised. Legacy behaviour; only for a relay that mis-advertises it. | **Warning: Upgrading from 1.11.1 or earlier** The default was `auto`. If you never set this variable, the mail hop is now required to be encrypted, and there are **two** ways that can stop delivery — with two different fixes: | Symptom | Cause | Fix | |---|---|---| | No upgrade, no delivery | the relay does not offer STARTTLS | `NUXT_SMTP_REQUIRE_TLS=auto` | | Handshake fails | the relay offers STARTTLS but its certificate is self-signed, expired or from a private CA | `NUXT_SMTP_TLS_REJECT_UNAUTHORIZED=false` | Reach for the second one first where it applies: it keeps STARTTLS mandatory and only stops verifying the certificate, where `auto` gives up on encryption altogether. A copied `.env.example` already pins `auto`, and the bundled `docker-compose.yml` pins it for the mailcatcher sidecar, so neither changes behaviour on upgrade. Ignored when `NUXT_SMTP_SECURE=true` — implicit TLS encrypts the session from the first byte. Certificates are validated **only where TLS is mandatory** (`true`, or implicit TLS). Under `auto` they deliberately are not: nodemailer cannot fall back from a failed TLS *handshake*, so checking there would stop mail for every self-signed relay rather than downgrading. That is the standard opportunistic-security posture (RFC 7435) — encryption without authentication still beats cleartext, and the admin page labels it honestly as *certificate not verified*. `NUXT_SMTP_TLS_REJECT_UNAUTHORIZED=false` relaxes validation in mandatory mode, for a self-hosted relay with a self-signed certificate. The hop stays encrypted against passive capture but is no longer proof against an active man-in-the-middle. Only a literal `false` disables it — a typo keeps validation on. Check what your deployment actually negotiated on the admin [Email & SMTP page](./admin#email-smtp); it reports the measured result, not the configured intent. `NUXT_EMAIL_RETRY_DELAYS_MS` controls automatic retry of verification, password-reset and resend emails: a comma-separated list of backoff delays (in milliseconds) before each re-attempt. The default `30000,120000` means three attempts — immediate, then +30 s, then +2 min. Set it to an empty string to disable retry (a single attempt). Retries are in-memory and **not durable**: pending retries are lost if the process restarts. Time-sensitive **2FA login codes are never retried** — a failed code send surfaces immediately so the user can request a fresh one. ## Password policy | Variable | Default | Notes | |--------------------------------|------------|-------| | `NUXT_PASSWORD_POLICY` | `standard` | Presets: `simple` (6+ chars), `standard` (8+ with upper/lower/number/special), `strong` (12+ with all). A fixed 256-character maximum applies regardless of preset (argon2 input bound). | | `NUXT_PASSWORD_MIN_LENGTH` | from preset | Override individual rule | | `NUXT_PASSWORD_REQUIRE_UPPERCASE` | from preset | | | `NUXT_PASSWORD_REQUIRE_LOWERCASE` | from preset | | | `NUXT_PASSWORD_REQUIRE_NUMBER` | from preset | | | `NUXT_PASSWORD_REQUIRE_SPECIAL`| from preset | | | `NUXT_PASSWORD_HISTORY_COUNT` | `0` | Prevent reuse of the last *N* passwords on change/reset. `0` disables it. See below. | ### Password reuse prevention Set `NUXT_PASSWORD_HISTORY_COUNT=N` (an integer ≥ 1) to reject a password change or reset that reuses one of the user's last *N* passwords — **the current password counts as the most recent of the N**. For example, `3` blocks the current password and the two before it. - `0` disables the check entirely, including allowing a change back to the current password. - **Default coupling**: when `NUXT_PASSWORD_HISTORY_COUNT` is left **unset**, the `strong` password preset turns reuse prevention on (count `3`) automatically; other presets default to `0`. Setting the variable explicitly (including `0`) always wins. - Only **hashed** retired passwords are stored (in a `password_history` table, argon2id like the live credential); they are deleted with the account and are never part of the [GDPR data export](security#data-export-gdpr). - Increasing *N* takes effect going forward; previously pruned history is not recovered. ## Rate limiting Format: `maxAttempts/windowMinutes`. In-memory per-IP counters (resets on restart — acceptable for a single-instance sidecar). | Variable | Default | |--------------------------------|---------| | `NUXT_RATE_LIMIT_LOGIN` | `10/15` | | `NUXT_RATE_LIMIT_REGISTER` | `10/60` | | `NUXT_RATE_LIMIT_PASSWORD_RESET`| `5/60` | ## CAPTCHA Off by default. Set a provider and every unauthenticated form that sends mail to a typed-in address requires a solved challenge: **sign-up** (password and passkey) and **forgot password**. Sign-in is not gated — it sends no mail and has its own per-account limit. The check runs before anything that reveals whether an address is taken, so the "already registered" answer sits behind it too. | Variable | Default | Description | |---------------------------------|-----------|-------------| | `NUXT_PUBLIC_CAPTCHA_PROVIDER` | _(empty)_ | `turnstile`, `recaptcha` (v2 checkbox), `hcaptcha` or `cap`. Empty = off. | | `NUXT_PUBLIC_CAPTCHA_SITE_KEY` | _(empty)_ | The provider's public site key. It is rendered into the page — public by design. | | `NUXT_CAPTCHA_SECRET` | _(empty)_ | The provider's secret key. Never leaves the server. | | `NUXT_PUBLIC_CAPTCHA_CAP_URL` | _(empty)_ | Cap only: base URL of your Cap instance, e.g. `https://cap.example.com`. | Only one provider is active at a time, so the provider is a value, not part of the variable name. A provider without its site key and secret (or `cap` without a URL) refuses to start — verification fails closed, so a half-configured CAPTCHA would otherwise reject every sign-up. The vendor providers need outbound HTTPS from Schleuse to the vendor, and fail closed without it — on an air-gapped deployment every sign-up would be rejected. Use `cap` there. | Provider | Hosted by | Get keys | |----------|-----------|----------| | `turnstile` | Cloudflare | Cloudflare dashboard → Turnstile | | `recaptcha` | Google | reCAPTCHA admin console, **v2 "I'm not a robot"** keys — v3 (score) keys are refused. Free up to 10 000 checks a month — see the [quota warning](./security#captcha). | | `hcaptcha` | Intuition Machines | hCaptcha dashboard | | `cap` | **you** | [Cap Standalone](https://capjs.js.org/guide/standalone/) → dashboard → *New key* | Quick start with Cloudflare's test keys (always pass — swap them for real ones): ```bash NUXT_PUBLIC_CAPTCHA_PROVIDER=turnstile NUXT_PUBLIC_CAPTCHA_SITE_KEY=1x00000000000000000000AA NUXT_CAPTCHA_SECRET=1x0000000000000000000000000000000AA ``` **Cap** is the self-hosted, open-source option (proof of work, no tracking). How to run it is [Cap's own documentation](https://capjs.js.org/guide/standalone/); Schleuse needs one thing from it: `ENABLE_ASSETS_SERVER=true`, because the widget script and its WASM solver are loaded from your instance (`/assets/…`), never from a CDN. The Cap URL must be reachable from your users' browsers **and** from Schleuse, which calls `//siteverify`. ## Observability (metrics) | Variable | Default | Notes | |----------------------|---------|-------| | `NUXT_METRICS_TOKEN` | _(unset)_ | Bearer token that enables `GET /metrics` (Prometheus). **Unset → the endpoint returns `404`** (feature off). When set it must be **≥ 32 characters** (startup fails otherwise), and every scrape must send `Authorization: Bearer ` (constant-time compared; never logged). Treated as a secret — the Configurator never echoes its value. Rotate by changing the env var and restarting. | ### Provisioning API | Variable | Default | Notes | |----------|---------|-------| | `NUXT_ADMIN_API_TOKENS_JSON` | _(unset)_ | JSON array of bearer tokens for the [provisioning API](/reference/api#provisioning-api-server-to-server): `[{"id":"migration-importer","token":"…"}]`. **Unset or `[]` → every `/api/provisioning/**` route returns `404`** (feature off). Each `token` must be **≥ 32 characters** and each `id` a lowercase slug (`[a-z0-9_-]`); duplicate ids or tokens fail startup. The `id` is not secret — it labels the caller in the audit log. Treated as a secret: the Configurator never echoes the value. | The `id` exists so rotation needs no coordinated restart: add a second entry, deploy, move your importer across, then remove the first — both are accepted in between. **Warning** This token can create sign-in-capable accounts. Keep the endpoints off the public internet where you can, and remove the token once a migration is finished. It **cannot** set `is_admin`. See [Migrating users in](migration). Metrics are in-memory counters/histograms (reset on restart): login success/failure, registrations, 2FA challenges, rate-limit hits, an HTTP request-duration histogram, and standard Node/process metrics. See [Deployment](deployment) for the scrape config and exposure guidance. ## Webhooks | Variable | Default | Description | |----------|---------|-------------| | `NUXT_WEBHOOKS_JSON` | _(empty)_ | Outbound webhooks: a JSON array of `{ id, target, events, secret? }`. Subscriptions name **audit events** — exactly (`register`), by prefix (`admin.user.*`) or all of them (`*`) — and an unknown name fails startup rather than never firing. With a `secret` (≥ 16 chars) every delivery is signed. Secrets are masked in the admin panel and in every export. See [Webhooks](./webhooks). | | `NUXT_WEBHOOK_RETRY_DELAYS_MS` | _(empty)_ | Comma-separated backoff delays (ms) before each re-attempt after the first. Empty = one attempt. In-memory and lost on restart — there is no delivery queue by design. | ## Localization See [Multilingual](./multilingual) for the full story. | Variable | Default | Description | |-----------------------------|---------|-------------| | `NUXT_DEFAULT_LOCALE` | `en` | BCP-47 tag. Must be listed in `NUXT_SUPPORTED_LOCALES`. | | `NUXT_SUPPORTED_LOCALES` | `en` | Comma-separated allow-list (e.g. `en,de,fr,it`). The UI ships `en`, `de`, `fr`, `it`; any other tag is email-only. | ## Theme All optional — defaults produce a dark neon-modern look. Variables use `NUXT_PUBLIC_THEME_*` and are exposed to the client. See the [THEMES.md](https://gitlab.com/meowww_dev/schleuse/-/blob/main/THEMES.md) ready-to-use presets. The theme also covers error pages: an unknown URL or an invalid / expired emailed link renders in the same layout, with a localised explanation and a way back to sign-in. | Variable | Default | Notes | |-------------------------------------------|-----------------|-------| | `NUXT_PUBLIC_THEME_NAME` | `Schleuse` | App name shown in the UI and page titles. | | `NUXT_PUBLIC_THEME_PRIMARY_COLOR` | `#06d6a0` | Primary hex colour; the full 50–950 palette is auto-derived via HSL. | | `NUXT_PUBLIC_THEME_BACKGROUND_COLOR` | `#0a0e17` | Page background hex colour. | | `NUXT_PUBLIC_THEME_TEXT_COLOR` | `#f1f5f9` | Base text hex colour. | | `NUXT_PUBLIC_THEME_BORDER_RADIUS` | `12px` | Global border radius (CSS length). | | `NUXT_PUBLIC_THEME_DARK_MODE` | `true` | Whether the theme is dark-first (`true` \| `false`). | | `NUXT_PUBLIC_THEME_SURFACE_COLOR` | _(auto)_ | Card / surface background. Auto-derived when empty. | | `NUXT_PUBLIC_THEME_SURFACE_SECONDARY_COLOR` | _(auto)_ | Secondary surface background. Auto-derived when empty. | | `NUXT_PUBLIC_THEME_BORDER_COLOR` | _(auto)_ | Border colour. Auto-derived when empty. | | `NUXT_PUBLIC_THEME_MUTED_COLOR` | _(auto)_ | Muted / secondary text colour. Auto-derived when empty. | | `NUXT_PUBLIC_THEME_LOGO_URL` | _(empty)_ | URL or path to a logo shown on the login pages, in the admin sidebar and in email. Empty falls back to the bundled Schleuse mark — see the note below. | | `NUXT_PUBLIC_THEME_BACKGROUND_IMAGE` | _(empty)_ | URL or path to a background image for the auth pages. | | `NUXT_PUBLIC_THEME_BACKGROUND_LAYOUT` | `split` | Auth-page layout with a background image: `split` (form left, image right) or `full`. | **Tip: Empty does not mean "no logo"** Leaving `NUXT_PUBLIC_THEME_LOGO_URL` empty shows the bundled Schleuse mark on the login pages, in the admin sidebar and as the browser-tab icon. The one exception is deliberate: if you also set `NUXT_PUBLIC_THEME_NAME` to something other than `Schleuse`, the mark is **not** shown and your name is rendered as text instead. Schleuse will not put its own logo above your brand. To show your own mark, set `NUXT_PUBLIC_THEME_LOGO_URL`. Email follows the same rule, including the fallback. A relative value is resolved against `NUXT_APP_URL` before it is sent, since a mail client has no origin of its own — but use a **PNG or JPEG**, because Gmail and Outlook render no SVG. See [Email templates](/guide/email-templates#logo-in-email). ## Public UI flags Also `NUXT_PUBLIC_*` (exposed to the client), but not part of the colour theme: | Variable | Default | Notes | |-----------------------------------|-----------|-------| | `NUXT_PUBLIC_CUSTOM_DISPLAY_NAME` | `false` | Allow users to set a custom display name. When `false`, it is auto-composed from first + last name. | | `NUXT_PUBLIC_HOME_REDIRECT_URL` | _(empty)_ | When set, visitors to `/` are redirected here (internal path or external URL), and the logo on the account page links here too. | | `NUXT_PUBLIC_PASSWORD_HINT` | _(empty)_ | Override the auto-generated password-complexity hint shown under password fields. | ## JWT keys Both `NUXT_JWT_SECRET` and `NUXT_JWT_PRIVATE_KEY` are auto-generated on first start when not set. Generated values are persisted to the `data/` directory: | File | Contents | Purpose | |-----------------------------|-----------------------------|------------------------| | `data/jwt-secret.txt` | 48-byte random base64url | HMAC for session JWTs | | `data/jwt-private-key.pem` | RSA 2048 PKCS8 PEM | OIDC ID-token signing | | `data/totp-encryption-key.txt` | 48-byte random base64url | Encrypts TOTP secrets at rest | Files are created with mode `0600`. Mount `/app/data` as a Docker volume to persist keys across container restarts. **Priority:** ENV variable > persisted file > generate new. To generate keys manually: ```bash # JWT secret (≥ 32 chars) node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # RSA private key (newlines escaped for .env) node -e " const {generateKeyPairSync} = require('crypto'); const {privateKey} = generateKeyPairSync('rsa', {modulusLength: 2048}); const pem = privateKey.export({type:'pkcs8',format:'pem'}); console.log(pem.replace(/\n/g,'\\\\n')); " ``` ## Startup validation The app validates the environment **once at startup** and **refuses to boot** on contradictory configuration — so operator typos surface immediately in the logs instead of as a mysterious outage months later. Fatal checks (the process exits with a clear `level: fatal` log naming the variable): - `NUXT_ISSUER` must end with `/oidc` — the OIDC endpoints (discovery, authorize, token, jwks) are mounted there. - `NUXT_COOKIE_SECURE=true` together with an `http://` `NUXT_APP_URL` — secure cookies are never sent over plain HTTP, so no session would survive. Use HTTPS, or set `NUXT_COOKIE_SECURE=false` for local HTTP. Non-fatal (logged as `level: warn`, app still starts): - `NUXT_APP_URL` and `NUXT_RP_ORIGIN` resolving to different origins — WebAuthn rejects assertions whose origin doesn't match the relying-party origin. Make them match unless the split is intentional. These are in addition to the per-variable validation (URL shape, required values, secret length, etc.). If the container exits on boot, check the startup logs for a `fatal` line. --- # OIDC provider Authorization Code Flow with mandatory PKCE (S256). Refresh tokens rotate on every use. Introspection (RFC 7662) and revocation (RFC 7009) endpoints are live. ## Discovery ``` GET /.well-known/openid-configuration GET /.well-known/jwks.json ``` The document is identical whichever hostname served it: every URL in it derives from `NUXT_ISSUER`, not from the request. An instance reachable under a second name — a compose service, an internal mesh address — still advertises the canonical origin, so a relying party never receives endpoints it cannot use. The document is also served under the issuer-path forms RFC 8414 §3.1 prescribes — `/.well-known/oauth-authorization-server/oidc` and `/.well-known/openid-configuration/oidc` — so a client that probes in the specified order finds it on the first attempt. All four locations return the same bytes. See [MCP servers](./mcp#discovery-paths). **Warning: `claims_supported` spans two endpoints** It lists what the provider can supply **across the ID token and `/oidc/userinfo` together** — not what any single token carries. Schleuse's ID tokens hold `sub`, `aud`, `iss`, `iat`, `exp` and `nonce`; `email`, `name`, `roles` and the rest come from [`/oidc/userinfo`](#supported-scopes-and-claims). `sid` is [per-client](#session-scoped-logout). This is permitted — OIDC Core §5.4 allows exactly this for the Authorization Code flow — but most providers put `email` in the ID token, so code that reads `idToken.email` returns `undefined` here with no error to explain it. The discovery document also carries a non-standard **`account_url`** field pointing at the self-service account page (`/account`), so an app can deep-link a user to "manage your account" without hard-coding it: ```jsonc { "issuer": "https://auth.example.com/oidc", "authorization_endpoint": "https://auth.example.com/oidc/authorize", // …standard fields… "account_url": "https://auth.example.com/account" } ``` OIDC defines no standard field for this, so `account_url` is a Schleuse extension — clients that don't read it can derive the same value as **the issuer minus the `/oidc` suffix, plus `/account`**. The page is auth-gated: a signed-in user lands on their account; otherwise they're sent to login first. ### Cross-origin access A browser-based relying party — a SPA doing the flow in the page — reaches these endpoints cross-origin: | Endpoint | Browser may call it | |---|---| | `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/oidc/jwks` | yes | | `/oidc/authorize` | a redirect, not a fetch | | `/oidc/token`, `/oidc/revoke`, `/oidc/introspect` | yes | | `/oidc/userinfo` | yes, including with an `Authorization: Bearer` header | | `/api/**` | **no** | `/oidc/userinfo` answers the preflight that a bearer token triggers, so `oidc-client-ts` with `loadUserInfo: true`, `oauth4webapi` and the rest work without a workaround. `/api/**` sends no CORS headers at all, deliberately. Those endpoints are either cookie-based (and cross-origin reads would expose tokens and user data) or authenticate an *application* rather than a user — `/api/apps/roles` and `/api/apps/metadata` want a `client_secret`, which a browser has no business holding. A public client calling them from a page therefore sees an opaque `TypeError` rather than the `401 invalid_client` the server actually sent; check the server log for the real status. **Return-to-app link.** Append a Keycloak-style `?referrer=&referrer_uri=` to the account URL and the page renders a "← Back to \" link. The `referrer_uri` is validated server-side against that client's registered redirect URIs (it must share an origin with one) — an off-origin or unknown target simply produces no link, so it can't be abused as an open redirect. ## Supported scopes and claims | Scope | Claims / effect | |------------------|----------------------------------------------------------------| | `openid` | `sub` | | `email` | `email`, `email_verified` | | `profile` | `name`, `given_name`, `family_name`, `preferred_username` | | `roles` | `roles` — the user's per-application roles, **from `/oidc/userinfo`, not the ID token** (only for clients that define `roles`). See [Per-application roles](#per-application-roles). | | `offline_access` | Issues a refresh token (7-day lifetime, rotated on each use) | **Tip: Where claims land** `email`, `profile` and `roles` are **scope-requested** claims — the provider returns them from `/oidc/userinfo`, not inside the ID token. Read the access token, then call `/oidc/userinfo` to get them. **Warning: `email` is always lowercase** Schleuse stores addresses canonically (trimmed, lowercased), so `email` and `preferred_username` are always lowercase regardless of how the user typed the address when registering. If you are upgrading an installation that predates this, the claim **value changes once** for users whose stored address was mixed-case. `sub` is unaffected, so keying your local records on `sub` — as OIDC Core recommends — needs no migration. A relying party that keys on the `email` string instead may need to match case-insensitively once. See the [upgrade pre-flight](/guide/deployment#email-canonicalisation). ## Client configuration `NUXT_OIDC_CLIENTS_JSON` is a JSON array. One entry per relying party: ```json [ { "clientId": "my-app", "redirectUris": ["https://my-app.example.com/callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "email", "profile", "roles", "offline_access"], "public": true, "roles": ["admin", "editor", "viewer"], "roleMode": "multiple", "backchannelLogoutUri": "https://my-app.example.com/oidc/backchannel-logout" } ] ``` | Field | Description | |----------------|----------------------------------------------------------------| | `clientId` | Unique client identifier | | `redirectUris` | Allowed callback URLs. Matched **exactly, character for character**: `https://app.example` does not match `https://app.example/`, nor does a different host case, an explicit default port (`:443`) or other percent-encoding. Copy the value the app actually sends. The same applies to `postLogoutRedirectUris`. | | `grantTypes` | Must include `authorization_code` | | `scopes` | Subset of `openid`, `email`, `profile`, `roles`, `offline_access`. Listing `roles` lets the client *request* the scope; it produces a claim only if the client also declares a `roles` array — see [Per-application roles](#per-application-roles). Schleuse warns at startup when the two disagree. | | `public` | `true` — no secret (SPAs/native). `false` — secret derived as `sha256(NUXT_JWT_PRIVATE_KEY + clientId + secretVersion?)` | | `roles` | Optional. Role names this app defines. Admins assign them per user; returned in the `roles` claim. Omit (or `[]`) to disable roles for the client. | | `roleMode` | Optional (default `multiple`). `single` restricts each user to at most one role for this client. | | `roleGroup` | Optional. Clients sharing a `roleGroup` **share** a user's role assignment — so one logical app split across several clients (e.g. a public mobile + a confidential backend) stays in sync. Absent = the group is the `clientId` (per-client isolation). See [App-managed roles](#app-managed-roles-server-to-server). | | `secretVersion`| Optional (confidential clients). A rotation salt folded into the derived secret — set or change it to rotate **only this client's** secret in isolation (no effect on other clients or the signing key). Absent = the legacy `sha256(key + clientId)`. See [Deployment → Secret rotation](./deployment#secret-rotation). | | `clientSecret` | Optional (confidential clients, ≥16 chars). An **explicit** secret used verbatim for both OIDC token auth and the [app API](#app-managed-roles-server-to-server). Set it to deploy multiple apps reproducibly from one env/compose file — no derived-secret copying. Takes precedence over the derived secret (`secretVersion` is then ignored). Masked in `GET /api/admin/config`; revealable via the admin **Integration** page. | | `backchannelLogoutUri` | Optional. Back-channel logout endpoint — receives a signed `logout_token` when the user signs out. See [Back-channel logout](#back-channel-logout). | | `backchannelLogoutSessionRequired` | Optional (default `false`). When `true`, **both** the `logout_token` **and this client's ID tokens** carry a matching `sid` — that pairing is what lets an RP end one session rather than all of them. Required for [session-scoped logout](#session-scoped-logout); without it there is no `sid` anywhere. | | `loginRequiresRole` | Optional (default `false`). When `true`, a user may only complete this app's authorization if they hold **any** role in the client's role-group. See [Role-gated sign-in](#role-gated-sign-in). | | `loginRequiredRoles` | Optional. The user must hold **at least one** of these specific roles (any-of). Stricter than `loginRequiresRole` and wins when both are set. | | `pkceExempt` | Optional (default `false`). Waives the PKCE requirement for this client. **Confidential clients only** — ignored for public ones. See [PKCE](#pkce-and-the-one-exemption). | | `metaGroup` | Optional. Metadata namespace shared with other clients declaring the same value. Absent = the namespace is the `clientId` (per-client isolation). See [Per-user app metadata](#per-user-app-metadata). | ### Role-gated sign-in By default any authenticated user can sign in to any client. Set `loginRequiresRole` (any role) and/or `loginRequiredRoles` (specific roles, any-of) to gate an app by role — e.g. only `staff` may reach an internal console: ```json { "clientId": "console", "roles": ["staff", "viewer"], "loginRequiredRoles": ["staff"] } ``` The gate is **per client**, not the IdP session: a user without a qualifying role is still signed in to Schleuse (they can manage their account), but the authorization for the gated app is refused — no code is issued and they see a "you don't have a role required to access this application" message. Assign the role (in the admin panel, or via the [app-managed roles API](#app-managed-roles-server-to-server)) and they can sign in. Roles are read from the client's `roleGroup`, so a shared group's roles gate every member client consistently. #### The gate roles must be declared `loginRequiredRoles` **selects** roles, it does not **declare** them — that is what `roles` is for. A gate only ever honours roles the client's role-group still declares, so both fields are needed (or a sibling client in the same `roleGroup` must declare them — note that a client which declares no `roles` of its own is gated fine but emits no `roles` claim, since a client is only ever told about roles it declares). A beta-access gate looks like this: ```json { "clientId": "beta-app", "roles": ["beta"], "loginRequiredRoles": ["beta"] } ``` Two consequences worth knowing: - **Removing a role from `roles` revokes access to a gated app**, not just the `roles` claim. Assignments in the database are never pruned when a declaration shrinks, but an assignment nobody declares any more grants nothing — the declaration is the single lever. Closing the beta is one edit: drop `beta` from `roles`. - **A gate naming an undeclared role can never open.** Schleuse logs this at startup — `error` when no declared role can satisfy the gate (nobody can sign in to that app), `warning` when only some of the named roles are undeclared: ``` ERROR: client "beta-app" requires role(s) beta, none of which role group "beta-app" declares — declare them on a member client, or nobody can sign in ``` ## Service clients (machine-to-machine) A backend, a cron job or a CI pipeline has no user. Give it a client that declares the `client_credentials` grant and it can obtain its own access token — one that your API verifies exactly like a user's. ```json { "clientId": "nightly-sync", "grantTypes": ["client_credentials"], "scopes": ["openid"], "public": false, "clientSecret": "…32+ random characters…", "allowedResources": ["https://api.example.com"] } ``` The admin panel writes this for you: **Configurator → + New service client** asks for the name, the API, the permissions and the lifetime, generates the secret, and fills in both this entry and the resource server below. See [Admin panel → Configurator](./admin#configurator-admin-configurator). No `redirectUris` — there is no browser in this flow. `public: false` is mandatory, and enforced: a public client authenticates with no secret at all, so the grant is **removed** from it at registration (a token request then answers `requested grant type is not allowed for this client`) and startup names the client in an error. The client id is not a secret — it travels in every authorization URL. **Tip: There is no account behind it** A service client creates **no user**. Nothing appears in the admin user list, `/oidc/userinfo` refuses the token, and no `roles` claim can ever reach it — roles are a statement about a person. A service is authorised by **scope**; see below. Revoking one means removing it from `NUXT_OIDC_CLIENTS_JSON` (or bumping its `secretVersion`) and restarting. ### Getting a token One request, HTTP Basic, no redirect: ```bash curl -u 'nightly-sync:' \ -d grant_type=client_credentials \ -d scope=reports:read \ -d resource=https://api.example.com \ https://idp.example.com/oidc/token ``` ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCI…", "expires_in": 600, "token_type": "Bearer", "scope": "reports:read" } ``` Decoded, that token carries no user: ```json { "sub": "nightly-sync", "client_id": "nightly-sync", "aud": "https://api.example.com", "scope": "reports:read", "iss": "https://idp.example.com/oidc", "exp": 1789765712 } ``` There is **no refresh token** and that is deliberate: the service holds its secret permanently and asks again when the ten minutes are up. Cache the token in memory until shortly before `exp` — a token per request writes a row per request. ### Authorising a service: scopes, not roles The scope names an authorization server accepts are fixed (`openid`, `email`, `profile`, `offline_access`, `roles`), so a client entry listing `"scopes": ["reports:read"]` is rejected. Permission names belong to the **API**, declared as a [resource server](#per-resource-scopes-ttl): ```bash NUXT_OIDC_RESOURCE_SERVERS_JSON='[{"audience":"https://api.example.com","scopes":["reports:read","reports:write"],"accessTokenTTL":300}]' ``` The service then requests `scope=reports:read&resource=https://api.example.com`, and your API authorises on the `scope` claim while identifying the caller by `client_id`. `allowedResources` on the client pins which APIs it may ask for at all. Give **each machine its own client**. Then `client_id` is a real identity, and you can revoke or re-scope one service without touching the others. ### Verifying a service token Identical to the [MCP recipe](./mcp#_5-verify-the-token) — signature against the JWKS, then `iss`, `aud`, `exp` and `scope`. One line tells the two apart: ```js const isService = payload.sub === payload.client_id ``` Without `resource=` the token stays opaque; your API would then have to call [introspection](#token-introspection-rfc-7662), which for a service token answers `active`, `client_id` and `scope` — and no `sub`. ## Authorization flow **1. Generate PKCE values** ```bash CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_') STATE=$(openssl rand -hex 8) ``` **2. Redirect the user to authorize** ``` GET /oidc/authorize ?client_id=my-app &redirect_uri=https://my-app.example.com/callback &response_type=code &scope=openid email profile &code_challenge= &code_challenge_method=S256 &state= ``` The user lands on the login page. After authentication, the provider redirects to `redirect_uri` with `?code=…&state=…`. That includes a [sign-in link](./security#magic-link-sign-in) requested from inside the flow — as long as it is opened in the same browser; opened elsewhere, it signs the user into Schleuse but cannot resume the app's authorization. There is no separate consent screen: every configured client is trusted, and picking the account is the consent. A request with `prompt=consent` is accepted and answered the same way — the user still sees the sign-in or account choice, then returns to the app. Pass **`ui_locales`** (a space-separated, priority-ordered list, e.g. `ui_locales=de`) on the authorize request to render the login/register pages in that language for the flow — honoured when it maps to an enabled UI locale, applied per-request (it doesn't change the user's stored preference). **3. Exchange code for tokens** ```bash curl -X POST https://auth.example.com/oidc/token \ -d grant_type=authorization_code \ -d client_id=my-app \ -d redirect_uri=https://my-app.example.com/callback \ -d code=AUTHORIZATION_CODE \ -d code_verifier=$CODE_VERIFIER ``` ```json { "access_token": "…", "id_token": "…", "refresh_token": "…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid email profile" } ``` **4. Fetch user info** ```bash curl -H "Authorization: Bearer " \ https://auth.example.com/oidc/userinfo ``` ```json { "sub": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "email_verified": true, "name": "Jane Doe", "roles": ["editor"] } ``` The `roles` claim only appears when the request includes the `roles` scope **and** the client defines roles. See [Per-application roles](#per-application-roles). **Tip: Notifications moved** The per-client `webhookUrl` was removed in 1.11. Subscribe to `account.deleted` — or to any of the other events — in [`NUXT_WEBHOOKS_JSON`](./webhooks), which signs its deliveries and retries them. ## Per-application roles A client can declare a fixed set of role names in its configuration. An admin then assigns a subset of those roles to each user, per client, from the [admin user-detail page](./admin#per-application-roles). The roles are returned to the relying party as a standardised `roles` claim. ```json { "clientId": "my-app", "roles": ["admin", "editor", "viewer"], "roleMode": "multiple" } ``` - **Source of truth is the env config.** A role removed from `roles` disappears from every user's claim immediately on restart, even if still stored — only roles the client currently defines are returned. - **`roleMode`** — `multiple` (default) allows any subset; `single` caps each user at one role for that client. - **Surface.** Roles are a scope-gated claim: the relying party must request the `roles` scope, and the claim is delivered from `/oidc/userinfo` (not the ID token). A client that defines no roles never receives a `roles` claim — **even when it requests the scope and the request succeeds**. Requesting `roles` without declaring any is a silent no-op, so Schleuse warns about it at startup: ``` WARN: client "my-app" requests the `roles` scope but declares no roles — the `roles` claim will never be emitted for it. Add a `roles` array to the client, or drop `roles` from its `scopes` ``` A `roleGroup` sibling declaring roles does **not** cover for it: each client's claim is built from its own declaration, so both members of a group need one. - **Env is the source of truth** — a role removed from a client's `roles` drops from the claim, and a `single`-mode client never receives more than **one** role in the claim (deterministically the first of its declared `roles`), even if two assignments linger from before the mode was tightened. The stored assignment is left untouched; the cap is applied when the claim is built. - The `roles` claim (like `email` / `profile`) is emitted only when the request's scope set contains that **exact** token. - Assignments are exported in the user's [GDPR data export](./security#data-export-gdpr). - **Shared across clients via `roleGroup`.** By default roles are per-client. Give two clients the same `roleGroup` and they share a user's assignment — a role set on one (admin UI or the app API) shows up in the other's `roles` claim. Roles are stored under the group key; each client's claim is still filtered to the roles *it* declares. This is how a public mobile client and its confidential backend (which writes roles via the app API) stay in sync. Admins assign roles **per group** on the user-detail page (a group's available roles = the union across its member clients; its mode is the strictest — `single` if any member is `single`). Keep a group's members consistent (same `roles` and `roleMode`) — and note that a `roleGroup` equal to another client's `clientId` merges them into one group. On startup, the server **warns** in its logs if a group's members declare divergent `roles`/`roleMode`, to catch config drift. - **Changing a `roleGroup`.** Assignments are stored under the *old* group key, so renaming a client's `roleGroup` orphans them. An admin can move them with `POST /api/admin/roles/rekey` (`{ from, to }`, conflict-safe) — see the [API reference](../reference/api#admin). To read a user's roles, request the `roles` scope and call `/oidc/userinfo`: ```bash # authorize with: &scope=openid roles curl -H "Authorization: Bearer " https://auth.example.com/oidc/userinfo # → { "sub": "…", "roles": ["editor"] } ``` ### App-managed roles (server-to-server) A **confidential** client can set its own app-roles for a user programmatically — no admin session needed. `PUT /api/apps/roles` authenticates with the client's `client_id` + `client_secret` via HTTP Basic: ```bash # Set roles (target by userId OR email) curl -u "$CLIENT_ID:$CLIENT_SECRET" -X PUT https://auth.example.com/api/apps/roles \ -H "Content-Type: application/json" \ -d '{"userId":"","roles":["editor"]}' # → { "clientId": "my-app", "roleGroup": "…", "userId": "…", "roles": ["editor"] } # Read the current roles back (same auth; ?userId= or ?email=) curl -u "$CLIENT_ID:$CLIENT_SECRET" "https://auth.example.com/api/apps/roles?email=user@example.com" # → { "clientId": "my-app", "roleGroup": "…", "userId": "…", "roles": ["editor"] } ``` - **Target selector**: supply **exactly one** of `userId` (the OIDC **`sub`** the app received at login) or `email` — both or neither is a `400`. The response always echoes the resolved `userId`. - The write is scoped to the authenticated client's **role group** (its `roleGroup`, or its `clientId`) — the `clientId` comes from the credentials, **never** the body, so an app can't touch another group. - It replaces only the roles **this client declares**; roles managed by *other* clients in the same group are preserved (no cross-client clobber). `roles` must be a subset of the client's env-defined `roles` (and ≤1 for a `roleMode:"single"` client). Only the role store is written — the IdP admin flag (`is_admin`) is unrelated and unreachable here. - **`GET /api/apps/roles`** returns the user's current roles **for this client** — the group assignment intersected with the roles this client declares, so a client never sees roles it doesn't define. - Auth failures return `401 invalid_client`; an unknown user → `404`; an invalid role → `400`. Every write is audited (`oidc.client.roles_updated`); reads aren't. - The email selector lets a (trusted, operator-configured) client learn whether an address has an account (`404` vs. found) — expected for a credentialed API. - Set `NUXT_APP_ROLES_REQUIRE_GRANT=true` to gate **reads and writes** on the user having an OIDC grant with the calling client — a user with no grant then returns `404` (uniform with an unknown user), so a grant-less account can be neither enumerated nor role-disclosed. Off by default. - Write changes are reflected in the `roles` claim on the user's next `/oidc/userinfo`. Get the `client_secret` from the admin **Integration** page ("Reveal secret") or compute it (see [Deployment → Per-client secret rotation](./deployment#per-client-secret-rotation)). ## Per-user app metadata A small key→value store per user, so an app deployed next to Schleuse can remember one or two things — an entitlement, an external customer id, a preference — without running a database of its own. Off by default; set `NUXT_APP_METADATA_ENABLED=true`. Same credential as the roles API: a **confidential** client's `client_id` + `client_secret` over HTTP Basic. There is no separate API key, and no path a user's browser can reach. ```bash # Write: a string upserts the key, null deletes it, unmentioned keys are untouched curl -u "$CLIENT_ID:$CLIENT_SECRET" -X PATCH https://auth.example.com/api/apps/metadata \ -H "Content-Type: application/json" \ -d '{"userId":"","data":{"plan":"pro","trial_ends":null}}' # → { "clientId": "my-app", "namespace": "my-app", "userId": "…", "data": { "plan": "pro" } } # Read (same auth; ?userId= or ?email=) curl -u "$CLIENT_ID:$CLIENT_SECRET" "https://auth.example.com/api/apps/metadata?email=user@example.com" ``` - **Namespace** — `metaGroup ?? clientId`, taken from the authenticated credentials and never from the request, so a client only ever reaches its own data. Exactly one of `userId` / `email`, as with roles. - **Values are opaque text.** Schleuse never parses or interprets them; an app that wants structure puts JSON in the value and owns its own schema. `""` stores an empty value — only `null` deletes a key. - **Caps** (per user, per namespace): 50 keys, 4096 bytes per value, 25 keys per request, and keys matching `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`. Over a cap ⇒ `400` naming which one. This is a key→value store, not a database. - Writes are logged but deliberately record **no audit row** — metadata can be written on every request, and one row per write would turn the audit log into an app's event stream. Admin edits *are* audited. - `NUXT_APP_METADATA_REQUIRE_GRANT=true` restricts both verbs to users who already have an OIDC grant with the calling client (else `404`, uniform with an unknown user). ### Sharing a namespace with `metaGroup` Clients declaring the same `metaGroup` share one namespace — the deliberate way to express a store several apps read: ```json [ { "clientId": "shop-web", "metaGroup": "shop", "public": false }, { "clientId": "shop-api", "metaGroup": "shop", "public": false } ] ``` Unlike `roleGroup`, there is **no clobber protection**. Roles get per-client sub-scoping for free because each client declares its own role names; metadata has no such declaration, so group members share one flat key space and **can overwrite each other's keys**. Sharing a `metaGroup` means sharing write access — do not share one unless you mean exactly that. ### What the user can and cannot do The store is **tamper-proof by the user, not secret from them**. No browser path writes it, so an app can store a flag and know the user cannot forge it. But the values appear in the user's own GDPR export (`Account → Download my data`), and an operator can correct them from the admin panel — so the owning app is not the sole writer. Metadata is never emitted as an OIDC claim, by design: an ID token travels the front channel and the user can decode it. ## PKCE, and the one exemption Every authorization request must carry a `code_challenge` (S256; `plain` is rejected), for public and confidential clients alike. That is stricter than most IdPs, which leave PKCE optional for confidential clients — and it is the posture OAuth 2.1 §7.5.2 recommends, because the authorization server then recognises an authorization-code injection *before* issuing anything, instead of relying on the client to notice afterwards. The same section permits waiving it when **both** hold: the client is confidential, **and** the server has "reasonable assurance" that the client implements the OpenID Connect `nonce` mechanism properly. That assurance has no protocol signal. A `nonce` in the request only proves the client *sent* one, not that it *validates* the ID token's — the spec says as much in the following paragraph. So Schleuse does not try to detect it. `pkceExempt` records **your** decision about a specific client: ```json { "clientId": "some-gateway", "public": false, "clientSecret": "…", "pkceExempt": true } ``` **Warning: What you are accepting** The flag is honoured for confidential clients only — a public client is never exempt, whatever the config says, since the specification allows no exemption there and public clients are the case PKCE exists for. Nothing checks whether the exempt client sends a `nonce`. If it sends neither `nonce` nor `code_challenge`, that client has **no** protection against authorization-code injection on its redirect. Schleuse logs a warning naming the client on every start, so the exemption stays visible. Use it when a third-party component cannot send PKCE upstream and you have weighed the alternative. Remove it as soon as the component can. ## Pushed authorization requests (RFC 9126) Available since 1.0 and **optional** — the endpoint has always been in the discovery document; this section is the documentation catching up. Normally a client puts every parameter in the URL it sends the browser to: ``` GET /oidc/auth?client_id=app&redirect_uri=https://app.example/cb &scope=openid%20email&state=xyz&code_challenge=…&nonce=… ``` With PAR the client posts them to the authorization server first, over a back-channel and authenticated as itself, and gets back a short-lived handle: ```bash curl -u "$CLIENT_ID:$CLIENT_SECRET" \ -d "response_type=code&client_id=$CLIENT_ID" \ -d "redirect_uri=https://app.example/cb&scope=openid email" \ -d "code_challenge=$CHALLENGE&code_challenge_method=S256" \ https://id.example.com/oidc/request # → { "request_uri": "urn:ietf:params:oauth:request_uri:6esc_…", "expires_in": 60 } ``` Then the browser only ever sees two parameters: ``` GET /oidc/auth?client_id=app&request_uri=urn:ietf:params:oauth:request_uri:6esc_… ``` Two things you get for it. The request parameters never traverse the browser, so they stay out of history, `Referer` headers and any proxy log along the way. And they are **authenticated at the push**, so nothing between the client and the authorization server can alter the `scope` or the `redirect_uri` in flight — a plain authorization request is unsigned and offers no such guarantee. **Nothing requires you to use it.** `require_pushed_authorization_requests` is not advertised, so a client that ignores `pushed_authorization_request_endpoint` and keeps sending parameters in the URL behaves exactly as before. PKCE is still mandatory either way — push the `code_challenge` with the rest. ## Token introspection (RFC 7662) Resource servers that want live token state without parsing JWTs or fetching JWKS locally: ```bash curl -X POST https://auth.example.com/oidc/introspect \ -d client_id=my-app \ -d token= ``` - Confidential clients: authenticate via `-u client_id:secret` (Basic auth); secret is derived as described above. - Public clients: send `client_id` in the body and can only introspect their own tokens. Active token → `{ "active": true, "sub", "client_id", "scope", "exp", "iat", "iss", "token_type" }`. Revoked or expired → `{ "active": false }` (no further details, per RFC 7662). ## Token revocation (RFC 7009) ```bash curl -X POST https://auth.example.com/oidc/revoke \ -d client_id=my-app \ -d token= \ -d token_type_hint=access_token ``` Returns 200 on success (and on already-revoked — the spec requires idempotent 200). ## JWT access tokens By default every access token is **opaque** — a resource server validates it by calling [`/oidc/introspect`](#token-introspection-rfc-7662) or [`/oidc/userinfo`](#authorization-flow). Set `NUXT_OIDC_RESOURCE_AUDIENCE` to one or more comma-separated absolute-URI audiences to additionally offer **JWT** access tokens (RFC 9068) via Resource Indicators (RFC 8707): ```bash NUXT_OIDC_RESOURCE_AUDIENCE=https://api.example.com ``` A client then **opts in per request** by adding a registered `resource` value to the authorization and token requests: ``` GET /oidc/authorize?...&resource=https://api.example.com POST /oidc/token ...&resource=https://api.example.com ``` The returned access token is a JWT signed `RS256` with the same key published at `/.well-known/jwks.json`, with `iss` = your issuer and `aud` = the requested resource. A resource server verifies it locally (signature + `iss` + `aud` + `exp`) — no round-trip to the IdP. - **Opt-in only / nothing changes for existing clients.** Without a `resource` parameter, tokens stay opaque and continue to work at `/oidc/userinfo` and introspection exactly as before. A `resource` value that isn't configured — or that the requesting client hasn't listed in its `allowedResources` — is rejected with `invalid_target`. - **Not revocable before expiry.** A JWT access token is self-contained and **not stored server-side**, so it cannot be revoked or introspected — it is valid until `exp`. Keep the access-token TTL short (default 1 hour) and use opaque tokens where you need revocation-aware checks. (Refresh tokens are still server-side and rotate/​revoke normally.) ### Per-resource scopes & TTL `NUXT_OIDC_RESOURCE_AUDIENCE` gives every audience the same scope set (`openid email profile`) and the global access-token TTL. For finer control, list resources in `NUXT_OIDC_RESOURCE_SERVERS_JSON` — each entry can set its own `scopes` and a shorter `accessTokenTTL` (seconds): ```json [{ "audience": "https://api.example.com", "scopes": ["openid", "email"], "accessTokenTTL": 300 }] ``` `scopes` takes **either** an array or the space-delimited string OAuth uses on the wire — `"openid email"` and `["openid", "email"]` mean the same thing. The string is what the protocol carries and what older configurations contain; the array matches how `NUXT_OIDC_CLIENTS_JSON` spells a client's scopes, and is what the admin Configurator writes. The token's `scope` is still intersected with what the user granted (never broader), and its lifetime is bounded to the resource's TTL. The two variables merge: an audience present in both is governed by the JSON entry. ### Per-client resource allowlists Audiences are **deny-by-default**: a client may only request resources its `allowedResources` array (in `NUXT_OIDC_CLIENTS_JSON`) names — any other `resource` value, and any `resource` from a client without the field, returns `invalid_target`. Every client that should receive JWT access tokens must opt in explicitly; an audience no client lists is reported as an `error` at startup, since no request for it can succeed. ```json { "clientId": "reports-app", "…": "…", "allowedResources": ["https://api.example.com"] } ``` > **Upgrading from ≤ 1.11:** previously a client *without* `allowedResources` > could request **any** configured resource. That default let any client mint > JWT access tokens for any other app's API, so it is gone. Add > `allowedResources` to each client that uses JWT access tokens; nothing else > changes. ### Gating an audience by login role (`gateClientId`) A resource entry may name the OIDC client whose [login role gate](#role-gated-sign-in) protects it: ```json [{ "audience": "https://api.b.example", "gateClientId": "app-b" }] ``` When set, a user whom `app-b`'s `loginRequiresRole` / `loginRequiredRoles` gate would refuse gets `error=access_denied` instead of a token with that `aud` — even when the authorization runs through a *different* client, and also on authorizations that skip the login page entirely because an IdP session is already live. Without `gateClientId`, holding the audience in a client's allowlist is the only requirement (today's behaviour for every existing entry). Notes: the gate applies to **user** flows only — `client_credentials` tokens carry no account and are not gated. A `gateClientId` naming an unknown client is an `error` at startup; naming a client with no gate configured is a `warn` (the gate would be a no-op). ## Back-channel logout [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0-final.html) lets relying parties drop their **server-side** sessions when a user signs out — no reliance on browser state. Give a client a `backchannelLogoutUri`: ```json { "clientId": "my-app", "...": "...", "backchannelLogoutUri": "https://my-app.example.com/oidc/backchannel-logout" } ``` When the user signs out of Schleuse (`POST /api/auth/logout`), the IdP POSTs a signed `logout_token` (a JWT) to that URI for every app the user had an OIDC session with, then ends those sessions. The RP verifies the token against the JWKS and invalidates the matching session. The `logout_token` carries `iss` (issuer), `aud` (your `clientId`), `sub` (the user), a `jti`, and the event claim `{"http://schemas.openid.net/event/backchannel-logout": {}}` — plus `sid` when `backchannelLogoutSessionRequired` is `true`. RP requirements are in the spec (verify `iss`/`aud`/signature, reject tokens with a `nonce`). - **Best-effort, with retry.** Delivery is attempted inline on logout (with a short timeout) and never blocks the user's logout. A transient RP failure is retried in the background per `NUXT_BACKCHANNEL_LOGOUT_RETRY_DELAYS_MS` (default two retries at +2s/+15s); the final give-up is logged. Retries are in-memory and lost on restart. - **Discovery** advertises `backchannel_logout_supported: true` and `backchannel_logout_session_supported: true`. The second means the provider *can* pass a `sid` — see below for the per-client switch that makes it do so. - Browser-redirect **front-channel** logout is intentionally not offered (removed from the OIDC library; broken by third-party-cookie blocking). ### Session-scoped logout By default a `logout_token` identifies only the **user** (`sub`), so an RP that receives one can end every session it holds for that person, but cannot tell *which* session ended at the IdP. To end one session, the RP needs a `sid` in the logout token **and** the same `sid` in the ID token it received at login — that pair is the correlation. Set `backchannelLogoutSessionRequired` alongside the URI to get both: ```json { "clientId": "my-app", "backchannelLogoutUri": "https://my-app.example.com/oidc/backchannel-logout", "backchannelLogoutSessionRequired": true } ``` | Client config | `sid` in the ID token | `sid` in the logout token | |---|---|---| | neither field | no | no | | `backchannelLogoutUri` only | no | no | | both | **yes** | **yes** | Both fields are required: the flag alone, without an endpoint, produces no `sid` anywhere. If your ID tokens have no `sid` while discovery says sessions are supported, this flag is why — the metadata describes what the provider *can* do, and this switch turns it on per client. The admin [Integration page](./admin#integration-guide-admin-integration) shows the state for each of your clients. ## RP-initiated logout An RP can end the user's session by redirecting the browser to the `end_session_endpoint` (`/oidc/session/end`): ``` GET /oidc/session/end ?id_token_hint= &post_logout_redirect_uri= &client_id= ``` - The `id_token_hint` identifies the session and lets the IdP validate the redirect target — **always send it**: the logout is click-free only when the request carries a valid `id_token_hint` whose `sub` matches the signed-in session. Without one (or with a token for a different account), the user sees a one-click **confirmation page** instead — otherwise any website could force-logout a signed-in user with a bare top-level navigation. - `post_logout_redirect_uri` must be listed in the client's **`postLogoutRedirectUris`** — an unregistered value is rejected (no open redirect). Omit it to land on a built-in "signed out" page instead. - On **confirmed** logout the IdP ends the OP session, fires **back-channel logout** to every other RP the user had a session with, and clears the Schleuse first-party session cookie — so the app session can't silently re-establish a new OP session on the next `/authorize`. A bare GET of the endpoint changes nothing; "stay signed in" keeps both sessions. ```json { "clientId": "my-app", "…": "…", "postLogoutRedirectUris": ["https://my-app.example.com/", "https://my-app.example.com/logged-out"] } ``` ## Token TTLs | Token | Lifetime | |----------------------|------------------------------------| | Access token | 1 hour | | ID token | 1 hour | | Refresh token | 7 days (rotated on each use) | | Authorization code | 10 minutes | | Session (internal) | 24 hours | | Grant | 14 days | ## Interactive testing ```bash pnpm test:oidc # runs the full authorize → token → userinfo flow (opaque token) pnpm test:oidc:setup # prints the client config to add to your .env # Request a JWT access token in the browser flow (audience must be configured): node scripts/test-oidc.mjs --resource https://api.example.com # Non-interactive JWT self-test: registers a throwaway user, runs the whole # flow, and verifies the JWT against the JWKS. Pass the configured audience. pnpm test:jwt-at https://api.example.com ``` `test:jwt-at` needs registration enabled and (if SMTP is configured) Mailcatcher reachable for the verification email; without SMTP the account auto-verifies. Override `APP_URL`, `CLIENT_ID`, `REDIRECT_URI`, `MAIL_URL` via env to point at a non-default deployment. --- # MCP servers behind Schleuse Model Context Protocol servers let an AI client — Claude, ChatGPT — call your tools. Anyone who can reach the endpoint can call them, so it needs authentication, and Schleuse can be the authorization server for it. **This needs no new Schleuse feature.** Two environment variables and about twenty lines in your MCP server. This page is the whole recipe. **Tip: What you get** Your MCP server receives a signed token naming the user, bound to *your* server specifically, verifiable offline against Schleuse's public keys. No shared secrets, no callback to the IdP per request. ## 1. Register the AI client Claude lets you enter a client id when adding a custom connector, so no dynamic registration is involved. Add it to `NUXT_OIDC_CLIENTS_JSON`: ```json { "clientId": "claude-mcp", "redirectUris": ["https://claude.ai/api/mcp/auth_callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "email", "profile", "offline_access"], "public": true } ``` `public: true` because the client holds no secret — PKCE protects the exchange, and Schleuse requires it (S256) for every client. `offline_access` in the scope list matters: without a refresh token the user re-authorises whenever the access token expires. ## 2. Declare your MCP server as a resource This is the step that is easy to miss, and nothing works without it: ```bash NUXT_OIDC_RESOURCE_SERVERS_JSON='[{ "audience": "https://mcp.example.com/mcp", "scopes": "openid email profile", "accessTokenTTL": 86400 }]' ``` The `audience` must be **byte-identical** to the URL the user types into Claude. A conformant MCP client sends `resource=` on both the authorization and the token request; an audience Schleuse does not know is rejected with `invalid_target`, and the connection fails before your server sees anything. The connecting OIDC client must also list that URL in its `allowedResources` (audiences are deny-by-default — a client without the field may request no resource at all): ```json { "clientId": "claude", "…": "…", "allowedResources": ["https://mcp.example.com/mcp"] } ``` What this buys you: the access token becomes a **JWT** with `aud` set to your server, instead of an opaque string you would have to introspect on every call. `accessTokenTTL` is worth setting. The one-hour default is short for MCP — some clients fall back to a full re-authorisation instead of refreshing. A day is reasonable; the security anchors are refresh rotation and revocation, not a short access-token life. ## 3. Return `401` with a pointer This is the bootstrap of the entire flow. Without this header no client ever attempts discovery, and the connection fails with nothing in your log: ```http HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" ``` ## 4. Serve the resource metadata At the URL you just named: ```json { "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/oidc"], "scopes_supported": ["openid", "email", "profile"], "bearer_methods_supported": ["header"] } ``` `resource` must match your MCP URL exactly, and `authorization_servers` is your `NUXT_ISSUER`. From here the client finds Schleuse on its own. ## 5. Verify the token The whole server side, using [`jose`](https://github.com/panva/jose): ```js import { createRemoteJWKSet, jwtVerify } from 'jose' const ISSUER = 'https://auth.example.com/oidc' const RESOURCE = 'https://mcp.example.com/mcp' const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json')) /** Returns the caller, or throws. */ export async function authenticate(authorizationHeader) { const token = /^Bearer (.+)$/.exec(authorizationHeader ?? '')?.[1] if (!token) throw new Error('no bearer token') const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: RESOURCE, }) return { userId: payload.sub, clientId: payload.client_id, scope: payload.scope } } ``` `createRemoteJWKSet` caches the keys and refetches on rotation, so this makes no network call per request. **Danger: Do not omit `audience`** Without it your server accepts **any** token Schleuse issued — including one minted for a different application entirely. That is the difference between "authenticated" and "authenticated *for me*", and the MCP specification makes the check mandatory. Everything else on this page is convenience; this line is the security boundary. ### A caller with no user The same check accepts a **service token** — an MCP server or API is often called by a backend rather than a person. Such a token is minted with the `client_credentials` grant and carries no account: its `sub` *is* the `client_id`, and `/oidc/userinfo` refuses it. So key your authorisation off `payload.scope` and `payload.client_id`, and only treat `payload.sub` as a user when it differs from `client_id`: ```js const isService = payload.sub === payload.client_id ``` See [Service clients](./oidc#service-clients-machine-to-machine). ## What Schleuse does not do - **No proxying.** Requests go straight to your server; Schleuse only issues the token. - **No tool filtering.** Which tools a caller may use is your server's decision. - **No role enforcement.** Roles are available — call [`GET /api/apps/roles`](/reference/api#app-api-server-to-server) with your client credentials to look up what `payload.sub` holds — but nothing is applied for you. ## Requirements and limits - **HTTPS.** Both Schleuse and your MCP server. Redirect URIs must be HTTPS or loopback. - **Reachability.** Anthropic's discovery traffic comes from `160.79.104.0/21`. A WAF in front of the IdP breaks the flow even when the MCP server itself is reachable. - **Latency.** Claude allows about ten seconds for discovery and token requests. A cold-starting container can exceed that; `/api/ready` makes a good health check — it stays `503` until the startup migrations are done, so a gate built on it never lets Claude in too early. ## Discovery paths Schleuse's issuer carries a path (`/oidc`), so RFC 8414 puts that component after the well-known segment. All of these return the same document: ``` /.well-known/oauth-authorization-server/oidc /.well-known/openid-configuration/oidc /.well-known/openid-configuration /oidc/.well-known/openid-configuration ``` A client that probes in the specified order finds Schleuse on its first attempt. `/.well-known/oauth-authorization-server` **without** the suffix deliberately returns `404` — it describes a root issuer, which Schleuse never has. ## Gateways that cannot send PKCE Some MCP gateways act as an OIDC client toward the IdP and do not send a `code_challenge` on that leg — `mcp-auth-proxy` is one. Schleuse enforces PKCE for confidential clients too, so such a gateway is rejected with `Authorization Server policy requires PKCE to be used for this request`. Set `pkceExempt: true` on that client, having read [what the exemption costs](./oidc#pkce-and-the-one-exemption): if the gateway sends no `nonce` either, it has no protection against authorization-code injection on its redirect. Schleuse warns about the exemption on every start. ## When you outgrow this The recipe repeats the twenty lines per MCP server. At three or four, or when an upstream cannot host the check itself, a small gateway in front of them — one token check, identity passed back as signed headers — is the better shape. It needs nothing from Schleuse beyond what is on this page. Clients that cannot be pre-registered (Claude Code identifies itself with a metadata document rather than a client id) would need dynamic registration or CIMD, neither of which Schleuse offers today. --- # Admin panel Admin surface is mounted at `/admin`. Access is gated by the `is_admin` column on the `users` table; there's no separate admin app or additional role system. ## First-admin bootstrap Two ways, depending on whether a human is there to click. **From the environment.** Name an address and the service creates that admin at startup, once: ```yaml NUXT_BOOTSTRAP_ADMIN_EMAIL: admin@example.com ``` Leave the password out and one is generated and written to the log **once**, on the start that creates the account: ```bash docker compose logs app | grep oneTimePassword ``` Sign in with it, change it, then drop the variable. Set `NUXT_BOOTSTRAP_ADMIN_PASSWORD` instead if you ship logs off-host — a supplied password is never logged, and it must satisfy your password policy or the service refuses to start. This is **create-only**. If the address already has an account, nothing happens: no password reset, no promotion, no profile edit. Changing `NUXT_BOOTSTRAP_ADMIN_PASSWORD` later therefore has no effect — an env var that reset a password on every boot would be a backdoor, not a bootstrap. To change it, sign in and use the account page. **By self-registration.** The first registered user is auto-promoted when `NUXT_AUTO_ADMIN_FIRST_USER=true` (the default). Note that a bootstrapped admin already occupies that slot, so the next person to register is an ordinary user. **From the console.** `docker compose exec app schleuse user create admin@example.com --admin` creates an admin and prints its password. It also recovers a locked-out one: `user set-password` and `user reset-2fa`. The same user actions as the panel, audited the same way. See [Console (CLI)](cli). **Tip: Closed instances** With `NUXT_REGISTRATION_ENABLED=false` the bootstrap variables are the only route to a first admin — the auto-promotion lives in the signup handlers, and the [provisioning API](/reference/api#provisioning-api-server-to-server) deliberately cannot create administrators. ## Features The sidebar header shows the running version (`Administration · v0.3.3`) next to the app name, and the same value appears as a muted footer line on every auth page and in `GET /api/ready`. That's the authoritative source when you need to answer "is my upgrade live?" without leaving the browser. ### Dashboard — `/admin` Counts at a glance: total users, verified share, recent signups (last 7 days), number of admins. An **Account security** panel below the counts offers **Sign out all users** — a confirmed action that invalidates login sessions in bulk (see [Admin-only API](#admin-only-api)). A **Scope** picker chooses **All users** or **Non-admins only** (leaves admin accounts working — useful mid-incident). For the *All users* scope, the acting admin's current device stays signed in by default; tick **Sign me out too** to drop the acting session as well. ### Users — `/admin/users` - Paginated list (up to 100 per page) with search by email / name - Filters: `Verified / Unverified` and `Admins / Users / All roles` - Click a row to open the user detail view - **Add user** — provision an account from the panel, with a choice of password mode: - *Set a password now* — the account is usable immediately and marked email-verified (the admin vouches; share the credentials out-of-band). - *Send an invite* — no password is set; the user receives a "set your password" link (emailed when SMTP is configured, and always shown for the admin to copy). On first use the link sets their password, verifies the email, and signs them in. The link is single-use — accepting it is the only thing that consumes it, so ending the user's sessions, suspending them or resetting their 2FA leaves it working — and it expires after 7 days. A suspended account cannot be activated with one. Logged as `admin.user.created`. **User detail:** - Edit first name, last name, display name - Toggle `Email verified` — useful when a user can't receive the verify email - Toggle `Admin` — but admins can't un-admin themselves from the panel - **Language** — set the user's language (`users.locale`), which decides their transactional emails and their UI once signed in. Shown when more than one language is enabled in `NUXT_SUPPORTED_LOCALES`; *Automatic* clears it, so cookie, `Accept-Language` and `NUXT_DEFAULT_LOCALE` decide again. With a single language the stored value is shown read-only. Logged as part of `admin.user.updated`. - Assign **per-application roles** (see below) for any OIDC client that defines them - Export data — downloads a GDPR data-portability JSON for the user (same shape as the user's own export; excludes password hashes and OIDC token values). Logged as `admin.user.exported`. - **Reset 2FA** — clear a locked-out user's second factors so they can re-enrol: removes TOTP always, and passkeys only when the user has a password (a passwordless user's passkey is their login, so it's preserved). Ends the user's sessions. Logged as `admin.user.two_factor_reset`. - **Suspend / Unsuspend** — a reversible, non-destructive block. Suspending ends the user's sessions, blocks new logins (password + passkey), and revokes their OIDC tokens; the account's data is kept and a **Suspended** badge shows on the detail page. Unsuspend lets them log in again (re-authenticating fresh). You can't suspend your own account. Logged as `admin.user.suspended` / `admin.user.unsuspended`. - **Force logout** — invalidate just this user's login sessions (bumps their `token_version`) without suspending or resetting anything. Forcing yourself out keeps your current device signed in (other devices drop). Logged as `admin.user.force_logout`. - Delete — revokes OIDC tokens and fires the client webhook #### Per-application roles If an OIDC client declares a `roles` array in `NUXT_OIDC_CLIENTS_JSON`, the user detail page shows a **Roles** section — one block per **role group** (clients collapsed by `roleGroup ?? clientId`; the member clients are listed next to the group name), with the group's roles as checkboxes (or radio buttons for a `single`-mode group). Tick the roles to grant and press **Save**; each group is saved independently. The assignment is returned to every client in the group as a `roles` claim — see [OIDC → Per-application roles](./oidc#per-application-roles). Clients that don't define roles don't appear here. Each save is logged as `admin.user.roles_updated`. ### Audit logs — `/admin/audit-logs` All security events are persisted to the `audit_log` table and surfaced here. Every row records **who acted**, and — for an action taken on someone else's account — **who it was about**, so a single user's full history is one filter away. The [user detail page](#user-management) shows the most recent entries for that account and links here pre-filtered; clearing the filter returns the full log. An admin suspending a user appears in *both* their trails. That is not duplication: one performed the action, the other was subject to it. **Warning: Rows from before the upgrade** `target_user_id` was added in a later release and is not backfilled — the subject of older admin actions lived only in free-text `reason`, in three different formats. On an instance that predates it, a subject's trail can look emptier than their real history, and the user detail page says so rather than showing an unqualified empty list. | Event | When | |--------------------------------|-------------------------------------------------| | `register` | New account created | | `login.success` / `login.failed` | Password or passkey login | | `logout` | User ends their session | | `email.verified` | Verification link clicked | | `password.changed` | From `/account` | | `password.reset_requested` / `password.reset_completed` | Forgot-password flow | | `password.reuse_blocked` | A change/reset was rejected for reusing a recent password (when history is enabled) | | `account.deleted` | User deletes own account | | `webauthn.login` / `webauthn.registered` | Passkey usage | | `magic_link.requested` / `magic_link.login` | Sign-in link emailed / used to sign in | | `admin.user.created` | Admin created a user (set-password or invite) | | `admin.user.updated` / `admin.user.deleted` | Admin actions | | `admin.user.roles_updated` | Admin changed a user's per-app roles | | `admin.force_logout_all` | Admin invalidated every login session | | `admin.client.secret_revealed` | Admin revealed a confidential client's secret | | `oidc.client.roles_updated` | An app set a user's roles via the app API | | `admin.audit.anonymized` | Admin ran the orphaned-row anonymisation sweep | | `admin.roles.rekeyed` | Admin moved role assignments between role-group keys | | `admin.user.two_factor_reset` | Admin reset a user's second factors (TOTP/passkeys) | | `admin.user.suspended` | Admin suspended an account (blocked login) | | `admin.user.unsuspended` | Admin lifted a suspension | | `admin.user.force_logout` | Admin invalidated one user's sessions | Each row shows the time, event, status, email, IP, reason and the **user agent** (browser/device) captured for the request. Filters: event type, date range, pagination. An **Anonymize orphaned rows** action strips PII from audit rows whose user no longer exists (leftovers from accounts deleted before deletion-time anonymisation existed) — idempotent, audited as `admin.audit.anonymized`. It reaches rows whose *subject* is gone as well as those whose *actor* is: an admin action on a since-deleted user keeps a live admin's own details and loses only the `reason`, which is the column that named the deleted person. See [Security → erasure](./security#self-service-account-deletion-erasure). ### Email & SMTP — `/admin/email` {#email-smtp} Everything you need to answer "is mail actually working?" without shelling into the container. - **Connection** — the running SMTP configuration (host, port, implicit TLS, from address, whether authentication is configured — never the password) plus a **Test connection** button that runs a live probe. On failure it shows the SMTP server's own error: the code (`ECONNREFUSED`, `EAUTH`, `ESOCKET`…), the command that failed, and the raw response line. **Transport security** reports what the connection actually negotiated — *Encrypted (implicit TLS)*, *Encrypted (STARTTLS)*, *…certificate not verified*, or *Not encrypted* — measured on a real connection rather than read back from your config, alongside the configured **TLS policy**. An unencrypted hop is called out in red, and more strongly still when SMTP credentials are travelling with it. See [Configuration → Transport security](configuration#transport-security). - **Template preview** — renders any of the five outbound emails (verification, invite, password reset, 2FA login code, new-sign-in alert) with sample data, in any configured language. It goes through the same code path a real send uses, so your `NUXT_EMAIL_*` overrides and their `_` variants appear exactly as recipients would see them — each part is badged *built-in*, *overridden* or *locale override*. The HTML renders in a sandboxed frame (scripts disabled), and the preview needs no SMTP at all. - **Send test email** — delivers the selected template to one address: one attempt, no retry, with the SMTP error surfaced verbatim if it fails. Pre-filled with your own address. Limited to 10 sends per 10 minutes per admin and logged as `admin.email.test_sent`. The sample payload is deliberately inert: links carry the placeholder token `EXAMPLE-TOKEN-NOT-VALID` and the 2FA code is a fixed `123456`, so a test email is never a usable credential — safe to send to a shared inbox or paste into a ticket. Test sends are excluded from the `schleuse_emails_total` metric so they can't distort your delivery dashboards. With `NUXT_SMTP_HOST` unset the page says so and disables the send form; the preview keeps working. Nothing on this page writes configuration — SMTP settings and templates are environment variables ([configuration](./configuration), [email templates](./email-templates)) and change with your deployment config. ### Integration guide — `/admin/integration` A copy-paste reference for downstream OIDC clients: authorize URL shape, token exchange curl, PKCE primer, scope catalogue. Read-only — mirrors [OIDC provider](./oidc) with your configured issuer URL baked in. Each **confidential** client also has a **Reveal secret** control that shows its derived `client_secret` on demand (hidden by default) so you can hand it to the app — instead of computing the `sha256` by hand. Revealing is audited as `admin.client.secret_revealed`. A **service client** — one whose only grant is `client_credentials` — gets a different card: the authorize URL, the PKCE primer and the code exchange are replaced by the single token request it actually needs, with its configured `resource=` filled in. Those three would otherwise be nonsense for a client with no browser flow, complete with a `redirect_uri` it never registered. See [OIDC → Service clients](./oidc#service-clients-machine-to-machine). A client with a back-channel logout endpoint shows whether its logout is **session-scoped** or covers **all sessions** — that is, whether its ID and logout tokens carry a matching `sid`. It is off unless the client sets `backchannelLogoutSessionRequired`, and the card names the flag, so "my app gets no `sid`" is answerable by looking at the client rather than reading the spec. See [OIDC → Session-scoped logout](./oidc#session-scoped-logout). ### Configurator — `/admin/configurator` Renders the running instance's env-var mapping as a drop-in export for another deployment target. Pick `docker-compose.yml`, `.env`, or `shell export` at the top; the preview updates live. Every recognised `NUXT_*` / `NUXT_PUBLIC_*` variable appears with its current value pre-filled (edit inline — changes are client-side only, never written back). Secrets (`NUXT_JWT_SECRET`, `NUXT_JWT_PRIVATE_KEY`, `NUXT_SMTP_PASS`) are deliberately **not** shown — they appear in the export as commented placeholders that the operator must fill in on the target host. Use it to bootstrap a second instance, duplicate prod → staging, or check which defaults you've drifted away from. The page speaks the admin's language throughout: the variable descriptions and the category headings are translated into all four bundled locales, and the exported `.env` carries its comments in that same language. Variable names, values and the `# ── category ──` separators stay as they are — they are configuration, not prose. A variable whose translation is missing falls back to the English text from the catalogue, so nothing can render blank. Strictly a generator. There is no "apply" button and no server-side POST; the page cannot change the running instance. See [Configuration](./configuration) for the env-only configuration principle. `NUXT_OIDC_CLIENTS_JSON` gets a **structured editor** under its raw-JSON field: one collapsible card per client with typed inputs for `clientId`, `redirectUris` (add / remove rows), `grantTypes` / `scopes` (checkbox groups with a free-text fallback for values outside the app's known set — `client_credentials` is offered here, and a client that declares only that is a [service client](./oidc#service-clients-machine-to-machine): remove every redirect row, it has no browser flow), `public`, `roles` (add / remove role-name rows, with a `roleMode` selector — `multiple` / `single` — shown once a role exists), the [role-gated sign-in](./oidc#role-gated-sign-in) controls (`loginRequiresRole` checkbox + `loginRequiredRoles` list — these *select* roles rather than declaring them, so a name typed there must also exist in `roles`), and `secretVersion` (confidential clients only — change it to rotate that client's secret). Edits re-serialise back into the raw-JSON field, so the exported Compose / `.env` / shell output stays in sync. Fields the editor has no input for — `roleGroup`, `metaGroup`, `pkceExempt`, and anything a newer Schleuse adds — are **carried through untouched**. They stay invisible on the page but survive the round trip, so editing one client cannot quietly strip configuration from another. (Earlier builds dropped them: one edit anywhere in the list removed them from every client in the export, which silently revoked role-gated access and broke PKCE-exempt clients on the next deploy. If you exported a config from an affected build, compare it against your source of truth before deploying it.) `NUXT_WEBHOOKS_JSON` gets its own structured editor: one card per subscription with its id, target, the audit events as **checkboxes grouped by prefix**, and a secret with a *Generate* button. Each card has a **Send test** button that delivers a signed `webhook.test` event using the configuration this instance is *running* — not what is typed in the form — and reports the receiver's status, duration and the start of its body. Secrets are masked here and in every export, exactly like a client secret. See [Webhooks](./webhooks). Below the client editor sits **+ New service client (machine-to-machine)** — a guided form for the case with the most ways to get it wrong. It asks four things: the service's name, the API it calls, what it may do there, and the token lifetime. From that it writes *both* `NUXT_OIDC_CLIENTS_JSON` (one grant, no redirect URIs, confidential, pinned to that API with `allowedResources` — which every JWT-AT client now needs, audiences being deny-by-default) and `NUXT_OIDC_RESOURCE_SERVERS_JSON` — merging the permission names into an audience that already exists rather than adding a second entry — and generates a 32-byte client secret. **Warning: The generated secret is shown once** Every other secret on this page is masked, because the Configurator never echoes what it read from the server. A secret it just generated is the exception: it appears in the export, in the clipboard, and nowhere else. Copy it when you insert it — reloading the page does not bring it back. The form refuses what the server would reject anyway, with the reason: a duplicate client id, an audience that is not an absolute URI, and — the usual trip-up — an API permission named after one of the identity provider's own scopes (`openid`, `email`, `profile`, `offline_access`, `roles`). If you type invalid JSON directly into the raw field the structured editor pauses at its last good state and shows a warning — no lost work. ### App metadata on the user detail page Below the role controls, the user detail page lists every [app metadata](./oidc#per-user-app-metadata) namespace — one card per `metaGroup ?? clientId`, showing the member clients and the key/value pairs stored for this user. Namespaces that already hold data always appear (including one orphaned by a `metaGroup` change, so it can be cleaned up); configured-but-empty ones are offered for seeding while the store is enabled. You can edit a value, add a key and remove a key; changes are saved by the same **Save changes** button as the profile and roles. Two things to know before you do: - **The values are raw and owned by the application.** Schleuse does not know whether a value is a flag, a number or JSON — a malformed one may break that app. You are a *second* writer alongside it, and the app may overwrite your correction on its next write. - **Your edits are audited** (`admin.user.metadata_updated`) with the namespace and the key names — never the values, which may hold personal data the audit log has no reason to duplicate. The application's own writes are *not* audited. ## Admin-only API Requires an authenticated admin session. | Method | Path | Purpose | |--------|------------------------------|--------------------------------------------------| | GET | `/api/admin/stats` | Dashboard counters | | GET | `/api/admin/email/status` | SMTP config + live connection probe | | GET | `/api/admin/email/preview` | Render one outbound email with sample data | | POST | `/api/admin/email/test` | Send a sample email through the live transport | | GET | `/api/admin/users` | List users (pagination + filters) | | GET | `/api/admin/users/:id` | Single user | | PATCH | `/api/admin/users/:id` | Update name / verify email / toggle admin | | GET | `/api/admin/users/:id/roles` | Per-client roles: defined + assigned | | PUT | `/api/admin/users/:id/roles` | Replace a user's roles for one client | | DELETE | `/api/admin/users/:id` | Delete user (fires webhooks) | | GET | `/api/admin/audit-logs` | Paginated audit-log entries | | GET | `/api/admin/config` | Env-var catalog + current values (secrets masked) | | POST | `/api/admin/force-logout-all`| Invalidate **all** login sessions (bumps every `token_version`); acting admin stays signed in unless `{ alsoLogOutSelf: true }` | | POST | `/api/admin/users/:id/reset-two-factor` | Clear a user's TOTP (+ passkeys when they have a password) so they can re-enrol; ends their sessions | Guards: admins cannot demote themselves or delete their own account via the admin API. ## Imported users A user brought in through the [provisioning API](/reference/api#provisioning-api-server-to-server) shows an extra read-only line on their detail page: > **Imported from**: `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` (sub preserved) That is the identifier the user had in the IdP they came from. "(sub preserved)" appears when the old value was UUID-shaped and is therefore also the `sub` this IdP issues — meaning downstream apps keyed on it needed no re-mapping. Without the note, the user's `sub` changed during the migration. The field is informational only; it is not editable here, and it is included in that user's GDPR export. Provisioning **tokens** have no admin UI — they live in `NUXT_ADMIN_API_TOKENS_JSON`, like every other piece of configuration. See [Migrating users in](migration). ## What the admin panel is not - **Not translated.** The admin surface ships English-only today; multilingual UI currently covers user-facing pages. Tracked on the roadmap. - **Not a configuration UI.** Every operator-level setting lives in env vars (see [Configuration](./configuration)). No admin-settable OIDC clients, no runtime-editable policies. This is deliberate — the IdP image is reproducible from compose files + env. --- # Email templates Five outbound emails are sent by the server: **verification**, **invite** (admin-created user "set your password"), **password reset**, **2FA login code**, and the **new-sign-in alert** (only when [`NUXT_SUSPICIOUS_LOGIN_ALERTS`](./security#new-sign-in-alerts) is enabled). Each has three overridable pieces (subject, HTML body, text body) plus a shared HTML/text layout that wraps every email. Built-in templates ship as fallbacks; override only the pieces you want to change. The built-ins are translated: **English, German, French and Italian**. They live in the `email` subtree of the shared i18n catalogue (`app/i18n/.ts`) — the same file as that locale's UI strings, so a locale is added in one place. The locale used is the one already resolved for the recipient (see [Multilingual](./multilingual#resolution-cascade)), region subtags stripped — `de-CH` gets the German copy. Any other locale gets the English built-in unless you supply `NUXT_EMAIL_*_` overrides. ## The built-in design The defaults are deliberately restrained — editorial rather than decorated: a plain white ground with no card or shadow, left-aligned copy with generous spacing, the app name as a small letterspaced wordmark above a hairline rule, and one filled button per email. `{{primaryColor}}` is used **only** on that button, so any brand colour stays legible; the button's label flips between white and near-black automatically depending on how light the colour is. They are also written for how mail clients actually behave, which is worth knowing if you replace the layout: - **Table-based, with a `width="600"` attribute as well as `max-width`.** Outlook renders through the Word engine and ignores `max-width` on a block element — a `
`-based layout sprawls to the full window there. - **Every declaration is inline.** Some clients strip `