AUTH4D-208 Flows token patches at issuance

The Flows patch_claims (console: Add token claims) and patch_roles (Add token roles) steps now change the access tokens of the sign-in their flow lets through. Nothing is stored: the patch lives with the sign-in and its session. No route or wire shape is added. The contract changes are in packages/contracts (FlowClaimNameSchema, FlowTokenPatchSchema, flowTokenPatch, mergeFlowTokenPatch, flowDefinitionProblems). The engine is apps/api/src/modules/flows/engine.ts and issuance is packages/sessions.

Where patches apply

Trigger Sign-in Tokens patched
post_login Hosted browser sign-in (/oauth/authorize/continue) authorization_code exchange
post_login prompt=none session restore authorization_code exchange
post_login POST /t/{tenantId}/guest and GET /t/{tenantId}/guest/authorize the guest response, or the code exchange
device_approval POST /device/approval the device’s device_code poll

pre_registration and pre_guest_upgrade flows still only allow or deny; any patch they carry is ignored. The authorization code and the approved device record hold the patch until the code is redeemed or the device polls.

Token shape

{
  "sub": "usr_…",
  "client_id": "cli_…",
  "auth4_roles": ["player", "veteran"],
  "auth4_meta": { "level": 3, "tier": "gold" }
}
  • Claims go into auth4_meta and roles into auth4_roles, the AUTH4D-154 claims. Every SDK already reads these, and every SDK refuses unknown top-level claims, so a patch never adds a top-level claim and can’t change one auth4.dev sets.
  • The stored values (AUTH4D-154, including set_role and set_claim changes from the same flow) are read first. Patched roles join the stored roles, sorted and unique. Patched claims are merged over stored metadata; on the same key the patch wins.
  • patch_roles remove takes back roles an earlier step of the same flow added. It never hides a stored role.
  • ID tokens and UserInfo never carry patches.

Claim names and values

Claim names follow the AUTH4D-154 metadata key rule: ^[a-z][a-z0-9_]{0,39}$, not one of RESERVED_CLAIM_NAMES (sub, email, scope and the rest) and not starting with auth4. Values are strings up to 256 characters, finite numbers or booleans; null removes a claim an earlier step added. A draft with a list value under such a name is refused with 400 validation_failed.

Before AUTH4D-208, names were namespace:name (game:tier). Such names, and their list values, still parse so stored definitions keep loading and bound flows never start failing closed. They never reach a token, and publishing a draft that has one answers 400 flow_invalid with a detail for definition.steps.{n}.claims.{name}.

Limits

  • The flow’s own patch must fit on its own: at most 20 roles, and at most 32 keys in 2,048 bytes of UTF-8 JSON. The step that goes over fails like any failed step, so onError decides (failed_open skips it, failed_closed denies with flow_step_failed). Namespaced claims don’t count.
  • At issuance, if the stored roles plus the patched roles exceed 20, the patched roles are left out and the stored roles are used alone. The same goes for metadata over 32 keys or 2,048 bytes. Each half is decided separately. A change to stored data therefore never makes a sign-in fail, and a missing claim can only take privileges away. The Worker logs flows.token_patch_dropped with the tenant id only.

Refresh

The patch is stored with the session in the session Durable Object (StoredSession.flowPatch) and applied again on every refresh_token grant of that session’s family. Stored roles and metadata are still re-read on every refresh, so management changes show up at once and the patch is merged over the current values. The patch ends with the session: the next sign-in runs the flow again and gets a fresh patch, and a guest upgrade starts a new session without one. Flows never run at refresh, so a newly published flow reaches existing sessions only at their next sign-in.

A session stored by an earlier Worker version has no patch and refreshes as before. A stored patch that no longer parses is dropped rather than failing the refresh.

Rollout notes

An authorization code or device record that carries a patch can’t be read by a Worker version older than AUTH4D-208 (their schemas are strict). Codes live 60 seconds and device codes at most their configured lifetime, so a rollback during sign-ins can make those few fail with invalid_grant or expired_token; the player signs in again.

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