The foundational plugin: who is this? and what may they do?
- A global
AuthGuard: every route needs a session unless marked@Public(). - Invite-only. No public registration. Accounts come from an admin’s invite or a first SSO sign-in.
- Sessions are a random 256-bit token in the
orthacms_sessioncookie (httpOnly), stored as a SHA-256 hash. No signing secret to configure.
Install
npm install @orthacms/identity-server @orthacms/identity-admin
# optional, for single sign-on
npm install @orthacms/identity-provider-oidc| Provider | Package | Speaks |
|---|---|---|
| OpenID Connect | @orthacms/identity-provider-oidc | Okta, Auth0, Keycloak, Google, Entra ID… |
| GitHub | @orthacms/identity-provider-github | OAuth2, no identity token |
| SAML 2.0 | @orthacms/identity-provider-saml | HTTP-POST binding, XML signatures |
| Scripted | @orthacms/identity-provider-fake | Nothing — tests only, never registered |
| Your own | — | Anything |
Configure
# .env — sessions, tokens, rate limit
# default http://localhost:4200 (the port follows ADMIN_PORT)
ALLOWED_ORIGINS=https://cms.example.com
# 7 days
SESSION_TTL_SECONDS=604800
# 7 days
INVITE_TTL_SECONDS=604800
# 1 hour
RESET_TTL_SECONDS=3600
# attempts per window, per client — every public auth route
LOGIN_RATE_LIMIT=10
LOGIN_RATE_LIMIT_TTL_SECONDS=60
# behind a proxy, or the rate limit is one shared bucket
TRUST_PROXY=1
# first administrator — provisioned on boot, idempotent
ORTHACMS_ROOT_ADMIN_EMAIL=admin@example.com
ORTHACMS_ROOT_ADMIN_PASSWORD=change-me
ORTHACMS_ROOT_ADMIN_NAME=Admin
# single sign-on handshake
# default: first ALLOWED_ORIGINS entry
SSO_PUBLIC_BASE_URL=https://cms.example.com
SSO_REQUEST_TTL_SECONDS=600
# reference server only:
# just-in-time accounts; unset = off
SSO_PROVISION_DOMAINS=example.com
SSO_PROVISION_ROLE=viewer
# "false" disables passwords (root admin exempt)
SSO_ALLOW_PASSWORD_LOGIN=true
# shorter lifetime for SSO sessions
SSO_SESSION_TTL_SECONDS=28800ALLOWED_ORIGINS: comma-separated origins allowed on state-changing routes. A request with noOriginheader (curl, a server) is not blocked.- The cookie is
SameSite=lax, andSecurein production. - A blank
ORTHACMS_ROOT_ADMIN_PASSWORDwith an email set fails the boot (MissingRootAdminPasswordError). - Provisioning matches domains exactly:
acme.comnever admitsevil-acme.com.
A generated app’s config/identity.ts reads everything above except the four
“reference server only” variables. Set the matching sso fields there by hand:
// config/identity.ts (generated app) — add to ssoConfig()
return defined({
publicBaseUrl: readEnv('SSO_PUBLIC_BASE_URL'),
requestTtlSeconds: readPositiveInt('SSO_REQUEST_TTL_SECONDS', 600),
provisioning: { domains: ['example.com'], defaultRole: 'viewer' },
allowPasswordLogin: true,
sessionTtlSeconds: 8 * 60 * 60
});Per-provider variables (SSO_OIDC_*, SSO_GITHUB_*, SSO_SAML_*) are on
each provider’s page.
Register
// apps/server/src/plugins.ts
import { IdentityPlugin } from '@orthacms/identity-server';
import type { SsoRegistration } from '@orthacms/identity-domain';
import { createOidcProvider } from '@orthacms/identity-provider-oidc';
export function ssoProviders(config: OrthaCmsConfig): SsoRegistration[] {
const { oidc } = config.plugins.identity.ssoProviders;
const registrations: SsoRegistration[] = [];
if (oidc) {
const { name, ...settings } = oidc;
registrations.push({ name, provider: createOidcProvider(settings) });
}
return registrations; // order = order of buttons on the sign-in page
}
IdentityPlugin(config.plugins.identity, {
sso: {
providers: ssoProviders(config),
// optional: map a directory profile to a role key, or null to leave it
resolveRole: ({ profile, isNewAccount }) =>
isNewAccount && profile.groups?.includes('cms-editors') ? 'contributor' : null
}
});// apps/admin/src/plugins.ts — first in the list (it owns the public sign-in routes)
import { IdentityPlugin } from '@orthacms/identity-admin';
IdentityPlugin();Boot-time refusals:
- SSO providers with
session.cookieSameSite: 'strict'. The cookie would not survive the provider’s redirect back. - Provisioning with an empty domain list or an empty
defaultRole.
Without resolveRole, a sign-in never changes a role. A handler can never
demote an account that holds admin.
Verify
curl -s -c cookies.txt http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"change-me"}'
# {"ok":true} — 401 {"message":"Invalid credentials"} otherwise
curl -s -b cookies.txt http://localhost:3000/api/auth/me
# {"id":"…","email":"admin@example.com","name":"Admin","roleId":"…","status":"active","permissions":["content:read", …]} curl -s http://localhost:3000/api/auth/sso
# [{"name":"oidc","label":"acme.okta.com","kind":"oidc"}] — [] when none
curl -si http://localhost:3000/api/auth/sso/oidc/start | grep -i '^location'
# location: https://acme.okta.com/oauth2/v1/authorize?client_id=…&state=… Register this callback with the provider. ssoCallbackUrl builds the exact
string:
import { ssoCallbackUrl } from '@orthacms/identity-server';
ssoCallbackUrl(config.plugins.identity, 'oidc');
// "https://cms.example.com/api/auth/sso/oidc/callback"Routes
| Route | What it does |
|---|---|
POST /api/auth/login | { email, password } → session cookie |
POST /api/auth/logout | Ends the session |
GET /api/auth/me | Current user plus permission keys |
GET /api/auth/invite/:token, POST /api/auth/invite/accept | Redeem an invite. The GET names the address, the role and the workspaces — never who sent it |
GET /api/auth/reset/:token, POST /api/auth/reset | Redeem a reset |
GET /api/auth/password-recovery | { available } — whether this deployment sends recovery mail. Public |
POST /api/auth/password-recovery | { email } → 202 { ok: true }, the same for every address. 404 without mail. Public, since 0.13.0 |
DELETE /api/users/:id/sessions | Sign the member out everywhere but the caller’s own session → { revoked } (users:update). Since 0.13.0 |
GET / PUT /api/preferences | The caller’s own { theme, layout }. PUT is a partial patch — see below |
GET /api/auth/sso | [{ name, label, kind }] — public |
GET /api/auth/sso/:provider/start | 302 to the provider. ?redirect= a same-origin path |
GET / POST /api/auth/sso/:provider/callback | Completes sign-in. POST is SAML’s form post |
POST /api/auth/sso/:provider/backchannel-logout | logout_token form field → { "revoked": n }. 400 { "error": "invalid_request" } if the token does not verify or the provider has no verifyLogoutToken |
confirmPasswordon accept and reset is optional since 0.13.0; when sent it must still match.- Every SSO failure is the same 302 to
/identity/signin?error=sso. - An unknown provider name is a 404.
- Issuing invites and resets belongs to users; sending them to mail.
Full list: endpoint reference. Permission keys: permission reference.
Forgotten passwords
Since 0.13.0 the sign-in page’s Forgot it? link opens
/identity/forgot-password (ADR-0018 phase 2). What it shows depends on the
deployment:
- With a mail backend: an email field and Send reset link, then Check your email — “If … belongs to an account here, a link to choose a new password is on its way.” The same card for every address.
- Without one: “This server doesn’t send reset emails”, and where an admin makes the link — Members → your name → Sessions & access → Password reset link. It names nobody.
POST /api/auth/password-recovery answers 202 before it looks anything up, so
status, body and timing never say whether an account uses the address. Behind
the response:
- Per client: the same
LOGIN_RATE_LIMITbucket as sign-in — a429withRetry-After. - Per address: three messages an hour. Over that the request is dropped
silently; the key is a SHA-256 of the address, so
throttle_bucketsholds no list of what was typed. - Only an active member gets a message, and not within 60 seconds of the last link. A disabled account gets nothing; an invited one finishes through its invite.
- A sent link rotates the token, so any earlier reset link stops working. The
message is the
password_recoverykind, and the activity log recordsmember.password_reset_issuedwith the member as their own actor.
Identity owns the route; the users plugin issues the
link, registering itself with identity’s PasswordRecoveryRegistry (one
PasswordRecoveryIssuer per deployment — a second registration throws). With no
issuer, or one without a mail provider behind it, the POST is a 404.
The public pages were redrawn in 0.13.0 too: one password field with a Show
password toggle and a strength meter (advice only — the server’s minimum is
the rule), no confirm field; the invitation says as a {role} and in
{workspaces}, from the INVITE_WORKSPACE_DIRECTORY port the workspaces plugin
binds; and a dead link reads This invite has expired, This reset link has
expired or This link isn’t complete.
What it owns
Twelve tables, with migrations: users, roles, permissions,
role_permissions, sessions, tokens, api_tokens,
api_token_workspaces, user_preferences, sso_identities,
sso_auth_requests, throttle_buckets.
user_preferences holds a user’s colour theme and, since 0.11.0, the admin’s
panel layout — a map from panel id to { width?, collapsed? }.
PUT /api/preferences takes either or both; an absent field keeps its value,
and a body naming neither is a 400. layout is merged per panel in the
database, so two tabs resizing different panels never undo each other. Bounds:
panel ids like shell.sidebar, integer widths 120–2000px, at most 16 panels a
save, and at most 64 stored — a save past that is a 422.
{ "layout": { "shell.sidebar": { "width": 288, "collapsed": false } } }It seeds three system roles, admin, contributor and viewer, plus the
permission catalogue, idempotently on every boot.
Identity mints tokens; it never sends them
With no mail backend, invite and reset routes return the raw token once, for an admin to relay, and the “Forgot it?” page says to ask an admin: self-service recovery needs mail.