sc-idp/docs/oidc.md
2026-06-01 16:40:54 -05:00

21 KiB

OIDC / OAuth2

Provider-side OpenID Connect (OIDC) and OAuth2 for the saltcorn-idp plugin. This document covers the provider that turns Saltcorn into an OIDC Identity Provider: the endpoints, the authorization-code + PKCE + consent flow, client registration, token signing and key lifecycle, claims, and the integration notes for the underlying oidc-provider library.

For the other protocols this plugin implements, see ./ldap.md and ./saml.md. The admin pages used to register clients and groups are documented in the "Admin UI pages" section of ./configuration.md. Multi-tenant behaviour is covered in ./architecture.md (overview) and ./operations.md (per-tenant install/routing); the OIDC specifics are in "Per-tenant issuer and keys" below.

All OIDC/OAuth2 work is built on oidc-provider v9 (an ESM-only module loaded via dynamic import()). Saltcorn's own sessions provide the login; this plugin mounts the provider under /idp and hosts only the login/consent interaction screens itself.


Endpoint table

All public endpoints live under IDP_BASE_PATH (/idp) and are registered with noCsrf: true (machine-to-machine; oidc-provider manages its own CSRF/state). Paths are defined in lib/constants.js; routing is in lib/oidc/routes.js (delegated endpoints) and lib/oidc/interactions.js (the login/consent screens this plugin hosts).

Endpoint Path Methods Source / handler Purpose
Discovery /idp/.well-known/openid-configuration GET oidcRoutes -> delegate OIDC discovery document; oidc-provider advertises issuer, endpoint URIs, supported scopes/claims, and jwks_uri.
JWKS /idp/jwks GET oidcRoutes -> delegate Public signing keys (the public half of active + retiring keys). Advertised as jwks_uri in discovery.
Authorization /idp/auth GET, POST oidcRoutes -> delegate Authorization endpoint; starts the code flow, triggers login/consent interactions when needed.
Authorization resume /idp/auth/:uid GET, POST oidcRoutes -> delegate Resumes the authorization request after an interaction (login/consent) completes.
Token /idp/token POST oidcRoutes -> delegate Token endpoint; exchanges an authorization code (with PKCE verifier and/or client secret) for tokens.
Userinfo /idp/me GET, POST oidcRoutes -> delegate Userinfo endpoint; returns granted claims for the bearer access token.
Interaction /idp/interaction/:uid GET interactionRoutes -> interactionHandler Login or consent screen, hosted by this plugin.
Interaction confirm /idp/interaction/:uid/confirm POST interactionRoutes -> confirmHandler Consent submit (Allow / Deny).

Notes from the source:

  • The JWKS path is mount-relative /jwks (not under /.well-known); the discovery document advertises it as jwks_uri (lib/constants.js).
  • The discovery document and JWKS are generated and served by oidc-provider itself; lib/oidc/discovery.js now only derives the issuer URL that feeds the Provider (see comment at the bottom of that file).
  • All endpoints except the two interaction routes are handled by delegate, which forwards to oidc-provider's callback (see "oidc-provider integration").

End to end, an authorization-code flow with PKCE and an interactive consent screen. Step references below are grounded in lib/oidc/routes.js, lib/oidc/interactions.js, and lib/oidc/provider.js.

  1. Relying party redirects the browser to /idp/auth with the usual code-flow parameters (client_id, redirect_uri, response_type=code, scope, state, nonce, code_challenge, code_challenge_method=S256). The route delegates to oidc-provider, which validates the client, redirect URI, scopes, and PKCE challenge against the dynamically-loaded client metadata.

  2. oidc-provider determines whether it needs an interaction (login and/or consent). The interaction URL is produced by the interactions.url callback in buildProvider (lib/oidc/provider.js), which returns IDP_BASE_PATH + "/interaction/" + interaction.uid (i.e. /idp/interaction/:uid).

  3. Login prompt -- handled by interactionHandler (lib/oidc/interactions.js):

    • It calls provider.interactionDetails(req, res) to read the pending interaction; an invalid/expired interaction returns HTTP 400 invalid or expired interaction.
    • If req.user && req.user.id (the user already has a Saltcorn session), it finishes the login with provider.interactionFinished(req, res, { login: { accountId: String(req.user.id) } }, { mergeWithLastSubmission: false }).
    • Otherwise it redirects to Saltcorn's own login: /auth/login?dest=<encoded /idp/interaction/:uid>. After the user logs in, Saltcorn returns to the dest, the handler re-runs, now sees req.user, and finishes the login. Only EXISTING Saltcorn users authenticate; there is no auto-provisioning (see findAccount below).
  4. Consent prompt -- also handled by interactionHandler:

    • When details.prompt.name === "consent", it loads the client row via clients.getClient(details.params.client_id), splits the requested scope string on spaces, and renders an HTML consent screen (renderConsent) listing the client label/id and the scopes. All interpolated values are escaped with web.escapeHtml.
    • The form POSTs to /idp/interaction/:uid/confirm.
  5. Consent confirm -- handled by confirmHandler (lib/oidc/interactions.js):

    • Deny: if req.body.deny is truthy, it finishes with interactionFinished(..., { error: "access_denied", error_description: "User denied the request" }, { mergeWithLastSubmission: false }), which redirects the browser back to the client with error=access_denied.
    • Allow: it creates a grant with new provider.Grant({ accountId: details.session.accountId, clientId: details.params.client_id }), calls grant.addOIDCScope(details.params.scope) when a scope is present, await grant.save() (persisted through the storage adapter), then interactionFinished(..., { consent: { grantId: grantId } }, { mergeWithLastSubmission: true }).
  6. oidc-provider resumes the authorization request (via /idp/auth/:uid), issues an authorization code, and redirects to the client's redirect_uri with code and the echoed state.

  7. The client exchanges the code at /idp/token (POST). oidc-provider validates the client credentials (PKCE code_verifier for public clients, or the client secret for confidential clients), validates and consumes the code via the adapter, calls findAccount + claims, signs the id_token with the active RS256 key, and returns the token response (access_token, id_token, token_type, expires_in).

  8. The client may call /idp/me (GET or POST) with Authorization: Bearer <access_token> to fetch the granted claims again.


