Skip to content

Auth Configuration

Floh signs every user in through Authifi. This document covers the Authifi objects Floh depends on (OIDC clients, resource servers, roles, and groups) and the Floh settings that must agree with them.

Contents

Key terms

  • Channel. One of Floh's three ways in: the admin console, the user portal, and the MCP server that AI tools connect to. Each channel has its own Authifi client and resource server.
  • OIDC client. The Authifi registration a channel signs users in with: floh-client for the console, floh-portal-client for the portal, and floh-mcp-client for the MCP server.
  • Resource server and audience. A resource server is Authifi's record of an API that accepts tokens. Its identifier, called the audience, looks like a URL (for example https://console.floh.api) but is only a name; nothing is served there. Each token names the audience it was issued for, and the Floh API accepts only tokens issued for one of the three channel audiences.
  • BFF (backend for frontend). The small server in front of the console and the portal. It handles sign-in and keeps tokens on the server, so the browser never holds them.
  • Token slot. A token the console BFF holds for each signed-in user. The console has two: one for the Floh API, and one for Authifi's admin API, which the Authifi sync and Floh roles pages use.
  • Step-up. A fresh MFA prompt Floh requires before a sensitive change, such as applying the Authifi sync.

Quickstart

Most deployments set up Authifi once, from the console. These steps are the short version; each links to the full description.

On a new tenant deployed with the GitHub Actions workflow, follow the first-time bring-up checklist instead of steps 1 and 2, then continue at step 3. The workflow also requires the portal client's secret, PORTAL_OIDC_CLIENT_SECRET, and that client does not exist until the checklist creates it.

  1. Create the console's Authifi objects. In Authifi, create the Floh Console API resource server and the floh-client OIDC client linked to it. The console signs in through these two, so the sync cannot create them. See Before you run the sync.
  2. Deploy Floh with the floh-client secret as OIDC_CLIENT_SECRET and the Authifi admin API address as AUTHIFI_ADMIN_TARGET. See Floh deployment.
  3. Sign in to the console as an Authifi tenant admin or super admin who can complete MFA.
  4. Preview the sync. Go to Admin > Authifi sync and run Preview. It lists the changes it would make and writes nothing.
  5. Apply. Confirm and complete the MFA prompt. Apply creates what is missing in Authifi, such as the portal and MCP resource servers and Floh's roles and groups, then saves the role list to Floh.
  6. If the portal or MCP client is missing, Apply creates it and stops. Rotate the new client's secret in Authifi, deploy it as PORTAL_OIDC_CLIENT_SECRET or MCP_OIDC_CLIENT_SECRET, then return to step 4.
  7. If saving the role list is denied, the page offers to add you to the floh-console:admin group. Accept, choose Sign in again, then run Preview and Apply once more to save the list.
  8. Verify. Run Preview again. It should report "No changes needed — Authifi already matches the desired state.", or "Preview ready: 0 changes" followed by a count of reports.
  9. Reports are differences the sync shows but never changes, such as an extra Floh role or group in Authifi, or an edited namespace description. They don't block setup, and Apply won't clear them. Fix them in Authifi, or remove extras with the CLI's sync-authifi-rbac -- --apply --prune.
  10. "Authifi already matches; update Floh catalog cache." means the role list still needs saving. Apply once more.
  11. Any remaining change means a step is unfinished; see Running the sync.
  12. Test sign-in by role. In Admin > Floh roles, on the Members tab, add test users to different groups, for example floh-console:requestor, floh-console:approver, and floh-portal:requestor. Sign in to the console and the portal as each user and check that each sees only what the role allows. A user picks up a group change at their next sign-in or session refresh.

Channel audiences

Each channel's tokens are issued for that channel's audience (see Key terms). This section covers which audiences the API accepts and where their values come from.

The API verifies a token's aud against an allow-list built from FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, and FLOH_MCP_AUDIENCE. An unset or blank value defaults to https://console.floh.api, https://portal.floh.api, or https://mcp.floh.api, and the API logs which ones it defaulted outside development and test. Startup fails when two channels resolve to the same audience or when one equals OIDC_ISSUER. The API publishes the resolved values as channelAudiences on GET /api/auth/config, which is how the console Authifi sync knows what identifier to give a resource server it creates. FLOH_RESOURCE_ID (and its deprecated alias OIDC_AUDIENCE) is resolved into config but has no production consumer — its real job is the scripts/sync-authifi-rbac.mjs operator tool and unrelated legacy configuration paths. OIDC client setup ignores and preserves the pair. It is not a startup requirement.

Console BFF token slots

The console BFF holds two tokens for each signed-in user (see Key terms): one for the Floh API, and one for Authifi's admin API. This section covers how both are configured and why the Authifi one needs token exchange.

For the console BFF, NODE_CONFIG owns the bffProxy array and the named bff.tokenSlots. Every slot must include scopes as a JSON string array — BFF 3.8.1 rejects a missing or non-array scopes field at startup. Both slots declare "scopes": [] with "scopeMatch": "contains", which together mean the slot asserts nothing about the issued token and leaves each resource server as the authority. Both parts are required: scopeMatch defaults to "contains" only for a primary slot and to "exact" for every other acquisition mode, and under "exact" an empty array rejects any token that carries any scope at all — surfacing as resource_token_rejected with reason: "extra_scope". Neither token here carries a fixed scope set, because the Floh API token carries the caller's Floh permissions and the exchanged Authifi token carries the caller's Authifi permissions, both derived per user.

