# 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`](https://www.npmjs.com/package/oidc-provider) library. For the other protocols this plugin implements, see [`./ldap.md`](./ldap.md) and [`./saml.md`](./saml.md). The admin pages used to register clients and groups are documented in the "Admin UI pages" section of [`./configuration.md`](./configuration.md#admin-ui-pages). Multi-tenant behaviour is covered in [`./architecture.md`](./architecture.md) (overview) and [`./operations.md`](./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"). --- ## 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`. 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=`. 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 ` 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: ```js { 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`](./configuration.md#admin-ui-pages)); 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_token`s 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`](./configuration.md); the at-rest sealing rationale is in [`./security.md`](./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_PREFIX = "role:"`); and - each custom group the user belongs to (via `_idp_group_members` -> `_idp_groups`), emitted as `group:` (`GROUP_PREFIX = "group:"`). Example claim value: ```json { "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`](./configuration.md#admin-ui-pages)). --- ## 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`): ```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`](./architecture.md) for the broader tenancy model and [`./operations.md`](./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`](./configuration.md). | Saltcorn's `base_url` config (not an env var) drives the issuer; see above.