Skip to content

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

MethodPathPurpose
GET/api/healthLiveness. Returns {status: "ok"}.
GET/api/readyReadiness. 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/metricsPrometheus 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

MethodPathPurpose
POST/api/auth/registerCreate account + send verification email. No session issued.
POST/api/auth/loginPassword login. Sets httpOnly JWT cookie on success.
POST/api/auth/logoutClear 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-verificationResend the verification email.
POST/api/auth/password/forgotRequest a password-reset link.
POST/api/auth/password/resetSet a new password using the reset token.
POST/api/auth/accept-inviteAccept 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-codeComplete 2FA login with the emailed 6-digit code.
POST/api/auth/verify-totpComplete 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.

MethodPathPurpose
GET/api/meCurrent user profile — including hasPassword, passkeyCount, locale, supportedLocales.
PATCH/api/meUpdate first/last/display name or language.
POST/api/me/passwordChange or set password (requires current password when one exists; invalidates other sessions).
POST/api/me/password-reauthStart 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/passwordRemove 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-othersSign 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-othersWhen the user last used "Sign out other devices" — { at: string | null }, derived from their sessions.revoked_others audit events.
GET/api/me/referrerValidate 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/activityThe 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/totpTOTP status: { enabled, backupCodesRemaining }.
POST/api/me/totp/startBegin TOTP enrolment: returns { secret, otpauthUri } for the authenticator app. Stored as pending — does not change an existing active credential.
POST/api/me/totp/confirmFinalise enrolment by verifying a { code }; enables TOTP and returns one-time { backupCodes } (shown once).
POST/api/me/totp/backup-codesRegenerate backup codes (requires a current { code }); returns the new set.
DELETE/api/me/totpDisable TOTP (requires a current TOTP or backup { code }).
GET/api/me/exportDownload a GDPR data-portability export (JSON file). Excludes password hashes and OIDC token values.
DELETE/api/meDelete 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.

MethodPathPurpose
POST/api/webauthn/register/startBegin passkey registration (auth required)
POST/api/webauthn/register/finishComplete passkey registration
POST/api/webauthn/login/startBegin passkey login
POST/api/webauthn/login/finishComplete passkey login; sets auth cookie
GET/api/me/passkeysList the current user's passkeys (no key material)
DELETE/api/me/passkeys/:idRemove 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.

MethodPathPurpose
GET/.well-known/openid-configurationDiscovery document
GET/.well-known/jwks.jsonPublic key set
GET/oidc/authorizeAuthorization endpoint
POST/oidc/tokenToken endpoint
GET/oidc/userinfoUser info endpoint
GET/oidc/jwksJWKS endpoint (alternate path)
GET/oidc/session/endRP-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/revokeToken revocation (RFC 7009)
POST/oidc/introspectToken introspection (RFC 7662)
POST/api/oidc/interaction/:uid/finishComplete 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.

MethodPathPurpose
GET/api/admin/statsDashboard counters
GET/api/admin/usersList users with pagination, search, filters
POST/api/admin/usersCreate 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/:idSingle user (includes locale)
PATCH/api/admin/users/:idUpdate: verify email, toggle admin, edit name
GET/api/admin/users/:id/exportDownload a user's GDPR data export (same shape as /api/me/export)
GET/api/admin/users/:id/rolesPer-group roles for the user: { groups: [{ group, roleMode, available, assigned, clientIds }] }
PUT/api/admin/users/:id/rolesReplace the user's roles for one group: body { roleGroup, roles[] }
DELETE/api/admin/users/:idDelete user (revokes OIDC tokens, fires webhooks)
GET/api/admin/audit-logsAudit log entries with filters
POST/api/admin/audit-logs/anonymize-orphansMaintenance 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/configEnv-var catalog + current runtime values (secrets masked, for /admin/configurator)
POST/api/admin/force-logout-allInvalidate 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-factorClear 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/suspendReversibly 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/unsuspendLift 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-logoutInvalidate 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/rekeyMove 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/statusSMTP 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/previewRender 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/testSend 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/secretReveal 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.

MethodPathPurpose
GET/api/apps/rolesRead 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/rolesSet 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).

MethodPathPurpose
POST/api/provisioning/usersCreate one user → { status, user: { id, email, legacySub, subPreserved }, inviteLink? }
POST/api/provisioning/users/importBatch (≤ 500), optional dryRun → summary + per-item results
GET/api/provisioning/users/lookupResolve ?legacySub= or ?email={ id, email, legacySub, subPreserved }

Walkthrough with a Keycloak example: Guide → Migrating users in.

User payload

jsonc
{
  "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.

statuscodeMeaning
createdUser created (or, under dryRun, would be)
skippedalreadyImportedSame email and same legacySub — an idempotent re-run
conflictemailExistsEmail taken by a user with a different (or no) legacySub
conflictlegacySubExistsThat legacySub belongs to another user
conflictidExistsThe id a UUID legacySub resolves to is taken
conflictduplicateInBatchThe same email / legacySub appears twice in this request
invalidunknownFieldUnrecognised or misspelt field (named in message)
invalidbadHashUnsupported prefix, malformed payload, or a mislabelled algorithm
invalidbadRoleRole not declared by that client, or too many for roleMode:"single"
invalidvalidationMissing/malformed required field
failedwriteFailedA 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

MethodPathPurpose
GET/api/csrf-tokenGet a CSRF token. Also sets the csrf_token cookie.

The token is rotated on login. OIDC endpoints are exempt (they use PKCE + state).

A small but polished sidecar identity provider.