Identity moves: service contract, architecture, bifrost profile docs and changelog line

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 01:02:13 +00:00
commit 18bd5cbbd0
4 changed files with 103 additions and 3 deletions

View file

@ -96,3 +96,18 @@ ticket would turn cookie theft into bearer theft and, with a passkey or email
code, into unlink power. And unlink accepts any method: a `bifrost-email-code`
proof can unlink an identity a Google re-authentication would have guarded.
Requiring a method per route is the later hardening (identity moves).
## Identity moves
Bifrost's profile carries `identityMoves: { accountProofMethods:
["bifrost-passkey"] }`, the only such grant (its methods are a subset of
`stepUpAssertion.methods`). With its secret, `POST /v1/apps/bifrost/identity-moves`
moves logins from one Heimdall account to another when it presents fresh proofs
of both: Bifrost's merge saga owns survivor choice, preview and undo timing;
Heimdall moves what the proofs cover. A proof through one login moves only that
login; an asserted `bifrost-passkey` proof covers the whole source account;
`bifrost-email-code` proves an inbox and does not. A multi-login source without a
passkey therefore needs one fresh sign-in per login. Until the Google client is
live, a member's strong proof is the asserted passkey only; Discord and Patreon
give weak (300 s) proofs. Contract, refusal order and idempotency are in
`docs/service-contract.md`.

View file

@ -348,13 +348,29 @@ for the account that holds it. The store role therefore holds DELETE on
`linked_identities`, checked at startup. The HTTP side (`/v1/me/identities`) is
in `service-contract.md`; an unlink needs a step-up proof inside 600 seconds.
`moveIdentities` is the only statement that sets `linked_identities.account_id`
to another account, reached only through `POST /v1/apps/{app}/identity-moves`.
One transaction locks both accounts in id order (opposite moves queue, they do
not deadlock), answers a repeated `moveId` from its `identity_moves` row,
re-checks the proof sessions, applies `decideMove` (one pure function both stores
call, so memory and Postgres cannot disagree), moves the rows with credentials
stripped, revokes the source's sessions, expires its pending links and unredeemed
completions, and appends the row. `identity_moves` is append-only (SELECT and
INSERT only, checked at startup). An alias is derived by `resolveAccount`, never
stored.
### Step-up proof has two sources
An access token's `step_up_at` is set in exactly two places: the `sign_in`
step-up callback (Heimdall asked the provider to re-authenticate; label
`provider:<name>`) and `assertStepUp`, the app-asserted route (the app says it
checked a factor; label from the profile's `stepUpAssertion.methods`). Both
feed the one reader, the unlink check, and a refresh sets neither. The
feed the one reader, `proofTier` in `claims.ts` (unlink and identity moves both
read it: `strong` is a step-up inside 600 s, `weak` a sign-in inside 300 s via
`auth_at`), and a refresh sets neither. `proofCovers` says what a proof speaks
for: only a strong proof whose method the profile lists as an account proof
covers the whole account; any other proof covers the one login in
`auth_identity_id`. The
asserted route trusts the calling app and is bounded by the app secret, a live
bearer for that app, the method allowlist, a nonce claimed once per session
through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant;

View file

@ -720,8 +720,9 @@ cookie ticket would turn cookie theft into bearer theft and, with
Bifrost keeps the bearer is its own decision and is not made here.
Unlink accepts any method today. A `bifrost-email-code` proof unlinks an
identity a Google re-authentication would have guarded. Requiring a method per
route (identity moves first) is the later hardening and is not in this change.
identity a Google re-authentication would have guarded. Identity moves
(below) are the first route to require a method for whole-account coverage;
unlink is unchanged.
What this does not prove: Heimdall cannot see the passkey or the code. It trusts
the calling app's word, exactly as it trusts `stepUpRequested` (asked, not
@ -732,6 +733,71 @@ live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod`
(`provider:<name>`, an app label, or null), and unlink does not yet require any
particular method.
### `POST /v1/apps/{appSlug}/identity-moves`
The one sanctioned way a login changes account (`moveIdentities` is the only
writer of `linked_identities.account_id`). Bifrost owns the merge: survivor
choice, preview, grace and undo timing. Heimdall receives the choice as which
proofs fill which side, renders no preview and picks nothing. Only a profile with
`identityMoves` may call it (today bifrost).
Credential: the app's secret. Body (closed; unknown properties are ignored, and
no account is ever named by the body):
- `moveId` (`^[A-Za-z0-9_-]{16,64}$`): the caller's idempotency key.
- `fromProofs`: 1 to 8 live Heimdall access tokens for this app, all of the
source account.
- Forward move: `toProof` (a live token of the destination) and optionally
`identityIds` (1 to 32; default every login the source holds).
- Reversal: `reversesMoveId` instead of `toProof`/`identityIds`. It moves back
exactly the logins the forward move carried, once; `fromProofs` must be of the
forward move's destination. A reversal is not itself reversible.
Each proof must be fresh: a step-up inside 600 s (`strong`) or a sign-in inside
300 s (`weak`), by `proofTier`, the same freshness rule the unlink route reads.
A token with neither claim (a refresh, a link) is stale.
Coverage: a proof through login X (`auth_identity_id`) moves X and nothing else
from that account. Only a strong proof asserted with a method in the profile's
`identityMoves.accountProofMethods` (`bifrost-passkey`; not `bifrost-email-code`,
which proves an inbox) speaks for the whole account. Moving several logins takes
one proof per login, or the passkey.
Order of refusals, each a fixed code: `401 app_auth_required`; `403
not_allowed_for_app`; a replayed `moveId` is answered next (below); `409
not_reversible` (reversal names a missing, foreign or reversal move); `403
proof_invalid` `{proof: "from"|"to"}` (not a live bearer of this app, or
`fromProofs` naming two accounts); `409 same_account`; `403 proof_stale`
`{proof}`; `403 identity_not_proved`; then the store: `403 proof_invalid` (a
proof session lapsed under the lock), `409 already_moved` (either side is an
alias), `409 provider_conflict` `{provider}`, `409 not_reversible`, `409
move_id_conflict`, `404 identity_not_found`, `403 identity_not_proved`. A refusal
body is `{error, detail?, proof?, provider?}` and never names an account id, an
identity the caller did not send, an email or a name.
On success, in one transaction: the logins move to the destination with their
stored credentials stripped; every live session of the source is revoked and its
pending links and unredeemed completions expire; one append-only `identity_moves`
row is written. Afterwards, for each moved provider the source no longer holds,
its live grants get revocation receipts under the source, and `identity_moved`
is audited. Answer `200`, `Cache-Control: no-store`:
`{moveId, accountId: <destination>, movedIdentityIds, proof: {from, to}, reversesMoveId?}`,
with `proof.from` `strong` if any from-proof was strong and `to` null on a
reversal.
Idempotency: the same `moveId` with the same app, accounts and `reversesMoveId`
answers the stored receipt byte for byte, even though the first success revoked
the source's sessions: the proofs are then checked for a valid Heimdall
signature, issuer, type and audience, and account, not for liveness. The replay
re-runs the revocation receipts, so a crash between commit and receipts heals on
the caller's retry. Anything else under a used `moveId` is `409 move_id_conflict`.
What a move leaves: an account holding no login with an unreversed move from it
is an alias of the move's destination (derived by `resolveAccount`, never
stored; one hop). A move into or out of an alias is `already_moved`. Bearers of
the destination keep their old identity list until they refresh. A sign-in
through a moved login lands on the destination because the row moved.
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
Purpose:
@ -869,6 +935,8 @@ Current claim payload fields:
- `access_revision`
- `step_up_at` optional: epoch seconds of the proof; set only by a `sign_in` step-up callback or `POST /v1/apps/{appSlug}/step-up`, never by a refresh
- `step_up_method` optional: present exactly when `step_up_at` is; `provider:<name>` for a provider step-up sign-in, otherwise the method label the app asserted
- `auth_at` optional: epoch seconds of the sign-in this token was minted by; set only by a `sign_in` callback (never link, connect or refresh), always with `auth_identity_id`
- `auth_identity_id` optional: the `linked_identities.id` that sign-in came through; the only record of which login proved the session. An asserted step-up spreads the bearer's claims, so these pass through unchanged
- `display_name` optional
- `app.slug`
- `app.profile_version`

