# Authentication


The backend integrates with [Casdoor](https://casdoor.org/) 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.

<Mermaid chart={`sequenceDiagram
  participant FE as Frontend
  participant BE as Backend
  participant CD as Casdoor IDP

  FE->>BE: POST /api/v1/auth/login { username, password }
  BE->>CD: OAuth password grant
  CD->>BE: access_token (JWT) + refresh_token
  BE->>FE: 200 + Set-Cookie access_token / refresh_token (HttpOnly)
  FE->>BE: GET /api/v1/auth/me (Cookie: access_token=...)
  BE->>BE: verifyAuth: verify JWT via JWKS, resolve sub -> user_profiles.uuid
  BE->>BE: expandPermissions(roles) -> { patterns, isAdmin }
  BE->>FE: { user, permissions }
`} />

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

```bash
# 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
```

```json
{
  "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](./rbac#the-rbachandler-declaration)):

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

```typescript
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.

```typescript
// 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](./environment-configuration#database-stored-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:

```typescript
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](./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](./module-navigation#access-axis--accessappmodulepage).

## 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).

```text
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.

| 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

```bash
# 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.

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

```bash
# 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](./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) |

```bash
# Expired JWT → 401
curl -i http://localhost:3001/api/v1/auth/me -H "Authorization: Bearer <expired-jwt>"
```

```json
{
  "type": "/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "jwt expired",
  "instance": "/api/v1/auth/me",
  "internal_code": "UNAUTHORIZED",
  "severity": "MEDIUM"
}
```

## Next steps

- [RBAC](./rbac) — the wildcard permission system, `rbacHandler`, and role mappings.
- [Architecture](./architecture) — the request-flow diagram and HTTP layer internals.
- [Collaboration](./collaboration) — how the auth user context feeds the audit actor.
- [DAL connections & transactions](/dal/dev/connections) — `runWithSession` and the actor pattern.
