PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
Backend
    Roles and PermissionsWorking Together on the Same RecordManaging ModulesSigning In and MFA
    Generated
    Manual
    Developer Guide
      OverviewArchitectureAPI ConventionsEntity metadata contractEntity CRUD PipelineEntities and CRUD conventionsModule Navigation MetadataService RegistryMCP GatewayThe Service ProxyRotating Secrets EngineTranslationsAuthenticationRBACEnvironment ConfigurationCollaboration & Visual Merge
Frontend
Microservices
powered by Zudoku
Developer Guide

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

MethodPathPermissionPurpose
POST/api/v1/auth/loginPUBLICCasdoor password grant; sets cookies
POST/api/v1/auth/refreshPUBLICCasdoor refresh grant; rotates cookies
GET/api/v1/auth/configPUBLICPublic auth flags (enable_webauthn, passkey_required, enable_mfa)
GET/api/v1/auth/meAUTHENTICATED_USERFetch own profile + permissions
PATCH/api/v1/auth/meAUTHENTICATED_USERSelf-service profile update
POST/api/v1/auth/mfa/verifyPUBLICVerify TOTP code for a login challenge; sets cookies
POST/api/v1/auth/mfa/challenge/refreshPUBLICSwap a stale login challenge for a fresh one
POST/api/v1/auth/mfa/step-up/initiateAUTHENTICATED_USERMint a step-up challenge for a sensitive action
POST/api/v1/auth/mfa/step-up/verifyAUTHENTICATED_USERVerify step-up TOTP; returns single-use action token
POST/api/v1/auth/mfa/enroll/beginAUTHENTICATED_USERStart TOTP enrollment (secret + QR + recovery codes)
POST/api/v1/auth/mfa/enroll/finishAUTHENTICATED_USERFinish enrollment with a TOTP passcode
GET/api/v1/auth/mfa/factorsAUTHENTICATED_USERList own MFA factors
DELETE/api/v1/auth/mfa/factors/:uuidAUTHENTICATED_USERRemove an MFA factor

curl examples

TerminalCode
# Login — cookies are set on the response curl -i -X POST http://localhost:3001/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"<user>","password":"<password>"}' # Set-Cookie: access_token=eyJhbGciOi...; Path=/; HttpOnly # Set-Cookie: refresh_token=...; Path=/; HttpOnly # Fetch own profile (cookie jar carries the access_token) curl http://localhost:3001/api/v1/auth/me -b cookies.txt
Code
{ "user": { "uuid": "550e8400-e29b-41d4-a716-446655440000", "email": "admin@example.com", "display_name": "Alice Admin", "is_admin": true }, "permissions": ["customer.*", "user_profile.*", "modules.read.all"] }

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):

  1. Reads the JWT from the Authorization: Bearer header (or access_token cookie in STANDALONE mode).
  2. Calls verifyAuth(HeaderProvider, cfg, ports) from @primebrick/sdk — verifies the JWT signature via OIDC discovery, resolves the IDP subject to an internal user_profiles.uuid, and expands roles into a permission set.
  3. Attaches req.user (type AuthUser from @primebrick/sdk).
  4. Mirrors the user into AsyncLocalStorage via runWithSession() 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
import type { AuthUser } from "@primebrick/sdk"; // req.user after authMiddleware runs: const user: AuthUser = { id: "550e8400-e29b-41d4-a716-446655440000", // internal user_profiles.uuid idp_code: "casdoor-sub-123", // original JWT sub (traceability) email: "alice@example.com", name: "Alice", roles: ["administrators"], permissions: new Set(["customer.*", "user_profile.*"]), isAdmin: true, // bypasses all RBAC checks isSystem: false, // true for system API keys idp_org: "admin", idp_username: "alice", raw_access_token: "eyJhbGciOi...", // captured for proxy forwarding };

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
// src/index.ts (simplified) import { initAuthPorts, loadRoleMappings } from "./modules/auth/auth.middleware.js"; async function runStartupTasks() { initAuthPorts(); // creates DomainUserResolverPort + DomainRoleMappingPort await loadRoleMappings(); // preloads role_mappings into memory // ...NATS subscriber, stale-detection job, etc. } app.listen(3001, () => console.log("API listening on http://localhost:3001")); void runStartupTasks().catch((err) => console.error("[startup]", err));

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.

