API reference
All non-OIDC state-changing endpoints require the X-CSRF-Token header (how). Cookie-based authentication — same-origin only. JSON request + response bodies unless otherwise noted.
Health
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health | Liveness. Returns {status: "ok"}. |
| GET | /api/ready | Readiness. Verifies DB connection + OIDC config. Response includes version (running semver), database, oidcConfigured, registrationEnabled, cookieSecure, smtpEnabled (booleans only — config posture for ops dashboards, no secrets), and passwordRules (min length + required character classes, so the client can render a localised password hint). Admin /api/admin/users/:id* endpoints return 404 (not 500) for a malformed non-UUID id. |
| GET | /metrics | Prometheus metrics (text exposition). Bearer-gated by NUXT_METRICS_TOKEN; returns 404 when the token is unset, 401 without a valid Authorization: Bearer <token>. See Configuration and Deployment. |
Authentication
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/register | Create account + send verification email. No session issued. |
| POST | /api/auth/login | Password login. Sets httpOnly JWT cookie on success. |
| POST | /api/auth/logout | Clear session cookie. Also fires OIDC back-channel logout to relying parties the user had a session with. |
| GET | /api/auth/verify-email?token=… | Verify email (redirects to /login?verified=1). |
| POST | /api/auth/resend-verification | Resend the verification email. |
| POST | /api/auth/password/forgot | Request a password-reset link. |
| POST | /api/auth/password/reset | Set a new password using the reset token. |
| POST | /api/auth/accept-invite | Accept an admin invite: set the first password ({ token, newPassword }), which activates + verifies the account and signs the user in. Single-use — accepting burns the link (and any other invite outstanding for that account); a replay, or an account that already has a password, → 400. Invalid/expired token → 401, suspended account → 403. Nothing but accepting invalidates the link: it does not expire early because an admin ended the user's sessions. |
| POST | /api/auth/verify-login-code | Complete 2FA login with the emailed 6-digit code. |
| POST | /api/auth/verify-totp | Complete 2FA login with an authenticator-app code or a one-time backup code. |
Registration does not issue a session. The user must verify their email (or the NUXT_SMTP_HOST must be empty, in which case they're auto-verified) before they can log in.
Account management
Requires authentication.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/me | Current user profile — including hasPassword, passkeyCount, locale, supportedLocales. |
| PATCH | /api/me | Update first/last/display name or language. |
| POST | /api/me/password | Change or set password (requires current password when one exists; invalidates other sessions). |
| POST | /api/me/password-reauth | Start a passkey re-authentication for removing the password: returns WebAuthn authentication options + a challenge scoped to the session user. 400 if no passkeys. |
| DELETE | /api/me/password | Remove password to go passwordless. Confirm with either { password } or a passkey assertion { challenge, credential } (from /api/me/password-reauth). Requires at least one registered passkey (else 403); invalidates other sessions. |
| POST | /api/me/sessions/revoke-others | Sign out of all other devices: bumps token_version (invalidates every other session) and keeps the current one. Returns { message, at } (the moment, for the "last signed out" display). |
| GET | /api/me/sessions/last-revoked-others | When the user last used "Sign out other devices" — { at: string | null }, derived from their sessions.revoked_others audit events. |
| GET | /api/me/referrer | Validate a "back to the app" link: ?referrer=<clientId>&referrer_uri=<url> → { uri, label } when the URL is same-origin as one of the client's registered redirect URIs (open-redirect guard), else {}. |
| GET | /api/me/activity | The user's own recent account activity (sign-ins, password changes, exports, suspicious-login flags). Self-scoped to the session user; safe fields only (event, status, ip, userAgent, createdAt). Query: limit (default 20, max 100). |
| GET | /api/me/totp | TOTP status: { enabled, backupCodesRemaining }. |
| POST | /api/me/totp/start | Begin TOTP enrolment: returns { secret, otpauthUri } for the authenticator app. Stored as pending — does not change an existing active credential. |
| POST | /api/me/totp/confirm | Finalise enrolment by verifying a { code }; enables TOTP and returns one-time { backupCodes } (shown once). |
| POST | /api/me/totp/backup-codes | Regenerate backup codes (requires a current { code }); returns the new set. |
| DELETE | /api/me/totp | Disable TOTP (requires a current TOTP or backup { code }). |
| GET | /api/me/export | Download a GDPR data-portability export (JSON file). Excludes password hashes and OIDC token values. |
| DELETE | /api/me | Delete account (requires password; notifies OIDC clients via webhook). |
The export is returned as a file download (Content-Disposition: attachment, Cache-Control: no-store) with top-level keys meta, profile, passwordCredential (metadata only — never the hash), webauthnCredentials, auditLog (the user's own entries), oidcGrants (clientId + scope, no tokens) and roles (role assignments, { roleGroup, role } — the group is the client's roleGroup, or its clientId). Each export is recorded in the audit log.
WebAuthn (passkeys)
Passkeys can be added to any verified account as an alternative login method.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/webauthn/register/start | Begin passkey registration (auth required) |
| POST | /api/webauthn/register/finish | Complete passkey registration |
| POST | /api/webauthn/login/start | Begin passkey login |
| POST | /api/webauthn/login/finish | Complete passkey login; sets auth cookie |
| GET | /api/me/passkeys | List the current user's passkeys (no key material) |
| DELETE | /api/me/passkeys/:id | Remove a passkey. Refuses (403) to remove the last passkey when the account has no password |
The two guards above jointly maintain the invariant that an account always keeps at least one authentication factor: a password OR at least one passkey.
OIDC
See OIDC provider for the full flow.
| Method | Path | Purpose |
|---|---|---|
| GET | /.well-known/openid-configuration | Discovery document |
| GET | /.well-known/jwks.json | Public key set |
| GET | /oidc/authorize | Authorization endpoint |
| POST | /oidc/token | Token endpoint |
| GET | /oidc/userinfo | User info endpoint |
| GET | /oidc/jwks | JWKS endpoint (alternate path) |
| GET | /oidc/session/end | RP-initiated logout (id_token_hint + registered post_logout_redirect_uri); ends the OP session, fires back-channel logout, and clears the first-party app cookie. See OIDC → RP-initiated logout. |
| POST | /oidc/revoke | Token revocation (RFC 7009) |
| POST | /oidc/introspect | Token introspection (RFC 7662) |
| POST | /api/oidc/interaction/:uid/finish | Complete OIDC interaction (internal). Returns 403 loginRoleRequired when the client is role-gated and the user lacks a qualifying role — the user stays signed in, but no code is issued. |
/oidc/authorize and /oidc/token accept an optional resource parameter (RFC 8707). When it matches a value in NUXT_OIDC_RESOURCE_AUDIENCE, the access token is issued as a JWT audienced to that resource; otherwise tokens stay opaque and an unconfigured resource is rejected with invalid_target. See OIDC → JWT access tokens.
Admin
Requires an authenticated admin session. The first registered user is auto-promoted to admin by default.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/admin/stats | Dashboard counters |
| GET | /api/admin/users | List users with pagination, search, filters |
| POST | /api/admin/users | Create a user. { email, firstName, lastName?, isAdmin?, locale?, passwordMode: 'set'|'invite', password? }. set → account usable + email-verified immediately; invite → passwordless + unverified, returns { inviteLink } (also emailed when SMTP is on). Duplicate email → 409; weak password in set mode → 400. Audited admin.user.created. |
| GET | /api/admin/users/:id | Single user (includes locale) |
| PATCH | /api/admin/users/:id | Update: verify email, toggle admin, edit name |
| GET | /api/admin/users/:id/export | Download a user's GDPR data export (same shape as /api/me/export) |
| GET | /api/admin/users/:id/roles | Per-group roles for the user: { groups: [{ group, roleMode, available, assigned, clientIds }] } |
| PUT | /api/admin/users/:id/roles | Replace the user's roles for one group: body { roleGroup, roles[] } |
| DELETE | /api/admin/users/:id | Delete user (revokes OIDC tokens, fires webhooks) |
| GET | /api/admin/audit-logs | Audit log entries with filters |
| POST | /api/admin/audit-logs/anonymize-orphans | Maintenance sweep: null PII on audit rows whose user_id is no longer in users (pre-anonymisation-deletion leftovers). { anonymized: N }; idempotent; audited admin.audit.anonymized. |
| GET | /api/admin/config | Env-var catalog + current runtime values (secrets masked, for /admin/configurator) |
| POST | /api/admin/force-logout-all | Invalidate login sessions in bulk; returns { affected, scope, message, self }. Body { scope?: 'all' | 'non_admins', alsoLogOutSelf?: boolean } — non_admins bumps only non-admin accounts (admins keep working); alsoLogOutSelf (scope all only) signs the acting admin out too. |
| POST | /api/admin/users/:id/reset-two-factor | Clear a user's second factors so they can re-enrol: removes TOTP always, and passkeys only when the user has a password (else preserved — they're the login); bumps the target's token_version. { clearedTotp, clearedPasskeys }; audited admin.user.two_factor_reset. |
| POST | /api/admin/users/:id/suspend | Reversibly block an account: sets suspended_at, bumps token_version (kills sessions), and revokes the user's OIDC tokens. New logins are refused. 400 if suspending yourself. { suspended: true }; audited admin.user.suspended. |
| POST | /api/admin/users/:id/unsuspend | Lift a suspension (suspended_at → null); the user can log in again (re-authenticating fresh). { suspended: false }; audited admin.user.unsuspended. |
| POST | /api/admin/users/:id/force-logout | Invalidate one user's sessions (bumps their token_version). If the target is the acting admin, this device stays signed in (other devices drop). { affected: 1 }; audited admin.user.force_logout. |
| POST | /api/admin/roles/rekey | Move all role assignments from one role-group key to another ({ from, to }) — for when a client's roleGroup changes and its old assignments are orphaned. Conflict-safe; returns { from, to, moved }; audited admin.roles.rekeyed. |
| GET | /api/admin/email/status | SMTP configuration + a live probe: { configured, ok, durationMs, host, port, secure, from, authConfigured, tls, error? }. tls is measured on a real connection, not echoed from config: { mode: 'implicit'|'starttls'|'none', encrypted, authorized, protocol, requireTls, rejectUnauthorized, error? } — authorized is null when the hop isn't encrypted. Never returns NUXT_SMTP_PASS. A failed probe is still 200 — the failure detail is the payload (error.code / command / responseCode / response); configured: false when NUXT_SMTP_HOST is unset. |
| GET | /api/admin/email/preview | Render one outbound email with sample data: query template (verification | invite | passwordReset | loginCode | newLogin) and optional locale (must be in NUXT_SUPPORTED_LOCALES). Returns { template, locale, subject, html, text, sources }, where each sources.* is builtin | override | localeOverride. Sends nothing — works with SMTP disabled. 400 on unknown template/locale. |
| POST | /api/admin/email/test | Send a sample email through the live SMTP transport: { to, template, locale? }. One attempt, no retry. 200 { ok, messageId, durationMs, to, template, locale }; 409 { code: 'smtp_not_configured' } when SMTP is off; 429 past 10 sends / 10 min per admin; 502 { code: 'smtp_send_failed', smtp: {…} } carrying the SMTP server's own error. Audited admin.email.test_sent. |
| GET | /api/admin/clients/:clientId/secret | Reveal a confidential client's derived secret: { clientId, secret } (400 for public clients). Audited as admin.client.secret_revealed |
Query parameters for /api/admin/users:page, perPage (max 100), search (email/name), emailVerified (true/false), isAdmin (true/false)
Query parameters for /api/admin/audit-logs:page, perPage (max 100), event (event type), from (ISO date), to (ISO date)
PUT /api/admin/users/:id/roles replaces the user's roles for the given roleGroup (clients collapsed by roleGroup ?? clientId). roles must be a subset of the group's roles (the union across member clients in NUXT_OIDC_CLIENTS_JSON); an unknown role, an unknown group, or more than one role for a single-mode group returns 400. Logged as admin.user.roles_updated. See OIDC → Per-application roles.
GET /api/admin/email/preview and POST /api/admin/email/test render through the same code path a real send uses, so NUXT_EMAIL_* overrides and their <VAR>_<LOCALE> variants appear exactly as recipients would see them. The sample payload is 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. Test sends are deliberately excluded from the schleuse_emails_total metric.
App API (server-to-server)
Authenticated by a confidential OIDC client's client_id + client_secret via HTTP Basic — no session, no CSRF.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/apps/roles | Read the calling client's roles for a user: ?userId= or ?email= → { clientId, roleGroup, userId, roles } (filtered to the roles this client declares) |
| PUT | /api/apps/roles | Set the calling client's roles for a user: body { userId or email, roles[] } |
The clientId is taken from the authenticated credentials, never the body, so a client can only write its own app-roles. roles must be a subset of the client's env-defined roles (≤1 for roleMode:"single"); only the role store is written (is_admin is never touched). Responses: 200 { clientId, userId, roles }; 400 (invalid role), 401 invalid_client (bad/missing/public credentials), 404 (unknown user), 429 (per-client rate limit). Logged as oidc.client.roles_updated. See OIDC → App-managed roles.
Guards: admins cannot remove their own admin status or delete their own account via the admin API.
POST /api/admin/force-logout-all invalidates every login session in one bulk update (the stateless-JWT revocation point is each user's token_version). The acting admin's current device is re-issued a fresh cookie so it stays signed in; already-issued OIDC tokens are unaffected (they have their own lifecycle via /oidc/revoke). Logged as admin.force_logout_all. See Security → Incident response.
Provisioning API (server-to-server)
Bulk user creation / import, for migrating an existing realm onto Schleuse. Authenticated by a bearer token from NUXT_ADMIN_API_TOKENS_JSON — no session, no CSRF. A session cookie never satisfies these routes, even an admin's.
Unset / empty NUXT_ADMIN_API_TOKENS_JSON ⇒ every route below returns 404 (the feature is off; there is no half-open state that merely rejects credentials).
| Method | Path | Purpose |
|---|---|---|
| POST | /api/provisioning/users | Create one user → { status, user: { id, email, legacySub, subPreserved }, inviteLink? } |
| POST | /api/provisioning/users/import | Batch (≤ 500), optional dryRun → summary + per-item results |
| GET | /api/provisioning/users/lookup | Resolve ?legacySub= or ?email= → { id, email, legacySub, subPreserved } |
Walkthrough with a Keycloak example: Guide → Migrating users in.
User payload
{
"email": "erika@example.com", // required
"firstName": "Erika", // required
"lastName": "Mustermann",
"locale": "de", // falls back to NUXT_DEFAULT_LOCALE if unsupported
"emailVerified": true, // default false
"createdAt": "2021-03-04T10:00:00Z", // ISO-8601; preserves the original date
"suspended": false, // true ⇒ imported already blocked from login
"legacySub": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"roles": { "my-app": ["editor"] }, // per client or role group
"hashedPassword": { "value": "$2b$12$…", "algorithm": "bcrypt" },
"invite": false // mutually exclusive with hashedPassword
}The schema is strict: an unknown or misspelt field is an error, not a silent drop. isAdmin is therefore rejected outright — a provisioning token cannot mint admins. Omit both hashedPassword and invite for a credential-less account.
hashedPassword.value is one self-describing crypt/PHC string; algorithm is optional but must agree with the string's prefix when present. Supported prefixes: $argon2id$/$argon2i$, $2a$/$2b$/$2y$ (bcrypt), and $pbkdf2-sha1|sha256|sha512$ (passlib/passwap encoding). A legacy hash is verified as-is and silently re-hashed to argon2id on the user's next successful login.
subPreserved
users.id is the OIDC sub. A UUID-shaped legacySub is used verbatim as the id, so the sub survives the migration (subPreserved: true). Any other value gets a fresh UUID and is recorded for mapping only (subPreserved: false) — downstream apps keyed on the old sub must re-map those users.
Per-item outcomes
The batch endpoint always returns 200 when the request itself is well-formed: one bad row never fails the others.
status | code | Meaning |
|---|---|---|
created | — | User created (or, under dryRun, would be) |
skipped | alreadyImported | Same email and same legacySub — an idempotent re-run |
conflict | emailExists | Email taken by a user with a different (or no) legacySub |
conflict | legacySubExists | That legacySub belongs to another user |
conflict | idExists | The id a UUID legacySub resolves to is taken |
conflict | duplicateInBatch | The same email / legacySub appears twice in this request |
invalid | unknownField | Unrecognised or misspelt field (named in message) |
invalid | badHash | Unsupported prefix, malformed payload, or a mislabelled algorithm |
invalid | badRole | Role not declared by that client, or too many for roleMode:"single" |
invalid | validation | Missing/malformed required field |
failed | writeFailed | A write failed; the partially-created user was rolled back |
Email matching for alreadyImported, emailExists and duplicateInBatch is case-insensitive — addresses are canonicalised (trimmed, lowercased) before they are compared or stored, so two spellings of one address are one identity.
POST /api/provisioning/users (single) maps the same outcomes onto status codes instead: 200 created/skipped, 400 invalid, 409 conflict.
Request-level responses: 400 (malformed envelope, batch over 500), 401 (bad/missing bearer), 404 (feature disabled), 429 (60 requests/min per token).
Every processed user is audited as provisioning.user.created / .skipped / .rejected, attributed to the token's id. Counted by schleuse_provisioning_users_total{result} (dry runs land under result="dry_run").
CSRF
| Method | Path | Purpose |
|---|---|---|
| GET | /api/csrf-token | Get a CSRF token. Also sets the csrf_token cookie. |
The token is rotated on login. OIDC endpoints are exempt (they use PKCE + state).