Identity moves: auth_at/auth_identity_id claims, proofTier, identity_moves table and moveIdentities on both stores
proofTier is the one freshness rule (unlink now reads it); the sign_in callback stamps auth_at and auth_identity_id; moveIdentities is the only writer that changes linked_identities.account_id, decided by one shared decideMove. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d957e5accb
commit
312fd496e8
9 changed files with 485 additions and 4 deletions
|
|
@ -65,6 +65,13 @@ 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. */
|
||||
|
|
@ -164,6 +171,8 @@ 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",
|
||||
|
|
|
|||
10
src/app.ts
10
src/app.ts
|
|
@ -24,7 +24,7 @@ import {
|
|||
type OAuthStartRequest,
|
||||
type Provider,
|
||||
} from "./contracts.js";
|
||||
import { issueAccessClaim, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { issueAccessClaim, proofCovers, proofTier, 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";
|
||||
|
|
@ -1976,8 +1976,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
}
|
||||
|
||||
let signedInIdentity: StoredLinkedIdentity;
|
||||
try {
|
||||
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
signedInIdentity = await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
} catch (error) {
|
||||
// The check above is the fast path; this is the write refusing a move another account won the race for.
|
||||
if (error instanceof IdentityHeldElsewhereError) {
|
||||
|
|
@ -2043,6 +2044,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
// Proof of a fresh re-authentication, from the signed state alone: only a sign_in the app started with stepUp.
|
||||
...(statePayload.step_up && statePayload.mode === "sign_in" ? { stepUpAt: nowEpochSeconds(), stepUpMethod: `provider:${statePayload.provider}` } : {}),
|
||||
// Which login proved this session, and when: only a sign_in, so a link or connect never counts as a sign-in.
|
||||
...(statePayload.mode === "sign_in" ? { authAt: nowEpochSeconds(), authIdentityId: signedInIdentity.id } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
issueInput.displayName = account.displayName;
|
||||
|
|
@ -2911,8 +2914,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
grantsReply(reply);
|
||||
const { accountId, claims } = bearer;
|
||||
const nowS = nowEpochSeconds();
|
||||
const provedAt = claims.step_up_at;
|
||||
if (typeof provedAt !== "number" || provedAt > nowS || nowS - provedAt > STEP_UP_PROOF_TTL_SECONDS) {
|
||||
if (proofTier(claims, nowS) !== "strong") {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: "step_up_required",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ 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);
|
||||
}
|
||||
|
|
@ -55,6 +58,10 @@ export interface IssueAccessClaimInput {
|
|||
stepUpAt?: number;
|
||||
/** Required with stepUpAt (`provider:<name>`); 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 {
|
||||
|
|
@ -144,6 +151,11 @@ 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;
|
||||
}
|
||||
|
|
@ -229,3 +241,38 @@ 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<string, unknown>, 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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,10 @@ 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;
|
||||
|
|
|
|||
88
src/store/identity-move.ts
Normal file
88
src/store/identity-move.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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<Provider>;
|
||||
/** 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -26,9 +26,13 @@ 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<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
|
|
@ -63,6 +67,7 @@ export class InMemoryStore implements HeimdallStore {
|
|||
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
|
||||
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
|
||||
private readonly consentReceipts: StoredConsentReceipt[] = [];
|
||||
private readonly identityMoves = new Map<string, StoredIdentityMove>();
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
return;
|
||||
|
|
@ -202,6 +207,84 @@ 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<MoveResult> {
|
||||
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<StoredIdentityMove | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
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<CredentialDecision>,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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,
|
||||
|
|
@ -19,6 +20,9 @@ import {
|
|||
type CreatePrivateCommandReceiptInput,
|
||||
type HeimdallStore,
|
||||
IdentityHeldElsewhereError,
|
||||
type MoveIdentitiesInput,
|
||||
type MoveResult,
|
||||
type StoredIdentityMove,
|
||||
OAUTH_STATE_PRUNE_BATCH,
|
||||
oauthStatePruneCutoffMs,
|
||||
type StoredAccount,
|
||||
|
|
@ -175,6 +179,42 @@ 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<Pool, "query">, moveId: string): Promise<StoredIdentityMove | null> {
|
||||
const result = await db.query<IdentityMoveRow>("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<Pool, "query">, accountId: string): Promise<boolean> {
|
||||
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,
|
||||
|
|
@ -457,6 +497,9 @@ 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<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE" | "DELETE")[]>> = {
|
||||
|
|
@ -471,6 +514,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
|
|||
capability_grants: ["SELECT", "INSERT"],
|
||||
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
|
||||
consent_receipts: ["SELECT", "INSERT"],
|
||||
identity_moves: ["SELECT", "INSERT"],
|
||||
audit_events: ["INSERT"],
|
||||
};
|
||||
|
||||
|
|
@ -853,6 +897,117 @@ export class PostgresStore implements HeimdallStore {
|
|||
}
|
||||
}
|
||||
|
||||
async moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult> {
|
||||
const client = await this.pool.connect();
|
||||
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]]);
|
||||
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<LinkedIdentityRow>(
|
||||
"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<StoredIdentityMove | null> {
|
||||
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>,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,29 @@ 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,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,60 @@ 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;
|
||||
|
|
@ -339,6 +393,22 @@ export interface HeimdallStore {
|
|||
* `not_found`).
|
||||
*/
|
||||
unlinkLinkedIdentity(accountId: string, identityId: string): Promise<UnlinkResult>;
|
||||
/**
|
||||
* 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<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue