Skip to content

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_csrf and the portal BFF mints floh_portal_csrf. The API does not mint browser CSRF cookies.
  • Each SPA reads its own CSRF cookie and sends it as the x-csrf-token header on every proxied HTTP method (the BFF asserts CSRF on GET as well as writes).
  • Mutating /api requests with Authorization: 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.

  1. Configure a webhook secret on the connector (stored encrypted in the DB).
  2. The caller computes HMAC-SHA256(secret, request_body) and sends it as the X-Webhook-Signature header.
  3. 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=cookie for the lightweight/default deployment
  • SESSION_STORAGE_TYPE=redis for 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:

  1. The server refuses with a 401 carrying both an RFC 9470 WWW-Authenticate header and the legacy JSON body (see RFC 9470 challenge).
  2. stepUpInterceptor reads the challenge, preferring the header — it is the only source that carries max_age, and it is what the Authifi BFF keys off. The MFA_OR_AAL_2_REQUIRED body remains the fallback for responders that predate the header.
  3. 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, which window.open requires to bypass popup blockers.
  4. Where the popup goes depends on who owns the OIDC transaction. Both SPAs forward the challenge's acr_values and max_age unchanged; 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
  1. The landing page signals the opener over three same-origin paths (BroadcastChannel, postMessage, and a localStorage marker the opener also polls). No single path is reliable: after the popup returns from a cross-origin IdP, Chromium's COOP handling often severs window.opener and can partition BroadcastChannel.
  2. On success stepUpInterceptor retries the original request exactly once. On failure it rejects with the BFF's stable code and rethrows the original 401 — retrying would only earn a second denial.
  3. 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, carrying return_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.

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 own stepUpWindowSeconds override.

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_ISSUER
  • OIDC_CLIENT_ID
  • at least one channel audience — FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, or FLOH_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:

openssl rand -hex 32

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_ISSUER is set
  • [ ] JWT_SECRET is a strong random value
  • [ ] DB_PASSWORD is not the default
  • [ ] SESSION_SECRET is a strong random value
  • [ ] CONNECTOR_ENCRYPTION_KEY is a 64-char hex key
  • [ ] SESSION_ENCRYPTION_KEY is a 64-char hex key
  • [ ] AUDIT_CHECKPOINT_KEY is a 64-char hex key
  • [ ] ALLOWED_ORIGINS is restricted to known frontend URLs
  • [ ] SHOW_ERROR_DETAILS=false
  • [ ] Rate limiting is enabled