Skip to content

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 discovery document also carries a non-standard account_url field pointing at the self-service account page (<NUXT_APP_URL>/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.

Return-to-app link. Append a Keycloak-style ?referrer=<clientId>&referrer_uri=<url> to the account URL and the page renders a "← Back to <clientId>" 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

ScopeClaims / effect
openidsub
emailemail, email_verified
profilename, given_name, family_name, preferred_username
rolesroles — the user's per-application roles (only for clients that define roles). See Per-application roles.
offline_accessIssues a refresh token (7-day lifetime, rotated on each use)

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.

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.

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",
    "webhookUrl":   "https://my-app.example.com/webhooks/idp",
    "backchannelLogoutUri": "https://my-app.example.com/oidc/backchannel-logout"
  }
]
FieldDescription
clientIdUnique client identifier
redirectUrisAllowed callback URLs
grantTypesMust include authorization_code
scopesSubset of openid, email, profile, roles, offline_access
publictrue — no secret (SPAs/native). false — secret derived as sha256(NUXT_JWT_PRIVATE_KEY + clientId + secretVersion?)
rolesOptional. Role names this app defines. Admins assign them per user; returned in the roles claim. Omit (or []) to disable roles for the client.
roleModeOptional (default multiple). single restricts each user to at most one role for this client.
roleGroupOptional. 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.
secretVersionOptional (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.
clientSecretOptional (confidential clients, ≥16 chars). An explicit secret used verbatim for both OIDC token auth and the app API. 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.
webhookUrlOptional. URL notified on account-lifecycle events (e.g. account.deleted) via POST
backchannelLogoutUriOptional. Back-channel logout endpoint — receives a signed logout_token when the user signs out. See Back-channel logout.
backchannelLogoutSessionRequiredOptional (default false). When true, the logout_token includes a sid the RP can match to its session.
loginRequiresRoleOptional (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.
loginRequiredRolesOptional. The user must hold at least one of these specific roles (any-of). Stricter than loginRequiresRole and wins when both are set.

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) and they can sign in. Roles are read from the client's roleGroup, so a shared group's roles gate every member client consistently.

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>
  &code_challenge_method=S256
  &state=<STATE>

The user lands on the login page. After authentication, the provider redirects to redirect_uri with ?code=…&state=….

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 <ACCESS_TOKEN>" \
  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

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. 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.
  • roleModemultiple (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.
  • 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.
  • 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.

To read a user's roles, request the roles scope and call /oidc/userinfo:

bash
# authorize with: &scope=openid roles
curl -H "Authorization: Bearer <ACCESS_TOKEN>" 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":"<the OIDC sub>","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 only allow writes for users who already have an OIDC grant with the calling client — a user with no grant then returns 404 (uniform with an unknown user). 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).

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=<ACCESS_OR_REFRESH_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=<ACCESS_OR_REFRESH_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 or /oidc/userinfo. 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 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 }]

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

By default any configured client may request any configured resource. Give a client an allowedResources array (in NUXT_OIDC_CLIENTS_JSON) to restrict it to specific audiences — requesting any other resource returns invalid_target. A client without allowedResources is unrestricted (backward compatible).

json
{ "clientId": "reports-app", "…": "…", "allowedResources": ["https://api.example.com"] }

Back-channel logout

OIDC Back-Channel Logout 1.0 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.
  • Browser-redirect front-channel logout is intentionally not offered (removed from the OIDC library; broken by third-party-cookie blocking).

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=<the id_token the RP received>
      &post_logout_redirect_uri=<where to send the browser after>
      &client_id=<your clientId>
  • The id_token_hint identifies the session and lets the IdP validate the redirect target.
  • 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.
  • The IdP ends the OP session, fires back-channel logout to every other RP the user had a session with, and also clears the Schleuse first-party session cookie — so the app session can't silently re-establish a new OP session on the next /authorize.
  • The confirmation is auto-submitted (no manual "are you sure" click); the xsrf token is preserved.
json
{ "clientId": "my-app", "…": "…",
  "postLogoutRedirectUris": ["https://my-app.example.com/", "https://my-app.example.com/logged-out"] }

Token TTLs

TokenLifetime
Access token1 hour
ID token1 hour
Refresh token7 days (rotated on each use)
Authorization code10 minutes
Session (internal)24 hours
Grant14 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.

A small but polished sidecar identity provider.