Authentication
Local accounts, Keycloak login, API tokens, organizations and access control.
All routes except GET /openapi.json, GET /docs, and GET /settings/messages require a local or Keycloak access token — either as an access_token cookie (see Local accounts and Keycloak login) or an Authorization: Bearer <token> header — or a personal API token.
Organizations and roles
Workspaces belong to Organizations, not directly to users. An organization has members with one of three roles:
| Role | Read | Write (elements/relationships/views/…) | Manage members | Rename organization |
|---|---|---|---|---|
owner | ✅ | ✅ | ✅ | ✅ |
editor | ✅ | ✅ | ❌ | ❌ |
viewer | ✅ | ❌ | ❌ | ❌ |
- Every organization/workspace route resolves the caller's role through the
single authorization gateway,
apps/server/lib/archimate/access.ts— never a per-route check. - Two-level error convention:
404 Not Foundif the caller has no membership in the target organization (deliberately masks "not a member" as "not found");403 Forbiddenif the caller is a recognized member but their role is insufficient for the action, or the organization has been suspended by an Admin. - A fourth role, Admin (Keycloak identifier
platform_admin), is a Keycloak realm role (set on the Keycloak user, not anorganization_membersrow). It administers organizations, users, plugins and the image library from/platform/**//api/platform/**(metadata only: list, suspend/reactivate, delete, member management), gated bywithSuperAdminand independent of organization membership. For an organization's actual content (workspaces, elements, views, dashboards), Admin has no special access: it goes through the exact same role/ suspension checks as any other user, and needs a realorganization_membersrow on that organization — which it can create for itself from/platform/organizations/[id]'s member management, exactly like adding any other user — to see or act on anything, including being refused on a suspended organization even as a real owner. Because it has no unconditional cross-organization access, Admin cannot create a personal API token (POST /api/settings/api-tokensreturns403for it). - Organizations are never self-provisioned: creating one is restricted to
the admin console (
POST /api/platform/organizations). A user with noorganization_membersrow cannot create a workspace either — it must be granted membership first (by an existing owner/editor, or by an Admin adding itself from/platform/organizations/:id) — and sees the simple starter home page (/) instead of the workspace picker until then. Organization members cannot create or delete team organizations. - Adding a member by username (
POST /api/organizations/:id/members) requires an existing Keycloak account — to invite someone who doesn't have one yet, see Organization invitations by e-mail below.
Organization invitations by e-mail
An owner can invite anyone by e-mail (POST /api/organizations/:id/invitations,
email + role + optional delivery_mode), even if they have no Keycloak
account yet. This is only enabled on the shared/pooled Keycloak realm — see
One Keycloak realm per client — since it
requires self-registration to be turned on for that realm.
- Uniqueness: only one active invitation per (organization, e-mail) can
exist at a time, enforced by a partial unique index in Postgres
(
packages/db/src/schema.ts), not just an application-level check. Creating an invitation for an e-mail that already has an active one revokes the old one and issues a new token — this is also how "resend" works (POST /api/organizations/:id/invitations/:invitationId/resend), there's no separate code path. - Token: only its SHA-256 hash (
tokenHash) is stored — the clear-text token exists solely in the e-mail and the accept-page URL, never persisted. Creating the row and sending the e-mail aren't atomic (SMTP isn't part of the DB transaction): the row'ssent_atstaysnullif the send fails, and the invitation must be resent — it isn't lost. - Missing identity: when the address has no Keycloak identity,
ArchiSpark creates an enabled account without credentials and asks
Keycloak to send
UPDATE_PROFILE,UPDATE_PASSWORD, andVERIFY_EMAILactions. If the first action e-mail fails, the newly created identity is removed so a copied link can still use Keycloak self-registration; Resend reissues the action e-mail for an account whose setup is still pending. A normal invitation e-mail is sent instead when the identity already exists. - Delivery mode (
delivery_mode:bothdefault,email, ormanual): the owner chooses Email and link, Email only, or Link only in the member-management interface. The clear link is never stored or included when invitations are listed. If SMTP fails inbothmode, the owner can still copy the link. An air-gapped setup requires the invitee to have a locally provisioned, verified Keycloak account; use the Keycloak admin console orpnpm run seed:demo-userswhen no mail service is available. - Accept endpoints:
GET /api/invitations/:token(preview) andPOST /api/invitations/:token/acceptboth still require an authenticated caller (requireAuth, mounted globally) — an unauthenticatedGETreturns401before the token is ever looked up. They deliberately bypassaccess.ts/assertOrgAccess, though: the invitee isn't a member yet, so there's nothing inorganization_membersto check. The guard instead is the triplet authentication + valid/non-expired token + a Keycloakemail_verified: trueclaim whoseemailmatches the invited address — a token alone never proves identity, only which invitation is being redeemed. - Concurrency: acceptance runs a compare-and-swap
(
UPDATE … WHERE accepted_at IS NULL AND revoked_at IS NULL … RETURNING *) inside a transaction, so two concurrent accepts (double click, two tabs) can't both succeed; the membership insert usesON CONFLICT DO NOTHINGrather than a try/catch, since a constraint violation would otherwise abort the whole transaction. - Local development: finish-registration, verification, invitation, and
Forgot password messages are captured by Mailpit at
http://localhost:8025. Inmanualmode no identity is pre-provisioned, so an invitee without an account follows the copied link and chooses Register in Keycloak instead. - Open question: self-registered accounts creating a duplicate e-mail
is blocked realm-side (
duplicateEmailsAllowed: false, applied only when self-registration is on) — but this alone doesn't resolve what happens when a local account later signs in via an SSO identity provider (Google/Microsoft) with the same e-mail.
/api/settings/messages (PUT) is restricted to users holding the global
Admin realm role (platform_admin; requireSuperAdmin).
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/me | user | Returns current user |
Default credentials: admin / admin (Admin, no organization membership by design), user / user, contrib / contrib, archi / archi, open / open. The demo seed creates two organizations, deliberately isolated from each other: Archi (archi as owner, contrib/user as editor/viewer) and Open (open as sole owner) — see Demo seed.
Local accounts
The default sign-in method — a username/password account stored in the
shared users table, no external identity provider required. Keycloak SSO
(below) is an optional, off-by-default alternative (KEYCLOAK_SSO_ENABLED),
offered as a "Continue with {name}" button on the login page when enabled.
| Aspect | Detail |
|---|---|
| Password hashing | argon2id, OWASP-minimum params (packages/auth/src/local-password.ts) |
| Access token | HS256 JWT, 15 min, issuer archispark-local (local-jwt.ts) — same claim shape as a Keycloak token, so requireAuth reads either via verifyAnyAccessToken |
| Refresh token | Opaque, 30 days, stored as a sha256 hash (local_refresh_tokens); rotated on every use — presenting an already-rotated token revokes every session for that user (reuse detection) |
| Rate limiting | 5 failed attempts / 15 min, keyed by both username and IP (local_login_attempts) |
| Login | POST /api/auth/local/login — { username, password } |
| Refresh / logout | POST /api/auth/refresh, GET /api/auth/logout — same routes as Keycloak, dispatched by an auth_provider cookie |
| First-boot account | Migration packages/db/drizzle-pg/0025_seed_local_admin.sql creates admin/admin (platform_admin) the first time it runs against an empty users table — no seed command needed for a bare login |
| Forced password change | users.mustChangePassword (set on the seeded account above) is carried as a must_change_password JWT claim; proxy.ts redirects every page but /change-password until changing the password or selecting Skip clears it. Skip keeps the current password and is intended only for local development. |
| Re-seed | pnpm seed:local-admin — recreates admin/admin on demand (.docker/local-auth/admin-user.json), e.g. after pnpm --filter @workspace/db reset or to recover a locked-out account |
Self-registration and "forgot password" (a reset link by e-mail) aren't
implemented yet — beyond the seeded admin account, users are provisioned
by pnpm seed:local-admin, or (in a future admin UI) by a platform_admin.
Keycloak login
| Command | Effect |
|---|---|
pnpm run infra:up | Starts a local Keycloak (classic quay.io/keycloak/keycloak distribution, http://localhost:8080, admin console login from KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD in .env), pre-loaded via --import-realm from .docker/keycloak/realm-export.json with realm archispark, client archispark-web, the Admin realm role (platform_admin), and the api service account (archispark-api, manage-users/view-users/query-users/view-realm). |
pnpm seed:demo-users | Creates/updates the 4 demo accounts (admin/user/contrib/archi, passwords match usernames) via the Keycloak Admin API, from .docker/keycloak/demo-users.json (not part of realm-export.json). Works against any Keycloak instance, including a client's dedicated realm on a remote server. |
pnpm run seed:keycloak (pnpm setup:realm) | Creates or updates the realm itself (roles, clients, service account) from the same realm-export.json via the Admin REST API — an alternative to --import-realm for environments where the Keycloak container isn't recreated from scratch (e.g. onboarding a new client's realm on a shared remote Keycloak, see Deployment). |
One Keycloak realm per client
Each ArchiSpark client gets its own Keycloak realm (archispark-<tenant>)
on a shared, self-hosted classic Keycloak instance. A realm is a fully
separate identity namespace — its own users, roles, Identity Providers,
sessions, and JWKS/issuer — so this gives complete tenant isolation with
no application code involved:
verifyAccessToken
already validates the token's issuer
(${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}), which includes the realm
name. A token issued for archispark-acme is therefore automatically
rejected by a deployment configured with KEYCLOAK_REALM=archispark-other.
Each client's apps/server deployment points at its own realm via env vars:
| Variable | Role |
|---|---|
KEYCLOAK_URL | The shared Keycloak instance |
KEYCLOAK_REALM | archispark-<tenant> for this client |
KEYCLOAK_CLIENT_ID_WEB | archispark-web |
KEYCLOAK_ADMIN_CLIENT_ID / KEYCLOAK_ADMIN_CLIENT_SECRET | The service account created in that realm |
KEYCLOAK_SELF_REGISTRATION / KEYCLOAK_VERIFY_EMAIL / KEYCLOAK_RESET_PASSWORD | Off by default for every realm — a dedicated client realm never gets them unless its deployment explicitly sets these env vars for pnpm setup:realm (see Organization invitations by e-mail); today only the shared/pooled realm turns them on. |
SSO (Google/Microsoft/other OIDC or SAML) is configured per realm via the admin console's Identity providers menu — a client's SSO configuration is never visible to another client. See Deployment for the full onboarding runbook.
Bearer token:
apps/server(via@workspace/auth,packages/auth) accepts a Keycloak-issued access token as a Bearer token, verified against the realm's JWKS (KEYCLOAK_URL/KEYCLOAK_REALM).- The resolved
AuthContext.useris built directly from the verified claims —id: claims.sub,username: claims.preferred_username, androle: "platform_admin"ifrealm_access.rolesincludesplatform_admin("user"otherwise). - A request may alternatively present a personal API token (
apiTokenstable) as the Bearer value —lookupApiTokenresolves it to the sameAuthContext.usershape via the Keycloak Admin API. A token is created scoped to one organization (organization_id, required) and optionally pinned to one workspace of that organization (workspace_id); this scope is carried asAuthContext.tokenContextand takes priority over interactive active-organization/workspace selection inresolveActiveContext. - The token's
owner/editor/viewerrole is never frozen on the token itself — it's re-resolved live fromorganization_memberson every request, so a revoked or demoted membership takes effect immediately even for an existing token.
Browser login for apps/server: the app signs in via the OIDC
authorization-code + PKCE flow against Keycloak. /login is a single "Se
connecter" link to /api/auth/login, using the KEYCLOAK_CLIENT_ID_WEB
client (archispark-web).
| Route | Purpose |
|---|---|
GET /api/auth/login?from=<path> | Generates a PKCE pair + state, stores them in short-lived (5 min) httpOnly cookies (pkce_verifier, oidc_state, auth_redirect), redirects to Keycloak's /protocol/openid-connect/auth |
GET /api/auth/callback | Validates state, exchanges the code for tokens, sets httpOnly access_token / refresh_token / id_token cookies (SameSite=lax, max-age from the token response), redirects to auth_redirect |
GET /api/auth/logout | Clears the three token cookies and redirects through Keycloak's RP-initiated end-session back to /login |
POST /api/auth/refresh | Exchanges refresh_token for a new token set — 204 + new cookies on success, 401 + cleared cookies on failure |
GET /api/auth/me | Verifies access_token and returns {id, username, name, email, role} (role is platform_admin when realm_access.roles contains it, else user) |
proxy.ts(Next middleware) decodes theaccess_token'sexplocally on every navigation; if it's expired (or missing) it calls/api/auth/refreshusing therefresh_tokencookie and forwards the resultingSet-Cookies before continuing, and only redirects to/api/auth/login?from=<path>if the refresh also fails.apps/server'srequireAuthalso accepts theaccess_tokencookie — verified and bridged the same way as the Bearer path above, resolving to the sameAuthContext.useras a Bearer token for the same person. Because UI, REST API, and MCP now shareapps/server, no cross-application rewrite is involved.