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 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.

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. sid is per-client.

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

Cross-origin access ​

A browser-based relying party — a SPA doing the flow in the page — reaches these endpoints cross-origin:

EndpointBrowser may call it
/.well-known/openid-configuration, /.well-known/jwks.json, /oidc/jwksyes
/oidc/authorizea redirect, not a fetch
/oidc/token, /oidc/revoke, /oidc/introspectyes
/oidc/userinfoyes, 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=<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, from /oidc/userinfo, not the ID token (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",
    "backchannelLogoutUri": "https://my-app.example.com/oidc/backchannel-logout"
  }
]
FieldDescription
clientIdUnique client identifier
redirectUrisAllowed 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.
grantTypesMust include authorization_code
scopesSubset 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. Schleuse warns at startup when the two disagree.
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.
backchannelLogoutUriOptional. Back-channel logout endpoint — receives a signed logout_token when the user signs out. See Back-channel logout.
backchannelLogoutSessionRequiredOptional (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; without it there is no sid anywhere.
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.
pkceExemptOptional (default false). Waives the PKCE requirement for this client. Confidential clients only — ignored for public ones. See PKCE.
metaGroupOptional. Metadata namespace shared with other clients declaring the same value. Absent = the namespace is the clientId (per-client isolation). See 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) 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.

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.

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:<secret>' \
     -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:

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 — 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, 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>
  &code_challenge_method=S256
  &state=<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 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 <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.

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, 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. 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.

  • 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 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).

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":"<the OIDC sub>","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 }

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=<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 — 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 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 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 configsid in the ID tokensid in the logout token
neither fieldnono
backchannelLogoutUri onlynono
bothyesyes

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 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=<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 — 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 ​

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. · llms.txt