Do not copy auth.authorizationParams.scope into a slot's scopes. Those are authorization request scopes; openid and offline_access are consumed by the OIDC and refresh machinery and never appear in an issued access token, so a slot asserting them rejects every login with reason: "missing_scope". The login scopes stay where they take effect — auth.authorizationParams.scope in docker/bff/console.json (openid, profile, email, offline_access) — while both slots assert "scopes": [] with "scopeMatch": "contains". Do not reintroduce standalone BFF_PROXY_TARGET or BFF_PROXY_PATH entries for the console service: node-config's custom-environment-variable precedence would override the array shape with scalar values and silently corrupt the proxy contract. AUTHIFI_ADMIN_TARGET is the remaining GitHub var for the tenant-pinned Authifi admin HTTP API. The admin token slot resource is OIDC_ISSUER (Authifi API RSID). jwksUri is OIDC_JWKS_URI from ci.env (the issuer's discovery jwks_uri). Deploy fetches {OIDC_ISSUER}/.well-known/openid-configuration and fails if jwks_uri disagrees. The workflow writes OIDC_JWKS_URI into ~/floh/.env and AUTHIFI_ADMIN_TARGET into ~/floh/env/console.env.

The admin slot uses "acquisition": "exchange", which obtains its token by RFC 8693 token exchange against the login session's access token rather than by the refresh grant. The refresh grant cannot serve this slot at all: the authorization server pins resource indicators onto the authorization code at /authorize, and a refresh token can only ever request a resource already on that list, so asking it for the Authifi admin resource fails with invalid_target regardless of configuration.

This places a prerequisite on the OIDC client registration in each environment: the client named by OIDC_CLIENT_ID must have urn:ietf:params:oauth:grant-type:token-exchange among its registered grant types. pnpm run setup-authifi-oidc-clients registers it when it creates the console client, and refuses to proceed against a pre-existing console client that lacks it rather than reconciling URIs and reporting success over a client that cannot serve /authifi. A client registered by an earlier version of that tooling needs the grant added in Authifi, or deleting so setup can recreate it. Without it the exchange fails as unauthorized_client and every request through the /authifi mount returns 401, while ordinary login continues to work — the failure is confined to that mount and never ends the session. Check the gateway log for token_exchange_failed, which records the slot name and the provider's error code.

Authifi client registration

Before client registration, reconcile the Authifi authorization catalog for all three channels. The deploy workflow never runs this command automatically:

pnpm run sync-authifi-rbac -- --dry-run
pnpm run sync-authifi-rbac -- --apply
pnpm run sync-authifi-rbac -- --dry-run

The command rebuilds @floh/shared before planning so a leftover gitignored packages/shared/dist cannot apply an older catalog. It loads repo .env when it exists, with direct shell values taking precedence. Paste the admin JWT into gitignored .authifi-admin-token (not .env).

Do not use the Floh console or the portal while running these commands. The admin token belongs to the same Authifi SSO session as both apps, and each one ends that session at the IdP on its ordinary logout and on a re-login after the API has rejected its token — signing in from the session-expired overlay always does, because both apps deliberately route that Sign In through /bff/logout (RP-initiated logout) so Authifi re-prompts instead of replaying a token the API already refused. RP-initiated logout is single-logout: it takes the copied admin token down with it, and the next command fails with HTTP 401 (UnauthorizedError; revoked_token) well before the token's exp.

A revocation during --apply can interrupt the reconciliation partway, after earlier mutations have already landed. Re-running is safe, since the sync plans against live state and only writes what is still missing, but avoid the interruption rather than recover from it: keep both apps closed for the duration, and on a revoked token take a fresh copy and re-run immediately rather than waiting. This applies to every command here, including the read-only dry runs, and it is separate from the MFA requirement that gates --apply alone, described below. Cross-app session coupling is tracked in LSA-10232. The CLI derives AUTHIFI_BASE_URL, numeric AUTHIFI_TENANT_ID, and AUTHIFI_ADMIN_RESOURCE from that token when they are unset. Set all three FLOH_*_AUDIENCE identifiers in .env or the shell. OIDC client setup uses the same token file (SERVICES_AUTH_URL / AUTH_CLI_ACCESS_TOKEN remain deprecated aliases of the derived base URL and in-memory token copy).

sync-authifi-rbac needs nothing beyond that admin token. It resolves the three known OIDC clients and their current resource-server bindings from Authifi's own tenant-scoped admin API (GET /clients and the resource-server listing with its clients relation included), so there is no separately-authenticated external CLI to install or log in to for this command. Client registration (setup-authifi-oidc-clients) now uses the same Authifi admin API directly.

On a tenant where none of floh-client, floh-portal-client, or floh-mcp-client exists yet, the command plans the RBAC catalog without client assignments and says so — that is the bootstrap run, and it is what lets client registration (which requires the channel resource servers) happen at all. Rerun the same command after registration to verify the bindings that setup-authifi-oidc-clients created against desired state and repair managed channel links that are missing or wrong; on a clean tenant it reports no changes. A tenant carrying only some of the three, a duplicate of any one of them, or a matching client row with no usable positive integer id is neither state: the run fails closed naming the counts, before any mutation.

--apply additionally requires the token file to hold an MFA / AAL2 session. Authifi gates group writes behind step-up authentication and answers a password-only token with 401 and WWW-Authenticate: … error="insufficient_user_authentication", acr_values="mod-mf". The command inspects the token before contacting Authifi and refuses to apply when the token carries no MFA, when its auth_time is older than the max_age Authifi enforces on those writes, or when its exp / step-up window has fewer than 30 seconds left before mutation, so a partial apply is not started. Re-authenticate with MFA, paste the fresh token, and run the apply promptly; resource-server, permission, and access-role writes are unaffected, so an interrupted run resumes on the next apply.

STEP_UP_ACR_ALIASES controls which JWT acr values satisfy that acr_values=mod-mf challenge. The sync parser accepts only a JSON object whose keys are safe non-empty challenge strings and whose values are non-empty arrays of safe ACR strings. Invalid JSON, empty keys, empty arrays, unsafe characters, or prototype-pollution keys fail closed before client construction or network I/O.

That preflight is intentionally limited to readable JWTs. If the admin token file is opaque rather than JWT-shaped, the CLI cannot inspect acr, auth_time, or exp locally; Authifi's server-side authorization response remains authoritative for whether the token satisfies the step-up requirement.

The first dry-run must show the expected console, portal, and MCP changes and perform no writes. Apply requires confirmation. The final dry-run should report No changes. For removal of stale Floh-owned roles, groups, relationships, or managed permissions, first preview with --dry-run --prune, then run --apply --prune; prune requires an additional confirmation and never deletes resource servers, client assignments, or unrelated tenant objects. Do not use --yes for production maintenance unless an approved non-interactive procedure has captured and reviewed the exact plan.

Duplicate access-role names on different resource servers are safe, but prune is still authoritative for Floh-owned leftovers. The sync planner and apply path qualify each managed role by both resource-server identifier and role name. A leftover floh-* role on another resource server remains a prune target even when a desired role with the same name exists elsewhere, while the concrete desired same-named live role is preserved because prune only unlinks a group when that exact linked role id is scheduled for deletion.

On Authifi HTTP failures, CLI output is permanently redacted down to the allowlisted top-level WWW-Authenticate fields error, acr_values, and numeric max_age. The command never prints the admin token, any HTTP response body, error_description, arbitrary challenge parameters, excluded quoted challenge text, or URL userinfo/query secrets from credential-bearing Authifi base URLs.

Two OIDC clients back a deploy, and both must exist in the Authifi tenant before the first login attempt:

Client Callback Notes
floh-client https://<DEPLOY_DOMAIN>/bff/callback Console. The console BFF is the relying party.
floh-portal-client https://<DEPLOY_PORTAL_DOMAIN>/bff/callback Portal. The portal BFF is the relying party.

Both callbacks are /bff/callback, not /api/auth/callback. The portal gateway derives its callback from AUTH_BASE_URL and its bff.pathPrefix, so it cannot be overridden through PORTAL_OIDC_REDIRECT_URI.

The same command also registers floh-mcp-client, a confidential client with no browser origins. It backs the MCP server rather than a deployed web tier, so it is not required for login and its minted secret belongs in env/mcp.env, not in a deploy secret. See MCP Server Setup.

A client that already exists is never re-created, so setup checks the live one instead: if floh-mcp-client is missing the authorization_code or refresh_token grant, or authenticates with anything other than client_secret_post, the run stops before its first write and names the field to fix. Correct it in the Authifi admin UI, or delete the client and re-run.

The API also accepts floh-mcp-agent, the public client that will replace floh-mcp-client for agent sign-in by device approval (LSA-11274). Setup does not register it yet, and until it exists in Authifi no token can reach that path, so nothing changes on upgrade. Its tokens are accepted only as Authifi NHE delegation tokens on the MCP channel, and agents are refused on step-up routes; see Agent delegation tokens. Rolling back is a code revert with no data or configuration step.

Each managed client must be authorized for its channel resource server: floh-client uses FLOH_CONSOLE_AUDIENCE, floh-portal-client uses FLOH_PORTAL_AUDIENCE, and floh-mcp-client uses FLOH_MCP_AUDIENCE. The console BFF requests FLOH_CONSOLE_AUDIENCE on authorize; the portal BFF requests FLOH_PORTAL_AUDIENCE. The three channel identifiers must be pairwise distinct, and none may equal the AUTHIFI_ADMIN_RESOURCE derived from the admin token. Setup removes each managed client's sibling-channel assignments before binding each client only to its own channel server. It does not remove unrelated client relations. In particular, pre-existing transitional links remain untouched until LSA-10182.

Setup ignores and preserves FLOH_RESOURCE_ID and its deprecated OIDC_AUDIENCE alias even when both exist with conflicting values. sync-authifi-rbac still validates that pair and may fail until the operator corrects the values or removes the stale assignments.

pnpm run setup-authifi-oidc-clients reconciles all of this idempotently. Existing-client reconciliation is additive: every registered callback and post-logout URI is preserved — including a deployed origin still needed on a tenant shared with local development — except a callback whose parsed pathname is exactly /api/auth/callback or /api/auth/callback/, the API-hosted callback the BFF migration retired. The origins you pass are appended if missing, not substituted for what is already registered. Always preview first:

pnpm run setup-authifi-oidc-clients -- --tenant <tenant-slug> --yes --dry-run \
  --identity-provider <your-authifi-identity-provider> \
  --console-origin https://floh.authilize.com --console-origin https://localhost:7072 \
  --portal-origin https://myfloh.authilize.com --portal-origin https://localhost:7073

--identity-provider is repeatable and is required whenever the plan includes creating the console, portal, or MCP client — the run aborts before the dry run prints anything without it. AUTHIFI_IDENTITY_PROVIDERS=nih,google in the environment is equivalent.

--vault does not exempt you. It is additive rather than exclusive: computePlan receives no vault flag and queues create-console whenever the console client is absent, so on a genuinely new tenant even setup-authifi-oidc-clients -- --vault --dry-run aborts on the same guard. Supply a provider on the first run against a tenant, whatever else the run is for.

The dry run prints required additions separately from known-deprecated callback removals — the exact /api/auth/callback[/] classification is the only automatic removal this tool performs — and closes with a count of each. Read that output before dropping --dry-run. See the portal guide for the full flag reference.

The OIDC dry-run does not preview numeric client-resource bindings before the clients exist, but it does resolve the tenant, validate all three resource servers, and perform the same final reread verification the apply path uses. Setup requires the target tenant to be stated in TENANT (or --tenant for a one-off) and fails closed when neither is set, then persists the derived tenant fields before any Authifi mutation. Run the RBAC sync first, then verify in Authifi that each client is linked to its matching resource server. For catalog rollback, restore the prior catalog revision, preview with pnpm run sync-authifi-rbac -- --dry-run --prune, then apply with --apply --prune. A normal dry-run/apply only reports extras; deletions and unlinks of catalog removals require prune. Relationship removals are planned before object deletion.

Rollback of the channel-only setup behavior is code-only: restore the prior setup version to restore its former prerequisite and relationship behavior. No database or Authifi data migration runs in LSA-10181, and rollback neither recovers nor mutates client secrets.

Rolling back past LSA-10194 restores the old authoritative-replacement policy for existing-client URI reconciliation. That older code treats every registered URI you do not pass as absent and deletes it, so it cannot restore a callback this version already removed as known-deprecated — code rollback undoes the policy, not a past deletion. Before running the older script against a client this version has reconciled, re-add via --console-origin / --portal-origin (or CONSOLE_ORIGINS / PORTAL_ORIGINS) any origin that must still resolve, and preview with --dry-run first.

This rollout does not add runtime Authifi grant rechecks on delayed steps (LSA-9940).

Upgrading past LSA-9939 (legacy authorization removal)

The 20260901130000_drop_legacy_authorization_rbac migration drops the api_token, permission_override, role_permission, user_role, permission, and role tables. Two things must be true before you deploy it, or the release will fail closed:

  1. A default approver group must exist. Approval fallback and default escalation used to scan user_role for admins; they now resolve settings.system.defaultApproverGroupId only. Migration 20260901125000_backfill_default_approver_group runs immediately before the drop. If the setting already names a user_group with at least one live member, it is a no-op. Otherwise it copies every live user (deleted = false and active = true) who currently has the legacy DB admin role — excluding the reserved system user — into a group named Default Approvers (reused if that name already exists) and writes the setting. If that live-admin set is empty, or the legacy tables are already gone and the setting is still missing, migrate throws; it will not invent approvers. After deploy you can change the group in Settings → System, but you cannot clear it: PUT /api/admin/settings rejects an explicit null, and the group routes already refuse to delete the selected group or remove its last member. The drop's up() still refuses any DDL unless the setting is a UUID naming a user_group with at least one non-deleted, active member.
  2. Authifi must already be issuing scopes. Every request's permissions come from the access token's scope claim after this release. Re-run pnpm run sync-authifi-rbac -- --dry-run and then --apply so the catalog's roles — including the new console-only system:administer — exist in Authifi, and confirm your admin users hold them. A user whose token lacks system:administer loses admin-only console behavior (permanent user delete, project-authority bypass, scope=all listings, template/scheduler/email-log administration).

Also remove ALLOW_PERMISSION_OVERRIDES and any OIDC_ROLE_* variables from your environment; the server no longer reads them. floh_-prefixed API tokens are rejected with 401, so migrate any script or MCP client still using one to an OIDC refresh token (see MCP Setup).

Migrating channel resource identifiers to https

FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, and FLOH_MCP_AUDIENCE now default to the https scheme, per RFC 9728, which requires a resource identifier to be an https URL — a hard requirement for the MCP channel, whose authorization spec builds discovery on that document. Each value must match its Authifi resource-server identifier byte for byte: acceptedAudiences is an exact-match union of exactly these three values, so a scheme mismatch means no token for that channel can ever validate.

Three consequences when you change an identifier in Authifi:

  1. There is no overlap window. The allow-list has one slot per channel and holds no alternates, so the tenant rename and the config/public/ci.env update must land in the same maintenance window. Whichever side moves first, that channel is down until the other follows.
  2. Active sessions must re-authenticate. Access tokens already issued carry the old aud and fail verification immediately; they are not refreshed in place. Expect console and portal users to log in again.
  3. Update your operator .env before the next sync run. sync-authifi-rbac provisions against whatever identifier is configured. If the tenant is on https and your .env still says http, the next run plans a duplicate resource server on the stale identifier, together with the whole permission and role tree. Confirm with pnpm run sync-authifi-rbac -- --dry-run — a correctly aligned run plans no resource-server creates.

Rollback. Reverting the application config alone is not sufficient once the tenant has been renamed; the two must be reverted together, or the channel stays broken in the opposite direction. There is no migration or image component to roll back — the change is config-only on the Floh side.

Platform access rollout

The console labels this section Floh roles, with Roles and Members tabs headed Floh roles and Floh members. The configuration keeps the platform-access name — PLATFORM_ACCESS_GROUP_IDS and the /platform-access/... routes are unchanged — so expect the variable you set and the heading an administrator reports to differ.

As of LSA-10171, the normal way to run this rollout once the API and console are already deployed is the console Authifi sync page, not the CLI. A signed-in Authifi tenant or super admin previews and applies the same desired state the CLI applies, then the browser persists the resulting group-id map through a normal Floh API call — no GitHub repository variable or redeploy required. The CLI (sync-authifi-rbac, --emit-group-ids) and the PLATFORM_ACCESS_GROUP_IDS environment variable described in the rest of this section are not removed: they remain the supported path for first-time bring-up (before any administrator can sign in), headless/CI-driven maintenance, break-glass recovery when the console is unreachable, and environment-level rollback. Authifi itself stays authoritative either way — the persisted map is only a non-secret id cache the console reads to address groups it already has live authorization for.

This rollout assumes the console BFF's Authifi admin proxy is configured only by committed NODE_CONFIG plus OIDC_ISSUER (admin slot resource / Authifi API RSID), OIDC_JWKS_URI (discovery jwks_uri), and deploy-time AUTHIFI_ADMIN_TARGET. AUTHIFI_ADMIN_TARGET must stay the fixed tenant-scoped <authifi-base>/auth/admin/tenants/<positive-tenant-id> URL for the deploy target. Never derive it from browser input or request parameters.

For first-time bring-up (before any administrator can sign in to run the console flow) or headless/CI-driven maintenance, apply in this order:

  1. apply Authifi desired state
  2. capture the group id map (below) and set it on the API
  3. deploy BFF image/config
  4. deploy the API and console images

Once the API and console are live, an Authifi tenant or super admin normally does steps 1–2 from the Console Authifi sync page instead — see below.

The Floh roles catalog

This section describes the CLI/environment-variable capture path. It is the fallback for headless operation, break-glass recovery, and any environment without a signed-in Authifi admin available to run the console flow; the normal path once the console is deployed is Console Authifi sync.

The console renders one row per canonical role. Floh owns those names — they are the same list the sync CLI applies as desired state — but Authifi assigns each group a numeric id at creation time, and the console needs the id to read the group's members. That map is deploy-time configuration rather than a runtime lookup: the console has no credential that can enumerate groups it is not itself a member of, so a runtime catalog read would show each administrator a different, silently incomplete list.

Capture the map after the sync has created the groups:

pnpm run sync-authifi-rbac -- --emit-group-ids

It prints a single PLATFORM_ACCESS_GROUP_IDS=… line, or exits non-zero naming the groups it could not find — it never emits a partial map, because a partial map is exactly what produces a console screen that looks complete and is not. Store the value as the PLATFORM_ACCESS_GROUP_IDS repository variable; the deploy appends it to the API's .env when set.

An absent map never blocks startup. LSA-10117 retired the PLATFORM_ACCESS_ENABLED gate, so there is no off position to fall back to and a rolling deploy can carry an API that has the map alongside one that does not. Outside development and test the API logs a [config] warning naming the variable and the console renders an empty catalog, which is the same fail-closed outcome the browser already produces for a partial map.

An invalid map — malformed JSON, an unknown group name, a non-positive id, two groups sharing an id, or a missing group — fails startup in every environment, including development. Silently dropping a bad entry would degrade the screen in the same invisible way the old runtime lookup did.

Every subsequent sync-authifi-rbac run compares the configured map against live Authifi and exits non-zero if they disagree, naming the groups and both ids — but only when the deployed value is present in that run's environment. The repository variable reaches the API's .env on the deploy host, not the shell you run the sync from, so export it first or the run passes without checking anything:

PLATFORM_ACCESS_GROUP_IDS="$(env -u GITHUB_TOKEN gh variable get PLATFORM_ACCESS_GROUP_IDS)" \
  pnpm run sync-authifi-rbac

A run that resolves every managed group but finds no configured value says so on stderr rather than reporting a clean comparison. It still exits 0, because the run that first creates the groups legitimately has no map to compare yet.

Recover from reported drift by re-running --emit-group-ids, updating the repository variable, and then re-running the Deploy workflow — the drift means the deployed console is addressing groups that no longer exist or now belong to different roles.

# `--emit-group-ids` prints a whole `PLATFORM_ACCESS_GROUP_IDS=…` line, and
# pnpm's reporter writes its own lines to stdout ahead of it. Select the emitted
# line, then strip the key — storing the raw capture would put the reporter
# banner and the variable's own name inside the value, which the sync's JSON
# parser then reports as an unusable map.
# Chained: pasted lines do not stop on failure without `set -e`, and every way
# this can fail is silent. An expired token or an unreachable Authifi leaves
# `emitted` empty, and an empty map is not rejected downstream — the API now
# boots with an empty catalog and only warns — so an unchained sequence would
# store nothing and deploy it, turning a drift recovery into a total loss of
# the roles screen.
emitted="$(pnpm run sync-authifi-rbac -- --emit-group-ids | grep -m1 '^PLATFORM_ACCESS_GROUP_IDS=')" \
  && [ -n "${emitted#PLATFORM_ACCESS_GROUP_IDS=}" ] \
  && env -u GITHUB_TOKEN gh variable set PLATFORM_ACCESS_GROUP_IDS \
    --body "${emitted#PLATFORM_ACCESS_GROUP_IDS=}" \
  && env -u GITHUB_TOKEN gh workflow run deploy.yml

Restarting the API is not sufficient and is the failure mode worth naming: the variable is read only by the Deploy workflow, which re-copies the public overlay from the repository and appends PLATFORM_ACCESS_GROUP_IDS to ~/floh/public/ci.env on the host. Nothing on the host reads the repository variable, so a restart re-reads the same ci.env and reloads the very map the drift check just rejected — leaving the console pointed at the stale groups while the operator believes the recovery succeeded. Deploying is what propagates the new value; if a full deploy is not acceptable, edit ~/floh/public/ci.env on the host directly and restart, accepting that the next deploy overwrites it from the repository variable.

There is no runtime flag to flip. The surface is visible to any caller holding both system:administer and user:read, so access is granted and revoked entirely through the predefined Authifi groups — see Floh roles terminology and authority, which also covers the refresh/login-based removal semantics. The gate that previously guarded this rollout (PLATFORM_ACCESS_ENABLED) was retired in LSA-10117 once LSA-9824 shipped — effective scopes now provably equal requested ∩ client/resource allowed ∩ user-granted on both the authorization-code exchange and refresh. No client-credential fallback. A stale PLATFORM_ACCESS_ENABLED left in an overlay is inert and does not fail startup.

To take the surface away from an operator, remove their system:administer or user:read grant, or revert the console image. Because there is no environment-level off switch, the Authifi desired state in step 1 is what decides who sees these screens.

Console Authifi sync

The normal, post-deployment way to apply Authifi desired state and capture the group-id map is the console itself, not the CLI above.

Before you run the sync

The sync creates almost everything, but it cannot start until someone can sign in to the console and the console can read Authifi. That needs the following, created once by hand in Authifi or with the CLI (see item 5).

# Prerequisite Details
1 Authifi resource server Floh Console API, identified by FLOH_CONSOLE_AUDIENCE Console resource server
2 Authifi client floh-client linked to Floh Console API, with the console callback, response type code, token exchange, and an identity provider Console OIDC client
3 An Authifi tenant admin or super admin who can complete MFA Admin account
4 API and console on the same release, with OIDC_CLIENT_SECRET and AUTHIFI_ADMIN_TARGET set (the GitHub Actions deploy also needs PORTAL_OIDC_CLIENT_SECRET) Floh deployment

The sync creates or repairs the rest; see What the sync creates.

Console resource server

A resource server named Floh Console API whose identifier equals the API's FLOH_CONSOLE_AUDIENCE (default https://console.floh.api).

Console OIDC client

A web client with client ID floh-client that has:

  • the callback <console-origin>/bff/callback;
  • the response type code, which the console BFF requests at sign-in;
  • the grant types authorization_code, refresh_token, and urn:ietf:params:oauth:grant-type:token-exchange. Without token exchange, sign-in works but every Authifi request from the console returns 401 (see Variables);
  • at least one identity provider. The sync copies these to the portal and MCP clients;
  • a link to Floh Console API.

Set the token endpoint auth method to client_secret_post as well. Sign-in does not depend on it, but the sync plans a repair on the first run when it differs. scripts/authifi/floh-client.json is the full reference definition.

Admin account

An account that is an Authifi tenant admin or super admin and can complete MFA through the client's identity provider. Apply requires step-up (item 6).

Floh deployment
  • The API and console deployed from the same release.
  • OIDC_CLIENT_SECRET set to the floh-client secret, and AUTHIFI_ADMIN_TARGET set (Variables).
  • For the GitHub Actions deploy, PORTAL_OIDC_CLIENT_SECRET as well. The workflow refuses to run without it, so on a new tenant follow the first-time bring-up checklist, which creates every client, and use the sync afterward.
What the sync creates

Everything else is created by the sync: the portal and MCP clients, their resource servers, the floh namespace, and the Floh permissions, roles, and groups. Once sign-in works, it also repairs drift on floh-client itself (item 4). Saving the group map needs Floh system:administer; when the account lacks it, the page offers to grant it (item 8).

Running the sync

  1. Sign in to the console as an Authifi tenant or super admin. Any authenticated Floh user can open the page — the nav entry ("Authifi sync") is permission-unconstrained (no permission predicate at all), and the route /authifi-sync is guarded only by authGuard (authentication, not authorization). The page body itself is labeled "Authifi admins only," and every Authifi mutation is authorized by Authifi against the caller's own delegated token, so a non-admin's actions are rejected by Authifi, not by Floh.
  2. Preview. The page first runs an OIDC-client reconciliation preflight, then, when the three known clients are already usable, reads live Authifi RBAC state and reports the planned dependency-ordered actions (resource servers, permissions, access roles, groups, and the floh namespace grant) plus any "reports" — drift Authifi holds that the console will not apply automatically (for example, a description changed on an already-managed object). Preview performs no writes, and does not prompt for reauthentication.
  3. Preview issues one Authifi read per entity list plus one per parent row for each relation, which on a populated tenant is a few dozen sequential requests. The page reports them as read N of M with the stage being read, so a slow tenant is distinguishable from a hung page. The count of expected reads grows as each list read reveals how many per-row reads follow, so M rising mid-run is normal. Collapsing these into a handful of relation-bearing reads is tracked as LSA-10216.
  4. Apply, after an explicit confirmation dialog naming the change count. Apply requires step-up reauthentication before its first write: the console requests it up front, and declining (closing the popup, a blocked popup, or a timeout) leaves both Authifi and Floh untouched. Nothing is written until the verification succeeds, so a declined prompt is a no-op rather than a partial run. Mutations run sequentially in the previewed order. If any mutation fails, the run stops immediately at that action — later actions never run and no group-id map is persisted. Earlier mutations in that run may already be live in Authifi; re-run Preview to see and finish the remaining drift. This is a stop, not a rollback: nothing in Floh reverts an Authifi object a partial run already created or updated. Because the planner is idempotent, the next Preview/Apply reconciles the rest of the desired state on top of whatever landed — there is nothing to manually undo in Authifi.
  5. OIDC-client setup before RBAC sync.
  6. floh-client remains a hard prerequisite. If it is missing or ambiguous, Preview and Apply fail closed with setup guidance rather than guessing.
  7. If floh-portal-client or floh-mcp-client is missing, Preview shows a client setup only plan. Apply creates the missing clients from explicit desired payloads, copies the console client's projected direct provider relation (GET /authifi/clients/{id}/providers with id/name only), verifies the result, and then stops before any RBAC write or Floh map persistence.
  8. That successful setup-only state intentionally does not surface a plaintext secret. Rotate the newly created client secrets in Authifi, deploy them into Floh config, then run Preview again. The next preview proceeds into normal RBAC sync against the newly deployed clients.
  9. The rotation reminder persists for the rest of the browser session, including after a run that failed partway. Once a client exists, no later preview can rediscover that its secret was minted and discarded — the planner reports no create — so that banner is the only remaining record. Treat it as an open task, not a status line; reloading the page clears it.
  10. When all three clients already exist, the preflight may still find additive URI, provider-link, canonical resource-server-link, or namespace drift. That repair pass is still client reconciliation only: Preview enumerates the exact PATCH/PUT actions, Apply performs only those actions and verifies them, then re-previews automatically so the RBAC plan is computed against the state those writes just produced. You still approve that second Apply. Repair-only completion does not require secret rotation; a run that created clients stops instead, because Floh does not keep the secrets it minted and they must be rotated and deployed first.
  11. Namespace ownership. Floh's desired state places all three clients in the floh namespace. A client Authifi reports in another namespace — or in none — produces one move client … into namespace "floh" action that writes only the namespaceId field and leaves the client's configuration untouched. The console has no way to express an empty namespace, so a sync cannot detach a client from a namespace it already belongs to.
  12. On a tenant whose floh namespace does not exist yet, ownership is deferred rather than skipped silently: Preview lists it under Deferred to a later run, the RBAC phase of the same sync creates the namespace, and the following Preview offers the move.
  13. The console resource server is a prerequisite; the sync creates the other two. The API publishes the identifier it accepts for each channel as channelAudiences on GET /api/auth/config. The values come from FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, and FLOH_MCP_AUDIENCE, which default to https://console.floh.api, https://portal.floh.api, and https://mcp.floh.api. The console finds each channel's resource server by its canonical Floh name — Floh Console API, Floh Portal API, and Floh MCP API — and checks it against the published identifier.
  14. Floh Console API must exist with the published console identifier, and floh-client must be linked to it. The console signs in through that resource server, so it cannot create it.
  15. When Floh Portal API or Floh MCP API is missing, Preview lists the link from that channel's client under Deferred to a later run, and the RBAC phase of the same sync creates the resource server with the published identifier and links the client. A run that also creates clients stops for secret rotation first (item 4); the next Preview creates the resource servers.
  16. Preview fails closed, with zero writes, when a canonical name exists more than once, when a server under a canonical name has an identifier other than the published one, or when a differently named resource server holds a published identifier. The message names both values. Fix the identifier in Authifi, or set the matching FLOH_*_AUDIENCE on the API and the BFFs, then run Preview again. The console never renames or re-identifies a resource server.
  17. Each Preview re-reads the API's public config. It blocks without writing when that read fails or the API does not publish channelAudiences, which happens when the API is older than the console.
  18. Additional bindings on those servers are ignored, including pre-existing legacy transitional links that ordinary setup leaves untouched until LSA-10182. Do not rename these resource servers in Authifi — the name is how the console tells the channels apart.

On a tenant without Floh Console API or floh-client, create the two by hand as listed in Before you run the sync, or run the CLI Authifi client registration flow once (sync-authifi-rbac -- --apply, then setup-authifi-oidc-clients, as in the bring-up checklist, steps 7–9a); after that the console is the normal path. Use the CLI flow for a GitHub Actions deploy, which also needs the portal client's secret before it runs.

  1. Step-up (AAL2). Two places enforce it, and both use the existing same-origin step-up popup with no separate configuration:
  2. Before Apply's first write, requested by the page itself (item 3). Enforcement here is console-side: the Authifi mutations travel through the BFF gateway, whose route allowlist carries no assurance condition. Adding one is tracked as LSA-10218.
  3. PUT /api/auth/platform-access-group-ids enforces it server-side, so the Floh-side map cannot be written from a session without recent MFA regardless of what the browser did. An authenticated caller lacking it receives an RFC 9470 401 challenge (MFA_OR_AAL_2_REQUIRED) and the interceptor completes the challenge and retries the save.
  4. If an individual Authifi mutation returns 401 MFA_OR_AAL_2_REQUIRED mid-run, the same interceptor handles the challenge and retries that one mutation.
  5. A tenant whose identity provider cannot satisfy mod-mf cannot complete Apply from the console. Use the CLI Authifi client registration flow for that environment instead — the same requirement already applies to connector and PAM administration in this console, so it is not new capability.
  6. Known limitation: a deployment that sets STEP_UP_POPUP_ENABLED=false also cannot complete Apply. The page requests the popup challenge directly rather than through the interceptor, so it does not fall back to the full-page login that flag selects, and the run stops at reauthentication without writing anything. No shipped configuration sets the flag today; honouring it is tracked as LSA-10230. Use the CLI flow on such a deployment until that lands.
  7. Post-apply proof and persistence. After every mutation in a run succeeds (or Apply found nothing to change), the browser re-reads Authifi live, proves an exact canonical floh namespace group map, and only then calls PUT /api/auth/platform-access-group-ids — which requires Floh system:administer plus step-up, and writes the map plus one audit row in a single transaction. The persisted map is a cache/index for the console's own GET /api/auth/config, never an input back into what gets applied to Authifi: every Preview re-derives desired state from live Authifi, not from the cache.
  8. Persistence denied (403). A caller who can sync Authifi but lacks system:administer sees the sync complete against Authifi while saving the map to Floh is denied. The page offers a separate, explicitly confirmed action to grant the caller's own subject membership in the floh-console:admin group — the same group covered under Tenant-admin recovery for administrator self-removal below. Granting it does not save the map: the current session's Floh permissions are unchanged until the operator signs in again, and signing in navigates the whole page away, so nothing the page had computed survives. The page therefore offers Sign in again and no in-page retry. After signing back in, run Preview and Apply once more — Authifi is already in sync by then, so that run takes the cache-only path in item 9 below: zero Authifi mutations, a fresh live re-verification, and the save. 8b. Two operators at once. Floh does not serialize concurrent operators, and deliberately so. If someone else changes the same tenant between your Preview and Apply — including while you are verifying your identity — the failure is safe rather than silent: a create is refused by Authifi and nothing is overwritten, and a client-configuration patch is refused by Floh itself, which re-reads the client immediately before writing and stops the run if it moved. Both cases name the conflict in the error and ask you to run Preview again, which re-plans from Authifi's current state.
  9. Cache-only update. When Authifi already matches desired state (Preview reports zero changes) but the persisted map is absent or does not match what the live namespace actually holds, Apply is still meaningful: it performs zero Authifi mutations, re-verifies live state, and persists the freshly proven map — the fresh-database / stale-cache recovery path, with no separate command needed.
  10. Verify convergence. After a successful Apply, click Run preview again (the button relabels itself once a preview has already run in this session) — a second Preview against caught-up Authifi reports no actionable changes. If the caller holds both system:administer and user:read, the success state also links directly to Floh roles.

The console makes no server-side Authifi call and introduces no new Authifi credential: every request goes through the existing tenant-pinned /authifi BFF proxy using the signed-in caller's own exchanged token. See Roles & Entitlements — Console Authifi sync for the security model, the exact BFF allowlist, and the residual blast radius.

Rollback, precisely. There is no single "undo" command; roll back the pieces that actually moved:

  • Image/config. Revert the console and API images (and their compose NODE_CONFIG) the same way as any other deploy rollback — see Deploying. This affects only the sync UI and the persistence route; it does not touch anything already written to Authifi or to the persisted map.
  • The environment map. Restore a known-good PLATFORM_ACCESS_GROUP_IDS repository variable and redeploy to force the API back onto the env-fallback path documented under The Floh roles catalog. This does not delete the persisted setting — see the next bullet — it only changes what GET /api/auth/config serves while the stored value is absent or itself invalid.
  • The persisted platform-access-group-ids setting. There is no delete endpoint. Remove or replace it only through an authorized operational means: either run a fresh, successful console Apply as a caller holding system:administer (which overwrites it with a freshly proven map), or call PUT /api/auth/platform-access-group-ids directly as that caller with a corrected map. Do not edit the system_setting row by hand outside a reviewed operational procedure — the route's transaction is what keeps the setting and its audit row consistent.
  • Authifi desired-state writes are not transactionally rolled back. A stopped or interrupted Apply leaves any already-applied Authifi mutations in place, by design (see step 3 above). Recovery is a subsequent Preview/Apply — including from the CLI, which shares the same planner and desired state — never a manual revert of individual Authifi objects.

Verify the proxy allowlist before checking anything that needs a session. The gateway matches allowlist routes against the request's full original URL, so the committed routes carry the /authifi mount prefix; a route missing that prefix matches nothing and the gateway denies the whole mount with 404 — before the session check runs, and identically for every method and path, which is easy to misread as "the route does not exist upstream". An unauthenticated request separates the two denials with no credentials or session:

# Expect 401 (allowlist matched, session required). 404 means the allowlist
# routes lost their /authifi mount prefix.
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://<console-domain>/authifi/groups/1/members"

# Expect 404 — a non-allowlisted sibling stays denied.
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://<console-domain>/authifi/roles"

On a host-runtime dev stack the console BFF serves HTTPS on 7074 directly, so target it with curl -sk https://localhost:7074/authifi/groups/1/members.

As of LSA-10196 the shared /authifi allowlist is 21 exact method+prefix entries spanning access roles, clients, groups, identity providers, namespaces, resource servers, and UMRS roles/grants — not the three group-only routes this section described before console Authifi sync existed. The Floh roles/Members screens above still use only the GET/PUT/DELETE subset under /authifi/groups/…/users; the remaining entries exist for the console Authifi sync feature. For the exact table, the residual DELETE/GET-subtree overmatches, and the mitigating Authifi contract, see Roles & Entitlements — Console Authifi sync. The Floh roles screens still read their group catalog (which named groups exist) from Floh's own configuration rather than listing from Authifi — see the role catalog below — even though GET /authifi/groups (the list endpoint) is now reachable through the proxy for the sync feature.

Live non-production verification matrix:

  • Floh console admin succeeds for assigned-namespace membership reads and writes.
  • Ordinary non-admin access fails.
  • Outside-namespace operations, privileged-group operations, and wrong-tenant operations fail.
  • Group CRUD and role CRUD fail.
  • Ordinary console login and Floh /api WebSocket traffic remain healthy.
  • forged browser Authorization is stripped before proxying, and CSRF-less mutation fails.
  • Authifi audit attributes the mutation to the human administrator without Floh minting or logging credentials.

Removal semantics:

  • Access removal becomes authoritative on the next token refresh/login, not immediately.
  • Worst-case exposure is exactly 3600 s, the validated access-token lifetime of the Authifi admin resource server identified by AUTHIFI_ADMIN_RESOURCE. The sync CLI resolves this value from the live resource-server list and fails closed if it exceeds one hour. RESOURCE_SERVER_CONFIG.accessToken.lifetime in scripts/sync-authifi-rbac.mjs owns only the Floh channel resource servers (console, portal, MCP) and must not be used for the admin proxy removal window.

Tenant-admin recovery for administrator self-removal

  1. Sign in as an Authifi tenant administrator, or use another console admin that still retains namespace access.
  2. Restore the affected administrator's membership in the predefined floh-console:admin group through the supported Authifi recovery path.
  3. Re-authenticate in Floh so the refreshed session picks up the restored grant.

Rollback paths:

  • Restore the prior console and console BFF images and NODE_CONFIG.
  • have an Authifi tenant administrator remove the namespace grant through Authifi's administrative surface. The Floh sync CLI intentionally exposes no grant-revocation method.

Back-channel logout

The deployed console and portal BFFs accept OIDC back-channel logout at /bff/backChannelLogout (enabled through NODE_CONFIG in docker/docker-compose.deploy.yml), which lets Authifi end a Floh browser session directly. Authifi only sends it to a client that has registered the URI, and the Console Authifi sync registers it:

  1. After a deploy, run Preview from the deployed console. On a client that has no back-channel URI yet, the plan includes a config patch for floh-client and floh-portal-client, whose detail mentions back-channel logout. Apply it.
  2. A sync run from localhost, or from any http: origin, never registers the URI: Authifi cannot reach that origin, and a client holds only one URI, so a local run must not replace the deployed one.
  3. If a client already has a different back-channel URI, the sync leaves it and shows a note naming both values. Correct it in Authifi if that origin should not be receiving logout notifications.

To verify, sign in to the Floh console and to the Authifi admin console in the same browser, sign out of the Authifi admin console, then reload the Floh console: it should show the signed-out state without any request failing first. Administrator termination and the concurrent-session limit trigger the same behavior once Authifi sends logout tokens for them (LSA-10334).

To roll back, remove "auth":{"backchannelLogout":true} from the two BFF NODE_CONFIG values and redeploy. Optionally clear the registered URI in Authifi — an empty value disables back-channel logout for the client.