Authentication
The auth module provides JWT/OIDC verification, API key auth, RBAC, and session
context propagation via AsyncLocalStorage. It is framework-agnostic: the same
verifyAuth() works with raw Node.js® HTTP, Express, and NATS™ messages through
a single HeaderProvider abstraction.
Auth modes
Two operating modes control who validates the token and how the user identity reaches the service:
| Mode | Who uses it | Token verification | Ports needed |
|---|---|---|---|
STANDALONE | BE | Service validates JWT against IDP via OIDC discovery | UserResolverPort, RoleMappingPort |
GATEWAY | Microservices | BE already resolved the user; microservice verifies gateway secret + deserializes headers | None |
AuthConfig
Auth configuration is loaded once at startup from the service's config table and cached in memory. The config determines the mode, OIDC settings, gateway secret, and the header names used for serialization.
Code
Call invalidateAuthConfig() to force a reload on the next loadAuthConfig().
STANDALONE mode (BE)
The BE validates the JWT, resolves the IDP subject to an internal UUID, and
expands roles into permissions using the role_mappings table.
Code
GATEWAY-RESOLVED mode (microservices)
Microservices do not validate JWTs or touch the database. The BE serializes the
fully resolved AuthUser into headers (HTTP proxy or NATS™), and the microservice
verifies the gateway secret and deserializes the user.
Code
For NATS™ subscribers:
Code
The BE publisher side uses buildNatsAuthHeaders() to serialize the user into
NATS™ headers with the gateway secret:
Code
AuthUser
The AuthUser type is the result of all auth verification — regardless of mode:
| Field | Type | Description |
|---|---|---|
id | string | Internal Primebrick UUID (or "system" for system API keys) |
idp_code | string | Original IDP subject (JWT sub) — traceability only |
email | string | null | User email |
name | string | null | Display name |
roles | string[] | Normalized role names from the IDP |
permissions | Set<string> | Flattened permissions derived from roles |
isAdmin | boolean | Bypasses all permission checks (admin role) |
isSystem | boolean | System API key — bypasses RBAC, actor = "system" |
idp_org | string | null | IDP organization |
idp_username | string | null | IDP username |
raw_access_token | string | undefined | Raw token (STANDALONE only, for proxy forwarding) |
API keys
API keys are machine-to-machine credentials stored in the api_keys table with
a SHA-256 hash. The SDK provides verifyApiKey(), hashApiKey(), and
generateApiKey().
Code
API keys accept two header formats:
Authorization: ApiKey <key>Authorization: Bearer <key>(when the key starts withpbk_)
Resolution — who owns api_keys
The api_keys table lives in the BE-owned public schema. Exactly one
implementation may read it directly:
| Port | Where | Resolves via |
|---|---|---|
DomainApiKeyPort | BE only | public.api_keys (direct DB read) |
NatsApiKeyPort | microservices | governed auth.apikey.byHash req/reply → BE |
CachingApiKeyPort | everywhere | cache → inner resolver (one of the two above) |
CachingApiKeyPort is the single cached implementation: pass a CachePort
(or a lazy () => getSdkCachePort()) for the shared Redis key
be:api_keys:hash:<sha> (5 min TTL, positive results only), or omit it for
the bounded in-memory mode (60s positive / 10s negative / max 1000 entries —
the webhook DMZ mode). A microservice must never read public.api_keys
directly — sovereignty is enforced by the fact that only DomainApiKeyPort
exists in the BE repo.
RBAC
RBAC evaluates whether an authenticated user has the permissions required by an
endpoint. The Permission constant defines all known permissions. Three
sentinels are handled specially:
Permission.PUBLIC— endpoint reachable without authenticationPermission.AUTHENTICATED_USER— any authenticated caller passesPermission.AUTHENTICATED_ADMIN— only callers withisAdmin === truepass (admin-only operations, e.g. admin change-password)
Code
Admin users (isAdmin=true) and system API keys (isSystem=true) bypass all
permission checks. Wildcard patterns in role mappings (e.g. customer.read.*)
are supported via matchesWildcard().
For NATS™ subscribers, use enforceNatsRbac() — same logic, semantic separation.
The non-throwing variant checkRbac() returns { allowed, missing? } instead
of throwing.
Access axis — isAccessGranted()
Navigation visibility uses a separate vocabulary inside the same permission
set: access.{app}.{module}.{page} grants gate discovery metadata (modules,
nav links, routes), not entity APIs. isAccessGranted(user, required)
evaluates them:
Code
Semantics differ from entity RBAC:
- Hierarchical — a bare grant covers its subtree:
access.system.job⇒access.system.job.rules; wildcards work at any level (access.system.*,access.*). A page-level grant never climbs to its module ancestor. - Deny-first — an invalid
requiredkey fails closed. isAdminimpliesaccess.systemonly — the platform plane. Business roots (access.crm.*, …) require explicit grants even for admins, unlike the blanket RBAC bypass.
Grants live in role_mappings.permissions alongside entity permissions; the
two axes never collide because access.* is never an entity scope.
Session context
Session context uses AsyncLocalStorage to propagate the authenticated actor
through the async chain without passing it through every method signature. The
auth middleware sets the session; DAL code reads it via requireActor().
Code
getSession() returns the full Session object or undefined if no session is
in scope. runWithSession() is the low-level primitive — prefer runAsSystem()
or the auth middleware unless you are writing infrastructure code.
AuthError
All auth failures throw AuthError with an internal_code field. The HTTP
server's error handler reads internal_code and status from the error to
produce RFC 7807 responses. Common codes:
| Code | Meaning |
|---|---|
AUTH_TOKEN_MISSING | No Bearer token in Authorization header |
AUTH_TOKEN_INVALID | JWT verification failed (expired, bad signature, etc.) |
AUTH_GATEWAY_SECRET_INVALID | Gateway secret header mismatch |
AUTH_GATEWAY_HEADERS_MISSING | Required identity header not present |
AUTH_API_KEY_MISSING | No API key in Authorization header |
AUTH_API_KEY_INVALID | API key hash not found |
AUTH_API_KEY_INACTIVE | Key marked inactive |
AUTH_API_KEY_EXPIRED | Key past expires_at |
MFA step-up
auth/mfa-step-up.ts is the consumer-side half of the step-up flow: when a
route handler needs an elevated check before a sensitive action, it calls
assertMfaStepUp(ctx, spec) — which consumes a single-use action
authorization minted by the BE over the auth.mfa.consume NATS subject
and throws MfaStepUpRequiredError when none is present (the client then
shows the step-up dialog, collects the TOTP, and retries with the
x-mfa-action-authorization header). MfaConsumeUnavailableError covers
the "BE unreachable" path. In an entity router, wire it as a per-action
guards hook — see entity-router.
Next steps
- NATS Client — publish/subscribe with auth headers
- Service Registration — lifecycle events over NATS™
- HTTP Server — how auth errors become RFC 7807 responses
- API Reference — full auth API listing