AUTH4D-160 Flows built-in steps

AUTH4D-160 adds seven step types to the AUTH4D-159 flow definition, a fixed set of denial messages for hosted login, a terms interstitial on hosted login and a signing secret for call_backend. The schemas are in packages/contracts (FlowStepSchema, FlowDenyMessageSchema, FlowInteractionSchema, FlowBackendEventSchema, FlowBackendResponseSchema, FlowSigningSecretResponseSchema). The step handlers are apps/api/src/modules/flows/engine.ts and apps/api/src/modules/flows/steps.ts. Storage is migration 0020_flow_steps.sql.

All steps keep the AUTH4D-159 fields: id, onError (fail_closed by default) and when. A step that needs a user (see the table) fails when the trigger has none (pre_registration), and onError decides what happens.

Step types

type Needs a user Effect
deny no Denies with reasonCode. AUTH4D-160 adds an optional message (below).
require_verified_email no Denies unless the user has a verified email address.
allow_list no Denies unless one of the user’s values for attribute is in values.
block_list no Denies when one of the user’s values for attribute is in values.
set_role yes Assigns or removes one of the application’s AUTH4D-154 roles for the user.
set_claim yes Sets one key of the user’s AUTH4D-154 auth4_meta metadata; null removes it.
call_backend no POSTs a signed event to your backend, which answers continue, deny or a claims patch.
terms_screen yes Shows a terms interstitial on hosted login until the user accepts this version.
{
  "timeBudgetMs": 1500,
  "steps": [
    { "id": "verified", "type": "require_verified_email" },
    {
      "id": "regions",
      "type": "allow_list",
      "attribute": "country",
      "values": ["ZA", "NZ"],
      "message": "region_unavailable"
    },
    {
      "id": "banned",
      "type": "block_list",
      "attribute": "steam_id",
      "values": ["76561197960287930"]
    },
    {
      "id": "terms",
      "type": "terms_screen",
      "termsUrl": "https://example.com/terms",
      "version": "2026-10"
    },
    {
      "id": "backend",
      "type": "call_backend",
      "url": "https://hooks.example.com/auth4",
      "timeoutMs": 1000
    },
    { "id": "player", "type": "set_role", "role": "player" },
    { "id": "tier", "type": "set_claim", "key": "tier", "value": "gold" }
  ]
}

deny and denial messages

Every step that denies (deny, require_verified_email, allow_list, block_list, a backend’s deny) can name one of these message keys. Hosted login shows its own wording for the key; a flow can never supply text that end users see.

message Hosted login shows
not_allowed “{App} does not allow this account to sign in.” (the default)
region_unavailable “{App} is not available in your region.”
account_restricted “This account cannot sign in to {App}. Contact the developer of {App}…”
email_unverified “{App} needs a verified email address. Sign in with email to verify one…”
try_again_later “Sign-in could not be completed right now. Try again in a few minutes.”

A step that fails closed (flow_step_failed, flow_budget_exceeded) shows try_again_later. The reasonCode is for traces only and is never shown.

require_verified_email defaults to reasonCode email_not_verified and message email_unverified. Before registration (pre_registration) there is no user yet, so the step continues only when the user is signing up with an email code or link.

allow_list and block_list

attribute Values Compared with
steam_id SteamID64, 17 digits starting 7656 the user’s linked Steam accounts
discord_id Discord user id, 15 to 21 digits the user’s linked Discord accounts
email_domain lowercase domain, for example studio.example.com the domain of each verified email address
country ISO 3166-1 alpha-2, uppercase the edge’s country for the request

1 to 500 values per list; values are checked per attribute when the flow is saved. Domains match exactly (a subdomain is a different domain). A user with no value for the attribute (no linked Steam account, an unknown country, or no user yet before registration) is not on any list, so an allow list denies and a block list continues. Defaults: reasonCode not_on_allow_list or on_block_list, message not_allowed.

set_role and set_claim

These reuse AUTH4D-154 storage, so the change appears in the auth4_roles and auth4_meta access token claims from the next token on, refreshes included, and can be seen and changed in the management API and console like any other assignment.

  • set_role: role must be defined for the application the trigger runs for, or the step fails. action is assign (default) or remove. Assignments past the AUTH4D-154 limits (20 roles per user per application, 200 per user) are not made.
  • set_claim: key follows the AUTH4D-154 metadata key rules; value is a string (up to 256 characters), number, boolean or null to remove the key.

