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

440 lines
21 KiB
Markdown

# 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=<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:
```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>` (`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:
```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.