Skip to content

First Password Invitation step (first_password_invitation)

The first_password_invitation step lets a workflow hand an unmanaged new hire a one-shot link to choose their initial directory password, without ever round-tripping the credential through Floh, an admin, or an audit log.

Status: feature-flagged. Set FEATURE_FIRST_PASSWORD=true to enable on a deployment. Defaults to on in dev/test and off in production. Both the server endpoints and the portal-web /first-password landing page now ship; flip the flag once you've smoke-tested the full round-trip in a non-prod environment.

When to use it

Drop a first_password_invitation step after a user_create step that provisions an account on a credential-owning connector (Active Directory, Google Workspace) — and BEFORE any step that depends on the new hire being able to sign in. Typical placement:

user_create  →  first_password_invitation  →  notification ("welcome aboard")

For federated identities (Authifi-managed users whose passwords live with the upstream OIDC issuer), the step is not applicable — Authifi's setPassword command returns a typed AUTHIFI_FEDERATED_NO_LOCAL_PASSWORD error and the step will fail at runtime. Target the credential-owning connector directly instead (e.g. connectorId: "active-directory-prod" rather than "authifi").

How it works

  1. The step issues a high-entropy invitation token (HMAC-peppered against JWT_SECRET) and stores ONLY the hash in invitation_token.token — the plaintext is one-shot delivered in the welcome email and immediately discarded server-side.
  2. The recipient clicks the link, lands on the public /first-password?token=... portal page, and submits a password.
  3. The server route POSTs the password to the configured connector's setPassword command. On success the run advances along the step's success transition; on connector failure the invitation stays pending so the user can retry without re-triggering the workflow.
  4. A workflow.first_password_set audit row is emitted carrying { connectorId, userKey, runId, stepId } — explicitly NOT the password.

The token's lifetime is bounded (1–168 hours, default 24). After expiry the step routes through the configured onError outcome.

Step config

{
  "id": "first-password",
  "type": "first_password_invitation",
  "config": {
    // Required. The connector to call setPassword against. Must be
    // a credential-owning connector (AD / Google / etc); Authifi
    // returns NOT_SUPPORTED.
    "connectorId": "{{newUser.connectorId}}",

    // Required. Connector-side identifier for the new hire's account
    // (samAccountName for AD, primary email for Google Workspace).
    // Pulled from the upstream user_create step's output variables.
    "userKey": "{{newUser.samAccountName}}",

    // Required. Where to deliver the welcome email. Either a literal
    // address or a single Handlebars token resolving to one. Embedded
    // residue like "abc{{x}}@y.com" is rejected at design AND runtime.
    "recipientEmail": "{{newUser.personalEmail}}",

    // Optional. Token lifetime in hours. Defaults to 24, clamped to
    // [1, 168]. Choose shorter (4–8h) for high-security tenants;
    // longer for new hires who get the email mid-vacation.
    "expiresInHours": 24,

    // Optional. Variable name written to the run bag on success.
    // Defaults to "firstPasswordSet". The variable carries
    // { passwordSet: true, connectorId, userKey, acceptedAt }.
    "outputVariable": "firstPasswordSet",

    // Optional. Connector policy snapshot surfaced to the portal page
    // so the strength meter can render the right rules. The connector
    // remains the source of truth on POST — this hint is decoration
    // only and may legitimately drift from the connector's live policy.
    "passwordPolicyHint": {
      "minLength": 12,
      "requireMixedCase": true,
      "requireSymbol": true,
    },

    // Optional. Email subject + body overrides. Templated against
    // `{{firstPasswordUrl}}` and `{{firstPasswordExpiresAt}}` plus
    // any run-bag variables. Defaults to a minimal accept-link email.
    "customSubject": "Set your password to finish onboarding",
    "messageTemplate": "<p>Welcome, {{newUser.firstName}}! ...",
  },
  "transitions": [
    { "on": "success", "goto": "send-welcome" },
    { "on": "expired", "goto": "notify-it-admin" },
    { "on": "failure", "goto": "notify-it-admin" },
  ],
}

Outcomes routed via the step's transitions

Outcome When it fires
success The recipient submitted a password and the connector accepted it.
expired The TTL elapsed without a successful submission.
failure Issued for terminal-yet-non-expiry conditions (workflow definition edited mid-flight, run cancelled, etc.).

A connector_rejected response (e.g. password too short) does NOT fail the step — it leaves the invitation pending so the user can try again with a stronger password. The audit log records every rejection via the connector's own structured logs.

Public routes

All three routes are unauthenticated and gated on FEATURE_FIRST_PASSWORD=true:

