Skip to content

Public Portal

The public portal enables external users to interact with Floh without direct access to the firewalled admin interface. It provides a minimal, user-facing experience for accepting invitations, completing tasks, uploading documents, and managing approvals.

Architecture

                              ┌────── Firewall ──────┐
                              │                       │
 ┌──────────────┐   ┌──────────────┐   ┌──────────────────────┐   ┌──────────┐
 │  Portal SPA  │──▶│  Portal BFF  │──▶│  Floh Internal       │──▶│   DB     │
 │  (Angular)   │   │ (Authifi BFF │   │  Server (Fastify)    │   │  Redis   │
 │  port 7073   │   │   3.2.2)     │   │  port 7070           │   │  SMTP    │
 │              │   │  port 7071   │   │                      │   │          │
 └──────────────┘   └──────────────┘   └──────────────────────┘   └──────────┘
      Public             Public                Private

The portal consists of two public-facing services:

  1. Portal BFF (packages/portal-bff, docker/bff/portal.json) — the Authifi BFF gateway image ghcr.io/authifi/idbroker-tools/bff-gateway:3.3.0, acting as the portal OIDC relying party and browser-facing API gateway on port 7071
  2. Portal SPA (packages/portal-web) — a minimal Angular frontend

Both sit outside the organization's firewall. The internal Floh server, database, Redis, and OIDC provider remain inside the firewall, accessible only to the BFF.

Portal BFF

The Backend-for-Frontend is the Authifi BFF 3.2.2 gateway, not a Floh Fastify app. It owns the portal's OIDC browser flow under /bff/* and proxies browser /api/* requests to the Floh API while attaching the access token server-side. JavaScript never reads the access token or refresh token.

