Identity moves: pin Soul's mutation survivors, delete resolveAccount, lock order owned by the code

Tests now kill M09, M11, M14, M17, M18, M19, M21, M23, M28, M29, M31 (each
re-run by hand). The account locks are taken one statement each in sorted
order, so dropping the sort is observable (M01) instead of planner-dependent.
resolveAccount had no caller and answered wrongly after a chain of moves: deleted
with its docs claim; the alias test inside moveIdentities stays.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 01:39:32 +00:00
commit a591c71b5d
7 changed files with 173 additions and 39 deletions

View file

@ -356,8 +356,10 @@ re-checks the proof sessions, applies `decideMove` (one pure function both store
call, so memory and Postgres cannot disagree), moves the rows with credentials
stripped, revokes the source's sessions, expires its pending links and unredeemed
completions, and appends the row. `identity_moves` is append-only (SELECT and
INSERT only, checked at startup). An alias is derived by `resolveAccount`, never
stored.
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

View file

@ -793,8 +793,8 @@ re-runs the revocation receipts, so a crash between commit and receipts heals on
the caller's retry. Anything else under a used `moveId` is `409 move_id_conflict`.
What a move leaves: an account holding no login with an unreversed move from it
is an alias of the move's destination (derived by `resolveAccount`, never
stored; one hop). A move into or out of an alias is `already_moved`. Bearers of
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.

View file

@ -271,12 +271,6 @@ export class InMemoryStore implements HeimdallStore {
return clone(this.identityMoves.get(moveId) ?? null);
}
async resolveAccount(accountId: string): Promise<string> {
if ([...this.linkedIdentities.values()].some((identity) => identity.accountId === accountId)) return accountId;
const unreversed = [...this.identityMoves.values()].filter((move) => move.fromAccountId === accountId && !this.isReversed(move.id));
return unreversed.at(-1)?.toAccountId ?? accountId;
}
private isReversed(moveId: string): boolean {
return [...this.identityMoves.values()].some((move) => move.reversesMoveId === moveId);
}

View file

@ -902,8 +902,11 @@ export class PostgresStore implements HeimdallStore {
try {
await client.query("BEGIN");
try {
// Both accounts, in id order, before anything is read: opposite moves queue here instead of deadlocking.
await client.query("SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE", [[input.fromAccountId, input.toAccountId]]);
// 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) {
@ -996,18 +999,6 @@ export class PostgresStore implements HeimdallStore {
return selectIdentityMove(this.pool, moveId);
}
async resolveAccount(accountId: string): Promise<string> {
const held = await this.pool.query("SELECT 1 FROM linked_identities WHERE account_id = $1 LIMIT 1", [accountId]);
if (held.rowCount) return accountId;
const moved = await this.pool.query<{ to_account_id: string }>(
`SELECT m.to_account_id 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)
ORDER BY m.created_at DESC, m.id DESC LIMIT 1`,
[accountId]
);
return moved.rows[0]?.to_account_id ?? accountId;
}
async updateLinkedIdentityCredential(
id: string,
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,

View file

@ -404,11 +404,6 @@ export interface HeimdallStore {
*/
moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult>;
findIdentityMove(moveId: string): Promise<StoredIdentityMove | null>;
/**
* One hop, derived and never stored: the destination of an unreversed move
* from `accountId` when it now holds no identity, else `accountId` itself.
*/
resolveAccount(accountId: string): Promise<string>;
/**
* 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

View file

@ -4,7 +4,7 @@ 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 } from "../src/claims.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";
@ -46,6 +46,10 @@ function twitchRuntime(providerUserId: string): OAuthProviderRuntime {
};
}
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[] = [];
@ -54,7 +58,7 @@ async function rig(twitchUser = "t-signin") {
audit.push(structuredClone(input));
return realAudit(input);
};
const app = await buildApp({ config, store, oauthRuntimes: { twitch: twitchRuntime(twitchUser) } });
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 };
@ -184,6 +188,18 @@ describe("a sanctioned move", () => {
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);
@ -434,12 +450,10 @@ describe("what a move leaves behind", () => {
});
describe("aliases and reversal", () => {
it("an emptied source is an alias; moving into or out of it is 409 already_moved; resolution is one hop", async () => {
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);
expect(await r.store.resolveAccount(s.from.account.id)).toBe(s.to.account.id);
expect(await r.store.resolveAccount(s.to.account.id)).toBe(s.to.account.id);
// 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"]]);
@ -469,7 +483,6 @@ describe("aliases and reversal", () => {
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);
expect(await r.store.resolveAccount(s.from.account.id)).toBe(s.from.account.id);
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"]);
@ -480,6 +493,30 @@ describe("aliases and reversal", () => {
});
});
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("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();
@ -530,6 +567,31 @@ describe("claims: which sign-in a token came from", () => {
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"]]);

View file

@ -659,7 +659,6 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
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);
expect(await s.resolveAccount(account.id)).toBe(target.id);
return called;
}
@ -712,8 +711,6 @@ async function exerciseMoveContract(store: HeimdallStore): Promise<void> {
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);
expect(await store.resolveAccount(a.account.id)).toBe(b.account.id);
expect(await store.resolveAccount(b.account.id)).toBe(b.account.id);
// 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 });
@ -751,9 +748,82 @@ async function exerciseMoveContract(store: HeimdallStore): Promise<void> {
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.resolveAccount(a.account.id)).toBe(a.account.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
@ -968,6 +1038,26 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
}
});
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);