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:
parent
ca53defc90
commit
18bd5cbbd0
4 changed files with 103 additions and 3 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue