Authentication
The backend integrates with Casdoor as the identity provider (IDP). Authentication uses the OIDC authorization code flow with JWT access tokens.
Login flow
The backend operates in STANDALONE mode: it receives a raw JWT in the
Authorization: Bearer header (or access_token cookie), verifies it via OIDC
discovery against Casdoor's JWKS, resolves the IDP subject to an internal
Primebrick UUID, and expands the user's roles into a flat permission set.
The login endpoint sets access_token and refresh_token as HttpOnly cookies
so the frontend never reads the JWT in JavaScript. Subsequent requests send the
cookie automatically; the auth middleware also accepts an explicit
Authorization: Bearer header (e.g. for curl, mobile clients, or API keys).
Auth endpoints
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /api/v1/auth/login | PUBLIC | Casdoor password grant; sets cookies |
POST | /api/v1/auth/refresh | PUBLIC | Casdoor refresh grant; rotates cookies |
GET | /api/v1/auth/config | PUBLIC | Public auth flags (enable_webauthn, passkey_required, enable_mfa) |
GET | /api/v1/auth/me | AUTHENTICATED_USER | Fetch own profile + permissions |
PATCH | /api/v1/auth/me | AUTHENTICATED_USER | Self-service profile update |
POST | /api/v1/auth/mfa/verify | PUBLIC | Verify TOTP code for a login challenge; sets cookies |
POST | /api/v1/auth/mfa/challenge/refresh | PUBLIC | Swap a stale login challenge for a fresh one |
POST | /api/v1/auth/mfa/step-up/initiate | AUTHENTICATED_USER | Mint a step-up challenge for a sensitive action |
POST | /api/v1/auth/mfa/step-up/verify | AUTHENTICATED_USER | Verify step-up TOTP; returns single-use action token |
POST | /api/v1/auth/mfa/enroll/begin | AUTHENTICATED_USER | Start TOTP enrollment (secret + QR + recovery codes) |
POST | /api/v1/auth/mfa/enroll/finish | AUTHENTICATED_USER | Finish enrollment with a TOTP passcode |
GET | /api/v1/auth/mfa/factors | AUTHENTICATED_USER | List own MFA factors |
DELETE | /api/v1/auth/mfa/factors/:uuid | AUTHENTICATED_USER | Remove an MFA factor |
curl examples
Code
Code
Auth middleware
The auth middleware (src/modules/auth/auth.middleware.ts) is a thin Express
wrapper around the SDK's verifyAuth(). It runs on every protected route (the
rbacHandler(...) declaration calls it internally — see
RBAC):
- Reads the JWT from the
Authorization: Bearerheader (oraccess_tokencookie in STANDALONE mode). - Calls
verifyAuth(HeaderProvider, cfg, ports)from@primebrick/sdk— verifies the JWT signature via OIDC discovery, resolves the IDP subject to an internaluser_profiles.uuid, and expands roles into a permission set. - Attaches
req.user(typeAuthUserfrom@primebrick/sdk). - Mirrors the user into
AsyncLocalStorageviarunWithSession()so the DAL can read the actor for audit fields (created_by,updated_by, etc.) without receiving it through every method signature.
AuthUser shape
Code
Initializing auth ports at startup
The auth middleware depends on two ports (DomainUserResolverPort and
DomainRoleMappingPort) that hit the database. They are created once at startup by
initAuthPorts(), and role mappings are preloaded into memory by
loadRoleMappings(). The middleware fails closed (503) while mappings are not
yet loaded.
Code
Auth configuration (config_entries)
All auth runtime configuration lives in the config_entries table (see
Environment configuration)
— never in .env. The loader validates at boot and throws on missing
mandatory keys: there are no fallback defaults.
| Key | Required when | Purpose |
|---|---|---|
auth_mode | always | STANDALONE (this API verifies the JWT itself via OIDC) or GATEWAY (trusted proxy injects identity headers) |
auth_roles_path | always | Dotted JWT path used to extract roles from any token shape (e.g. roles, realm_access.roles, resource_access.<client>.roles) |
idp_type, idp_endpoint, idp_organization, idp_client_id, idp_client_secret | IDP calls | Casdoor™ connection used by login, org sync, and user resolution |
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_audience | STANDALONE | OIDC discovery + JWT signature/audience verification |
service_client_shield_secret[_header], gateway_public_secret[_header], gateway_header_* | GATEWAY | Shield secrets + the user-identity header names the gateway sets |
enable_mfa, mfa_challenge_token_ttl_seconds, mfa_challenge_signing_secret | MFA | TOTP enrollment + login/step-up challenge minting |
enable_webauthn, passkey_required, password_policy, invitation_expiry_days | feature flags | Public auth config surfaced via GET /api/v1/auth/config |
redis_url | optional | MFA challenge stash cache; falls back to in-memory when absent |
GATEWAY mode contract
A trusted reverse proxy (Kong, Tyk, APISIX, Envoy) terminates the Bearer
token and forwards identity via the gateway_header_* configured headers
(user id, email, name, roles, idp code, idp org, idp username). Requests
are gated by service_client_shield_secret on authenticated routes and
gateway_public_secret on PUBLIC routes. The API MUST be
network-isolated — reachable only from the gateway IP range (or mTLS):
the shared secret is defense in depth, not a substitute for isolation.
Session context (AsyncLocalStorage)
requireActor() / runAsSystem() are exported by @primebrick/sdk
(auth/session-context.ts). The auth middleware mirrors the
authenticated user into an AsyncLocalStorage store via runWithSession()
so services and the DAL read the actor for audit columns without
plumbing it through every signature. requireActor() throws outside a
request/runAsSystem() scope — intentionally fail-closed, so user-facing
code can never silently fall back to system. Non-HTTP callers (seed
scripts, scheduled jobs, migrations) wrap their entry point:
Code
Only the internal user_profiles.uuid is ever written to audit columns;
the IDP sub (req.user.idp_code) is kept for observability but must
not appear in business records.
RBAC middleware
The RBAC middleware (src/modules/auth/rbac.middleware.ts) enforces permission
checks. It is invoked via the rbacHandler(...) declaration on every route —
see RBAC for the full system. In short:
- If the declaration is
PUBLIC, skip auth and callnext(). - Otherwise, run
authMiddleware()to populatereq.user. - If
req.user.isAdmin === trueorreq.user.isSystem === true, bypass all checks (handled by the SDK'scheckRbac()). - Otherwise, evaluate
checkRbac(req.user, declaredPerms, mode)— OR (any-of) by default, AND (all-of) viarbacHandler.all([...]).
The isAdmin/isSystem bypass covers entity/API RBAC only. Navigation
visibility is a separate axis: access.{app}.{module}.{page} grants travel in
the same permission set but gate discovery metadata, evaluated by
isAccessGranted() — is_admin implies access.system there, nothing more.
See Module navigation metadata — Access axis.
MFA (TOTP)
When a user has enrolled MFA factors, POST /api/v1/auth/login returns
mfa_required with an mfa_challenge_token instead of setting cookies. The
challenge is an HS256 JWT whose jti keys a server-side stash holding the
Casdoor tokens (Redis mfa:challenge:{jti}, 5-minute TTL, single-use; falls
back to in-memory when Redis is not configured).
Code
POST /api/v1/auth/mfa/challenge/refresh exchanges a stale challenge for a
fresh one: the old token's signature is verified (expiry ignored), the stash
is moved to a new jti with a fresh TTL. The frontend calls it every time
the OTP form is shown, so reloads and idle waits never strand the user on a
dead challenge. If the stash is already gone the endpoint returns 401 MFA_CHALLENGE_EXPIRED — the only recovery is a fresh password login.
Step-up MFA (step-up/initiate + step-up/verify) protects sensitive
operations on an already-authenticated session: verify returns a single-use
action_authorization_token that must be sent in the
X-MFA-Action-Authorization header of the protected call.
Passkeys (WebAuthn)
The backend supports WebAuthn passkey signin and enrollment. The browser's
Origin header is forwarded to Casdoor so it computes the correct WebAuthn
rpId — without this, navigator.credentials.get() would fail in the browser.
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /api/v1/auth/webauthn/signin/begin | PUBLIC | Start passkey signin |
POST | /api/v1/auth/webauthn/signin/finish | PUBLIC | Complete passkey signin; sets cookies |
POST | /api/v1/auth/webauthn/signup/begin | AUTHENTICATED_USER | Start passkey enrollment |
POST | /api/v1/auth/webauthn/signup/finish | AUTHENTICATED_USER | Complete passkey enrollment |
GET | /api/v1/auth/webauthn/credentials | AUTHENTICATED_USER | List enrolled passkeys |
DELETE | /api/v1/auth/webauthn/credentials/:id | AUTHENTICATED_USER | Remove a passkey |
Passkeys are stored in the user_passkeys table and linked to the user
profile. Whether passkey signin is offered is controlled by the
enable_webauthn flag in config_entries (returned by
GET /api/v1/auth/config).
Passkey signin flow
Code
User invitations (welcome flow)
Administrators invite users via Casdoor; the invitee completes onboarding through a public welcome flow that verifies the invitation token, sends an OTP to the invitation email, verifies the OTP, and lets the user set a password.
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /api/v1/auth/welcome/verify | PUBLIC | Verify invitation token |
POST | /api/v1/auth/welcome/send-otp | PUBLIC | Send OTP to invitation email |
POST | /api/v1/auth/welcome/verify-otp | PUBLIC | Verify OTP code |
POST | /api/v1/auth/welcome/complete | PUBLIC | Set password + complete onboarding |
POST | /api/v1/auth/invitations/:uuid/revoke | AUTHENTICATED_ADMIN | Revoke invitation |
POST | /api/v1/auth/invitations/:uuid/resend | AUTHENTICATED_ADMIN | Resend invitation |
GET | /api/v1/auth/login-alert | PUBLIC | Process "if this wasn't you" link |
Security notes:
- Public welcome endpoints return only boolean/flag fields, never the user's email or other PII.
- The welcome page (FE) sets
Referrer-Policy: no-referrerand uses a URL fragment (#token=...) so the token never leaks via Referer or server logs. - IP rate limiting for public endpoints is configured at the infra level.
Welcome flow example
Code
Organizations
The backend supports multi-tenant organizations. Organization CRUD follows
the standard entity pattern at /api/v1/entities/organization/... — see
API Conventions for the full endpoint list.
Organization writes are synchronized to Casdoor (the IdP) before the local
database write: create, update, delete, and restore each call the
matching Casdoor organization operation via the record's idp_code, and fail
with 502 CASDOOR_SYNC_FAILED if the IdP call fails.
Bulk operations are architecturally impossible for this entity. A bulk op
is an atomic set operation — a single transaction applying one change to many
rows — which cannot interleave N synchronous per-record IdP calls. A loop of
single deletes is not a bulk op, it is just N single deletes with partial
failure semantics. Therefore bulk-delete, bulk-restore, duplicate, and
export do not and cannot exist for organization unless the Casdoor sync
contract itself changes. meta.actions reports no bulk ops and the frontend
never renders bulk CTAs on the organizations list.
The only organization-related endpoint under /system is
GET /api/v1/system/organizations/active — an RPC that returns a minimal
DTO for the sidebar organization switcher. It requires only
AUTHENTICATED_USER (not ORGANIZATION_READ_ALL).
Users can belong to multiple organizations. Role mappings are scoped per organization.
Error responses
Every auth error is a RFC 7807 Problem Details object with a stable
internal_code. The frontend branches on internal_code to pick the right UI
(toast, redirect to login, show MFA form, etc.).
internal_code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid Bearer token / cookie |
AUTH_CLIENT_SHIELD_SECRET_INVALID | 401 | GATEWAY mode: wrong client-shield secret header |
AUTH_USER_CONTEXT_MISSING | 401 | authMiddleware ran but req.user is null |
AUTH_ROLE_MAPPINGS_NOT_LOADED | 403 | Startup race — role mappings not yet loaded |
RBAC_PERMISSION_DENIED | 403 | User lacks the required permission(s) |
RBAC_ADMIN_ONLY | 403 | AUTHENTICATED_ADMIN sentinel, user is not admin |
ROUTE_PERMISSION_NOT_DECLARED | 403 | Secure-first default-deny (route forgot rbacHandler) |
AUTH_CONFIG_NOT_LOADED | 500 | config_entries table missing/empty (CRITICAL) |
Code
Code
Next steps
- RBAC — the wildcard permission system,
rbacHandler, and role mappings. - Architecture — the request-flow diagram and HTTP layer internals.
- Collaboration — how the auth user context feeds the audit actor.
- DAL connections & transactions —
runWithSessionand the actor pattern.