Client registration

Relying parties are stored in the _idp_clients table and managed in lib/clients.js. There are no static clients in the Provider config (clients: [] in buildProvider); every client is looked up dynamically through the Client model in the storage adapter, so new registrations take effect without rebuilding the provider.

Public (PKCE) vs confidential (sealed secret)

createClient(opts) (lib/clients.js) keys behaviour off opts.authMethod, defaulting to "none":

  • Public client -- authMethod === "none": no secret is generated; the secret_ciphertext / secret_iv / secret_tag columns are left null. The client must use PKCE.
  • Confidential client -- any other authMethod (e.g. client_secret_basic, client_secret_post): a random 32-byte secret (SECRET_BYTES = 32) is generated as base64url, then sealed at rest with idpCrypto.sealText (AES-256-GCM under the plugin KEK). The plaintext secret is returned ONCE from createClient as { client_id, secret } for display; it is never stored in plaintext and never returned again.

createClient always stores grant_types as ["authorization_code"] and response_types as ["code"] (JSON arrays), plus redirect_uris (JSON array) and an optional scope string. It inserts with { noid: true }. A duplicate client_id violates the primary key and throws.

toOidcMetadata(row) renders a stored row into the metadata object oidc-provider expects:

{
  client_id,
  redirect_uris,                 // JSON.parse of the stored array
  grant_types,                   // JSON.parse
  response_types,                // JSON.parse
  token_endpoint_auth_method,    // from token_auth_method
  scope,                         // only if set
  client_secret                  // only for confidential clients, unsealed via openText
}

Redirect URIs

redirect_uris is stored as a JSON array and passed straight through to oidc-provider, which enforces an exact-string match of the request's redirect_uri against this list (standard oidc-provider behaviour). The admin UI splits a newline-separated textarea into the array (see the "Admin UI pages" section of ./configuration.md); it does not validate that entries are well-formed URLs.

Scopes

The Provider registers exactly these scopes (buildProvider):

openid  email  profile  groups

Clients can only request from this set. A client's stored scope string (e.g. the admin UI default openid email profile groups) is included in its metadata. The groups scope is a plugin-specific scope that maps to the groups claim (see "Claims" below).

_idp_clients columns (reference)

Defined in lib/schema.js:

Column Notes
client_id TEXT PRIMARY KEY
label TEXT, human label for the admin UI
token_auth_method TEXT, default none; none = public/PKCE
redirect_uris TEXT, JSON array
grant_types TEXT, JSON array (always ["authorization_code"])
response_types TEXT, JSON array (always ["code"])
scope TEXT, space-separated scope string (nullable)
secret_ciphertext TEXT hex; only for confidential clients
secret_iv TEXT hex
secret_tag TEXT hex
created_at TEXT ISO timestamp

Tokens and signing

Algorithm and key material

  • id_tokens are signed with RS256 (SIGNING_ALG = "RS256" in lib/constants.js), using a 2048-bit RSA key (RSA_MODULUS_BITS = 2048).
  • The Provider is configured with jwks: { keys: [privateJwk] } in buildProvider, where privateJwk comes from keys.getActivePrivateJwk(). oidc-provider both signs with this private key and serves its PUBLIC half at the JWKS endpoint.
  • conformIdTokenClaims: false is set deliberately so scope-granted claims (e.g. email, groups) appear in the id_token as well as at userinfo, so relying parties can read them straight from the id_token.

