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=trueto enable on a deployment. Defaults to on in dev/test and off in production. Both the server endpoints and the portal-web/first-passwordlanding 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:
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¶
- The step issues a high-entropy invitation token (HMAC-peppered
against
JWT_SECRET) and stores ONLY the hash ininvitation_token.token— the plaintext is one-shot delivered in the welcome email and immediately discarded server-side. - The recipient clicks the link, lands on the public
/first-password?token=...portal page, and submits a password. - The server route POSTs the password to the configured connector's
setPasswordcommand. On success the run advances along the step'ssuccesstransition; on connector failure the invitation stays pending so the user can retry without re-triggering the workflow. - A
workflow.first_password_setaudit 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.tstest 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 everyconsole.*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¶
- Confirm the portal-web first-password page is deployed (the
/first-passwordroute in@floh/portal-webreturns 200, not 404 — both the server endpoints and the page itself are now landed). - Set
FEATURE_FIRST_PASSWORD=trueon every server replica. - Restart the replicas (the flag is read at boot for the route registration gate).
- 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_setwill 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
passwordPolicyHintis 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).