View file

@ -40,3 +40,4 @@
{"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."}
{"ts": "2026-11-13T19:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: GET /v1/me/identities lists an account's identities (no credentials, profile or email); POST /v1/me/identities/:id/unlink deletes one with a step-up proof (access claim step_up_at, 600 s, never carried by refresh), refuses the last identity (409), writes revocation receipts when the provider's last identity goes. upsertLinkedIdentity no longer moves an identity between accounts (IdentityHeldElsewhereError; links/confirm 409 identity_linked_elsewhere, callback link_initiator_mismatch). The store role needs DELETE on linked_identities (startup refuses without it): owner-role GRANT at deploy."}
{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use via oauth_state_uses phase step_up_assert (no schema change), retained to the session expiry as of the claim (a refresh may slide the session past it); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest (429s and not-permitted refusals audited once per window; not-permitted has a 5 per 60 s per-app budget); nonce is base64url 16-128. Provider step-up sign-ins now carry step_up_method provider:<name>; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"}
{"ts": "2026-11-14T01:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-14: POST /v1/apps/{app}/identity-moves (profile grant identityMoves, bifrost only; accountProofMethods bifrost-passkey) and the identity_moves table, the only writer of linked_identities.account_id (moveIdentities, one transaction, accounts locked in id order, shared decideMove on both stores). Access claims gain auth_at and auth_identity_id (sign_in callback only). proofTier is the one freshness rule (unlink reads it; strong 600 s step_up_at, weak 300 s auth_at); proofCovers: only an asserted account-proof method covers a whole account, otherwise the login in auth_identity_id (ruling move-proof-strict-passkey). Moves strip credentials, revoke the source's sessions, expire its pending links and unredeemed completions, write revocation receipts under the source, audit identity_moved; moveId is idempotent (replay answers the stored receipt without proof liveness); a move is reversible once. Not deployed: the owner must apply the identity_moves DDL and GRANT SELECT, INSERT ON identity_moves to the store role before the new build starts (startup refuses SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise).", "branch": "heimdall/identity-moves"}