Browser-facing routes

  • GET /bff/login starts portal login
  • GET /bff/callback completes the portal OIDC callback
  • GET /bff/session reports portal session status and supports ?refresh=true
  • GET /bff/logout clears the portal BFF session
  • Browser GET/POST/... /api/* calls also go through the BFF, which attaches the access token before proxying to the Floh API

Committed gateway settings

The checked-in portal gateway config keeps these Authifi BFF settings fixed:

  • bff.exposeTokens=false — raw login tokens are not exposed to browser code
  • bffProxy.ws=false — WebSocket proxying is off for the portal gateway
  • bffProxy.allowlist.enabled=false — Floh does not currently claim unknown or unlisted /api/* paths return 404 at the gateway; cached allowlisting is follow-up work in LSA-9831
  • bffProxy.csrf.cookieName=floh_portal_csrf
  • bffProxy.csrf.headerName=x-csrf-token

Session storage

Session storage is explicit:

Deployment shape Setting Notes
Lightweight / default SESSION_STORAGE_TYPE=cookie Encrypted cookie session managed by the BFF
Multi-instance / Palantir SESSION_STORAGE_TYPE=redis Requires a dedicated Redis URL, database, and prefix

Redis misconfiguration fails closed. Floh's committed operational paths document cookie and Redis modes only, even though upstream Authifi also supports memory and MySQL backends.

Token audience

AUTH_RESOURCE is the portal channel catalog resource identifier (FLOH_PORTAL_AUDIENCE from env/portal.env), not the issuer and not FLOH_RESOURCE_ID. It becomes the resource on the authorization request and therefore the aud of the access token the BFF forwards. The API accepts that channel aud. OIDC_AUDIENCE is a deprecated alias of FLOH_RESOURCE_ID and is not a verified JWT audience. Set PORTAL_OIDC_RESOURCE only when the portal must request a different RS than FLOH_PORTAL_AUDIENCE. A mismatch or a fallback to OIDC_ISSUER produces a correctly signed token that the API rejects on audience; a config-invariant test pins the substitution.

The proxy target must include /api

BFF_PROXY_TARGET has to end with the same prefix as BFF_PROXY_PATH, as in http://server:7070/api. The gateway mounts the proxy at BFF_PROXY_PATH and strips that prefix before forwarding, so the target is the only place it can be restored. A bare http://server:7070 sends /auth/config to an API that serves /api/auth/config, and every proxied call 404s.

This one is hard to recognize from symptoms. The gateway stays healthy, login and the callback succeed, and a 404 is a well-formed API response, so neither side logs an error. The portal reports "Sign in is temporarily unavailable", which points at OIDC even though OIDC is working. If you see that message after a successful login, check for 404s on /api/* in the gateway log before touching anything OIDC-related. Config-invariant tests pin the suffix on the config file and on both compose files.

Step-up fails with invalid_reauthorization_state (known upstream defect)

Step-up authentication cannot complete on gateway 3.1.0. The user finishes MFA, the popup lands on /auth/step-up-failed, and the gateway logs an OIDC callback failure with category invalid_reauthorization_state.

The cause is not the authenticator. The gateway stores its reauthorization transaction on the session cookie, which is SameSite=Lax, while the OIDC callback arrives as a cross-site POST under the default response_mode=form_post. Browsers do not send Lax cookies on a cross-site POST, so the transaction is missing at exactly the moment it is needed — after the token exchange has already succeeded. It fails this way every time.

Do not attempt to fix it by setting the session cookie to SameSite=None; that sends the session on every cross-site request. Tracked upstream in docs/plans/2026-08-23-authifi-bff-step-up-samesite-handoff.md, which asks for the transaction to move into the login state, where the gateway's own OAuth state already lives.

Setting auth.authorizationParams.response_mode to query makes the callback a top-level GET, which Lax cookies do accompany, and unblocks the flow. That is a local workaround rather than a committed default: the gateway logs full query strings, so query mode writes authorization codes into its access log.

NODE_ENV must be set explicitly

Any deployment of the gateway must set NODE_ENV=production. The gateway derives the CSRF cookie's Secure flag from it and treats an unset value as development, which drops the flag — over HTTPS, with no startup warning. The committed docker-compose.deploy.yml sets it, and a config-invariant test keeps it set.

Setting it also makes the image load its own bundled production.json, which is an integration-test fixture rather than a production baseline: it carries a placeholder client secret and a demo gateway route table. Neither reaches a Floh deployment. Every credential-bearing value is supplied through the environment, which node-config ranks above all config files, and the gateway: [] in docker/bff/portal.json replaces the demo route table outright. Leave that empty array in place — Floh proxies through bffProxy, never through gateway, and removing the key stops the gateway from booting at all.

TLS and secrets

Browser traffic to the portal gateway is always HTTPS. Floh's committed defaults are HTTPS-first:

  • local portal development defaults to HTTPS
  • the committed Palantir deployment keeps HTTPS on Caddy -> BFF and BFF -> API
  • other deployments may terminate TLS at a trusted ingress and explicitly choose private-network HTTP for internal hops

Direct BFF TLS is recommended, but not universally required across every deployment. When the BFF terminates TLS directly:

  • SERVER_CERT and SERVER_KEY contain PEM contents, not file paths
  • NODE_EXTRA_CA_CERTS is used when the BFF must trust a private or self-signed HTTPS upstream

At the operator boundary, Floh uses env/portal.env (or secret injection into that host file) for PORTAL_OIDC_CLIENT_ID, PORTAL_OIDC_CLIENT_SECRET, and PORTAL_BFF_COOKIE_ENCRYPTION_SECRET. Compose maps those into the Authifi container as AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, and AUTH_COOKIE_ENCRYPTION_SECRET. Keep the operator-facing PORTAL_* values and any TLS private key in that process file only. Do not place the portal client secret in Angular config, committed JSON, the API .env, or other browser-visible settings.

Portal SPA

The portal SPA is a stripped-down Angular application derived from the main Floh frontend. It includes only the components and routes external users need.

Routes

Path Component Auth Required Description
/welcome WelcomeComponent No Landing page with login button
/dashboard PortalDashboardComponent Yes Pending invitations, tasks, approvals
/tasks TaskInboxComponent Yes Task inbox (tasks and approvals)
/tasks/:id PortalTaskDetailComponent Yes Task detail and response page
/requests/catalog RequestCatalogComponent Yes Browse and submit workflow requests
/requests/my MyRequestsComponent Yes View workflow requests submitted by the current user
/runs/:id RequestRunSummaryComponent Yes Read-only submitted request summary
/tickets MyTicketsComponent Yes Requester My Tickets list (LSA-9063)
/tickets/:ticketNumber PortalTicketDetailComponent Yes Ticket case view + public reply
/invitations/respond InvitationRespondComponent No* Accept or decline invitations
/auth/callback AuthCallbackComponent No OIDC callback handler

*Invitations require authentication to respond, but the page itself loads without auth to verify the token and prompt login.

Removed Features

Compared to the main Floh frontend, the portal SPA does not include:

  • Sidebar navigation
  • Workflow designer / definition management
  • User / role / organization management
  • Connector management
  • Audit log viewer
  • Reports / analytics
  • Admin panel
  • Permission override controls
  • Project and workflow set filters

Request Catalog

The Request Catalog allows portal users to browse published workflows and submit requests. Administrators configure which workflows appear in the catalog from the admin UI (catalog publishing toggle, icon, description, and tags on the workflow detail page).

  • Users browse published workflows displayed as cards with icons, descriptions, category tags, and searchable content
  • Filtering is available by category, free-text search, and tag selection
  • Administrators can restrict submission to members of specific groups via the "Submission Restrictions" setting on the catalog publishing card; restricted entries are hidden from non-members
  • Submitting a request opens a dynamic form built from the workflow's variable definitions (excluding secret variables)
  • On submission, POST /api/request-catalog/:id/submit starts a workflow run (LSA-9061; requires only authentication). When the workflow includes a create_ticket step, a linked service_ticket is created in the same transaction and the 201 response includes numeric ticketNumber; the portal confirmation shows #N (linked when accessible). Without that step, only the run is created and the portal shows a run-only success message. Optionally bind a ticket queue on the workflow’s Catalog Publishing settings so ticketed submits inherit that queue’s SLA policy.
  • Users can track their own submitted workflow runs from /requests/my, backed by GET /api/runs/my-submissions
  • Open request rows link to the active task detail (/tasks/:id) when a live task exists; resolved rows link to the read-only run summary (/runs/:id)
  • When no real catalog entries exist, example entries are shown to give users a sense of the UI

The Request Catalog, My Requests, and My Tickets links appear in the portal topbar alongside Dashboard and Tasks. When signed in, the display name opens an account menu with a compact identity preview (name / email / issuer), Account Details (same fields plus Floh roles), Open Admin Console when consoleUrl is configured, and Log out.

My Tickets

Portal requesters open My Tickets (/tickets) to list their own cases (ticket number, title, priority, status, SLA, updated time), filter by status tabs, open /tickets/:ticketNumber, read the public conversation, and post a public reply. List and detail are force-scoped to requester_id; the BFF does not proxy agent mutations (assign, status, priority, queue, snooze, create, or internal notes).

See Service Ticketing, the Service Ticketing Demo runbook, and the Support Portal Demo (pnpm seed:support-portal) for the full dual UX (portal requester + admin agent).

Draft Preview (workflow author testing)

To shorten the develop-and-test loop for workflow authors, draft workflows that have catalogPublished turned on are also visible in the request catalog — but only to users who hold the workflow:publish permission (admin and resource_manager by default). Other portal users continue to see only active catalog entries; the draft is filtered out of GET /api/request-catalog and POST /api/request-catalog/:id/submit returns 404 rather than disclosing that the draft exists.

This lets authors validate the real portal submission UX (the rendered form, group restrictions, step-up auth) without flipping the workflow to active for the entire org. Submissions of a draft from the portal create real workflow runs with all configured side effects (notifications, approvals, connector calls), so authors should treat them like a regular run when their workflow makes external changes.

In the admin workflow editor, the Catalog Publishing card is now also available while a workflow is in draft status. Toggle Published to Catalog on a draft to expose it in the portal preview; the card shows a Draft preview tag so authors can see that the entry is not yet visible to the wider org. The toggle remains disabled for deprecated workflows.

Dashboard

The portal dashboard displays four responsive cards (4-across on wide screens, 2x2 on medium screens):

  1. Pending Invitations — invitations awaiting the user's response, with a "Respond" link
  2. Active Tasks — the user's assigned tasks (up to 5), with a link to the full task inbox
  3. Pending Approvals — approvals awaiting the user's decision (up to 5)
  4. My Requests — open workflow requests submitted by the current user (up to 5), with a link to /requests/my

Internal Server Changes

The API is a Bearer-only resource server. Console and portal login run through the Authifi BFFs (/bff/login/bff/callback). The API verifies JWT aud against the channel catalog identifiers and does not exchange authorization codes.

Register a separate Authifi confidential client for the portal (PORTAL_OIDC_CLIENT_ID, typically floh-portal-client). On that client, set:

  • redirect URI https://<portal-origin>/bff/callback
  • local redirect URI https://localhost:7073/bff/callback
  • post-logout redirect URI https://<portal-origin>

Inject the portal client secret into env/portal.env only as PORTAL_OIDC_CLIENT_SECRET; Compose passes it to the Authifi container as AUTH_CLIENT_SECRET.

When PORTAL_FRONTEND_URL is configured, invitation emails link to the portal instead of the admin frontend. This is controlled by:

const baseUrl = this.config.portalFrontendUrl || this.config.frontendUrl;

in packages/server/src/modules/notifications/service.ts.

Internal Server Configuration

Variable Default Description
ALLOWED_PORTAL_ORIGINS (empty) Comma-separated list of allowed portal frontend URLs
PORTAL_FRONTEND_URL (empty) Portal frontend URL for invitation email links

Development

Starting the Portal

Preferred (HTTPS): configure TLS on the API (TLS_CERT_FILE, TLS_KEY_FILE, NODE_EXTRA_CA_CERTS) and use the HTTPS-first portal scripts.

# Start infrastructure (if not already running)
docker compose -f docker/docker-compose.yml up -d postgres redis mailhog

# Run migrations
pnpm migrate:latest

# Full HTTPS stack — API, both BFFs, both SPAs, form-builder
pnpm dev:https

Portal-only (API already running):

pnpm dev:portal

To split BFF and SPA (same pattern as pnpm dev:console:bff + pnpm dev:console:web:https):

# Terminal 2 — portal BFF only (inspector 127.0.0.1:9230)
pnpm dev:portal:bff

# Terminal 3 — portal SPA only
pnpm dev:portal:web:https

HTTP split: pnpm dev:portal:bff:http and pnpm dev:portal:web (or pnpm dev:portal:web:http). Hyphen aliases (pnpm dev:portal-bff, pnpm dev:portal-web:https) still work.

The launcher installs @authifi/auth-bff-gateway@3.3.0 into local/bff-gateway/ (GitHub Packages). It maps @authifi and @axleresearch to npm.pkg.github.com in that directory and uses --ignore-workspace. Your ~/.npmrc still needs //npm.pkg.github.com/:_authToken with read access to Authifi and AxleResearch packages. The process starts as host Node with the inspector on 127.0.0.1:9230. Pass --no-inspect or set FLOH_BFF_NO_INSPECT=1 to disable the inspector. Set FLOH_PORTAL_BFF_RUNTIME=docker to use docker/docker-compose.portal-dev.yml instead of host Node. pnpm docker:portal:up still starts docker-compose.portal.yml and ignores the runtime toggle. There is no Docker fallback if the Packages install fails. If port 7071 is already published by a leftover container (docker ps --filter publish=7071), stop that container before pnpm dev:portal — host Node and a Docker BFF cannot share 7071. If a host-Node BFF from pnpm dev:portal:bff is already listening, start only pnpm dev:portal:web:https instead of the combined launcher.

HTTP-only alternative:

pnpm dev:server &
pnpm dev:portal:http

Or start the main stack with pnpm dev, then pnpm dev:portal in another terminal.

Environment Setup

Public portal origin and issuer live in root .env. Portal RP secrets live in env/portal.env (copy env/portal.env.example):

# Root .env — SPA origin, issuer, CORS. No portal client secret.
PORTAL_FRONTEND_URL=https://localhost:7073
OIDC_ISSUER=https://<issuer>
ALLOWED_PORTAL_ORIGINS=https://localhost:7073
NODE_EXTRA_CA_CERTS=certs/localhost.crt

# env/portal.env — host-Node launcher and Compose both read these names
PORTAL_OIDC_CLIENT_ID=floh-portal-client
PORTAL_OIDC_CLIENT_SECRET=<set in env/portal.env>
PORTAL_BFF_COOKIE_ENCRYPTION_SECRET=<set in env/portal.env>

For HTTP-only local dev, switch the URLs above to http:// as needed and run pnpm dev:portal:http.

The local Authifi client callback must be https://localhost:7073/bff/callback.

Authifi OIDC clients

Register a separate confidential client for the portal (floh-portal-client) with redirect {PORTAL_FRONTEND_URL}/bff/callback and post-logout {PORTAL_FRONTEND_URL}. The callback is the BFF's route, not the API's — the BFF is the portal's relying party. Duplicate from floh-client so by-client domain-IdP mappings copy.

pnpm run setup-authifi-oidc-clients does all of that and also registers the dedicated MCP client floh-mcp-client. It reads .env plus env/console.env, env/portal.env, and env/mcp.env when present (OIDC_ISSUER, FRONTEND_URL, PORTAL_FRONTEND_URL, FLOH_RESOURCE_ID, FLOH_MCP_AUDIENCE), reconciles each client's registered URIs through the Authifi admin API, authorizes console and portal for their channel resource servers, writes public client ids and redirect URIs to .env, and writes minted RP secrets to env/*.env:

pnpm run setup-authifi-oidc-clients -- \
  --local-dev \
  --identity-provider nih \
  --dry-run

Every run that contacts Authifi — --dry-run included, since it lists the existing clients — needs gitignored .authifi-admin-token plus FLOH_RESOURCE_ID (OIDC_AUDIENCE is a deprecated alias) in .env or the shell. AUTHIFI_BASE_URL is derived from the token when unset. They are checked in preflight, so a run missing any of them exits non-zero before the first mutation. Use --print-commands for a preview that needs no credentials at all. If secret capture fails after an apply, re-run with --rotate-secrets.

--local-dev also registers https://localhost:7072/bff/callback (console) and https://localhost:7073/bff/callback (portal). Reconciliation is two-way: a client left over from before the BFF cutover has its stale /api/auth/callback entry removed.

That second direction is authoritative, not additive. The run replaces callbackUrls and postLogoutRedirectUris with exactly what the invocation resolves, so every registered URI you do not pass is deleted — including the deployed origins, when the tenant's clients are shared with a deployed environment. Pass every origin that must survive via repeatable --console-origin / --portal-origin flags (or CONSOLE_ORIGINS / PORTAL_ORIGINS), not just the local ones. --dry-run reads the live configuration and prints each URI it would add or remove, then closes with the total removal count; read that list before applying.

Beyond the root .env, the run writes one file per client under env/ (console.env, portal.env, and vault.env with --vault). Each carries only its own client's keys so API, console BFF, and portal BFF can mount independent process files; they are gitignored, and only the *.env.example templates beside them are tracked. Pass --no-client-env-files to keep secrets in the root .env (legacy). Re-runs overlay env/*.env when loading so secrets are not reported missing after they left the root file.

After apply, confirm OIDC_CLIENT_SECRET in env/console.env and PORTAL_OIDC_CLIENT_SECRET in env/portal.env. auth-cli does not print secrets, so the script captures them through Authifi's rotate-secret route. If a run created a client but died before capturing its secret, re-run with --rotate-secrets — Authifi cannot re-read an existing secret, so recovery means minting a new one.

Example import JSON lives in scripts/authifi/floh-client.json, scripts/authifi/floh-portal-client.json, and scripts/authifi/floh-vault-client.json for a manual auth-cli clients create-web-client --import-file.

Running Tests

pnpm test:portal-bff    # Portal BFF tests (vitest)
pnpm test:portal-web    # Portal frontend tests (jest)

Docker Deployment

Building

docker compose -f docker/docker-compose.portal.yml build

Running

# Start the portal stack (published BFF image). Loads existing
# .env / env/console.env / env/portal.env; does not merge docker-compose.yml
# (that would start a second API on :7070).
pnpm docker:portal:up

Or run only the portal stack (assumes the internal server is already running). Compose interpolates portal secrets from env/portal.env; a missing --env-file path fails closed, so copy the templates first:

docker compose --env-file .env --env-file env/console.env --env-file env/portal.env \
  -f docker/docker-compose.portal.yml up -d

Docker Services

Service Image Port Description
portal-bff ghcr.io/authifi/idbroker-tools/bff-gateway:3.3.0 7071 Portal OIDC RP and API gateway
portal-web Dockerfile.portal-web 7073 Angular SPA via nginx

Network Topology

┌─────────── Public Network ───────────┐
│  portal-web ──▶ portal-bff           │
└──────────────────┬───────────────────┘
┌──────────────────▼───────────────────┐
│  portal-bff ──▶ server               │
│         Internal Network             │
│  server ──▶ postgres, redis          │
└──────────────────────────────────────┘

The portal-bff and portal-web services are on the public network. The BFF also joins the internal network to reach the server. The server, database, and Redis are on the internal network only, never exposed publicly.

Rollout and rollback

Deploy the portal SPA /bff/* route changes together with the Authifi BFF image/config update. Roll forward and roll back those pieces as a pair:

  • rolling forward: deploy the SPA changes and the bff-gateway:3.3.0 image/config together
  • rolling back: restore both the prior portal SPA behavior and the prior BFF image/config together
  • there is no database migration in this task

Security Considerations

  1. Browser tokens stay off the SPA — the BFF stores the portal session and attaches access tokens on proxied /api/* requests; browser JavaScript never reads access or refresh tokens
  2. CSRF contract is unchanged at the browser edge — the portal still uses floh_portal_csrf and x-csrf-token, but the BFF now mints the cookie
  3. Bearer-only proxied mutations keep cookie-session CSRF intact — the API skips CSRF only when the request carries Bearer auth and no Floh session cookie; cookie-session requests still require the double-submit pair
  4. Explicit session modes — operators choose cookie or Redis storage deliberately; Redis misconfiguration fails closed
  5. Secrets stay in per-process host files — operators set PORTAL_OIDC_CLIENT_SECRET and PORTAL_BFF_COOKIE_ENCRYPTION_SECRET in env/portal.env / secret injection, and Compose maps them to the Authifi runtime's AUTH_CLIENT_SECRET and AUTH_COOKIE_ENCRYPTION_SECRET; none of those values belong in Angular config, committed JSON, or the API .env
  6. HTTPS-first deployment — browser-facing traffic is always HTTPS; the committed Palantir deployment also keeps Caddy -> BFF and BFF -> API on HTTPS, while other operators may explicitly choose trusted-ingress or private-network HTTP
  7. No checked-in path allowlist yetallowlist.enabled=false, so API permissions remain the current boundary until the cached allowlist work lands
  8. Step-up authentication — sensitive workflow steps (consent steps with requireStepUpAuth, catalog entries with catalogRequireStepUpAuth) trigger an OIDC re-auth. The portal reauthorizes through the BFF (GET /bff/login?popup=true&acr_values=…&max_age=…), not the Floh API: once the portal reaches the API Bearer-only, the API's cookie-session MFA state is no longer reachable, so the strength and recency must come from the replacement access token's acr and auth_time. The API remains the policy authority and re-verifies both on the proxied retry. See Security › Step-Up Authentication.

Three settings must agree or step-up breaks at runtime rather than at build time:

Setting Where Must equal
bff.stepUp.popupSuccessPath docker/bff/portal.json the auth/step-up-done Angular route
bff.stepUp.popupFailurePath docker/bff/portal.json the auth/step-up-failed Angular route
bff.stepUp.acrAliases docker/bff/portal.json the API's STEP_UP_ACR_ALIASES

The IdP-specific endpoints (AUTH_RESOURCE, BFF_STEP_UP_TOKEN_ISSUER, BFF_STEP_UP_TOKEN_AUDIENCE, BFF_STEP_UP_TOKEN_JWKS_URI) come from environment variables rather than the committed config, because they vary per deployment. node-config does not expand ${VAR} inside JSON — a placeholder there would be sent to the IdP literally.