diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index 9a7926c..41508e5 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -96,18 +96,3 @@ 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`. diff --git a/docs/architecture.md b/docs/architecture.md index 956b1db..f1d66c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -348,31 +348,13 @@ 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 (an account with no login and an -unreversed move from it) is derived inside the transaction to refuse moves into -or out of it, never stored; nothing resolves an alias to its destination, because -no consumer needs it and a chain of moves would make one hop wrong. - ### 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:`) 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, `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 +feed the one reader, the unlink check, and a refresh sets neither. 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; diff --git a/docs/service-contract.md b/docs/service-contract.md index 6b77caa..2266c0d 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -720,9 +720,8 @@ 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. Identity moves -(below) are the first route to require a method for whole-account coverage; -unlink is unchanged. +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. 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 @@ -733,71 +732,6 @@ live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod` (`provider:`, 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: , 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: derived at move time, never stored, and no route resolves it. 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: @@ -935,8 +869,6 @@ 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:` 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` diff --git a/src/app-profiles.ts b/src/app-profiles.ts index a77f76a..c1d5661 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -65,13 +65,6 @@ export interface AppProfile { * Absent for every app that may not assert. */ stepUpAssertion?: { methods: readonly string[] }; - /** - * The app may move linked identities between two accounts it holds proofs - * for (POST /v1/apps/{app}/identity-moves). `accountProofMethods` are the - * asserted step_up_method labels that prove a whole account rather than one - * login; each must be one of stepUpAssertion.methods. Absent: the app may not move. - */ - identityMoves?: { accountProofMethods: readonly string[] }; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -171,8 +164,6 @@ const bifrostProfile: AppProfile = { allowedReturnOrigins: ["https://bifrost.gamecult.org"], startRequiresAppCaller: true, stepUpAssertion: { methods: ["bifrost-passkey", "bifrost-email-code"] }, - // A passkey proves the account; an email code proves an inbox, which is one login's worth at most. - identityMoves: { accountProofMethods: ["bifrost-passkey"] }, capabilities: [ { key: "member_access", diff --git a/src/app.ts b/src/app.ts index b3c7755..9e8c334 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,7 +24,7 @@ import { type OAuthStartRequest, type Provider, } from "./contracts.js"; -import { issueAccessClaim, proofCovers, proofTier, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js"; +import { issueAccessClaim, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js"; import { FixedWindowBudget, type FixedWindowLimit } from "./fixed-window.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; @@ -47,7 +47,6 @@ import { OAUTH_STATE_SKEW_MS, type CreateAccountInput, type StoredCapabilityGrant, - type StoredIdentityMove, type StoredLinkedIdentity, type UpsertLinkedIdentityInput, } from "./store/types.js"; @@ -1065,184 +1064,6 @@ export async function assertStepUp( }; } -export const MOVE_ID_PATTERN = "^[A-Za-z0-9_-]{16,64}$"; - -export interface MoveIdentitiesBody { - moveId: string; - fromProofs: string[]; - toProof?: string; - identityIds?: string[]; - reversesMoveId?: string; -} - -/** The answer a move gives, derived from its stored row so a replay is byte-identical to the first answer. */ -function moveReceipt(move: StoredIdentityMove) { - const tier = (proofs: { tier: "strong" | "weak" }[]) => (proofs.some((proof) => proof.tier === "strong") ? "strong" : "weak"); - return { - moveId: move.id, - accountId: move.toAccountId, - movedIdentityIds: move.moved.map((moved) => moved.id), - proof: { from: tier(move.proof.from), to: move.proof.to ? move.proof.to.tier : null }, - ...(move.reversesMoveId !== undefined ? { reversesMoveId: move.reversesMoveId } : {}), - }; -} - -/** - * POST /v1/apps/{app}/identity-moves: the one sanctioned way a login changes - * account. The caller's secret and its profile's identityMoves grant open the - * route; the accounts come only from the proofs (live bearers of this app, - * fresh by proofTier), never from the body, an email or a name. A proof - * through login X moves X; only an asserted account proof (the profile's - * accountProofMethods) speaks for the whole account. The store decides and - * commits; this route verifies, maps refusals to fixed codes, then writes the - * revocation receipts the move owes (revokeLiveGrants stays their one writer) - * and the audit row. A replayed moveId is answered before the proofs are - * verified, because the first success revoked the source's sessions. - */ -export async function moveIdentitiesRoute( - ctx: Pick, - revokeLiveGrants: (accountId: string, provider: Provider, receipts: Parameters[0], at: string) => Promise, - appSlug: AppSlug, - headers: Record, - body: MoveIdentitiesBody -): Promise { - const { config, keys, store } = ctx; - const refuse = (statusCode: number, error: string, extra: Record = {}): HandlerResult => ({ statusCode, body: { error, ...extra } }); - if (!resolveAppCaller(config, appSlug, headers)) return refuse(401, "app_auth_required"); - const grant = getAppProfile(appSlug)?.identityMoves; - if (!grant) return refuse(403, "not_allowed_for_app"); - - // The providers a move carried away from its source that the source no longer holds: their grants are taken back under the source. - const settleGrants = async (move: StoredIdentityMove) => { - const held = new Set((await store.listStoredLinkedIdentitiesForAccount(move.fromAccountId)).map((identity) => identity.provider)); - const at = new Date().toISOString(); - for (const provider of new Set(move.moved.map((moved) => moved.provider))) { - if (!held.has(provider)) await revokeLiveGrants(move.fromAccountId, provider, await store.listConsentReceipts(move.fromAccountId), at); - } - }; - - const existing = await store.findIdentityMove(body.moveId); - if (existing) { - const signedFor = (token: string | undefined, accountId: string) => { - const verification = token ? verifyJwt(token, keys.publicKey) : null; - return ( - verification?.valid === true && - verification.payload.typ === "heimdall_access" && - verification.payload.iss === config.issuer && - verification.payload.aud === appSlug && - verification.payload.account_id === accountId - ); - }; - const same = - existing.appSlug === appSlug && - existing.reversesMoveId === body.reversesMoveId && - body.fromProofs.every((token) => signedFor(token, existing.fromAccountId)) && - (existing.reversesMoveId !== undefined ? body.toProof === undefined : signedFor(body.toProof, existing.toAccountId)); - if (!same) return refuse(409, "move_id_conflict"); - await settleGrants(existing); - return { statusCode: 200, body: moveReceipt(existing) }; - } - - let forward: StoredIdentityMove | null = null; - if (body.reversesMoveId !== undefined) { - forward = await store.findIdentityMove(body.reversesMoveId); - if (!forward || forward.appSlug !== appSlug || forward.reversesMoveId !== undefined) return refuse(409, "not_reversible"); - } - - const nowS = nowEpochSeconds(); - const resolve = (token: string) => resolveBearer(ctx, `Bearer ${token}`, appSlug); - const fromBearers = []; - for (const token of body.fromProofs) { - const bearer = await resolve(token); - if (!bearer) return refuse(403, "proof_invalid", { proof: "from" }); - fromBearers.push(bearer); - } - const fromAccountId = fromBearers[0]!.accountId; - if (fromBearers.some((bearer) => bearer.accountId !== fromAccountId)) return refuse(403, "proof_invalid", { proof: "from" }); - let toBearer: ResolvedBearer | null = null; - if (forward === null) { - toBearer = await resolve(body.toProof!); - if (!toBearer) return refuse(403, "proof_invalid", { proof: "to" }); - } else if (fromAccountId !== forward.toAccountId) { - return refuse(409, "not_reversible"); - } - const toAccountId = toBearer?.accountId ?? forward!.fromAccountId; - if (fromAccountId === toAccountId) return refuse(409, "same_account"); - - const proofOf = (bearer: ResolvedBearer, tier: "strong" | "weak") => ({ - tier, - method: tier === "strong" && typeof bearer.claims.step_up_method === "string" ? bearer.claims.step_up_method : null, - identityId: typeof bearer.claims.auth_identity_id === "string" ? bearer.claims.auth_identity_id : null, - }); - const fromProof = []; - for (const bearer of fromBearers) { - const tier = proofTier(bearer.claims, nowS); - if (!tier) return refuse(403, "proof_stale", { proof: "from" }); - fromProof.push(proofOf(bearer, tier)); - } - const toTier = toBearer ? proofTier(toBearer.claims, nowS) : null; - if (toBearer && !toTier) return refuse(403, "proof_stale", { proof: "to" }); - - const coverage = fromBearers.map((bearer) => proofCovers(bearer.claims, grant.accountProofMethods, nowS)); - if (coverage.every((covered) => covered === null)) return refuse(403, "identity_not_proved"); - const covers = coverage.includes("account") ? "account" : coverage.filter((covered): covered is string => covered !== null); - - const at = new Date().toISOString(); - const result = await store.moveIdentities({ - moveId: body.moveId, - appSlug, - fromAccountId, - toAccountId, - identityIds: forward ? forward.moved.map((moved) => moved.id) : (body.identityIds ?? "all"), - covers, - proofSessions: [...fromBearers, ...(toBearer ? [toBearer] : [])].map((bearer) => ({ - appSlug, - sessionId: bearer.claims.sid as string, - accountId: bearer.accountId, - accessRevision: bearer.claims.access_revision as number, - })), - ...(body.reversesMoveId !== undefined ? { reversesMoveId: body.reversesMoveId } : {}), - proof: { from: fromProof, to: toBearer && toTier ? proofOf(toBearer, toTier) : null }, - at, - }); - switch (result.kind) { - case "proof_lapsed": - return refuse(403, "proof_invalid"); - case "already_moved": - case "not_reversible": - case "move_id_conflict": - return refuse(409, result.kind); - case "provider_conflict": - return refuse(409, "provider_conflict", { provider: result.provider }); - case "identity_not_found": - return refuse(404, "identity_not_found"); - case "identity_not_proved": - return refuse(403, "identity_not_proved"); - case "replayed": - await settleGrants(result.move); - return { statusCode: 200, body: moveReceipt(result.move) }; - case "moved": { - const { move } = result; - await settleGrants(move); - await store.createAuditEvent({ - accountId: move.fromAccountId, - appSlug, - eventType: "identity_moved", - eventPayloadJson: { - moveId: move.id, - toAccountId: move.toAccountId, - movedIdentityIds: move.moved.map((moved) => moved.id), - providers: move.moved.map((moved) => moved.provider), - tiers: { from: moveReceipt(move).proof.from, to: moveReceipt(move).proof.to }, - reversesMoveId: move.reversesMoveId ?? null, - }, - createdAt: at, - }); - return { statusCode: 200, body: moveReceipt(move) }; - } - } -} - const grantsPageTokenType = "heimdall_grants_page"; const grantsPageTokenTtlSeconds = 600; @@ -2155,9 +1976,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise nowS || nowS - provedAt > STEP_UP_PROOF_TTL_SECONDS) { reply.code(403); return { error: "step_up_required", @@ -3130,44 +2949,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( - "/v1/apps/:appSlug/identity-moves", - { - schema: { - params: { - type: "object", - required: ["appSlug"], - additionalProperties: false, - properties: { appSlug: { type: "string", enum: [...appSlugs] } }, - }, - body: { - type: "object", - required: ["moveId", "fromProofs"], - additionalProperties: false, - properties: { - moveId: { type: "string", pattern: MOVE_ID_PATTERN }, - fromProofs: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 8192 } }, - toProof: { type: "string", minLength: 1, maxLength: 8192 }, - identityIds: { type: "array", minItems: 1, maxItems: 32, uniqueItems: true, items: { type: "string", minLength: 1 } }, - reversesMoveId: { type: "string", pattern: MOVE_ID_PATTERN }, - }, - // A forward move names its destination proof; a reversal names the move and nothing else about who or what. - oneOf: [ - { required: ["toProof"], not: { anyOf: [{ required: ["reversesMoveId"] }] } }, - { required: ["reversesMoveId"], not: { anyOf: [{ required: ["toProof"] }, { required: ["identityIds"] }] } }, - ], - }, - }, - }, - async (request, reply) => { - const result = await moveIdentitiesRoute({ config, keys, store }, revokeLiveGrants, request.params.appSlug, request.headers, request.body); - reply.code(result.statusCode).header("cache-control", "no-store"); - return result.body; - } - ); - // The signed-in surface for consent: every grant and receipt the account // holds, and the one-click way to take each back. app.get<{ Querystring: { t?: string } }>( diff --git a/src/claims.ts b/src/claims.ts index 8ddd423..5e1d3b0 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -14,9 +14,6 @@ import { type CreateSessionInput, type HeimdallStore } from "./store/types.js"; /** How long a step-up sign-in's proof stays good for an action that demands it (identity unlink). */ export const STEP_UP_PROOF_TTL_SECONDS = 600; -/** How long a plain sign-in's proof stays good for an action that accepts a weak proof (an identity move). */ -export const AUTH_PROOF_TTL_SECONDS = 300; - function nowEpochSeconds(): number { return Math.floor(Date.now() / 1000); } @@ -58,10 +55,6 @@ export interface IssueAccessClaimInput { stepUpAt?: number; /** Required with stepUpAt (`provider:`); the claim never carries one without the other. */ stepUpMethod?: string; - /** Epoch seconds of the sign-in that minted this token, with authIdentityId (both or neither). Only a sign_in callback sets them; a refresh, link or connect never does. */ - authAt?: number; - /** The linked_identities.id the sign-in came through. */ - authIdentityId?: string; } export interface IssuedAccessClaimResult { @@ -151,11 +144,6 @@ export async function issueAccessClaim(options: { } } - if (options.input.authAt !== undefined && options.input.authIdentityId !== undefined) { - claimSet.auth_at = options.input.authAt; - claimSet.auth_identity_id = options.input.authIdentityId; - } - if (options.input.displayName) { claimSet.display_name = options.input.displayName; } @@ -241,38 +229,3 @@ export function signAssertedStepUpClaim(options: { }; return { accessToken: signJwt(claimSet, options.keys), claimSet, stepUpAt }; } - -/** - * How fresh a bearer's proof of the member is, from its claims alone: - * `strong` when a step-up (Heimdall's or the app's asserted one) happened in - * the last STEP_UP_PROOF_TTL_SECONDS, else `weak` when the sign-in did in the - * last AUTH_PROOF_TTL_SECONDS, else null. The one freshness rule: identity - * unlink and identity moves both read it. - */ -export function proofTier(claims: Record, nowS: number): "strong" | "weak" | null { - const within = (at: unknown, window: number) => typeof at === "number" && at <= nowS && nowS - at <= window; - if (within(claims.step_up_at, STEP_UP_PROOF_TTL_SECONDS)) return "strong"; - if (within(claims.auth_at, AUTH_PROOF_TTL_SECONDS)) return "weak"; - return null; -} - -/** - * What a fresh proof covers of its account: `account` (every login) only for a - * strong proof whose step_up_method the app's profile lists as an account - * proof; otherwise the one login the sign-in came through (its id); otherwise - * nothing. A proof through login X never speaks for the account's other logins. - */ -export function proofCovers( - claims: Record, - accountProofMethods: readonly string[], - nowS: number -): "account" | string | null { - if ( - proofTier(claims, nowS) === "strong" && - typeof claims.step_up_method === "string" && - accountProofMethods.includes(claims.step_up_method) - ) { - return "account"; - } - return typeof claims.auth_identity_id === "string" ? claims.auth_identity_id : null; -} diff --git a/src/contracts.ts b/src/contracts.ts index 78a3a60..d7a521c 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -162,10 +162,6 @@ export interface AccessClaimPayload { * exactly when step_up_at is; never carried by a refresh. */ step_up_method?: string; - /** Epoch seconds of the sign-in this access token was minted by, with auth_identity_id; absent unless a sign_in callback minted it, and never carried by a refresh. */ - auth_at?: number; - /** The linked_identities.id the sign-in came through; present exactly when auth_at is. It is the only record of which login proved the session, and it describes the sign-in, not an asserted factor. */ - auth_identity_id?: string; display_name?: string; app: { slug: AppSlug; diff --git a/src/store/identity-move.ts b/src/store/identity-move.ts deleted file mode 100644 index 7b80d43..0000000 --- a/src/store/identity-move.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { type Provider } from "../contracts.js"; -import { type MoveIdentitiesInput, type StoredIdentityMove, type StoredLinkedIdentity } from "./types.js"; - -/** Everything a store read under its locks that the move rules decide on. */ -export interface MoveFacts { - /** The identity_moves row already holding input.moveId. */ - existing: StoredIdentityMove | null; - /** The move input.reversesMoveId names, and whether a reversal of it already exists. */ - forward: { move: StoredIdentityMove; reversed: boolean } | null; - /** Every proof session still carries its sid, app, account and access revision and has not expired. */ - proofSessionsLive: boolean; - sourceIdentities: readonly StoredLinkedIdentity[]; - destinationProviders: ReadonlySet; - /** An account with no identity and an unreversed move from it. */ - sourceIsAlias: boolean; - destinationIsAlias: boolean; -} - -export type MoveDecision = - | { kind: "replayed"; move: StoredIdentityMove } - | { kind: "refused"; result: { kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible" } | { kind: "provider_conflict"; provider: Provider } } - | { kind: "move"; identities: StoredLinkedIdentity[] }; - -/** - * The one statement of the move rules, in the order they are decided. Both - * stores gather MoveFacts under their locks and apply the answer, so memory - * and Postgres cannot disagree about what a move refuses. - */ -export function decideMove(input: MoveIdentitiesInput, facts: MoveFacts): MoveDecision { - const refuse = (kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible"): MoveDecision => ({ kind: "refused", result: { kind } }); - - const { existing } = facts; - if (existing) { - return existing.appSlug === input.appSlug && - existing.fromAccountId === input.fromAccountId && - existing.toAccountId === input.toAccountId && - existing.reversesMoveId === input.reversesMoveId - ? { kind: "replayed", move: existing } - : refuse("move_id_conflict"); - } - if (!facts.proofSessionsLive) return refuse("proof_lapsed"); - - const held = new Set(facts.sourceIdentities.map((identity) => identity.id)); - if (input.reversesMoveId !== undefined) { - const forward = facts.forward; - if ( - !forward || - forward.reversed || - forward.move.appSlug !== input.appSlug || - forward.move.reversesMoveId !== undefined || - forward.move.toAccountId !== input.fromAccountId || - forward.move.fromAccountId !== input.toAccountId || - !forward.move.moved.every((moved) => held.has(moved.id)) - ) { - return refuse("not_reversible"); - } - } else if (facts.sourceIsAlias || facts.destinationIsAlias) { - return refuse("already_moved"); - } - - let chosen: StoredLinkedIdentity[]; - if (input.identityIds === "all") { - chosen = [...facts.sourceIdentities]; - } else { - if (!input.identityIds.every((id) => held.has(id))) return refuse("identity_not_found"); - const wanted = new Set(input.identityIds); - chosen = facts.sourceIdentities.filter((identity) => wanted.has(identity.id)); - } - if (chosen.length === 0) return refuse("identity_not_found"); - - if (input.covers !== "account") { - const covered = new Set(input.covers); - if (!chosen.every((identity) => covered.has(identity.id))) return refuse("identity_not_proved"); - } - const clash = chosen.find((identity) => facts.destinationProviders.has(identity.provider)); - if (clash) return { kind: "refused", result: { kind: "provider_conflict", provider: clash.provider } }; - return { kind: "move", identities: chosen }; -} - -/** The history a move writes about what it carried. */ -export function movedRecord(identities: readonly StoredLinkedIdentity[]): StoredIdentityMove["moved"] { - return identities.map((identity) => ({ - id: identity.id, - provider: identity.provider, - providerUserId: identity.providerUserId, - credentialStripped: identity.accessTokenEncrypted !== undefined || identity.refreshTokenEncrypted !== undefined, - })); -} diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index f12661d..b35decb 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -26,13 +26,9 @@ import { type StoredLinkedIdentity, type StoredSession, type StoredPrivateCommandReceipt, - type MoveIdentitiesInput, - type MoveResult, - type StoredIdentityMove, type UnlinkResult, type UpsertLinkedIdentityInput, } from "./types.js"; -import { decideMove, movedRecord } from "./identity-move.js"; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -67,7 +63,6 @@ export class InMemoryStore implements HeimdallStore { private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); private readonly consentReceipts: StoredConsentReceipt[] = []; - private readonly identityMoves = new Map(); async ensureSchema(): Promise { return; @@ -207,78 +202,6 @@ export class InMemoryStore implements HeimdallStore { return { kind: "unlinked", identity: clone(target) }; } - /** No await between the reads and the writes: the event loop is the lock. */ - async moveIdentities(input: MoveIdentitiesInput): Promise { - const at = Date.parse(input.at); - const forwardMove = input.reversesMoveId !== undefined ? this.identityMoves.get(input.reversesMoveId) : undefined; - const sourceIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.fromAccountId); - const destinationIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.toAccountId); - const decision = decideMove(input, { - existing: this.identityMoves.get(input.moveId) ?? null, - forward: forwardMove ? { move: forwardMove, reversed: this.isReversed(forwardMove.id) } : null, - proofSessionsLive: input.proofSessions.every((proof) => { - const session = this.sessions.get(proof.sessionId); - return ( - session !== undefined && - session.appSlug === proof.appSlug && - session.accountId === proof.accountId && - session.accessRevision === proof.accessRevision && - Date.parse(session.expiresAt) > at - ); - }), - sourceIdentities: clone(sourceIdentities), - destinationProviders: new Set(destinationIdentities.map((identity) => identity.provider)), - sourceIsAlias: sourceIdentities.length === 0 && this.hasUnreversedMoveFrom(input.fromAccountId), - destinationIsAlias: destinationIdentities.length === 0 && this.hasUnreversedMoveFrom(input.toAccountId), - }); - if (decision.kind === "replayed") return { kind: "replayed", move: clone(decision.move) }; - if (decision.kind === "refused") return decision.result; - - for (const identity of decision.identities) { - const row = this.linkedIdentities.get(identity.id)!; - row.accountId = input.toAccountId; - row.updatedAt = input.at; - delete row.accessTokenEncrypted; - delete row.refreshTokenEncrypted; - delete row.tokenExpiresAt; - delete row.refreshFailedAt; - } - for (const session of this.sessions.values()) { - if (session.accountId === input.fromAccountId && Date.parse(session.expiresAt) > at) { - session.expiresAt = input.at; - session.lastSeenAt = input.at; - session.accessRevision += 1; - } - } - for (const row of [...this.pendingLinks.values(), ...this.authCompletions.values()]) { - if (row.accountId === input.fromAccountId && !row.consumedAt && Date.parse(row.expiresAt) > at) row.expiresAt = input.at; - } - const move: StoredIdentityMove = { - id: input.moveId, - appSlug: input.appSlug, - fromAccountId: input.fromAccountId, - toAccountId: input.toAccountId, - ...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}), - moved: movedRecord(decision.identities), - proof: clone(input.proof), - createdAt: input.at, - }; - this.identityMoves.set(move.id, clone(move)); - return { kind: "moved", move }; - } - - async findIdentityMove(moveId: string): Promise { - return clone(this.identityMoves.get(moveId) ?? null); - } - - private isReversed(moveId: string): boolean { - return [...this.identityMoves.values()].some((move) => move.reversesMoveId === moveId); - } - - private hasUnreversedMoveFrom(accountId: string): boolean { - return [...this.identityMoves.values()].some((move) => move.fromAccountId === accountId && !this.isReversed(move.id)); - } - async updateLinkedIdentityCredential( id: string, decide: (locked: StoredLinkedIdentity) => Promise, diff --git a/src/store/postgres.ts b/src/store/postgres.ts index f0da059..0b05f61 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { Pool, type PoolClient, type QueryResultRow } from "pg"; import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js"; -import { decideMove, movedRecord, type MoveFacts } from "./identity-move.js"; import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "./schema.js"; import { type CreateAccountInput, @@ -20,9 +19,6 @@ import { type CreatePrivateCommandReceiptInput, type HeimdallStore, IdentityHeldElsewhereError, - type MoveIdentitiesInput, - type MoveResult, - type StoredIdentityMove, OAUTH_STATE_PRUNE_BATCH, oauthStatePruneCutoffMs, type StoredAccount, @@ -179,42 +175,6 @@ function mapAccountRow(row: AccountRow): StoredAccount { return account; } -interface IdentityMoveRow extends QueryResultRow { - id: string; - app_slug: AppSlug; - from_account_id: string; - to_account_id: string; - reverses_move_id: string | null; - moved_json: StoredIdentityMove["moved"]; - proof_json: StoredIdentityMove["proof"]; - created_at: string; -} - -async function selectIdentityMove(db: Pick, moveId: string): Promise { - const result = await db.query("SELECT * FROM identity_moves WHERE id = $1", [moveId]); - const row = result.rows[0]; - if (!row) return null; - return { - id: row.id, - appSlug: row.app_slug, - fromAccountId: row.from_account_id, - toAccountId: row.to_account_id, - ...(row.reverses_move_id ? { reversesMoveId: row.reverses_move_id } : {}), - moved: row.moved_json, - proof: row.proof_json, - createdAt: new Date(row.created_at).toISOString(), - }; -} - -/** A move from this account that no reversal names: the account's identities went elsewhere. */ -async function hasUnreversedMoveFrom(db: Pick, accountId: string): Promise { - const result = await db.query( - "SELECT 1 FROM identity_moves m WHERE m.from_account_id = $1 AND NOT EXISTS (SELECT 1 FROM identity_moves r WHERE r.reverses_move_id = m.id) LIMIT 1", - [accountId] - ); - return (result.rowCount ?? 0) > 0; -} - function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { const identity: StoredLinkedIdentity = { id: row.id, @@ -497,9 +457,6 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri * expired oauth_state_uses rows (which needs SELECT for its WHERE), and the one * linked_identities row an unlink names (unlinkLinkedIdentity, the only * delete there; it needs SELECT for its WHERE and for the account's count). - * identity_moves is append-only. moveIdentities is the statement that updates - * linked_identities.account_id (its one writer), sessions, pending_links and - * auth_completions, all of which already hold UPDATE. * audit_events is written and never read back. */ export const STORE_TABLE_PRIVILEGES: Readonly> = { @@ -514,7 +471,6 @@ export const STORE_TABLE_PRIVILEGES: Readonly { - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - try { - // Both accounts, one statement each in id order, before anything is read: opposite moves queue here - // instead of deadlocking. The order is the code's own, not whatever order a plan locks a set in. - for (const id of [input.fromAccountId, input.toAccountId].sort()) { - await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [id]); - } - const existing = await selectIdentityMove(client, input.moveId); - let forward: MoveFacts["forward"] = null; - if (input.reversesMoveId !== undefined) { - const forwardMove = await selectIdentityMove(client, input.reversesMoveId); - const reversal = forwardMove ? await client.query("SELECT 1 FROM identity_moves WHERE reverses_move_id = $1", [forwardMove.id]) : null; - forward = forwardMove ? { move: forwardMove, reversed: (reversal?.rowCount ?? 0) > 0 } : null; - } - let proofSessionsLive = true; - for (const proof of input.proofSessions) { - const live = await client.query( - "SELECT 1 FROM sessions WHERE id = $1 AND app_slug = $2 AND account_id = $3 AND access_revision = $4 AND expires_at > $5", - [proof.sessionId, proof.appSlug, proof.accountId, proof.accessRevision, input.at] - ); - if (!live.rowCount) proofSessionsLive = false; - } - const source = await client.query( - "SELECT * FROM linked_identities WHERE account_id = $1 ORDER BY created_at ASC, id ASC FOR UPDATE", - [input.fromAccountId] - ); - const destination = await client.query<{ provider: Provider }>("SELECT provider FROM linked_identities WHERE account_id = $1", [input.toAccountId]); - const sourceIdentities = source.rows.map(mapLinkedIdentityRow); - const decision = decideMove(input, { - existing, - forward, - proofSessionsLive, - sourceIdentities, - destinationProviders: new Set(destination.rows.map((row) => row.provider)), - sourceIsAlias: sourceIdentities.length === 0 && (await hasUnreversedMoveFrom(client, input.fromAccountId)), - destinationIsAlias: destination.rowCount === 0 && (await hasUnreversedMoveFrom(client, input.toAccountId)), - }); - if (decision.kind !== "move") { - await client.query("ROLLBACK"); - return decision.kind === "replayed" ? { kind: "replayed", move: decision.move } : decision.result; - } - const ids = decision.identities.map((identity) => identity.id); - await client.query( - `UPDATE linked_identities - SET account_id = $2, access_token_encrypted = NULL, refresh_token_encrypted = NULL, - token_expires_at = NULL, refresh_failed_at = NULL, updated_at = $3 - WHERE id = ANY($1) AND account_id = $4`, - [ids, input.toAccountId, input.at, input.fromAccountId] - ); - await client.query( - "UPDATE sessions SET expires_at = $2, last_seen_at = $2, access_revision = access_revision + 1 WHERE account_id = $1 AND expires_at > $2", - [input.fromAccountId, input.at] - ); - for (const table of ["pending_links", "auth_completions"]) { - await client.query( - `UPDATE ${table} SET expires_at = $2 WHERE account_id = $1 AND consumed_at IS NULL AND expires_at > $2`, - [input.fromAccountId, input.at] - ); - } - const move: StoredIdentityMove = { - id: input.moveId, - appSlug: input.appSlug, - fromAccountId: input.fromAccountId, - toAccountId: input.toAccountId, - ...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}), - moved: movedRecord(decision.identities), - proof: input.proof, - createdAt: input.at, - }; - try { - await client.query( - `INSERT INTO identity_moves (id, app_slug, from_account_id, to_account_id, reverses_move_id, moved_json, proof_json, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [move.id, move.appSlug, move.fromAccountId, move.toAccountId, nullable(move.reversesMoveId), JSON.stringify(move.moved), JSON.stringify(move.proof), move.createdAt] - ); - } catch (error) { - // The row is the arbiter of a race the locks did not serialise (a reversal of the same move through another pool). - if ((error as { code?: string }).code !== "23505") throw error; - await client.query("ROLLBACK"); - const winner = await this.findIdentityMove(input.moveId); - if (!winner) return { kind: "not_reversible" }; - const again = decideMove(input, { existing: winner, forward: null, proofSessionsLive: true, sourceIdentities: [], destinationProviders: new Set(), sourceIsAlias: false, destinationIsAlias: false }); - return again.kind === "replayed" ? { kind: "replayed", move: again.move } : { kind: "move_id_conflict" }; - } - await client.query("COMMIT"); - return { kind: "moved", move }; - } catch (error) { - await client.query("ROLLBACK").catch(() => undefined); - throw error; - } - } finally { - client.release(); - } - } - - async findIdentityMove(moveId: string): Promise { - return selectIdentityMove(this.pool, moveId); - } - async updateLinkedIdentityCredential( id: string, decide: (locked: StoredLinkedIdentity) => Promise, diff --git a/src/store/schema.ts b/src/store/schema.ts index ff1fcf2..3678122 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -216,29 +216,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx CREATE INDEX IF NOT EXISTS consent_receipts_account_idx ON consent_receipts(account_id, created_at); --- One row per identity move, append-only: no UPDATE and no DELETE, and no --- ON DELETE CASCADE, so the record outlives nothing it names. moved_json is --- [{id, provider, providerUserId, credentialStripped}]; proof_json is --- {from: [{tier, method, identityId}], to: {...} | null}. Never a token, sid, --- email or name. A move is reversed once: the second row naming it conflicts. -CREATE TABLE IF NOT EXISTS identity_moves ( - id TEXT PRIMARY KEY, - app_slug TEXT NOT NULL, - from_account_id TEXT NOT NULL REFERENCES accounts(id), - to_account_id TEXT NOT NULL REFERENCES accounts(id), - reverses_move_id TEXT REFERENCES identity_moves(id), - moved_json JSONB NOT NULL, - proof_json JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS identity_moves_reverses_unique_idx - ON identity_moves(reverses_move_id) - WHERE reverses_move_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS identity_moves_from_idx - ON identity_moves(from_account_id); - CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, account_id TEXT, diff --git a/src/store/types.ts b/src/store/types.ts index 78d12a4..c044161 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -59,60 +59,6 @@ export type UnlinkResult = | { kind: "last_identity" } | { kind: "not_found" }; -/** One login a move carried; `credentialStripped` is history written at the move (the row held a token the move dropped). */ -export interface MovedIdentity { - id: string; - provider: Provider; - providerUserId: string; - credentialStripped: boolean; -} - -/** What each side's proofs were, as tiers and methods only: never a token, sid, email or name. */ -export interface IdentityMoveProof { - from: { tier: "strong" | "weak"; method: string | null; identityId: string | null }[]; - to: { tier: "strong" | "weak"; method: string | null; identityId: string | null } | null; -} - -/** The append-only record of one move. */ -export interface StoredIdentityMove { - id: string; - appSlug: AppSlug; - fromAccountId: string; - toAccountId: string; - reversesMoveId?: string; - moved: MovedIdentity[]; - proof: IdentityMoveProof; - createdAt: string; -} - -export interface MoveIdentitiesInput { - moveId: string; - appSlug: AppSlug; - fromAccountId: string; - toAccountId: string; - /** The logins to carry, or every login the source holds. */ - identityIds: readonly string[] | "all"; - /** What the caller's from-proofs cover: the whole account, or exactly these logins. */ - covers: "account" | readonly string[]; - /** Sessions the proofs rested on; each must still be live when the transaction commits. */ - proofSessions: readonly { appSlug: AppSlug; sessionId: string; accountId: string; accessRevision: number }[]; - reversesMoveId?: string; - proof: IdentityMoveProof; - at: string; -} - -/** What moveIdentities decided. Refusals write nothing. */ -export type MoveResult = - | { kind: "moved"; move: StoredIdentityMove } - | { kind: "replayed"; move: StoredIdentityMove } - | { kind: "move_id_conflict" } - | { kind: "proof_lapsed" } - | { kind: "already_moved" } - | { kind: "identity_not_found" } - | { kind: "identity_not_proved" } - | { kind: "provider_conflict"; provider: Provider } - | { kind: "not_reversible" }; - export interface UpsertLinkedIdentityInput { id?: string; accountId: string; @@ -393,17 +339,6 @@ export interface HeimdallStore { * `not_found`). */ unlinkLinkedIdentity(accountId: string, identityId: string): Promise; - /** - * The only writer that sets a linked identity's account to another account, - * in one transaction: lock both accounts (in id order), answer a repeated - * moveId from its row, re-check the proof sessions, refuse what the rules - * refuse, move the rows with their credentials stripped, revoke the source's - * sessions and expire its pending links and unredeemed completions, and - * append the identity_moves row. A reversal (`reversesMoveId`) moves back - * exactly what the forward move carried. The destination never loses an identity. - */ - moveIdentities(input: MoveIdentitiesInput): Promise; - findIdentityMove(moveId: string): Promise; /** * Serialise decisions about one row's credential. `decide` receives the row * as read under the lock (never a caller's snapshot) and the decision is diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 279b21e..caaa8e6 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -40,4 +40,3 @@ {"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:; 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"} diff --git a/tests/identity-moves.test.ts b/tests/identity-moves.test.ts deleted file mode 100644 index bc41597..0000000 --- a/tests/identity-moves.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; -import { buildApp } from "../src/app.js"; -import { builtInAppProfiles } from "../src/app-profiles.js"; -import { AUTH_PROOF_TTL_SECONDS, issueAccessClaim, proofTier } from "../src/claims.js"; -import { type AppSlug, type Provider } from "../src/contracts.js"; -import { type OAuthProviderRuntime } from "../src/oauth.js"; -import { createRuntimeKeyMaterial } from "../src/signing.js"; -import { InMemoryStore } from "../src/store/index.js"; -import { type CreateAuditEventInput } from "../src/store/types.js"; -import { createTestConfig, launchFlow } from "./oauth-flow.js"; - -// POST /v1/apps/{app}/identity-moves, driven over HTTP the way Bifrost would -// call it: the proofs are real access tokens whose claims say how and through -// which login the member signed in; the answers are what a caller can see. - -const now = "2026-11-14T12:00:00.000Z"; -const appAuth = { "x-heimdall-app-secret": "bifrost-secret" }; -const moveId = (tag: string) => `move-${tag}`.padEnd(20, "x"); -const nowS = () => Math.floor(Date.now() / 1000); - -const dir = mkdtempSync(join(tmpdir(), "heimdall-identity-moves-")); -const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; -afterAll(() => rmSync(dir, { recursive: true, force: true })); - -const apps: Awaited>[] = []; -afterEach(async () => { - vi.useRealTimers(); - vi.restoreAllMocks(); - await Promise.all(apps.splice(0).map((app) => app.close())); -}); - -function twitchRuntime(providerUserId: string): OAuthProviderRuntime { - return { - async exchangeAuthorizationCode() { - return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} }; - }, - async resolveIdentity() { - return { provider: "twitch", providerUserId, username: providerUserId, profile: { id: providerUserId } }; - }, - async evaluateEntitlements() { - return { facts: [], snapshots: [] }; - }, - }; -} - -function discordRuntime(providerUserId: string): OAuthProviderRuntime { - return { ...twitchRuntime(providerUserId), async resolveIdentity() { return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } }; } }; -} - -async function rig(twitchUser = "t-signin") { - const store = new InMemoryStore(); - const audit: CreateAuditEventInput[] = []; - const realAudit = store.createAuditEvent.bind(store); - store.createAuditEvent = async (input) => { - audit.push(structuredClone(input)); - return realAudit(input); - }; - const app = await buildApp({ config, store, oauthRuntimes: { twitch: twitchRuntime(twitchUser), discord: discordRuntime("d-linked") } }); - apps.push(app); - const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); - return { app, store, keys, audit }; -} -type Rig = Awaited>; - -let seq = 0; -/** An account holding the given logins, each with a credential, and one live-session proof factory. */ -async function acct(r: Rig, held: Array<[Provider, string?]>) { - seq += 1; - const account = await r.store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "DISPLAY-CANARY", primaryEmail: "canary@example.test" }); - const rows = []; - for (const [provider, user] of held) { - rows.push( - await r.store.upsertLinkedIdentity({ - accountId: account.id, - provider, - providerUserId: user ?? `${provider}-${seq}`, - scopes: ["identify"], - accessTokenEncrypted: "ACCESS-CIPHERTEXT", - refreshTokenEncrypted: "REFRESH-CIPHERTEXT", - profileJson: {}, - createdAt: now, - updatedAt: now, - }) - ); - } - return { account, rows }; -} -type Acct = Awaited>; - -interface ProofOptions { - app?: AppSlug; - /** Seconds since the sign-in; null mints a token with no sign-in claim (a refresh or link would). */ - signedInAgo?: number | null; - via?: number; - stepUp?: { agoS?: number; method: string }; -} -/** A live session for the account and the access token it would hold. */ -async function proof(r: Rig, a: Acct, o: ProofOptions = {}) { - const ago = o.signedInAgo === undefined ? 0 : o.signedInAgo; - const issued = await issueAccessClaim({ - config, - keys: r.keys, - store: r.store, - input: { - appSlug: o.app ?? "bifrost", - accountId: a.account.id, - linkedIdentities: a.rows.map((row) => ({ provider: row.provider, providerUserId: row.providerUserId })), - facts: [], - ...(ago === null ? {} : { authAt: nowS() - ago, authIdentityId: a.rows[o.via ?? 0]!.id }), - ...(o.stepUp ? { stepUpAt: nowS() - (o.stepUp.agoS ?? 0), stepUpMethod: o.stepUp.method } : {}), - }, - }); - return { token: issued.accessToken, sid: issued.session.sessionId }; -} -const passkey = { method: "bifrost-passkey" }; - -const move = (r: Rig, body: object, headers: Record = appAuth, app = "bifrost") => - r.app.inject({ method: "POST", url: `/v1/apps/${app}/identity-moves`, headers, payload: body }); -const held = async (r: Rig, a: Acct) => (await r.store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort(); -const err = (res: { statusCode: number; json(): { error?: string } }) => [res.statusCode, res.json().error]; - -/** Source with discord+google, destination with patreon: a passkey on the source and any fresh proof on the destination. */ -async function scene(r: Rig) { - const from = await acct(r, [["discord"], ["google"]]); - const to = await acct(r, [["patreon"]]); - return { - from, - to, - fromProof: await proof(r, from, { stepUp: passkey }), - toProof: await proof(r, to), - }; -} - -describe("a sanctioned move", () => { - it("two fresh proofs move every login of the source and answer the receipt, with no token, email or name", async () => { - const r = await rig(); - const s = await scene(r); - const sourceIds = await held(r, s.from); - const res = await move(r, { moveId: moveId("a"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }); - expect(res.statusCode).toBe(200); - expect(res.headers["cache-control"]).toBe("no-store"); - expect(res.json()).toEqual({ - moveId: moveId("a"), - accountId: s.to.account.id, - movedIdentityIds: expect.arrayContaining(sourceIds), - proof: { from: "strong", to: "weak" }, - }); - expect(res.json().movedIdentityIds).toHaveLength(2); - expect(res.body).not.toMatch(/CIPHERTEXT|canary|DISPLAY|ey[A-Za-z0-9_-]{20,}\./); - expect(await held(r, s.from)).toEqual([]); - expect(await held(r, s.to)).toEqual(expect.arrayContaining(sourceIds)); - expect(await held(r, s.to)).toHaveLength(3); - expect(r.audit.find((e) => e.eventType === "identity_moved")).toMatchObject({ - accountId: s.from.account.id, - eventPayloadJson: { moveId: moveId("a"), toAccountId: s.to.account.id, providers: ["discord", "google"], reversesMoveId: null }, - }); - }); - - it("answers the identical body for the same moveId after the source's sessions are revoked, and changes nothing twice", async () => { - const r = await rig(); - const s = await scene(r); - const body = { moveId: moveId("b"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }; - const first = await move(r, body); - expect(first.statusCode).toBe(200); - const session = await r.store.findSession("bifrost", s.fromProof.sid); - expect(Date.parse(session!.expiresAt), "the source's session is already dead").toBeLessThanOrEqual(Date.now()); - const again = await move(r, body); - expect([again.statusCode, again.body]).toEqual([200, first.body]); - expect(r.audit.filter((e) => e.eventType === "identity_moved")).toHaveLength(1); - }); - - it("a moveId reused for another account pair, another proof owner or another reversesMoveId is 409 move_id_conflict and changes nothing", async () => { - const r = await rig(); - const s = await scene(r); - const other = await acct(r, [["twitch"]]); - await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }); - const otherProof = await proof(r, other); - const toStranger = await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: otherProof.token }); - expect(err(toStranger)).toEqual([409, "move_id_conflict"]); - const fromStranger = await move(r, { moveId: moveId("c"), fromProofs: [otherProof.token], toProof: s.toProof.token }); - expect(err(fromStranger)).toEqual([409, "move_id_conflict"]); - const asReversal = await move(r, { moveId: moveId("c"), fromProofs: [s.toProof.token], reversesMoveId: moveId("zz") }); - expect(err(asReversal)).toEqual([409, "move_id_conflict"]); - expect(await held(r, other)).toHaveLength(1); - expect(await held(r, s.to)).toHaveLength(3); - }); - - it("a replay whose proofs are another app's tokens, or another account's, is 409 move_id_conflict, not the stored receipt", async () => { - const r = await rig(); - const s = await scene(r); - const body = { moveId: moveId("rp"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }; - expect((await move(r, body)).statusCode).toBe(200); - const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey }); - expect(err(await move(r, { ...body, fromProofs: [foreign.token] }))).toEqual([409, "move_id_conflict"]); - const foreignTo = await proof(r, s.to, { app: "streampixels" }); - expect(err(await move(r, { ...body, toProof: foreignTo.token }))).toEqual([409, "move_id_conflict"]); - expect((await move(r, body)).statusCode, "the genuine proofs still replay").toBe(200); - }); - - it("an app without the secret is 401; streampixels with its own secret is 403 not_allowed_for_app", async () => { - const r = await rig(); - const s = await scene(r); - const body = { moveId: moveId("d"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }; - expect(err(await move(r, body, {}))).toEqual([401, "app_auth_required"]); - expect(err(await move(r, body, { "x-heimdall-app-secret": "nope" }))).toEqual([401, "app_auth_required"]); - expect(err(await move(r, body, { "x-heimdall-app-secret": "streampixels-secret" }, "streampixels"))).toEqual([403, "not_allowed_for_app"]); - expect(await held(r, s.from)).toHaveLength(2); - }); - - it("only bifrost is granted moves, and its account proofs are methods it may assert", () => { - const granted = Object.values(builtInAppProfiles).filter((p) => p.identityMoves !== undefined); - expect(granted.map((p) => p.slug)).toEqual(["bifrost"]); - const bifrost = granted[0]!; - expect(bifrost.identityMoves!.accountProofMethods.every((m) => bifrost.stepUpAssertion!.methods.includes(m))).toBe(true); - expect(bifrost.identityMoves!.accountProofMethods).not.toContain("bifrost-email-code"); - }); -}); - -describe("what a proof must be", () => { - it("another app's token, a revoked session and a forged signature are 403 proof_invalid and nothing moves", async () => { - const r = await rig(); - const s = await scene(r); - const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey }); - const revoked = await proof(r, s.from, { stepUp: passkey }); - await r.store.revokeSession("bifrost", revoked.sid, s.from.account.id, 1, new Date().toISOString()); - const forged = s.fromProof.token.slice(0, -6) + "AAAAAA"; - for (const [fromProofs, toProof, who] of [ - [[foreign.token], s.toProof.token, "from"], - [[revoked.token], s.toProof.token, "from"], - [[forged], s.toProof.token, "from"], - [[s.fromProof.token], forged, "to"], - ] as const) { - const res = await move(r, { moveId: moveId("e"), fromProofs, toProof }); - expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_invalid", proof: who }]); - } - expect(await held(r, s.from)).toHaveLength(2); - }); - - it("a sign-in 300 s old passes and 301 s is proof_stale; a token with no sign-in claim (refresh, link) is proof_stale; a step-up inside 600 s reports strong", async () => { - const r = await rig(); - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date(now)); - const to = await acct(r, [["patreon"]]); - const toProof = await proof(r, to); - const providers: Provider[] = ["discord", "google", "twitch", "github", "spotify", "youtube"]; - const attempt = async (tag: string, o: ProofOptions) => { - const from = await acct(r, [[providers.shift()!]]); - const p = await proof(r, from, o); - return move(r, { moveId: moveId(tag), fromProofs: [p.token], toProof: toProof.token }); - }; - const edge = await attempt("edge", { signedInAgo: AUTH_PROOF_TTL_SECONDS }); - expect([edge.statusCode, edge.json().proof]).toEqual([200, { from: "weak", to: "weak" }]); - const late = await attempt("late", { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 }); - expect([late.statusCode, late.json()]).toEqual([403, { error: "proof_stale", proof: "from" }]); - const none = await attempt("none", { signedInAgo: null }); - expect(err(none)).toEqual([403, "proof_stale"]); - const strong = await attempt("strong", { signedInAgo: 5_000, stepUp: { agoS: 600, method: "provider:twitch" } }); - expect([strong.statusCode, strong.json().proof.from]).toEqual([200, "strong"]); - const stale = await attempt("stale", { signedInAgo: 5_000, stepUp: { agoS: 601, method: "bifrost-passkey" } }); - expect(err(stale)).toEqual([403, "proof_stale"]); - // The destination's proof is held to the same rule. - const from = await acct(r, [["google"]]); - const fresh = await proof(r, from); - const staleTo = await proof(r, to, { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 }); - const res = await move(r, { moveId: moveId("sto"), fromProofs: [fresh.token], toProof: staleTo.token }); - expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_stale", proof: "to" }]); - }); - - it("fromProofs naming two accounts is 403 proof_invalid; one account on both sides is 409 same_account", async () => { - const r = await rig(); - const s = await scene(r); - const split = await move(r, { moveId: moveId("f"), fromProofs: [s.fromProof.token, s.toProof.token], toProof: s.toProof.token }); - expect([split.statusCode, split.json()]).toEqual([403, { error: "proof_invalid", proof: "from" }]); - const twice = await proof(r, s.from, { stepUp: passkey }); - const same = await move(r, { moveId: moveId("g"), fromProofs: [s.fromProof.token], toProof: twice.token }); - expect(err(same)).toEqual([409, "same_account"]); - expect(await held(r, s.from)).toHaveLength(2); - }); - - it("the body is closed: a forward move needs toProof, a reversal takes no toProof or identityIds, ids are patterned", async () => { - const r = await rig(); - const s = await scene(r); - const t = s.fromProof.token; - for (const body of [ - { moveId: moveId("h"), fromProofs: [t] }, - { moveId: "short", fromProofs: [t], toProof: t }, - { moveId: moveId("h"), fromProofs: [], toProof: t }, - { moveId: moveId("h"), fromProofs: [t], toProof: t, reversesMoveId: moveId("i") }, - { moveId: moveId("h"), fromProofs: [t], reversesMoveId: moveId("i"), identityIds: ["x"] }, - ]) { - expect((await move(r, body)).statusCode, JSON.stringify(Object.keys(body))).toBe(400); - } - }); -}); - -describe("accounts come only from proofs", () => { - it("a stray accountId in the body steers nothing", async () => { - const r = await rig(); - const s = await scene(r); - const stranger = await acct(r, [["twitch"]]); - const res = await move(r, { moveId: moveId("y"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, accountId: stranger.account.id, toAccountId: stranger.account.id }); - expect([res.statusCode, res.json().accountId]).toEqual([200, s.to.account.id]); - expect(await held(r, stranger)).toHaveLength(1); - }); -}); - -describe("which logins a proof moves", () => { - async function multi(r: Rig) { - const from = await acct(r, [["discord"], ["google"], ["twitch"]]); - const to = await acct(r, [["patreon"]]); - return { from, to, toProof: await proof(r, to) }; - } - - it("a sign-in through login X moves X and cannot move the source's other logins", async () => { - const r = await rig(); - const s = await multi(r); - const [discord, google] = s.from.rows; - const viaDiscord = await proof(r, s.from, { via: 0 }); - const all = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token }); - expect(err(all)).toEqual([403, "identity_not_proved"]); - const other = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [google!.id] }); - expect(err(other)).toEqual([403, "identity_not_proved"]); - expect(await held(r, s.from)).toHaveLength(3); - const only = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [discord!.id] }); - expect([only.statusCode, only.json().movedIdentityIds]).toEqual([200, [discord!.id]]); - expect((await held(r, s.from)).sort()).toEqual([google!.id, s.from.rows[2]!.id].sort()); - }); - - it("one proof per login moves them all; an asserted passkey covers the whole account; an email code covers only its own login", async () => { - const r = await rig(); - let s = await multi(r); - const perLogin = await Promise.all(s.from.rows.map((_, via) => proof(r, s.from, { via }))); - const res = await move(r, { moveId: moveId("k"), fromProofs: perLogin.map((p) => p.token), toProof: s.toProof.token }); - expect([res.statusCode, res.json().movedIdentityIds]).toHaveLength(2); - expect(res.json().proof.from).toBe("weak"); - expect(await held(r, s.from)).toEqual([]); - - s = await multi(r); - const byPasskey = await proof(r, s.from, { stepUp: passkey }); - expect((await move(r, { moveId: moveId("l"), fromProofs: [byPasskey.token], toProof: s.toProof.token })).statusCode).toBe(200); - - s = await multi(r); - const byEmail = await proof(r, s.from, { stepUp: { method: "bifrost-email-code" } }); - const refused = await move(r, { moveId: moveId("m"), fromProofs: [byEmail.token], toProof: s.toProof.token }); - expect(err(refused)).toEqual([403, "identity_not_proved"]); - expect(await held(r, s.from)).toHaveLength(3); - }); - - it("a provider held on both sides is 409 provider_conflict naming only the provider, and nothing moves", async () => { - const r = await rig(); - const from = await acct(r, [["discord"], ["google"]]); - const to = await acct(r, [["discord"]]); - const res = await move(r, { moveId: moveId("n"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token }); - expect([res.statusCode, res.json()]).toEqual([409, { error: "provider_conflict", provider: "discord" }]); - expect(res.body).not.toContain(from.account.id); - expect(await held(r, from)).toHaveLength(2); - expect(await held(r, to)).toHaveLength(1); - }); - - it("naming an identity the source does not hold is 404 identity_not_found", async () => { - const r = await rig(); - const s = await scene(r); - const res = await move(r, { moveId: moveId("o"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [s.to.rows[0]!.id] }); - expect(err(res)).toEqual([404, "identity_not_found"]); - }); -}); - -describe("what a move leaves behind", () => { - it("strips the moved credentials, expires the source's sessions, pending links and unredeemed completions, and leaves the destination live", async () => { - const r = await rig(); - const s = await scene(r); - const linkInput = { appSlug: "bifrost" as const, provider: "discord" as const, providerUserId: "d-x", identity: { accountId: "x", provider: "discord" as const, providerUserId: "d-x", scopes: [], profileJson: {}, createdAt: now, updatedAt: now }, returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z" }; - const srcLink = await r.store.createPendingLink({ ...linkInput, accountId: s.from.account.id }); - const dstLink = await r.store.createPendingLink({ ...linkInput, accountId: s.to.account.id }); - const completion = (accountId: string) => - r.store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: "sess", returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z", payloadJson: {} }); - const srcCompletion = await completion(s.from.account.id); - const dstCompletion = await completion(s.to.account.id); - const res = await move(r, { moveId: moveId("p"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }); - expect(res.statusCode).toBe(200); - const movedIds = new Set(s.from.rows.map((row) => row.id)); - for (const row of (await r.store.listStoredLinkedIdentitiesForAccount(s.to.account.id)).filter((row) => movedIds.has(row.id))) { - expect(row.accessTokenEncrypted).toBeUndefined(); - expect(row.refreshTokenEncrypted).toBeUndefined(); - } - const record = await r.store.findIdentityMove(moveId("p")); - expect(record?.moved.map((m) => m.credentialStripped)).toEqual([true, true]); - expect((await r.store.findSession("bifrost", s.fromProof.sid))!.accessRevision).toBe(2); - expect((await r.store.findSession("bifrost", s.toProof.sid))!.accessRevision).toBe(1); - expect(Date.parse((await r.store.findSession("bifrost", s.toProof.sid))!.expiresAt)).toBeGreaterThan(Date.now()); - expect(await r.store.consumePendingLink("bifrost", srcLink.id, s.from.account.id, new Date().toISOString())).toBeNull(); - expect(await r.store.consumePendingLink("bifrost", dstLink.id, s.to.account.id, new Date().toISOString())).not.toBeNull(); - expect(await r.store.consumeAuthCompletion("bifrost", srcCompletion.code, new Date().toISOString())).toBeNull(); - expect(await r.store.consumeAuthCompletion("bifrost", dstCompletion.code, new Date().toISOString())).not.toBeNull(); - }); - - it("revokes the source's live grants for the providers it no longer holds, leaves the destination's receipts, and a replay heals a crash after commit", async () => { - const r = await rig(); - const s = await scene(r); - const grant = (accountId: string, provider: Provider) => - r.store.createConsentReceipt({ kind: "grant", accountId, appSlug: "bifrost", provider, createdAt: now, scopesAsked: ["identify"], scopesGranted: ["identify"], consentPageDigest: "d", consentText: "t" }); - await grant(s.from.account.id, "discord"); - await grant(s.to.account.id, "patreon"); - const receiptsOf = async (a: Acct) => (await r.store.listConsentReceipts(a.account.id)).map((x) => `${x.kind}:${x.provider}`); - const body = { moveId: moveId("q"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }; - vi.spyOn(r.store, "createConsentReceipt").mockRejectedValueOnce(new Error("crash after commit")); - expect((await move(r, body)).statusCode).toBe(500); - expect(await held(r, s.from), "the move itself committed").toEqual([]); - expect(await receiptsOf(s.from)).toEqual(["grant:discord"]); - const healed = await move(r, body); - expect(healed.statusCode).toBe(200); - expect(await receiptsOf(s.from)).toEqual(["grant:discord", "revocation:discord"]); - expect(await receiptsOf(s.to)).toEqual(["grant:patreon"]); - expect((await move(r, body)).body, "and it is idempotent").toBe(healed.body); - expect(await receiptsOf(s.from)).toHaveLength(2); - }); - - it("the destination never loses an identity, whatever is refused", async () => { - const r = await rig(); - const s = await scene(r); - const before = (await held(r, s.to)).length; - const refusals = [ - { moveId: moveId("r1"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: ["nope"] }, - { moveId: moveId("r2"), fromProofs: [s.fromProof.token], toProof: s.fromProof.token }, - { moveId: moveId("r3"), fromProofs: [s.toProof.token], toProof: s.toProof.token }, - ]; - for (const body of refusals) { - await move(r, body); - expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before); - } - expect((await move(r, { moveId: moveId("r4"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200); - expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before); - }); - - it("a sign-in through a moved login lands on the destination, with no alias lookup", async () => { - const r = await rig("t-moved"); - const from = await acct(r, [["twitch", "t-moved"]]); - const to = await acct(r, [["patreon"]]); - const mv = await move(r, { moveId: moveId("s"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token }); - expect(mv.statusCode).toBe(200); - const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/signin/return" } }); - const flow = await launchFlow(r.app, started.json()); - const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } }); - expect(called.statusCode).toBe(201); - expect(called.json().account.id).toBe(to.account.id); - }); -}); - -describe("aliases and reversal", () => { - it("an emptied source is an alias; moving into or out of it is 409 already_moved", async () => { - const r = await rig(); - const s = await scene(r); - expect((await move(r, { moveId: moveId("t"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200); - // A session can outlive its logins only by being minted for an account that holds none, which is how a stale caller would look. - const aliasProof = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] }, { stepUp: passkey }); - const third = await acct(r, [["github"]]); - const out = await move(r, { moveId: moveId("u"), fromProofs: [aliasProof.token], toProof: (await proof(r, third)).token }); - expect(err(out)).toEqual([409, "already_moved"]); - const aliasTo = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] }); - const into = await move(r, { moveId: moveId("u2"), fromProofs: [(await proof(r, third, { stepUp: passkey })).token], toProof: aliasTo.token }); - expect(err(into)).toEqual([409, "already_moved"]); - expect(await held(r, third)).toHaveLength(1); - }); - - it("a reversal through a moved login moves exactly those logins back; a second reversal is not_reversible; credentials stay stripped", async () => { - const r = await rig(); - const s = await scene(r); - const sourceIds = await held(r, s.from); - await move(r, { moveId: moveId("v"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }); - const [discord, google] = s.from.rows; - // The destination now holds the moved logins: a sign-in through one of them is a proof of the destination. - const viaMoved = await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 0 }); - const own = await proof(r, s.to, { via: 0 }); - const denied = await move(r, { moveId: moveId("w"), fromProofs: [own.token], reversesMoveId: moveId("v") }); - expect(err(denied), "the destination's own login does not speak for the moved ones").toEqual([403, "identity_not_proved"]); - const viaBoth = [viaMoved, await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 1 })]; - const back = await move(r, { moveId: moveId("w"), fromProofs: viaBoth.map((p) => p.token), reversesMoveId: moveId("v") }); - expect(back.statusCode, back.body).toBe(200); - expect(back.json()).toEqual({ moveId: moveId("w"), accountId: s.from.account.id, movedIdentityIds: expect.arrayContaining(sourceIds), proof: { from: "weak", to: null }, reversesMoveId: moveId("v") }); - expect(await held(r, s.from)).toEqual(sourceIds); - expect(await held(r, s.to)).toHaveLength(1); - expect((await r.store.listStoredLinkedIdentitiesForAccount(s.from.account.id)).every((i) => i.accessTokenEncrypted === undefined)).toBe(true); - const own2 = await proof(r, s.to, { via: 0 }); - const again = await move(r, { moveId: moveId("w2"), fromProofs: [own2.token], reversesMoveId: moveId("v") }); - expect(err(again)).toEqual([409, "not_reversible"]); - const reverseTheReversal = await move(r, { moveId: moveId("w3"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("w") }); - expect(err(reverseTheReversal)).toEqual([409, "not_reversible"]); - const unknown = await move(r, { moveId: moveId("w4"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("nothere") }); - expect(err(unknown)).toEqual([409, "not_reversible"]); - }); -}); - -describe("proofTier", () => { - it("a proof dated in the future proves nothing, whichever claim carries it", () => { - const n = nowS(); - expect(proofTier({ auth_at: n + 5 }, n)).toBeNull(); - expect(proofTier({ step_up_at: n + 5 }, n)).toBeNull(); - expect(proofTier({ step_up_at: n + 5, auth_at: n }, n), "a future step-up does not hide a valid sign-in").toBe("weak"); - expect(proofTier({ step_up_at: n, auth_at: n - 9_999 }, n)).toBe("strong"); - }); -}); - -describe("the reversal's source must be the forward move's destination", () => { - it("is refused as not_reversible before the proofs are judged, even for a stale proof", async () => { - const r = await rig(); - const s = await scene(r); - expect((await move(r, { moveId: moveId("fw"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200); - const third = await acct(r, [["github"]]); - const onward = await proof(r, s.to, { stepUp: passkey }); - expect((await move(r, { moveId: moveId("on"), fromProofs: [onward.token], toProof: (await proof(r, third)).token })).statusCode).toBe(200); - const stale = await proof(r, third, { signedInAgo: 9_999 }); - const res = await move(r, { moveId: moveId("rv"), fromProofs: [stale.token], reversesMoveId: moveId("fw") }); - expect(err(res)).toEqual([409, "not_reversible"]); - }); -}); - -describe("idempotency is by moveId", () => { - it("a replayed reversal under another reversesMoveId is 409 move_id_conflict, not the stored receipt", async () => { - const r = await rig(); - const s = await scene(r); - await move(r, { moveId: moveId("fw2"), fromProofs: [s.fromProof.token], toProof: s.toProof.token }); - const [discord, google] = s.from.rows; - const viaMoved = async (via: number) => (await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via })).token; - const body = { moveId: moveId("rv2"), fromProofs: [await viaMoved(0), await viaMoved(1)], reversesMoveId: moveId("fw2") }; - const back = await move(r, body); - expect(back.statusCode, back.body).toBe(200); - expect((await move(r, body)).body, "the same body replays").toBe(back.body); - expect(err(await move(r, { ...body, reversesMoveId: moveId("other") }))).toEqual([409, "move_id_conflict"]); - expect(await held(r, s.from)).toHaveLength(2); - }); - - it("a replay with the same moveId and different identityIds returns the first receipt (steward ruling: the key is the moveId, a retry carries the same body)", async () => { - const r = await rig(); - const from = await acct(r, [["discord"], ["google"]]); - const to = await acct(r, [["patreon"]]); - const fromProof = await proof(r, from, { stepUp: passkey }); - const toProof = await proof(r, to); - const first = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[0]!.id] }); - expect(first.json().movedIdentityIds).toEqual([from.rows[0]!.id]); - const again = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[1]!.id] }); - expect([again.statusCode, again.body]).toEqual([200, first.body]); - expect(await held(r, from), "and the second selection did not move").toEqual([from.rows[1]!.id]); - }); -}); - -describe("what a refusal says", () => { - it("no refusal body names an account id, an email, a name or an identity the caller did not send", async () => { - const r = await rig(); - const s = await scene(r); - const stranger = await acct(r, [["discord", "d-stranger"]]); - const bodies = [ - await move(r, { moveId: moveId("x1"), fromProofs: [s.fromProof.token], toProof: "garbage" }), - await move(r, { moveId: moveId("x2"), fromProofs: [(await proof(r, s.from, { via: 0 })).token], toProof: s.toProof.token }), - await move(r, { moveId: moveId("x3"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [stranger.rows[0]!.id] }), - await move(r, { moveId: moveId("x4"), fromProofs: [s.toProof.token], toProof: s.toProof.token }), - await move(r, { moveId: moveId("x5"), fromProofs: [s.fromProof.token], reversesMoveId: moveId("nothere") }), - await move(r, { moveId: moveId("x6"), fromProofs: [(await proof(r, s.from, { signedInAgo: 9_999 })).token], toProof: s.toProof.token }), - ]; - const ids = [s.from, s.to, stranger].flatMap((a) => [a.account.id, ...a.rows.map((row) => row.id), ...a.rows.map((row) => row.providerUserId)]); - for (const res of bodies) { - expect(res.statusCode).toBeGreaterThanOrEqual(400); - for (const id of ids) expect(res.body).not.toContain(id); - expect(res.body).not.toMatch(/canary|DISPLAY/); - expect(Object.keys(res.json()).every((k) => ["error", "detail", "proof", "provider"].includes(k))).toBe(true); - } - }); -}); - -describe("claims: which sign-in a token came from", () => { - const returnTo = "https://streampixels.gamecult.org/signin/return"; - async function twitchSignIn(r: Rig) { - const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo } }); - const flow = await launchFlow(r.app, started.json()); - const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } }); - expect(called.statusCode).toBe(201); - const redeemed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: called.json().completion.code } }); - expect(redeemed.statusCode).toBe(201); - return redeemed.json() as { refreshToken: string; claimSet: Record }; - } - - it("a sign_in stamps auth_at and the login it came through; a refresh does not renew them", async () => { - const r = await rig("t-claims"); - const before = nowS(); - const signed = await twitchSignIn(r); - const row = await r.store.findStoredLinkedIdentity("twitch", "t-claims"); - expect(signed.claimSet.auth_identity_id).toBe(row!.id); - expect(signed.claimSet.auth_at).toBeGreaterThanOrEqual(before); - expect(signed.claimSet.auth_at).toBeLessThanOrEqual(nowS()); - expect(signed.claimSet.step_up_at).toBeUndefined(); - const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: signed.refreshToken } }); - expect(refreshed.statusCode).toBe(201); - expect(refreshed.json().claimSet.auth_at).toBeUndefined(); - expect(refreshed.json().claimSet.auth_identity_id).toBeUndefined(); - }); - - it("a link callback mints no sign-in proof: no auth_at, no auth_identity_id", async () => { - const r = await rig(); - const holder = await acct(r, [["twitch"]]); - const started = await r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers: appAuth, payload: { appSlug: "bifrost", mode: "link", initiatingAccountId: holder.account.id, returnTo: "https://bifrost.gamecult.org/x" } }); - expect(started.statusCode).toBe(201); - const flow = await launchFlow(r.app, started.json()); - const called = await r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } }); - expect(called.statusCode).toBe(201); - expect(called.json().claimSet.identities).toHaveLength(2); - expect(called.json().claimSet.auth_at).toBeUndefined(); - expect(called.json().claimSet.auth_identity_id).toBeUndefined(); - expect(called.body).not.toMatch(/auth_at|auth_identity_id/); - }); - - it("a connect callback mints no sign-in proof either", async () => { - const r = await rig("t-connect"); - const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", headers: { "x-heimdall-app-secret": "streampixels-secret" }, payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/signin/return", connection: { kind: "creator", targetId: "creator-1" } } }); - expect(started.statusCode, started.body).toBe(201); - const flow = await launchFlow(r.app, started.json()); - const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } }); - expect(called.statusCode, called.body).toBe(201); - expect(called.body).toContain("claimSet"); - expect(called.body).not.toMatch(/auth_at|auth_identity_id/); - }); - - it("an asserted step-up carries the bearer's auth claims through unchanged", async () => { - const r = await rig(); - const a = await acct(r, [["discord"], ["google"]]); - const p = await proof(r, a, { via: 1, signedInAgo: 30 }); - const res = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/step-up", headers: { ...appAuth, authorization: `Bearer ${p.token}` }, payload: { method: "bifrost-passkey", nonce: "n".repeat(32) } }); - expect(res.statusCode).toBe(201); - expect(res.json().claimSet).toMatchObject({ auth_identity_id: a.rows[1]!.id, auth_at: nowS() - 30, step_up_method: "bifrost-passkey" }); - }); -}); diff --git a/tests/store.test.ts b/tests/store.test.ts index 03b869a..0e4dcac 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -249,10 +249,6 @@ describe.each(unlinkStores)("linked_identities writes, %s", (_name, make) => { expect(await store.listStoredLinkedIdentitiesForAccount(b.id)).toEqual([]); }); - it.skipIf(realPostgresOnly)("moveIdentities keeps one contract: moved, replayed, conflict, lapsed proof, coverage, reversal", async () => { - await exerciseMoveContract(await make()); - }); - it("upsert by the holder still refreshes its own row, keeping its id", async () => { const { store, a } = await twoAccounts(); const first = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "one" })); @@ -481,10 +477,9 @@ describe("PostgresStore.checkSchema", () => { "capability_grants", "entitlement_snapshots", "consent_receipts", - "identity_moves", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(121); + expect(REQUIRED_COLUMNS).toHaveLength(113); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -495,7 +490,7 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(19); + expect(REQUIRED_KEYS).toHaveLength(17); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" }, @@ -503,7 +498,6 @@ describe("PostgresStore.checkSchema", () => { { table: "oauth_state_uses", columns: "jti,phase", predicate: "" }, { table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" }, { table: "consent_receipts", columns: "account_id,id", predicate: "" }, - { table: "identity_moves", columns: "reverses_move_id", predicate: "reverses_move_id IS NOT NULL" }, { table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" }, { table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" }, ]) @@ -654,178 +648,9 @@ async function exerciseStore(store: PostgresStore): Promise> { expect(second.receipt.id).toBe(first.receipt.id); expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]); await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now }); - const target = await s.createAccount({ createdAt: now, lastSeenAt: now }); - await s.upsertLinkedIdentity({ ...identity, accountId: target.id, provider: "patreon", providerUserId: "u-target" }); - const moveId = `move-${randomUUID()}`; - expect((await s.moveIdentities({ moveId, appSlug: "bifrost", fromAccountId: account.id, toAccountId: target.id, identityIds: "all", covers: "account", proofSessions: [], proof: { from: [], to: null }, at: now })).kind).toBe("moved"); - expect((await s.findIdentityMove(moveId))?.id).toBe(moveId); return called; } -/** - * The move contract both stores keep, once. Each case builds its own accounts, - * so the order of cases proves nothing and a store holding earlier moves is fine. - */ -async function exerciseMoveContract(store: HeimdallStore): Promise { - const at = "2026-11-14T12:00:00.000Z"; - const later = "2999-01-01T00:00:00.000Z"; - const proofTiers = { from: [{ tier: "strong" as const, method: "bifrost-passkey", identityId: null }], to: { tier: "weak" as const, method: null, identityId: null } }; - const side = async (providers: Array<"discord" | "google" | "patreon">) => { - const account = await store.createAccount({ createdAt: at, lastSeenAt: at }); - const rows = []; - for (const provider of providers) { - rows.push(await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, accessTokenEncrypted: "A", refreshTokenEncrypted: "R", scopes: [], profileJson: {}, createdAt: at, updatedAt: at })); - } - const sessionId = randomUUID(); - await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: later, accessRevision: 1, claimsJson: {} }); - return { account, rows, proofSession: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } }; - }; - const input = (from: Awaited>, to: Awaited>, extra: Record = {}) => ({ - moveId: `move-${randomUUID()}`, - appSlug: "bifrost" as const, - fromAccountId: from.account.id, - toAccountId: to.account.id, - identityIds: "all" as const, - covers: "account" as const, - proofSessions: [from.proofSession, to.proofSession], - proof: proofTiers, - at, - ...extra, - }); - const held = async (a: { account: { id: string } }) => (await store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort(); - - // Moved: rows, credentials, sessions and the record. - const a = await side(["discord", "google"]); - const b = await side(["patreon"]); - const first = input(a, b); - const moved = await store.moveIdentities(first); - expect(moved.kind).toBe("moved"); - expect(await held(a)).toEqual([]); - expect(await held(b)).toEqual([...a.rows.map((r) => r.id), ...b.rows.map((r) => r.id)].sort()); - for (const row of await store.listStoredLinkedIdentitiesForAccount(b.account.id)) { - if (a.rows.some((r) => r.id === row.id)) expect([row.accessTokenEncrypted, row.refreshTokenEncrypted]).toEqual([undefined, undefined]); - else expect(row.accessTokenEncrypted, "the destination's own credential is untouched").toBe("A"); - } - const record = await store.findIdentityMove(first.moveId); - expect(record).toMatchObject({ id: first.moveId, fromAccountId: a.account.id, toAccountId: b.account.id, proof: proofTiers, createdAt: at }); - expect(record!.moved.map((m) => m.credentialStripped)).toEqual([true, true]); - expect((await store.findSession("bifrost", a.proofSession.sessionId))!.accessRevision).toBe(2); - expect((await store.findSession("bifrost", b.proofSession.sessionId))!.accessRevision).toBe(1); - - // Replay answers from the row, even though the proof session is gone; a different pair is a conflict. - expect(await store.moveIdentities(first)).toEqual({ kind: "replayed", move: record }); - const c = await side(["discord"]); - expect(await store.moveIdentities({ ...first, toAccountId: c.account.id })).toEqual({ kind: "move_id_conflict" }); - expect(await store.moveIdentities({ ...first, reversesMoveId: "x" })).toEqual({ kind: "move_id_conflict" }); - - // Into or out of an alias. - expect((await store.moveIdentities(input(a, c, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved"); - expect((await store.moveIdentities(input(c, a, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved"); - - // A proof session that was revoked, or has moved on a revision, has lapsed. - const d = await side(["google"]); - const e = await side(["patreon"]); - await store.revokeSession("bifrost", e.proofSession.sessionId, e.account.id, 1, at); - expect((await store.moveIdentities(input(d, e))).kind).toBe("proof_lapsed"); - expect(await held(d)).toHaveLength(1); - - // Coverage and conflict. - const f = await side(["discord", "google"]); - const g = await side(["patreon"]); - expect((await store.moveIdentities(input(f, g, { covers: [f.rows[0]!.id] }))).kind).toBe("identity_not_proved"); - expect((await store.moveIdentities(input(f, g, { identityIds: ["nope"] }))).kind).toBe("identity_not_found"); - const h = await side(["discord"]); - expect(await store.moveIdentities(input(f, h))).toEqual({ kind: "provider_conflict", provider: "discord" }); - expect(await held(f)).toHaveLength(2); - const onlyDiscord = await store.moveIdentities(input(f, g, { identityIds: [f.rows[0]!.id], covers: [f.rows[0]!.id] })); - expect(onlyDiscord.kind).toBe("moved"); - expect(await held(f)).toEqual([f.rows[1]!.id]); - - // Reversal: moves exactly what the forward carried, once, and a reversal is not itself reversible. - const forward = (moved as { move: { id: string } }).move; - const back = input(b, a, { reversesMoveId: forward.id, proofSessions: [], identityIds: a.rows.map((r) => r.id), covers: a.rows.map((r) => r.id) }); - const reversed = await store.moveIdentities(back); - expect(reversed.kind).toBe("moved"); - expect(await held(a)).toEqual(a.rows.map((r) => r.id).sort()); - expect(await held(b)).toEqual(b.rows.map((r) => r.id)); - expect((await store.moveIdentities({ ...back, moveId: `move-${randomUUID()}` })).kind).toBe("not_reversible"); - expect((await store.moveIdentities(input(a, b, { reversesMoveId: back.moveId, proofSessions: [], identityIds: "all" }))).kind).toBe("not_reversible"); - - // A moveId answers only the move that used it: each of app, source, destination and reversal must match. - const k = await side(["discord"]); - const l = await side(["patreon"]); - const m = await side(["google"]); - const once = input(k, l); - expect((await store.moveIdentities(once)).kind).toBe("moved"); - expect((await store.moveIdentities({ ...once, fromAccountId: m.account.id })).kind).toBe("move_id_conflict"); - expect((await store.moveIdentities({ ...once, toAccountId: m.account.id })).kind).toBe("move_id_conflict"); - expect((await store.moveIdentities({ ...once, appSlug: "streampixels" })).kind).toBe("move_id_conflict"); - expect((await store.moveIdentities(once)).kind).toBe("replayed"); - - // Every step of a move is on Postgres too: expiry of the source's pending links and unredeemed completions, and the stripped credential's expiry and failure mark. - const n = await side(["discord", "google"]); - const o = await side(["patreon"]); - await store.upsertLinkedIdentity({ accountId: n.account.id, provider: "discord", providerUserId: (await store.listStoredLinkedIdentitiesForAccount(n.account.id))[0]!.providerUserId, tokenExpiresAt: "2999-01-01T00:00:00.000Z", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }); - await store.updateLinkedIdentityCredential(n.rows[1]!.id, async () => ({ kind: "failed", failedAt: at })); - const linkOf = (accountId: string) => store.createPendingLink({ appSlug: "bifrost", accountId, provider: "discord", providerUserId: "p", identity: { accountId, provider: "discord", providerUserId: "p", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }, returnTo: "https://x.test/", createdAt: at, expiresAt: later }); - const completionOf = (accountId: string) => store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: n.proofSession.sessionId, returnTo: "https://x.test/", createdAt: at, expiresAt: later, payloadJson: {} }); - const sourceLink = await linkOf(n.account.id); - const destLink = await linkOf(o.account.id); - const sourceCompletion = await completionOf(n.account.id); - const destCompletion = await completionOf(o.account.id); - expect((await store.moveIdentities(input(n, o))).kind).toBe("moved"); - const after = "2026-11-14T12:00:01.000Z"; - expect(await store.consumePendingLink("bifrost", sourceLink.id, n.account.id, after)).toBeNull(); - expect(await store.consumeAuthCompletion("bifrost", sourceCompletion.code, after)).toBeNull(); - expect((await store.consumePendingLink("bifrost", destLink.id, o.account.id, after))?.id).toBe(destLink.id); - expect((await store.consumeAuthCompletion("bifrost", destCompletion.code, after))?.code).toBe(destCompletion.code); - for (const row of (await store.listStoredLinkedIdentitiesForAccount(o.account.id)).filter((r) => n.rows.some((x) => x.id === r.id))) { - expect([row.accessTokenEncrypted, row.refreshTokenEncrypted, row.tokenExpiresAt, row.refreshFailedAt], row.provider).toEqual([undefined, undefined, undefined, undefined]); - } - - // Each reversal guard on its own: the refusal is not_reversible, never another code, and nothing moves. - const fresh = async () => ({ p: await side(["discord", "google"]), q: await side(["patreon"]), t: await side(["patreon"]) }); - const reverse = (forwardId: string, from: { account: { id: string } }, to: { account: { id: string } }, ids: readonly string[]) => ({ - moveId: `move-${randomUUID()}`, appSlug: "bifrost" as const, fromAccountId: from.account.id, toAccountId: to.account.id, - identityIds: ids, covers: ids, proofSessions: [], reversesMoveId: forwardId, proof: { from: [], to: null }, at, - }); - // (a) already reversed, even though the logins are back on the destination by a later forward move - { - const { p, q } = await fresh(); - const ids = p.rows.map((r) => r.id); - const m1 = input(p, q); - expect((await store.moveIdentities(m1)).kind).toBe("moved"); - expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind).toBe("moved"); - expect((await store.moveIdentities(input(p, q, { proofSessions: [] }))).kind).toBe("moved"); - expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "reversed once").toBe("not_reversible"); - expect(await held(p)).toEqual([]); - } - // (d) the logins are still on the forward's destination but the reversal targets another account - { - const { p, q } = await fresh(); - const ids = p.rows.map((r) => r.id); - const m1 = input(p, q); - expect((await store.moveIdentities(m1)).kind).toBe("moved"); - const elsewhere = await side(["patreon"]); - expect((await store.moveIdentities(reverse(m1.moveId, q, elsewhere, ids))).kind, "to is not the forward's source").toBe("not_reversible"); - expect(await held(elsewhere)).toHaveLength(1); - } - // (b) the logins moved on from the destination: not held, so not_reversible (not identity_not_found) - // (c) the reversal's source is not the forward's destination, or its target is not the forward's source - { - const { p, q, t } = await fresh(); - const ids = p.rows.map((r) => r.id); - const m1 = input(p, q); - expect((await store.moveIdentities(m1)).kind).toBe("moved"); - expect((await store.moveIdentities(input(q, t, { identityIds: ids, covers: ids, proofSessions: [] }))).kind).toBe("moved"); - expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "ids no longer held").toBe("not_reversible"); - expect((await store.moveIdentities(reverse(m1.moveId, t, p, ids))).kind, "from is not the forward's destination").toBe("not_reversible"); - const u = await side(["google"]); - expect((await store.moveIdentities(reverse(m1.moveId, t, u, ids))).kind, "to is not the forward's source").toBe("not_reversible"); - expect(await held(t)).toEqual([...ids, ...t.rows.map((r) => r.id)].sort()); - } -} - // Against a real Postgres, through the startup path itself: createStore, with // and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role // that may create databases and roles. Each case clones a template database @@ -978,103 +803,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES")); }); - it("startup refuses a role that lacks INSERT or SELECT on identity_moves", async () => { - expect(STORE_TABLE_PRIVILEGES.identity_moves).toEqual(["SELECT", "INSERT"]); - for (const [kept, dropped] of [[["SELECT"], "INSERT"], [["INSERT"], "SELECT"]] as const) { - const { name, owner } = await appliedDatabase(); - await grant(owner, { ...STORE_TABLE_PRIVILEGES, identity_moves: kept }); - expect(await start(urlFor(name, role, rolePassword), false), `without ${dropped}`).toBe(refusal("SCHEMA_PRIVILEGES")); - } - }); - - it("on real Postgres: the move contract holds", async () => { - const { owner } = await appliedDatabase(); - await exerciseMoveContract(new PostgresStore(owner)); - }); - - describe("concurrent moves", () => { - const at = "2026-11-14T12:00:00.000Z"; - async function pair(store: PostgresStore) { - const make = async (provider: "discord" | "patreon") => { - const account = await store.createAccount({ createdAt: at, lastSeenAt: at }); - await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, scopes: [], profileJson: {}, createdAt: at, updatedAt: at }); - const sessionId = randomUUID(); - await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: "2999-01-01T00:00:00.000Z", accessRevision: 1, claimsJson: {} }); - return { id: account.id, proof: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } }; - }; - return { x: await make("discord"), y: await make("patreon") }; - } - const request = (from: { id: string; proof: object }, to: { id: string; proof: object }, moveId: string) => ({ - moveId, - appSlug: "bifrost" as const, - fromAccountId: from.id, - toAccountId: to.id, - identityIds: "all" as const, - covers: "account" as const, - proofSessions: [from.proof, to.proof] as never, - proof: { from: [], to: null }, - at, - }); - - it("opposite moves A->B and B->A over two pools leave one applied and never deadlock, ten rounds", async () => { - const { name, owner } = await appliedDatabase(); - const storeA = new PostgresStore(owner); - const other = fixturePool(urlFor(name)); - cleanup.push(async () => { - await other.end(); - }); - const storeB = new PostgresStore(other); - for (let round = 0; round < 10; round += 1) { - const { x, y } = await pair(storeA); - const results = await Promise.all([ - storeA.moveIdentities(request(x, y, `move-${randomUUID()}`)), - storeB.moveIdentities(request(y, x, `move-${randomUUID()}`)), - ]); - const kinds = results.map((r) => r.kind).sort(); - expect(kinds[0], `round ${round}: ${kinds}`).toBe("moved"); - expect(["already_moved", "proof_lapsed"], `round ${round}: ${kinds}`).toContain(kinds[1]); - const held = (await storeA.listStoredLinkedIdentitiesForAccount(x.id)).length + (await storeA.listStoredLinkedIdentitiesForAccount(y.id)).length; - expect(held, "no identity is lost or duplicated").toBe(2); - } - }); - - it("one moveId over two different pairs at once: one moved, the other move_id_conflict, never a receipt for a move it did not make", async () => { - const { name, owner } = await appliedDatabase(); - const storeA = new PostgresStore(owner); - const other = fixturePool(urlFor(name)); - cleanup.push(async () => { - await other.end(); - }); - const storeB = new PostgresStore(other); - for (let round = 0; round < 6; round += 1) { - const one = await pair(storeA); - const two = await pair(storeA); - const id = `move-${randomUUID()}`; - const results = await Promise.all([storeA.moveIdentities(request(one.x, one.y, id)), storeB.moveIdentities(request(two.x, two.y, id))]); - expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["move_id_conflict", "moved"]); - const winner = (await storeA.findIdentityMove(id))!; - const moved = results.find((r) => r.kind === "moved") as { move: { fromAccountId: string } }; - expect(winner.fromAccountId).toBe(moved.move.fromAccountId); - } - }); - - it("two concurrent identical moveIds: one moved, one replayed", async () => { - const { name, owner } = await appliedDatabase(); - const storeA = new PostgresStore(owner); - const other = fixturePool(urlFor(name)); - cleanup.push(async () => { - await other.end(); - }); - const storeB = new PostgresStore(other); - for (let round = 0; round < 5; round += 1) { - const { x, y } = await pair(storeA); - const body = request(x, y, `move-${randomUUID()}`); - const results = await Promise.all([storeA.moveIdentities(body), storeB.moveIdentities(body)]); - expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["moved", "replayed"]); - } - }); - }); - it("on real Postgres: an upsert never moves an identity, and concurrent unlinks of the last two leave one", async () => { const { name, owner } = await appliedDatabase(); const clientA = new PostgresStore(owner); @@ -1192,7 +920,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD const appliedByIndex = { auth_completions_attempt_unconsumed_unique_idx: "auth_completions", consent_receipts_revokes_unique_idx: "consent_receipts", - identity_moves_reverses_unique_idx: "identity_moves", }; const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }])); expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());