Changes are written only when the whole flow continues, after the last step. A later denial or terms screen writes nothing. If the merged metadata would break the AUTH4D-154 limits (32 keys, 2048 bytes), the trigger is denied.

The AUTH4D-159 patch_claims and patch_roles steps are unchanged: they only shape the hook decision and are not applied to tokens.

call_backend

url must be https on the default port, without credentials or a fragment, to a public DNS name: IP literals, single-label names, localhost, .local, .internal, .test, .example, .invalid, .arpa and other reserved names, and auth4.dev hosts are refused when the flow is saved, and checked again before every call. Redirects are not followed. timeoutMs is 100 to 1900 (default 1500), and the call is also cut off when the flow’s time budget runs out.

The request is a POST with Content-Type: application/json and this body:

{
  "type": "flow.step",
  "version": 1,
  "step": "backend",
  "sent_at": "2026-10-07T09:00:00.000Z",
  "input": {
    "version": 1,
    "trigger": "post_login",
    "tenant_id": "ten_…",
    "application_id": "cli_…",
    "user_id": "usr_…",
    "method": "email",
    "context": { "country": "ZA", "provider": "email" }
  }
}

input is the AUTH4D-158 trigger input: identifiers, the sign-in method and coarse context only, never email addresses, IP addresses, tokens or secrets.

It is signed the Standard Webhooks way with the project’s signing secret (below):

Header Value
webhook-id msg_ plus 24 random characters, unique per call
webhook-timestamp Unix seconds
webhook-signature v1, + base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}

The HMAC key is the base64 part of the secret after whsec_. Verify the signature over the raw body with a constant-time comparison, and reject timestamps more than five minutes old.

The backend must answer 200 with a JSON body of at most 4096 bytes:

{ "action": "continue" }
{ "action": "continue", "claims": { "tier": "gold", "legacy": null } }
{ "action": "deny", "reason_code": "account_banned", "message": "account_restricted" }

claims is a patch of 1 to 16 auth4_meta metadata keys, stored like set_claim (it needs a user). Any other status, a redirect, a larger or malformed body, unknown fields, reserved or namespaced claim names, free-text messages, a timeout or a missing signing secret fail the step, and onError decides.

terms_screen

termsUrl is an https page (at most 512 characters) and version 1 to 32 letters, digits, ., _ or -. Until the user has accepted this version for this application, the step pauses the flow:

  • On hosted login (post_login during /oauth/authorize) the user sees “Accept the terms of {App}”, a link that opens the page in a new tab, and Accept and continue or Decline. Accepting records the acceptance and runs the flow again from the start; declining returns to the app with access_denied.
  • prompt=none returns interaction_required to the redirect URI.
  • Device approval, guest sign-in and guest upgrades cannot show the screen, so they are denied (access_denied) until the user has accepted through a browser sign-in.

The trace records the step as interaction_required and the run as deny with reason code terms_required. Publishing a new version asks every user again.

Hosted login continuation

POST /oauth/authorize/continue (AUTH4D-23) changes as follows. Responses stay JSON and no-store.

  • When a post_login flow needs the terms screen, the answer is 200 { "interaction": { "type": "terms", "terms_url": "https://…", "terms_version": "2026-10" } }. No code is issued and the flow cookie stays valid.
  • The next request sends { tenant_id, flow_id, decision: "approve", accept_terms: true } (no proof), or decision: "deny" to decline. accept_terms without a pending terms screen, with a proof or with decision: "deny" answers 400 invalid_request.
  • A Flows denial still answers 200 { "redirect_to": "…?error=access_denied&state=…" }, now with "denial": { "message": "<key>" } so hosted login can explain it before returning to the app.

Signing secret

Method and path Scope / role Result
GET /management/tenants/{tenantId}/flow-signing-secret tenant:read, any role { configured, createdAt }
POST /management/tenants/{tenantId}/flow-signing-secret tenant:write, owner or admin, {} { secret, createdAt }

POST creates the project’s secret, or replaces it, and returns it once (whsec_ plus base64 of 32 random bytes). It is stored encrypted with AUTH_ENCRYPTION_KEY_RING and never returned again; rotate to get a new one. Without the key ring the answer is 503 not_ready. Each rotation writes the audit event management.flow.signing_secret_rotated; refusals are recorded with outcome denied. The console reaches both routes through its BFF.

Storage

0020_flow_steps.sql adds flow_terms_acceptances (user, application, terms version, the URL shown and when) and flow_signing_secrets (one encrypted secret per project). See d1-schema.md (repository file docs/operations/d1-schema.md).

Source: docs/api-spec/auth4d-160.md