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:
- Portal BFF (
packages/portal-bff,docker/bff/portal.json) — the Authifi BFF gateway imageghcr.io/authifi/idbroker-tools/bff-gateway:3.3.0, acting as the portal OIDC relying party and browser-facing API gateway on port7071 - 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/loginstarts portal loginGET /bff/callbackcompletes the portal OIDC callbackGET /bff/sessionreports portal session status and supports?refresh=trueGET /bff/logoutclears 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 codebffProxy.ws=false— WebSocket proxying is off for the portal gatewaybffProxy.allowlist.enabled=false— Floh does not currently claim unknown or unlisted/api/*paths return404at the gateway; cached allowlisting is follow-up work in LSA-9831bffProxy.csrf.cookieName=floh_portal_csrfbffProxy.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_CERTandSERVER_KEYcontain PEM contents, not file pathsNODE_EXTRA_CA_CERTSis 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/submitstarts a workflow run (LSA-9061; requires only authentication). When the workflow includes acreate_ticketstep, a linkedservice_ticketis created in the same transaction and the 201 response includes numericticketNumber; 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 byGET /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):
- Pending Invitations — invitations awaiting the user's response, with a "Respond" link
- Active Tasks — the user's assigned tasks (up to 5), with a link to the full task inbox
- Pending Approvals — approvals awaiting the user's decision (up to 5)
- 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.
Notification Invitation Links¶
When PORTAL_FRONTEND_URL is configured, invitation emails link to the portal instead of the admin frontend. This is controlled by:
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):
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:
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:
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¶
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.0image/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¶
- 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 - CSRF contract is unchanged at the browser edge — the portal still uses
floh_portal_csrfandx-csrf-token, but the BFF now mints the cookie - 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
- Explicit session modes — operators choose cookie or Redis storage deliberately; Redis misconfiguration fails closed
- Secrets stay in per-process host files — operators set
PORTAL_OIDC_CLIENT_SECRETandPORTAL_BFF_COOKIE_ENCRYPTION_SECRETinenv/portal.env/ secret injection, and Compose maps them to the Authifi runtime'sAUTH_CLIENT_SECRETandAUTH_COOKIE_ENCRYPTION_SECRET; none of those values belong in Angular config, committed JSON, or the API.env - 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
- No checked-in path allowlist yet —
allowlist.enabled=false, so API permissions remain the current boundary until the cached allowlist work lands - Step-up authentication — sensitive workflow steps (consent steps with
requireStepUpAuth, catalog entries withcatalogRequireStepUpAuth) 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'sacrandauth_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.