Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Identity plugin

Authentication, authorization, and the identity providers a deployment can reach.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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_session cookie (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
ProviderPackageSpeaks
OpenID Connect@orthacms/identity-provider-oidcOkta, Auth0, Keycloak, Google, Entra ID…
GitHub@orthacms/identity-provider-githubOAuth2, no identity token
SAML 2.0@orthacms/identity-provider-samlHTTP-POST binding, XML signatures
Scripted@orthacms/identity-provider-fakeNothing — 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=28800
  • ALLOWED_ORIGINS: comma-separated origins allowed on state-changing routes. A request with no Origin header (curl, a server) is not blocked.
  • The cookie is SameSite=lax, and Secure in production.
  • A blank ORTHACMS_ROOT_ADMIN_PASSWORD with an email set fails the boot (MissingRootAdminPasswordError).
  • Provisioning matches domains exactly: acme.com never admits evil-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

Example language
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

RouteWhat it does
POST /api/auth/login{ email, password } → session cookie
POST /api/auth/logoutEnds the session
GET /api/auth/meCurrent user plus permission keys
GET /api/auth/invite/:token, POST /api/auth/invite/acceptRedeem an invite. The GET names the address, the role and the workspaces — never who sent it
GET /api/auth/reset/:token, POST /api/auth/resetRedeem 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/sessionsSign the member out everywhere but the caller’s own session → { revoked } (users:update). Since 0.13.0
GET / PUT /api/preferencesThe 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/start302 to the provider. ?redirect= a same-origin path
GET / POST /api/auth/sso/:provider/callbackCompletes sign-in. POST is SAML’s form post
POST /api/auth/sso/:provider/backchannel-logoutlogout_token form field → { "revoked": n }. 400 { "error": "invalid_request" } if the token does not verify or the provider has no verifyLogoutToken
  • confirmPassword on 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_LIMIT bucket as sign-in — a 429 with Retry-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_buckets holds 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_recovery kind, and the activity log records member.password_reset_issued with 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.