Route Purpose
GET /api/first-password/:token Look up state. Returns active / accepted / expired / superseded so the portal page can render the right copy.
POST /api/first-password/:token Submit a password. Calls the connector's setPassword command. Returns accepted / expired / connector_rejected / etc.
POST /api/first-password/:token/resend Re-issue + email a fresh link. 30s cooldown per step (shared via ResendCooldownStore so two replicas can't both dispatch).

The token is in the URL path, not a query string, so it doesn't get logged by upstream proxies. The portal page MUST avoid putting the token in document.title or window.location.search for the same reason.

Portal page

The user-facing landing page lives at /first-password?token=<token> in @floh/portal-web (see packages/portal-web/src/app/features/first-password/first-password.component.ts). It's a public, unauthenticated route — the high-entropy token IS the credential, exactly as /verify is wired.

Server response shape (GET /api/first-password/:token) Page renders
{ status: "active", … } Password form with <p-password> strength meter (driven by passwordPolicyHint).
{ status: "accepted" } "Password set — you can close this window" terminal panel.
{ status: "expired" } "Link expired — contact whoever sent it for a fresh one" terminal panel.
{ status: "superseded" } "Password already set / link replaced by a newer one" terminal panel.
HTTP 404 "Link not recognized" terminal panel with the server's error.message echoed.

On submit the page POSTs { password } to POST /api/first-password/:token. The response surface mirrors the server contract:

Response Page reaction
{ status: "accepted" } Transitions to the success terminal panel.
{ status: "connector_rejected", message } Stays on the form; surfaces message verbatim so the connector's policy text reaches the user.
{ status: "expired" \| "superseded" } Routes to the matching terminal panel.
{ status: "already_accepted" } Routes to the "password already set" terminal panel.
HTTP 4xx / 5xx Stays on the form; surfaces error.error?.message verbatim. If the response carries retryAfterMs, starts a local cooldown ticker that disables submit until it elapses.

Security invariants enforced in the portal-page tests (see first-password.component.spec.ts for the full list — the spec mirrors the verify-contact harness 1:1 so a future TokenLandingShell refactor can extract a shared shell):

  • The password value is bound only to the <p-password>'s internal <input>. It is NEVER interpolated into any other DOM node — no audit pane, no debug echo, no {{password}} block. A snapshot of the rendered HTML asserts the password substring does not appear outside <input>.
  • The password travels ONLY in the JSON request body. Never the URL, never a query string, never a header. The matching api.service.spec.ts test asserts the request shape belt-and-suspenders.
  • Server 4xx/5xx error messages surface verbatim — no client-side rewriting that could mask a connector policy rejection.
  • The submit button is disabled while a request is in flight (no double-POST possible from a fast double-click).
  • The page never logs the password to console.* (the spec spies on every console.* method and asserts no call's serialized args contain the submitted password substring).

Screenshot deferred: the screenshot for this section will land once the page is deployed to a staging environment. The visual styling is intentionally minimal (PrimeNG <p-card> + <p-password>, same shell as /verify); themed-portal work is tracked separately as part of the portal-page follow-up roadmap.

Security invariants

# Invariant
1 The plaintext password is forwarded ONLY into the connector dispatch. Never logged, never persisted, never returned in payload.
2 The persisted token column carries an HMAC-peppered hash (HKDF of JWT_SECRET). DB-only attacker cannot replay a stolen token.
3 Token comparison uses timingSafeEqual against the recomputed hash, even though the DB lookup is already an indexed equality.
4 A resend supersedes prior pending tokens for the same step — only the most recent inbox email is redeemable.
5 The 30-second resend cooldown is enforced atomically across replicas via ResendCooldownStore (Redis-backed in production).
6 The public POST handler is gated on featureFlags.firstPasswordEnabled; an operator who hasn't flipped the flag can't expose it.
7 A connector failure leaves the invitation pending so the user can retry; only a successful CAS on pending → accepted advances.
8 Connector setPassword definitions never list password in their outputs array (architectural test enforces this).
9 The workflow.first_password_set audit row carries { connectorId, userKey, runId, stepId, notificationId } — never the password.

The architectural tests in packages/server/test/unit/architecture/first-password-token-storage.test.ts pin most of the above as build-time invariants — a drive-by change that violates one will fail CI.

Operator runbook

Enabling on a deployment

  1. Confirm the portal-web first-password page is deployed (the /first-password route in @floh/portal-web returns 200, not 404 — both the server endpoints and the page itself are now landed).
  2. Set FEATURE_FIRST_PASSWORD=true on every server replica.
  3. Restart the replicas (the flag is read at boot for the route registration gate).
  4. Smoke-test by triggering a workflow that hits the step; confirm the welcome email arrives, the link lands on the page, and the password write reaches the directory.

Disabling on a deployment

Set FEATURE_FIRST_PASSWORD=false and restart replicas. Existing in-flight invitations:

  • The GET / POST routes return 404 once the flag flips off (the route plugin doesn't register handlers when the flag is false).
  • The invitations are still in the DB; they expire on their own schedule.
  • Workflow runs paused at waiting_first_password_set will stay there until either (a) the flag is re-enabled, or (b) the run is cancelled / advanced manually.

Rotating JWT_SECRET

The HMAC pepper is derived (HKDF) from JWT_SECRET. Rotating the secret invalidates every outstanding first-password token — recipients with unused links will see a 404 on the portal page. Coordinate the rotation with a workflow re-issue cycle if there are in-flight onboardings.

Limitations + roadmap

  • Password policy enforcement is delegated to the connector. A future enhancement (LSA-8675) introduces a Floh-side policy DSL so operators can layer their own rules on top of (or instead of) the connector's. Until then, the passwordPolicyHint is a UI hint only.
  • Resend dispatch acquires the cooldown but does not yet re-issue the token + welcome email. The portal page in this PR (LSA-8674) intentionally does NOT expose a resend button — the dispatch wiring (and the matching portal control) remains tracked as a separate follow-up. Until then, a manual workflow re-trigger is the supported recovery for a lost link.
  • Second-factor enrollment is not part of this step — Floh's consent / SSO flows handle that for sessions; first-password is password-only by design (TOTP/U2F enrollment requires a session).