JWKS, key status, and rotation

Keys live in _idp_keys (one per tenant) with a status of active, retiring, or retired (KEY_STATUS in lib/constants.js). Lifecycle is in lib/keys.js:

  • ensureActiveKey() is idempotent: if an active key already exists for the tenant it returns its metadata; otherwise it generates a new RSA keypair, derives a kid (crypto.newKid()), stores the public JWK as JSON, seals the private key PEM (AES-256-GCM), and inserts the row with status = active. It returns only safe metadata (kid, alg, status, created_at) -- never the sealed private material. Called from the plugin onLoad.
  • getJwks() returns { keys: [...] } containing the public JWKs of both active and retiring keys. Publishing retiring keys lets relying parties keep validating recently-issued tokens across a rotation. (Note: the actual JWKS HTTP response is produced by oidc-provider from its configured jwks; getJwks() is the plugin's own public-key reader, used e.g. by the admin dashboard.)
  • getActiveKeyMeta() reads kid/alg/created_at without decrypting -- safe for the dashboard.
  • getActiveSigningKey() decrypts the sealed private key PEM and imports it to a key object; getActivePrivateJwk() exports that as a private JWK for the Provider config.

Each public/private JWK carries kid, alg, and use: "sig" (set in lib/crypto.js). The private key is sealed under a KEK derived from SALTCORN_SESSION_SECRET; rotating that secret invalidates all sealed material (KEK derivation and the schema-qualified DDL are documented in ./configuration.md; the at-rest sealing rationale is in ./security.md).

The code does not include an automated rotation routine; the retiring / retire_after machinery and getJwks() publishing retiring keys are the building blocks for it.

_idp_keys columns (reference)

Column Notes
kid TEXT PRIMARY KEY
alg TEXT (RS256)
public_jwk TEXT, JSON-stringified public JWK
private_ciphertext TEXT hex, sealed private key PEM
private_iv TEXT hex
private_tag TEXT hex
status TEXT, default active
created_at TEXT ISO timestamp
retire_after TEXT (nullable)

Claims

Claims are produced by oidcClaims(user, sub, grantedScopes) in lib/claims.js, called from the findAccount -> claims callback in buildProvider. The granted scope string is split on spaces and each scope adds its claims:

Scope Claims Source
openid sub always present; sub is the Saltcorn user id (as a string)
email email, email_verified user.email; email_verified = !!user.verified_on
profile name user._attributes.name, falling back to user.email
groups groups groups.effectiveGroups(user)

The groups claim and its prefixes

effectiveGroups(user) in lib/groups.js is the single source of truth for group identity (reused by LDAP and SAML). It returns an array combining:

  • the user's Saltcorn role, looked up in _sc_roles by user.role_id, emitted as role:<role> (ROLE_PREFIX = "role:"); and
  • each custom group the user belongs to (via _idp_group_members -> _idp_groups), emitted as group:<name> (GROUP_PREFIX = "group:").

Example claim value:

{ "groups": ["role:admin", "group:engineering"] }

The role: / group: prefixes keep a role and a custom group of the same name from colliding. Custom groups are managed in the admin UI (the /admin/idp/groups page; see the "Admin UI pages" section of ./configuration.md).


oidc-provider integration notes

The integration glue lives in lib/oidc/provider.js (Provider construction + per-issuer cache), lib/oidc/adapter.js (storage), and lib/oidc/routes.js (mounting + body re-streaming).

ESM import and per-issuer caching

oidc-provider v9 is ESM-only, so it is loaded from this CommonJS plugin via a cached dynamic import (loadProviderClass in lib/oidc/provider.js):

providerClassPromise = import("oidc-provider").then((m) => m.default);

Providers are built lazily and cached per issuer URL in a Map (providersByIssuer). getProviderEntry(req) derives the issuer via issuerForReq(req), returns the cached { provider, handler } if present, or builds one with buildProvider(issuer) and stores { provider, handler: provider.callback() }. The Provider also sets provider.proxy = true so it trusts X-Forwarded-* headers behind a proxy.

Cookie-signing keys are derived deterministically from SALTCORN_SESSION_SECRET (crypto.deriveSecretHex(COOKIE_KEY_INFO, COOKIE_KEY_BYTES), where COOKIE_KEY_INFO = "saltcorn-idp:oidc-cookie-keys:v1" and COOKIE_KEY_BYTES = 32) so interaction sessions survive a restart. The secure flag on the long/short cookies is set when the issuer is https://.

findAccount(ctx, sub) parses sub as an integer and looks up the Saltcorn user with User.findOne({ id: uid }); if the user does not exist it returns undefined (existing users only, never auto-provisioned). When found it returns { accountId: sub, claims: async (use, scope) => claims.oidcClaims(user, sub, scope) }.

Callback mounting and the delegate handler

OIDC endpoints are mounted under /idp and forwarded to oidc-provider's callback by delegate (lib/oidc/routes.js):

  • It fetches the per-issuer entry via getProviderEntry(req).
  • It computes stripped = fullUrl.slice(IDP_BASE_PATH.length) || "/" so the mount-relative router inside oidc-provider matches (e.g. /token instead of /idp/token), while keeping originalUrl so the provider derives the correct mount.
  • For GET/HEAD it just rewrites req.url; for other methods it builds a re-streamed request (see below).
  • It awaits entry.handler(target, res). On error it logs and, if headers are not yet sent, returns HTTP 500 { "error": "server_error" }.

Body re-streaming (restreamBody)

Saltcorn/Express has already drained and parsed the POST body, but oidc-provider reads the raw request stream itself. restreamBody (lib/oidc/routes.js) rebuilds a Readable carrying the body:

  • It prefers req.rawBody if present; otherwise it re-encodes req.body as JSON (when content-type is application/json) or as URL-encoded form data; if there is no body it uses an empty buffer.
  • It copies the IncomingMessage properties Koa / raw-body rely on (headers with a corrected content-length, rawHeaders, method, mount-relative url, full originalUrl, HTTP version fields, socket, connection, complete = false).

Storage adapter

SaltcornAdapter (lib/oidc/adapter.js) implements the oidc-provider storage interface against the single _idp_oidc_store table. oidc-provider instantiates one adapter per model name (new SaltcornAdapter(name)); rows are tenant-scoped because the db.* calls run in the request's tenant context.

Method Behaviour
upsert(id, payload, expiresIn) Stores model, id, JSON payload, plus extracted uid / grant_id (payload.grantId) / user_code (payload.userCode) and expires_at (epoch + expiresIn, or null). Inserts with { noid: true } or updates the existing (model, id) row.
find(id) For model Client, returns clients.toOidcMetadata(clients.getClient(id)) (dynamic client lookup); otherwise parses and returns the stored payload, or undefined.
findByUid(uid) Looks up by (model, uid).
findByUserCode(userCode) Looks up by (model, user_code).
consume(id) Sets payload.consumed = epoch() and writes it back (single-use authorization codes).
destroy(id) Deletes the (model, id) row.
revokeByGrantId(grantId) Deletes every row with that grant_id (revokes all artifacts of a grant).

oidc-provider validates expiry / consumed itself from the payload, so find() returns the stored payload as-is.

_idp_oidc_store columns (reference)

Defined in lib/schema.js:

Column Notes
model TEXT; oidc-provider model name (e.g. AuthorizationCode, Grant, RefreshToken)
id TEXT
payload TEXT, JSON
uid TEXT (interaction uid)
grant_id TEXT
user_code TEXT
expires_at INTEGER, Unix epoch seconds
PRIMARY KEY (model, id)

Indexes exist on uid, grant_id, and user_code.


Per-tenant issuer and keys

The issuer is derived by issuerForReq(req) (lib/oidc/discovery.js):

  1. Prefer Saltcorn's configured base_url (getState().getConfig("base_url", "")).
  2. If unset, fall back to req.protocol + "://" + req.get("host") and log a warning.
  3. Strip trailing slashes and append IDP_BASE_PATH (/idp).

Example: with base_url = https://example.com, the issuer is https://example.com/idp.

Security: the request-host fallback is vulnerable to Host-header injection (a forged Host could poison the advertised issuer/endpoints). base_url MUST be set in any multi-tenant or proxied deployment; the fallback exists for single-tenant localhost/dev. The issuer MUST exactly equal the URL prefix a relying party used to fetch the discovery document.

Because providers are cached per issuer and each tenant has its own _idp_keys (its own active signing key) and its own _idp_clients, multi-tenant / multi-host deployments get fully isolated issuers, JWKS, and client registries. See ./architecture.md for the broader tenancy model and ./operations.md for per-tenant install and host routing.


Variable Used for
SALTCORN_SESSION_SECRET Derives the at-rest KEK (sealing private keys and client secrets) and the deterministic oidc-provider cookie-signing keys. Required; see the "Environment variables" and "Crypto byte sizes" sections of ./configuration.md.

Saltcorn's base_url config (not an env var) drives the issuer; see above.