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 asjwks_uri(lib/constants.js). - The discovery document and JWKS are generated and served by
oidc-provideritself;lib/oidc/discovery.jsnow 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 tooidc-provider's callback (see "oidc-provider integration").
Authorization-code + PKCE + consent flow
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.
-
Relying party redirects the browser to
/idp/authwith the usual code-flow parameters (client_id,redirect_uri,response_type=code,scope,state,nonce,code_challenge,code_challenge_method=S256). The route delegates tooidc-provider, which validates the client, redirect URI, scopes, and PKCE challenge against the dynamically-loaded client metadata. -
oidc-providerdetermines whether it needs an interaction (login and/or consent). The interaction URL is produced by theinteractions.urlcallback inbuildProvider(lib/oidc/provider.js), which returnsIDP_BASE_PATH + "/interaction/" + interaction.uid(i.e./idp/interaction/:uid). -
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 400invalid or expired interaction. - If
req.user && req.user.id(the user already has a Saltcorn session), it finishes the login withprovider.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 thedest, the handler re-runs, now seesreq.user, and finishes the login. Only EXISTING Saltcorn users authenticate; there is no auto-provisioning (seefindAccountbelow).
- It calls
-
Consent prompt -- also handled by
interactionHandler:- When
details.prompt.name === "consent", it loads the client row viaclients.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 withweb.escapeHtml. - The form POSTs to
/idp/interaction/:uid/confirm.
- When
-
Consent confirm -- handled by
confirmHandler(lib/oidc/interactions.js):- Deny: if
req.body.denyis truthy, it finishes withinteractionFinished(..., { error: "access_denied", error_description: "User denied the request" }, { mergeWithLastSubmission: false }), which redirects the browser back to the client witherror=access_denied. - Allow: it creates a grant with
new provider.Grant({ accountId: details.session.accountId, clientId: details.params.client_id }), callsgrant.addOIDCScope(details.params.scope)when a scope is present,await grant.save()(persisted through the storage adapter), theninteractionFinished(..., { consent: { grantId: grantId } }, { mergeWithLastSubmission: true }).
- Deny: if
-
oidc-providerresumes the authorization request (via/idp/auth/:uid), issues an authorization code, and redirects to the client'sredirect_uriwithcodeand the echoedstate. -
The client exchanges the code at
/idp/token(POST).oidc-providervalidates the client credentials (PKCEcode_verifierfor public clients, or the client secret for confidential clients), validates and consumes the code via the adapter, callsfindAccount+claims, signs theid_tokenwith the active RS256 key, and returns the token response (access_token,id_token,token_type,expires_in). -
The client may call
/idp/me(GET or POST) withAuthorization: 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; thesecret_ciphertext/secret_iv/secret_tagcolumns are leftnull. 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 asbase64url, then sealed at rest withidpCrypto.sealText(AES-256-GCM under the plugin KEK). The plaintext secret is returned ONCE fromcreateClientas{ 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"inlib/constants.js), using a 2048-bit RSA key (RSA_MODULUS_BITS = 2048).- The Provider is configured with
jwks: { keys: [privateJwk] }inbuildProvider, whereprivateJwkcomes fromkeys.getActivePrivateJwk().oidc-providerboth signs with this private key and serves its PUBLIC half at the JWKS endpoint. conformIdTokenClaims: falseis set deliberately so scope-granted claims (e.g.email,groups) appear in theid_tokenas well as at userinfo, so relying parties can read them straight from theid_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 anactivekey already exists for the tenant it returns its metadata; otherwise it generates a new RSA keypair, derives akid(crypto.newKid()), stores the public JWK as JSON, seals the private key PEM (AES-256-GCM), and inserts the row withstatus = active. It returns only safe metadata (kid,alg,status,created_at) -- never the sealed private material. Called from the pluginonLoad.getJwks()returns{ keys: [...] }containing the public JWKs of bothactiveandretiringkeys. Publishing retiring keys lets relying parties keep validating recently-issued tokens across a rotation. (Note: the actual JWKS HTTP response is produced byoidc-providerfrom its configuredjwks;getJwks()is the plugin's own public-key reader, used e.g. by the admin dashboard.)getActiveKeyMeta()readskid/alg/created_atwithout 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_rolesbyuser.role_id, emitted asrole:<role>(ROLE_PREFIX = "role:"); and - each custom group the user belongs to (via
_idp_group_members->_idp_groups), emitted asgroup:<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
entryviagetProviderEntry(req). - It computes
stripped = fullUrl.slice(IDP_BASE_PATH.length) || "/"so the mount-relative router insideoidc-providermatches (e.g./tokeninstead of/idp/token), while keepingoriginalUrlso 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.rawBodyif present; otherwise it re-encodesreq.bodyas JSON (when content-type isapplication/json) or as URL-encoded form data; if there is no body it uses an empty buffer. - It copies the
IncomingMessageproperties Koa / raw-body rely on (headerswith a correctedcontent-length,rawHeaders,method, mount-relativeurl, fulloriginalUrl, 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):
- Prefer Saltcorn's configured
base_url(getState().getConfig("base_url", "")). - If unset, fall back to
req.protocol + "://" + req.get("host")and log a warning. - 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.
Related environment variables
| 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.