KeyRequired whenPurpose
auth_modealwaysSTANDALONE (this API verifies the JWT itself via OIDC) or GATEWAY (trusted proxy injects identity headers)
auth_roles_pathalwaysDotted 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_secretIDP callsCasdoor™ connection used by login, org sync, and user resolution
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_audienceSTANDALONEOIDC discovery + JWT signature/audience verification
service_client_shield_secret[_header], gateway_public_secret[_header], gateway_header_*GATEWAYShield secrets + the user-identity header names the gateway sets
enable_mfa, mfa_challenge_token_ttl_seconds, mfa_challenge_signing_secretMFATOTP enrollment + login/step-up challenge minting
enable_webauthn, passkey_required, password_policy, invitation_expiry_daysfeature flagsPublic auth config surfaced via GET /api/v1/auth/config
redis_urloptionalMFA 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
import { runAsSystem } from "@primebrick/sdk"; await runAsSystem(() => repo.seedIfEmpty()); // actor = "system"

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:

  1. If the declaration is PUBLIC, skip auth and call next().
  2. Otherwise, run authMiddleware() to populate req.user.
  3. If req.user.isAdmin === true or req.user.isSystem === true, bypass all checks (handled by the SDK's checkRbac()).
  4. Otherwise, evaluate checkRbac(req.user, declaredPerms, mode) — OR (any-of) by default, AND (all-of) via rbacHandler.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
login (password ok, has MFA) → { mfa_required, mfa_challenge_token, available_factors } → POST /api/v1/auth/mfa/verify { mfa_challenge_token, factor_id, code } → cookies set, { success, user }

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.

MethodPathPermissionPurpose
POST/api/v1/auth/webauthn/signin/beginPUBLICStart passkey signin
POST/api/v1/auth/webauthn/signin/finishPUBLICComplete passkey signin; sets cookies
POST/api/v1/auth/webauthn/signup/beginAUTHENTICATED_USERStart passkey enrollment
POST/api/v1/auth/webauthn/signup/finishAUTHENTICATED_USERComplete passkey enrollment
GET/api/v1/auth/webauthn/credentialsAUTHENTICATED_USERList enrolled passkeys
DELETE/api/v1/auth/webauthn/credentials/:idAUTHENTICATED_USERRemove 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

TerminalCode
# 1. Begin — the response contains the WebAuthn challenge options curl -X POST http://localhost:3001/api/v1/auth/webauthn/signin/begin \ -H "Content-Type: application/json" \ -d '{"username":"alice"}' # → { "publicKey": { "challenge": "...", "allowCredentials": [...] } } # 2. The browser calls navigator.credentials.get() with the options, # then POSTs the signed assertion back: curl -X POST http://localhost:3001/api/v1/auth/webauthn/signin/finish \ -H "Content-Type: application/json" \ -d '{"credential": { "id": "...", "response": { "authenticatorData": "...", "signature": "..." } }}' # → 200 + Set-Cookie access_token / refresh_token

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.

MethodPathPermissionPurpose
POST/api/v1/auth/welcome/verifyPUBLICVerify invitation token
POST/api/v1/auth/welcome/send-otpPUBLICSend OTP to invitation email
POST/api/v1/auth/welcome/verify-otpPUBLICVerify OTP code
POST/api/v1/auth/welcome/completePUBLICSet password + complete onboarding
POST/api/v1/auth/invitations/:uuid/revokeAUTHENTICATED_ADMINRevoke invitation
POST/api/v1/auth/invitations/:uuid/resendAUTHENTICATED_ADMINResend invitation
GET/api/v1/auth/login-alertPUBLICProcess "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-referrer and 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

TerminalCode
# 1. Verify the token (from the email link's URL fragment) curl -X POST http://localhost:3001/api/v1/auth/welcome/verify \ -H "Content-Type: application/json" \ -d '{"token":"<invitation-token>"}' # → { "valid": true } (never returns the email) # 2. Send OTP to the invitation email curl -X POST http://localhost:3001/api/v1/auth/welcome/send-otp \ -H "Content-Type: application/json" \ -d '{"token":"<invitation-token>"}' # 3. Verify the OTP the user typed in curl -X POST http://localhost:3001/api/v1/auth/welcome/verify-otp \ -H "Content-Type: application/json" \ -d '{"token":"<invitation-token>","otp_code":"123456"}' # 4. Set the password and complete onboarding curl -X POST http://localhost:3001/api/v1/auth/welcome/complete \ -H "Content-Type: application/json" \ -d '{"token":"<invitation-token>","otp_code":"123456","new_password":"<strong-password>"}'

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_codeHTTPMeaning
UNAUTHORIZED401Missing or invalid Bearer token / cookie
AUTH_CLIENT_SHIELD_SECRET_INVALID401GATEWAY mode: wrong client-shield secret header
AUTH_USER_CONTEXT_MISSING401authMiddleware ran but req.user is null
AUTH_ROLE_MAPPINGS_NOT_LOADED403Startup race — role mappings not yet loaded
RBAC_PERMISSION_DENIED403User lacks the required permission(s)
RBAC_ADMIN_ONLY403AUTHENTICATED_ADMIN sentinel, user is not admin
ROUTE_PERMISSION_NOT_DECLARED403Secure-first default-deny (route forgot rbacHandler)
AUTH_CONFIG_NOT_LOADED500config_entries table missing/empty (CRITICAL)
TerminalCode
# Expired JWT → 401 curl -i http://localhost:3001/api/v1/auth/me -H "Authorization: Bearer <expired-jwt>"
Code
{ "type": "/errors/unauthorized", "title": "Unauthorized", "status": 401, "detail": "jwt expired", "instance": "/api/v1/auth/me", "internal_code": "UNAUTHORIZED", "severity": "MEDIUM" }

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 — runWithSession and the actor pattern.
Last modified on October 28, 2026
TranslationsRBAC
On this page
  • Login flow
  • Auth endpoints
    • curl examples
  • Auth middleware
    • AuthUser shape
    • Initializing auth ports at startup
  • Auth configuration (config_entries)
    • GATEWAY mode contract
    • Session context (AsyncLocalStorage)
  • RBAC middleware
  • MFA (TOTP)
  • Passkeys (WebAuthn)
    • Passkey signin flow
  • User invitations (welcome flow)
    • Welcome flow example
  • Organizations
  • Error responses
  • Next steps
JSON
TypeScript
TypeScript
TypeScript
JSON