Security¶
CORS Configuration¶
The server restricts cross-origin requests to a whitelist of allowed origins. By default only the FRONTEND_URL is allowed.
| Env var | Default | Description |
|---|---|---|
ALLOWED_ORIGINS |
FRONTEND_URL value |
Comma-separated list of allowed origins |
Origins can also be managed at runtime via Admin > Security Settings in the web UI (settings:manage permission required).
CSRF Protection¶
State-changing API requests authenticated with Bearer tokens skip browser CSRF because browser sessions and CSRF cookies are owned by the BFF RPs.
- On login, the console BFF mints
floh_console_csrfand the portal BFF mintsfloh_portal_csrf. The API does not mint browser CSRF cookies. - Each SPA reads its own CSRF cookie and sends it as the
x-csrf-tokenheader on every proxied HTTP method (the BFF asserts CSRF on GET as well as writes). - Mutating
/apirequests withAuthorization: Bearer …skip API CSRF. The BFF enforces its own browser CSRF contract before proxying. API clients using Bearer tokens are not affected by API CSRF checks.
Channel authorization (azp)¶
Every IdP-issued access token is bound to a known OIDC client. The resource
server checks user may (the token's scope claim) and client may
(azp ∈ the local allow-set for that permission).
| Client id | Channel | What it may call |
|---|---|---|
floh-client |
Console BFF | Any permission the user holds |
floh-portal-client |
Public portal BFF | Portal bundle only — no workflow:* |
floh-mcp-client |
Dedicated MCP | MCP_SCOPES only |
Unknown or missing azp / string client_id is 403 (CHANNEL_DENIED).
OIDC_CLIENT_ID and PORTAL_OIDC_CLIENT_ID alias to the console and portal
channels when operators register custom Authifi client ids; other client ids
stay unknown until the Authifi-cached map (LSA-9823).
A Bearer value starting with floh_ is 401 in every environment; the
Floh-issued API token is gone. Browser sessions are owned by the console and
portal BFFs, not by the API.
authenticate() sets request.user.permissions to the intersection of the
verified access token's scope claim and the channel allow-set, so
authenticate-only handlers cannot honor console-only permissions on a portal
or dedicated MCP token. requirePermission / requireAnyPermission still
re-check the allow-set. The allow-set is a typed constant in @floh/shared
(AZP_CLIENTS_BY_PERMISSION); it is not loaded from Authifi in this release.
A missing scope/scp on a portal or console token authenticates with
an empty permission set — restriction is Authifi policy as 403. A missing
claim on dedicated floh-mcp-client, or a non-string claim on any channel,
is 401. Documented MCP uses floh-mcp-client; do not point MCP at
floh-client or omitted scope follows the console open-RP rule. The
API never falls back to a database lookup.
Webhook Authentication¶
Connector webhook endpoints require HMAC-SHA256 signature verification.
- Configure a webhook secret on the connector (stored encrypted in the DB).
- The caller computes
HMAC-SHA256(secret, request_body)and sends it as theX-Webhook-Signatureheader. - The server verifies the signature before processing the event.
Requests without a valid signature receive a 401 Unauthorized response.
Rate Limiting¶
The server applies rate limiting via @fastify/rate-limit:
| Scope | Limit | Window |
|---|---|---|
| Global | 200 req | 1 minute |
/api/auth/* |
20 req | 1 minute |
/api/entitlements/webhook/* |
30 req | 1 minute |
Localhost (127.0.0.1, ::1) can be excluded from global rate limits in local
development. When TRUST_PROXY=true, localhost allowlisting is disabled to prevent
forwarded-header spoofing from bypassing per-IP throttling.
Set TRUST_PROXY=true only when your reverse proxy/load balancer is trusted and
sanitizes X-Forwarded-* headers.
Session Security¶
Console and portal use distinct cookie pairs so a login in one app does not
overwrite the other. Browser logins terminate at Authifi BFF sessions
(floh_console_bff_sid / floh_portal_bff_sid); the API does not manage those
cookies.
| App | Session cookie | CSRF cookie |
|---|---|---|
| Console | floh_console_bff_sid |
floh_console_csrf |
| Portal | floh_portal_bff_sid |
floh_portal_csrf |
| Property | Value |
|---|---|
| Cookie names | See table above |
httpOnly |
true (session); false (CSRF double-submit) |
secure |
true for the browser-facing portal origin; Floh defaults the portal to HTTPS-first |
sameSite |
lax |
| TTL | 24 hours |
| Encryption | Operators set CONSOLE_BFF_COOKIE_ENCRYPTION_SECRET / PORTAL_BFF_COOKIE_ENCRYPTION_SECRET; Compose maps each to that process's AUTH_COOKIE_ENCRYPTION_SECRET. The API's optional SESSION_ENCRYPTION_KEY remains separate |
| Storage | Console and portal BFF sessions use explicit SESSION_STORAGE_TYPE=cookie or SESSION_STORAGE_TYPE=redis. Deploy uses Redis db 1 (portal-bff:) and db 2 (console-bff:) |
| Domain | Host-only by default. Console may set optional COOKIE_DOMAIN; portal browser sessions remain host-only |
The portal BFF supports both explicit Floh-documented session modes:
SESSION_STORAGE_TYPE=cookiefor the lightweight/default deploymentSESSION_STORAGE_TYPE=redisfor multi-instance deployments such as Palantir
Redis misconfiguration must fail closed; Floh does not silently fall back from a requested Redis mode to cookie sessions. Upstream Authifi also supports memory and MySQL session stores, but Floh's committed operator paths document only cookie and Redis.
The API ignores X-Portal-Origin for authentication and RP selection. Portal
browser logins terminate at the BFF. Cross-app SSO is an explicit account-menu item
(Open Portal / Open Admin Console); the IdP session provides silent
re-auth into the sibling browser session.
Step-Up Authentication¶
Floh enforces step-up (re-authenticated MFA) on a curated set of high-risk endpoints. The flow follows the Authifi step-up contract:
- The server refuses with a
401carrying both an RFC 9470WWW-Authenticateheader and the legacy JSON body (see RFC 9470 challenge). stepUpInterceptorreads the challenge, preferring the header — it is the only source that carriesmax_age, and it is what the Authifi BFF keys off. TheMFA_OR_AAL_2_REQUIREDbody remains the fallback for responders that predate the header.- By default a same-origin popup handles the challenge so the opener SPA
never unmounts and in-progress form state is preserved. A PrimeNG dialog
(
StepUpDialogComponent, shared from@floh/web-shared) asks the user to click Verify — the click is a user gesture, whichwindow.openrequires to bypass popup blockers. - Where the popup goes depends on who owns the OIDC transaction. Both SPAs
forward the challenge's
acr_valuesandmax_ageunchanged; substituting either would request the wrong strength or silently drop the recency constraint.
| SPA | Popup target | Completion |
|---|---|---|
Console (packages/web) |
/bff/login?popup=true&acr_values=…&max_age=… — the Authifi BFF is the relying party |
The BFF redirects to /auth/step-up-done on success and /auth/step-up-failed?error=<code> on failure |
Portal (packages/portal-web) |
/bff/login?popup=true&acr_values=…&max_age=… — the Authifi BFF is the relying party |
The BFF redirects to /auth/step-up-done on success and /auth/step-up-failed?error=<code> on failure |
- The landing page signals the opener over three same-origin paths
(
BroadcastChannel,postMessage, and alocalStoragemarker the opener also polls). No single path is reliable: after the popup returns from a cross-origin IdP, Chromium's COOP handling often severswindow.openerand can partitionBroadcastChannel. - On success
stepUpInterceptorretries the original request exactly once. On failure it rejects with the BFF's stable code and rethrows the original401— retrying would only earn a second denial. - When popups are unavailable (blocked by the browser, disabled by admin via
STEP_UP_POPUP_ENABLED=false, or the user chooses "Continue with full page"), the interceptor falls back to a full-page redirect through the same relying party, carryingreturn_to=<currentPath>.
For console and portal browser sessions, the Authifi BFF stores the replacement
access token server-side and the API re-verifies its acr and auth_time on
the proxied retry — the browser never sees token material.
Portal failure bridge¶
/auth/step-up-failed exists so a failed reauthorization can never be
mistaken for a completed one. The BFF redirects there with one of five stable
codes — reauthorization_cancelled, invalid_reauthorization_state,
expired_reauthorization_state, unmet_authentication_requirements, or
reauthorization_failed — and never with provider text, tokens, or raw OAuth
state. The page maps the code to a fixed message; the raw value is never
interpolated into the DOM, so an unrecognized or hand-crafted code degrades to
the generic message rather than rendering attacker-controlled text.
Unlike the success bridge, this page does not auto-close: the user needs to read why verification failed.
Its path must match the BFF's configured popupFailurePath in
docker/bff/portal.json / docker/bff/console.json exactly, and the
corresponding Angular route must exist. Both are pinned by
packages/portal-bff/test/config-invariants.test.ts and
packages/portal-bff/test/console-config-invariants.test.ts.
Verification model¶
Every guarded route reaches the same decision point, enforceStepUp, which
picks its evidence from how the caller authenticated:
| Caller | Evidence | Outcome |
|---|---|---|
| Floh cookie session | Session amr + mfaLoginTs |
Fresh MFA within the window proceeds. |
| IdP-issued JWT (including the portal via the Authifi BFF) | Access-token acr + auth_time |
Both must satisfy the policy; otherwise denied. |
Bearer value starting with floh_ |
— | 401 in every environment. The Floh-issued API token is gone. |
| No authentication context | — | Denied. |
For cookie sessions the guard trusts the amr claim once it has been recorded
in the session, but rejects the call when the most recent step-up is older than
the configured freshness window. Refresh-token rotation does not advance
mfaLoginTs — a fresh interactive /callback is required.
For token callers the guard reads the access token's acr and auth_time. It
never consults amr: amr names the methods used, not the strength reached, so
it cannot distinguish multi-factor from phishing-resistant. An absent or empty
acr satisfies nothing, even when auth_time is fresh — a password-only
Authifi token carries acr: "". Refreshing a token preserves auth_time, so a
refresh cannot turn a stale authentication into a fresh one.
| Setting | Default | Override |
|---|---|---|
STEP_UP_AUTH_WINDOW_SECONDS |
300 |
Per-call (consent step, catalog workflow). |
STEP_UP_POPUP_ENABLED |
true |
Set to false to force the legacy full-page redirect flow for every step-up challenge. Exposed to SPAs via GET /api/auth/config.stepUpPopupEnabled so the interceptor can bypass the popup entirely. |
STEP_UP_ACR_ALIASES |
see below | JSON object mapping each requestable acr_values string to the access-token acr values that satisfy it. |
STEP_UP_CLOCK_TOLERANCE_SECONDS |
60 |
Leeway applied to both sides of the auth_time freshness comparison. Capped at 300. |
The ACR alias matrix¶
acr_values is what Floh asks the IdP for; acr is what the issued token
asserts. They are different vocabularies, so the API needs a matrix saying
which asserted values satisfy which request. The shipped default is the
Authifi BFF recommended set plus the former console PAPE and InCommon
indicators on mod-mf:
{
"mod-mf": [
"http://schemas.openid.net/policies/modrna/multi-factor",
"http://schemas.openid.net/pape/policies/2007/06/multi-factor",
"http://schemas.openid.net/pape/policies/2007/06/multi-factor-physical",
"urn:mace:incommon:iap:silver",
"http://idmanagement.gov/ns/assurance/aal/2",
"http://idmanagement.gov/ns/assurance/aal/3",
"http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true",
"http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true&hspd12=true"
],
"mod-pr": [
"http://schemas.openid.net/policies/modrna/phishing-resistant",
"http://idmanagement.gov/ns/assurance/aal/3",
"http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true",
"http://idmanagement.gov/ns/assurance/aal/3?phishing_resistant=true&hspd12=true"
]
}
mod-pr deliberately omits the multi-factor URI: a phishing-resistant request
must not be satisfiable by an OTP-grade token.
Matching is by exact string against this matrix, never by echoing the request
value back. An IdP that reflects acr_values into acr without enforcing it
cannot assert its own compliance. The console OIDC callback uses this same
matrix when stamping MFA on a new cookie session.
A mod-mf challenge also accepts token values listed under mod-pr
(phishing-resistant is a stricter form of multi-factor). A mod-pr challenge
does not accept mod-mf-only values. Console cookie stamping uses that same
rule, so an ACR configured only under mod-pr clears both cookie sessions and
Bearer callers when the route requires mod-mf.
The matrix is validated at startup and fails closed. Malformed JSON, a
non-object, an empty or missing entry for a requestable value, a non-string
member, a key that would pollute the prototype, or any value containing
characters that cannot appear in a WWW-Authenticate challenge all refuse
startup rather than silently reverting to the default.
Keep this matrix identical to the Authifi BFF's bff.stepUp.acrAliases. A
BFF that is more lenient completes a reauthentication the API then rejects; a
BFF that is stricter refuses a token the API would have honored. Neither loops —
the browser retries once — but both cost the user a wasted reauthentication.
This is enforced, not just documented:
packages/server/test/unit/architecture/step-up-acr-alias-parity.test.ts
compares DEFAULT_STEP_UP_ACR_ALIASES against the committed
docker/bff/portal.json in both directions. Operators who override
STEP_UP_ACR_ALIASES at runtime must make the matching BFF change themselves —
the test can only cover the committed defaults.
RFC 9470 challenge¶
Alongside the existing JSON body, a step-up refusal now carries a standards-based challenge header:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="mod-mf", max_age="300"
Content-Type: application/json
{"statusCode":401,"error":"Unauthorized","message":"…","code":"MFA_OR_AAL_2_REQUIRED","windowSeconds":300}
The header is what the Authifi BFF reauthorization flow keys off. The body is unchanged, so existing SPA interceptors keep working without modification. The two must stay in sync: a client that sees one but not the other would either miss the challenge or reauthenticate at the wrong strength.
acr_values is the value that route actually enforces — mod-mf by default,
or mod-pr on the high-impact routes listed below. A mismatch would send the
browser to reauthenticate at a strength the API would still reject.
Diagnosing a denial¶
Every token-path denial logs a structured warning with reason, requiredAcr,
the observedAcr (or null), and the route. reason is one of
no_alias_configured, acr_missing, acr_not_accepted, auth_time_missing,
auth_time_future, or auth_time_stale. An IdP whose claim profile does not
match the configured matrix is otherwise indistinguishable from a user who
simply has not re-authenticated. No token material is logged; acr is a policy
identifier, not PII.
Popup flow HTTP headers¶
The popup flow depends on Cross-Origin-Opener-Policy: same-origin-allow-popups
so the popup retains window.opener long enough to postMessage back. The
server sets this explicitly in the @fastify/helmet registration; do not
downgrade it to same-origin without also disabling STEP_UP_POPUP_ENABLED.
Guarded endpoints¶
- PAM:
GET /pam/sessions/:id/credential(credential reveal),POST/PUT/DELETE /pam/policies(policy mutations). - Connectors:
POST /connectors,PUT /connectors/:id,POST /connectors/rotate-keys. - Config transfer:
POST /config-transfer/import. - Workflows (per-workflow opt-in): consent step (
requireStepUpAuth) and catalog submission (catalogRequireStepUpAuth) — each may set its ownstepUpWindowSecondsoverride.
Per-route required ACR¶
Default is mod-mf. High-impact routes require mod-pr. Per-route values
must be in the allowlist (mod-mf, mod-pr) or route registration throws.
| Route | Required ACR |
|---|---|
GET /pam/sessions/:id/credential |
mod-pr |
POST /connectors/rotate-keys |
mod-pr |
POST /config-transfer/import |
mod-pr |
All other requireStepUp / enforceStepUp sites |
mod-mf |
Bearer-token caller policy¶
Bearer is a transport, not an identity. Every API Bearer credential is an
IdP-issued JWT. A value starting with floh_ is 401 before any parsing —
there is no Floh-issued API token, and no environment branch re-enables one.
IdP-issued JWTs must prove step-up through acr and auth_time on every
guarded route, including the admin infrastructure operations above (PAM
credential reveal, PAM policy mutations, connector secret writes and key
rotation, config import) and the per-workflow opt-ins (consent step
requireStepUpAuth, catalog catalogRequireStepUpAuth).
Breaking change¶
IdP-issued JWTs were previously exempt from requireStepUp(), so external API
clients calling those admin routes with a JWT will now receive
401 MFA_OR_AAL_2_REQUIRED unless their token carries an accepted acr and a
sufficiently recent auth_time.
Reauthenticate with the acr_values named in the challenge — mod-mf on
most routes, mod-pr on the high-impact routes in the matrix above — so the
IdP issues a token with the required claims. This is the intended path and is
what the portal does automatically via the BFF.
To roll back, widen STEP_UP_ACR_ALIASES — but note this weakens the gate for
every caller on the affected route, so prefer fixing the token's claim profile.
OIDC Startup Guard¶
Dev auth bypass has been removed. The server refuses to start in all environments when required OIDC fields are missing:
OIDC_ISSUEROIDC_CLIENT_ID- at least one channel audience —
FLOH_CONSOLE_AUDIENCE,FLOH_PORTAL_AUDIENCE, orFLOH_MCP_AUDIENCE
The channel audiences are the allow-list the API verifies each access token's
aud claim against. FLOH_RESOURCE_ID (deprecated alias OIDC_AUDIENCE) is
not part of that allow-list and is not a startup requirement; it names the
API resource for operator tooling. Setting FLOH_RESOURCE_ID and
OIDC_AUDIENCE to different values is a startup error.
Secret Management¶
The following secrets must be explicitly set in production. The server will refuse to start if any of them use their default/fallback values:
| Secret | Env var | Default (dev only) |
|---|---|---|
| JWT secret | JWT_SECRET |
dev-jwt-secret |
| Database password | DB_PASSWORD |
floh_secret |
| Session secret | SESSION_SECRET |
Same as JWT_SECRET |
| Connector encryption | CONNECTOR_ENCRYPTION_KEY |
Insecure dev key |
| Session encryption | SESSION_ENCRYPTION_KEY |
Unencrypted storage |
Generate encryption keys with:
Portal BFF deployments additionally keep PORTAL_OIDC_CLIENT_ID,
PORTAL_OIDC_CLIENT_SECRET, and PORTAL_BFF_COOKIE_ENCRYPTION_SECRET in
operator .env / secret injection. Compose maps them to the Authifi runtime's
AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, and AUTH_COOKIE_ENCRYPTION_SECRET.
Do not place those values in Angular config, committed JSON, or other
browser-visible settings. When the BFF terminates TLS directly, SERVER_CERT
and SERVER_KEY contain PEM contents, not file paths.
Error Handling¶
In production, API error responses contain only statusCode, error, and a generic message. Stack traces and internal details are never exposed. In development, stack traces are included when SHOW_ERROR_DETAILS=true.
Connector and sync-management routes now use a shared API error envelope helper
so explicit route-level failures (for example 400/403/404/409 paths) follow the
same { statusCode, error, message } shape as global error-handler responses.
Sensitive Logging Controls¶
Authentication integrations must not log session payloads, bearer tokens, or raw response bodies from identity providers. Auth-related logs should include only sanitized URLs and status metadata.
OIDC and MCP outbound identity-provider/API calls should enforce bounded timeouts (AbortSignal) so network hangs fail fast and are observable.
Permission snapshots loaded from persisted JSON (workflow_run.accepted_permissions
and scheduled_trigger.accepted_permissions) are validated as string arrays
before use. Malformed payloads fail closed to an empty set.
Production Checklist¶
- [ ]
NODE_ENV=production - [ ]
OIDC_ISSUERis set - [ ]
JWT_SECRETis a strong random value - [ ]
DB_PASSWORDis not the default - [ ]
SESSION_SECRETis a strong random value - [ ]
CONNECTOR_ENCRYPTION_KEYis a 64-char hex key - [ ]
SESSION_ENCRYPTION_KEYis a 64-char hex key - [ ]
AUDIT_CHECKPOINT_KEYis a 64-char hex key - [ ]
ALLOWED_ORIGINSis restricted to known frontend URLs - [ ]
SHOW_ERROR_DETAILS=false - [ ] Rate limiting is enabled