diff --git a/docs/architecture.md b/docs/architecture.md index 12fbd4f..f50396f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -336,6 +336,18 @@ access token (`revoked`) or no refresh is due (`current`; due means a known expiry within 5 minutes, a stored refresh token, and a runtime that refreshes). It never decides that a refresh happens. +### One writer for `linked_identities` moves + +An identity belongs to the account that holds it. `upsertLinkedIdentity` writes +the holder's own row (`ON CONFLICT (provider, provider_user_id) DO UPDATE ... +WHERE linked_identities.account_id = EXCLUDED.account_id`; it never sets +`account_id`) and throws `IdentityHeldElsewhereError` when another account holds +the identity. `unlinkLinkedIdentity` is the only delete: one transaction locks +the account row, refuses the account's last identity, and deletes the row only +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. + ### Three nested guards 1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then diff --git a/docs/service-contract.md b/docs/service-contract.md index 0469186..9c40713 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -548,6 +548,84 @@ Important behavior: - the endpoint is not a provider OAuth start path and should not produce a provider authorization URL +### `GET /v1/me/identities` + +The identities the signed-in account holds, for a member's own view. + +Authentication: a live Heimdall access token for any app as +`Authorization: Bearer` (the same check as `/v1/me/grants`). `401 +sign_in_required` otherwise. The answer is `Cache-Control: no-store`. + +```json +{ + "accountId": "uuid", + "identities": [ + { + "id": "uuid", + "provider": "twitch", + "providerUserId": "12345", + "username": "name", + "displayName": "Name", + "linkedAt": "2026-11-13T12:00:00.000Z", + "scopes": ["user:read:email"] + } + ] +} +``` + +`username` and `displayName` are present when the provider gave them. The +answer never carries a token column, the stored provider profile or the +account's email. + +### `POST /v1/me/identities/{identityId}/unlink` + +Takes one identity off the signed-in account. It is the only way a linked +identity is deleted, and it never takes the account's last one. + +Authentication: a live Heimdall access token for any app, and fresh proof. The +body is empty or `null`. + +- `401 sign_in_required`: no live bearer. +- `403 step_up_required`: the access token carries no `step_up_at` claim, or it + is in the future, or it is older than 600 seconds + (`STEP_UP_PROOF_TTL_SECONDS`, `src/claims.ts`). The remedy is a `sign_in` + start with `stepUp: true` (see step-up above) through an identity on the + account whose provider has re-authentication parameters, today twitch, + youtube and google, then the access token that sign-in returns. +- `404 identity_not_found`: no such identity, or one that belongs to another + account. The two are not distinguishable. +- `409 last_identity`: the account holds no other identity. +- `200`: `{"unlinked": {"id", "provider", "providerUserId"}, "identities": [...]}` + with the remaining list in the shape above. + +`step_up_at` (epoch seconds) is set on the access claim only when the callback's +signed state has `step_up` true and mode `sign_in`. A refresh never carries it: +tokens a refresh issues cannot unlink, so proof is renewed only by a new step-up +sign-in. The unlink writes the audit event `identity_unlinked` (provider and +identity id only). When the account then holds no identity of that provider, the +revocation receipts for its live grants of that provider are written, as the +grants revoke route writes them. + +A Twitch step-up proof is weaker than YouTube's or Google's: `force_verify` does +not force credential re-entry (see the step-up note under the OAuth start +section, "The strengths differ"). + +Limits that stay: + +- Access tokens issued before the unlink keep the unlinked identity in their + `identities` claim and its facts until they expire (`sessionTtlSeconds`). +- Discord and Patreon identities cannot give proof (no re-authentication + parameter); an account holding only those cannot unlink. +- A stolen access token minted by a step-up sign-in can unlink more than one + identity during its 600 second window. +- A sign-in through a provider whose identity is no longer linked mints a new + account. + +A link no longer moves an identity: `upsertLinkedIdentity` refuses an identity +another account holds (`links/confirm` answers `409 identity_linked_elsewhere`, +the callback `link_initiator_mismatch`). An unlinked identity belongs to no +account and can be linked by any account through the normal link flow. + ### `POST /v1/apps/{appSlug}/managed-credentials/resolve` Purpose: diff --git a/src/app.ts b/src/app.ts index 2977f08..2eceba0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,7 +24,7 @@ import { type OAuthStartRequest, type Provider, } from "./contracts.js"; -import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; +import { issueAccessClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js"; @@ -41,7 +41,7 @@ import { import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js"; import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; -import { createStore, type HeimdallStore } from "./store/index.js"; +import { createStore, IdentityHeldElsewhereError, type HeimdallStore } from "./store/index.js"; import { OAUTH_STATE_SKEW_MS, type CreateAccountInput, @@ -879,28 +879,36 @@ export async function startOAuthFlow( }; } +/** What a live bearer proves: the account its session belongs to, and the claims it was signed with. */ +interface ResolvedBearer { + accountId: string; + claims: Record; +} + /** - * The account a link start binds to: the one a live Heimdall access token for - * this app names. The token must verify under Heimdall's own key, be an access - * token for `appSlug`, be inside its validity window, and belong to a session + * The one verifier of a Heimdall access token. The token must verify under + * Heimdall's own key, be an access token for `appSlug` (or, when none is + * named, for any app), be inside its validity window, and belong to a session * the store still holds for that account at that revision. A bearer that fails - * any of these binds nothing: a link is explicit or it does not happen. + * any of these proves nothing. */ -async function resolveLinkAccount( +async function resolveBearer( ctx: Pick, - appSlug: AppSlug, - authorization: string | undefined -): Promise { + authorization: string | undefined, + appSlug?: AppSlug +): Promise { const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1]; if (!token) return null; const verification = verifyJwt(token, ctx.keys.publicKey); if (!verification.valid) return null; const claims = verification.payload; + const audience = appSlug ?? claims.aud; + if (!isAppSlug(audience)) return null; const now = nowEpochSeconds(); if ( claims.typ !== "heimdall_access" || claims.iss !== ctx.config.issuer || - claims.aud !== appSlug || + claims.aud !== audience || typeof claims.account_id !== "string" || typeof claims.sid !== "string" || typeof claims.access_revision !== "number" || @@ -910,7 +918,7 @@ async function resolveLinkAccount( ) { return null; } - const session = await ctx.store.findSession(appSlug, claims.sid); + const session = await ctx.store.findSession(audience, claims.sid); if ( !session || session.accountId !== claims.account_id || @@ -919,7 +927,16 @@ async function resolveLinkAccount( ) { return null; } - return claims.account_id; + return { accountId: claims.account_id, claims }; +} + +/** The account a link start binds to: the one a live Heimdall access token for this app names. A link is explicit or it does not happen. */ +async function resolveLinkAccount( + ctx: Pick, + appSlug: AppSlug, + authorization: string | undefined +): Promise { + return (await resolveBearer(ctx, authorization, appSlug))?.accountId ?? null; } /** The account a Heimdall access token for any app names, when the token and its session are live. */ @@ -927,12 +944,7 @@ async function resolveAccessAccount( ctx: Pick, authorization: string | undefined ): Promise { - const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1]; - if (!token) return null; - const verification = verifyJwt(token, ctx.keys.publicKey); - if (!verification.valid) return null; - const audience = verification.payload.aud; - return isAppSlug(audience) ? resolveLinkAccount(ctx, audience, authorization) : null; + return (await resolveBearer(ctx, authorization))?.accountId ?? null; } const grantsPageTokenType = "heimdall_grants_page"; @@ -1847,7 +1859,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise[0], at: string): Promise => { + const revoked: string[] = []; + for (const live of grantViews(receipts).filter((view) => view.provider === provider && view.status === "active")) { + const { created } = await store.createConsentReceipt({ + kind: "revocation", + accountId, + appSlug: live.appSlug, + provider: live.provider, + createdAt: at, + revokesReceiptId: live.id, + }); + // A concurrent revoke may have written it first; only the writer reports it. + if (created) revoked.push(live.id); + } + if (revoked.length) { + await store.createAuditEvent({ accountId, eventType: "consent_revoked", eventPayloadJson: { provider, grantIds: revoked }, createdAt: at }); + } + return revoked; + }; + + const identityViews = async (accountId: string) => + (await store.listStoredLinkedIdentitiesForAccount(accountId)).map((identity) => ({ + id: identity.id, + provider: identity.provider, + providerUserId: identity.providerUserId, + ...(identity.username !== undefined ? { username: identity.username } : {}), + ...(identity.displayName !== undefined ? { displayName: identity.displayName } : {}), + linkedAt: identity.createdAt, + scopes: identity.scopes, + })); + + // The identities an account holds: ids and public profile facts, never a + // credential, a stored profile or an email. + app.get("/v1/me/identities", async (request, reply) => { + const bearer = await resolveBearer({ config, keys, store }, request.headers.authorization); + if (!bearer) return signInRequired(reply); + grantsReply(reply); + return { accountId: bearer.accountId, identities: await identityViews(bearer.accountId) }; + }); + + // Taking an identity off the account. The bearer alone is not enough: the + // access token must come from a step-up sign-in inside the proof window, and + // a refresh never carries that proof forward. + app.post<{ Params: { identityId: string }; Body?: null }>( + "/v1/me/identities/:identityId/unlink", + { + schema: { + params: { type: "object", required: ["identityId"], additionalProperties: false, properties: { identityId: { type: "string", minLength: 1 } } }, + body: { type: ["object", "null"], additionalProperties: false, properties: {} }, + }, + }, + async (request, reply) => { + const bearer = await resolveBearer({ config, keys, store }, request.headers.authorization); + if (!bearer) return signInRequired(reply); + 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) { + reply.code(403); + return { + error: "step_up_required", + detail: `Unlinking needs a sign-in with stepUp completed within the last ${STEP_UP_PROOF_TTL_SECONDS} seconds: POST /v1/oauth/{provider}/start with mode sign_in and stepUp true through an identity on this account whose provider has re-authentication parameters (today twitch, youtube and google; discord and patreon cannot prove), then use the access token that sign-in returns.`, + }; + } + const result = await store.unlinkLinkedIdentity(accountId, request.params.identityId); + if (result.kind === "not_found") { + reply.code(404); + return { error: "identity_not_found" }; + } + if (result.kind === "last_identity") { + reply.code(409); + return { error: "last_identity", detail: "This is the account's only identity; unlinking it would leave the account unreachable." }; + } + const { identity } = result; + const at = new Date().toISOString(); + await store.createAuditEvent({ + accountId, + eventType: "identity_unlinked", + eventPayloadJson: { provider: identity.provider, identityId: identity.id }, + createdAt: at, + }); + // The credential went with the row. When no identity of that provider + // is left, the grants that rested on it are taken back in the record too. + const remaining = await store.listStoredLinkedIdentitiesForAccount(accountId); + if (!remaining.some((other) => other.provider === identity.provider)) { + await revokeLiveGrants(accountId, identity.provider, await store.listConsentReceipts(accountId), at); + } + return { + unlinked: { id: identity.id, provider: identity.provider, providerUserId: identity.providerUserId }, + identities: await identityViews(accountId), + }; + } + ); + // 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 } }>( @@ -2730,26 +2862,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ kind: "revoke", updatedAt: at })); } - for (const live of grantViews(receipts).filter((view) => view.provider === grant.provider && view.status === "active")) { - const { created } = await store.createConsentReceipt({ - kind: "revocation", - accountId, - appSlug: live.appSlug, - provider: live.provider, - createdAt: at, - revokesReceiptId: live.id, - }); - // A concurrent revoke may have written it first; only the writer reports it. - if (created) revoked.push(live.id); - } - if (revoked.length) { - await store.createAuditEvent({ - accountId, - eventType: "consent_revoked", - eventPayloadJson: { provider: grant.provider, grantIds: revoked }, - createdAt: at, - }); - } + revoked.push(...(await revokeLiveGrants(accountId, grant.provider, receipts, at))); } if (prefersHtml(request.headers.accept) || request.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) { reply.type("text/html; charset=utf-8"); diff --git a/src/claims.ts b/src/claims.ts index 4440ed3..ef2da87 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -11,6 +11,9 @@ import { identityFacts } from "./facts.js"; import { signJwt, type RuntimeKeyMaterial } from "./signing.js"; 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; + function nowEpochSeconds(): number { return Math.floor(Date.now() / 1000); } @@ -48,6 +51,8 @@ export interface IssueAccessClaimInput { sessionId?: string; accessRevision?: number; ttlSeconds?: number; + /** Epoch seconds of a step-up sign-in completing; only that callback sets it, a refresh never does. */ + stepUpAt?: number; } export interface IssuedAccessClaimResult { @@ -130,6 +135,10 @@ export async function issueAccessClaim(options: { identities: options.input.linkedIdentities, }; + if (options.input.stepUpAt !== undefined) { + claimSet.step_up_at = options.input.stepUpAt; + } + if (options.input.displayName) { claimSet.display_name = options.input.displayName; } diff --git a/src/contracts.ts b/src/contracts.ts index 78b3534..2d582f5 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -153,6 +153,8 @@ export interface AccessClaimPayload { typ: "heimdall_access"; account_id: string; access_revision: number; + /** Epoch seconds of the step-up sign-in this access token was minted by; absent on every other token, and never carried by a refresh. */ + step_up_at?: number; display_name?: string; app: { slug: AppSlug; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 5df37ea..b35decb 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -18,6 +18,7 @@ import { type CreateSessionInput, type CreatePrivateCommandReceiptInput, type HeimdallStore, + IdentityHeldElsewhereError, type StoredAccount, type StoredAuthAttempt, type StoredAuthCompletion, @@ -25,6 +26,7 @@ import { type StoredLinkedIdentity, type StoredSession, type StoredPrivateCommandReceipt, + type UnlinkResult, type UpsertLinkedIdentityInput, } from "./types.js"; @@ -148,6 +150,9 @@ export class InMemoryStore implements HeimdallStore { const existing = [...this.linkedIdentities.values()].find( (identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId ); + if (existing && existing.accountId !== input.accountId) { + throw new IdentityHeldElsewhereError(); + } const id = existing?.id ?? input.id ?? randomUUID(); const linkedIdentity: StoredLinkedIdentity = { id, @@ -188,6 +193,15 @@ export class InMemoryStore implements HeimdallStore { return clone(linkedIdentity); } + async unlinkLinkedIdentity(accountId: string, identityId: string): Promise { + const target = this.linkedIdentities.get(identityId); + if (!target || target.accountId !== accountId) return { kind: "not_found" }; + const held = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === accountId).length; + if (held <= 1) return { kind: "last_identity" }; + this.linkedIdentities.delete(identityId); + return { kind: "unlinked", identity: clone(target) }; + } + async updateLinkedIdentityCredential( id: string, decide: (locked: StoredLinkedIdentity) => Promise, diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 247260c..0b05f61 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -18,6 +18,7 @@ import { type CreateSessionInput, type CreatePrivateCommandReceiptInput, type HeimdallStore, + IdentityHeldElsewhereError, OAUTH_STATE_PRUNE_BATCH, oauthStatePruneCutoffMs, type StoredAccount, @@ -27,6 +28,7 @@ import { type StoredLinkedIdentity, type StoredSession, type StoredPrivateCommandReceipt, + type UnlinkResult, type UpsertLinkedIdentityInput, } from "./types.js"; @@ -451,13 +453,15 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri /** * What the store's statements do to each table, and so the privileges its * database role needs: it reads what it returns or looks up, inserts what it - * creates, updates what it changes or upserts, and deletes only expired - * oauth_state_uses rows (which needs SELECT for its WHERE). + * creates, updates what it changes or upserts, and deletes two things: + * 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). * audit_events is written and never read back. */ export const STORE_TABLE_PRIVILEGES: Readonly> = { accounts: ["SELECT", "INSERT", "UPDATE"], - linked_identities: ["SELECT", "INSERT", "UPDATE"], + linked_identities: ["SELECT", "INSERT", "UPDATE", "DELETE"], sessions: ["SELECT", "INSERT", "UPDATE"], auth_attempts: ["SELECT", "INSERT", "UPDATE"], private_command_receipts: ["SELECT", "INSERT"], @@ -780,7 +784,6 @@ export class PostgresStore implements HeimdallStore { ) ON CONFLICT (provider, provider_user_id) DO UPDATE SET - account_id = EXCLUDED.account_id, username = EXCLUDED.username, display_name = EXCLUDED.display_name, primary_email = EXCLUDED.primary_email, @@ -791,6 +794,7 @@ export class PostgresStore implements HeimdallStore { scopes = EXCLUDED.scopes, profile_json = EXCLUDED.profile_json, updated_at = EXCLUDED.updated_at + WHERE linked_identities.account_id = EXCLUDED.account_id RETURNING * `, [ @@ -811,7 +815,42 @@ export class PostgresStore implements HeimdallStore { ] ); - return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); + // The conflict arm updates only the holder's own row; another account's yields no row. + if (!result.rows[0]) throw new IdentityHeldElsewhereError(); + return mapLinkedIdentityRow(result.rows[0]); + } + + async unlinkLinkedIdentity(accountId: string, identityId: string): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + try { + // The account row serialises its unlinks: two clients racing for the last two identities queue here. + await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [accountId]); + const held = await client.query<{ n: string | number }>( + "SELECT count(*) AS n FROM linked_identities WHERE account_id = $1", + [accountId] + ); + let result: UnlinkResult; + if (Number(held.rows[0]?.n ?? 0) <= 1) { + const mine = await client.query("SELECT 1 FROM linked_identities WHERE id = $1 AND account_id = $2", [identityId, accountId]); + result = mine.rowCount ? { kind: "last_identity" } : { kind: "not_found" }; + } else { + const deleted = await client.query( + "DELETE FROM linked_identities WHERE id = $2 AND account_id = $1 RETURNING *", + [accountId, identityId] + ); + result = deleted.rows[0] ? { kind: "unlinked", identity: mapLinkedIdentityRow(deleted.rows[0]) } : { kind: "not_found" }; + } + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } finally { + client.release(); + } } async updateLinkedIdentityCredential( diff --git a/src/store/types.ts b/src/store/types.ts index a2a792d..a056f9f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -41,6 +41,24 @@ export interface StoredLinkedIdentity { updatedAt: string; } +/** + * An upsert named an identity another account holds. A provider identity + * belongs to the account that holds it until that account unlinks it; no + * write moves it. + */ +export class IdentityHeldElsewhereError extends Error { + constructor() { + super("This provider identity is held by another account."); + this.name = "IdentityHeldElsewhereError"; + } +} + +/** What unlinkLinkedIdentity decided; `identity` is the row as it was deleted. */ +export type UnlinkResult = + | { kind: "unlinked"; identity: StoredLinkedIdentity } + | { kind: "last_identity" } + | { kind: "not_found" }; + export interface UpsertLinkedIdentityInput { id?: string; accountId: string; @@ -311,7 +329,15 @@ export interface HeimdallStore { findStoredLinkedIdentity(provider: Provider, providerUserId: string): Promise; findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise; listStoredLinkedIdentitiesForAccount(accountId: string): Promise; + /** Writes the holder's own row; throws IdentityHeldElsewhereError rather than move another account's identity. */ upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; + /** + * The only delete of a linked identity. One transaction locks the account, + * refuses to take its last identity, and deletes the row only if the + * account holds it (an id that is missing or another account's is the same + * `not_found`). + */ + unlinkLinkedIdentity(accountId: string, identityId: 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 f7fa724..5fd99b4 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -38,3 +38,4 @@ {"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"} {"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."} {"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."} diff --git a/tests/google-link.test.ts b/tests/google-link.test.ts index a33f27c..4a1fc6d 100644 --- a/tests/google-link.test.ts +++ b/tests/google-link.test.ts @@ -5,7 +5,7 @@ import { buildApp } from "../src/app.js"; import { loadConfig, type HeimdallConfig } from "../src/config.js"; import { resetGoogleJwksCache } from "../src/google-oidc.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; -import { InMemoryStore } from "../src/store/index.js"; +import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js"; import { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js"; const CLIENT_ID = "google-client"; @@ -188,6 +188,18 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); }); +describe("a link write that loses the race to another account", () => { + it("links/confirm answers 409 identity_linked_elsewhere when the store refuses the move after the check passed", async () => { + const { app, store } = await build(); + const { accessToken } = await signInWithDiscord(app); + const done = await linkGoogleCallback(app, accessToken); + expect(done.statusCode).toBe(201); + vi.spyOn(store, "upsertLinkedIdentity").mockRejectedValueOnce(new IdentityHeldElsewhereError()); + const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId); + expect([confirmed.statusCode, confirmed.json().error]).toEqual([409, "identity_linked_elsewhere"]); + }); +}); + describe("Google as a Heimdall OIDC provider", () => { it("builds the authorization request: openid email profile, the exact callback, state, nonce and an S256 challenge", async () => { const { app } = await build(); diff --git a/tests/identity-unlink.test.ts b/tests/identity-unlink.test.ts new file mode 100644 index 0000000..ae70b7d --- /dev/null +++ b/tests/identity-unlink.test.ts @@ -0,0 +1,330 @@ +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 { issueAccessClaim } from "../src/claims.js"; +import { type Provider } from "../src/contracts.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { createRuntimeKeyMaterial, decodeJwt, signJwt, verifyJwt } from "../src/signing.js"; +import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js"; +import { createTestConfig, launchFlow, launchState, stateOf } from "./oauth-flow.js"; + +// The member's identity surface: list what an account holds, and take one +// identity off it. Taking one off is the account's own decision made with +// fresh proof (a step-up sign-in inside the proof window), never the +// bearer's alone. These tests drive both routes over HTTP as an app would. + +const returnTo = "https://streampixels.gamecult.org/signin/return"; +const spAuth = { "x-heimdall-app-secret": "streampixels-secret" }; +const now = "2026-11-13T12:00:00.000Z"; + +function runtime(provider: "twitch" | "youtube", providerUserId: string): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} }; + }, + async resolveIdentity() { + return { provider, providerUserId, username: providerUserId, profile: { id: providerUserId } }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }; +} + +const dir = mkdtempSync(join(tmpdir(), "heimdall-unlink-")); +const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const apps: Awaited>[] = []; +afterEach(async () => { + vi.useRealTimers(); + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +async function rig() { + const store = new InMemoryStore(); + const app = await buildApp({ + config, + store, + oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") }, + }); + apps.push(app); + const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); + return { app, store, keys }; +} +type Rig = Awaited>; + +async function seedAccount(r: Rig, held: Array<[Provider, string]>) { + const account = await r.store.createAccount({ createdAt: now, lastSeenAt: now }); + const rows = []; + for (const [provider, providerUserId] of held) { + rows.push( + await r.store.upsertLinkedIdentity({ + accountId: account.id, + provider, + providerUserId, + username: `${provider}-name`, + displayName: `${provider} display`, + primaryEmail: "secret@example.test", + accessTokenEncrypted: "ACCESS-CIPHERTEXT", + refreshTokenEncrypted: "REFRESH-CIPHERTEXT", + scopes: ["identify"], + profileJson: { raw: "PROFILE-CANARY" }, + createdAt: now, + updatedAt: now, + }) + ); + } + return { account, rows }; +} + +/** A real sign_in through twitch, as StreamPixels runs it, redeemed for the tokens. */ +async function signIn(r: Rig, stepUp: boolean) { + const started = await r.app.inject({ + method: "POST", + url: "/v1/oauth/twitch/start", + headers: stepUp ? spAuth : {}, + payload: { appSlug: "streampixels", mode: "sign_in", returnTo, ...(stepUp ? { stepUp: true } : {}) }, + }); + expect(started.statusCode).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).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 { accessToken: string; refreshToken: string; claimSet: Record }; +} + +const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); +const list = (r: Rig, token?: string) => r.app.inject({ method: "GET", url: "/v1/me/identities", headers: token ? bearer(token) : {} }); +const unlink = (r: Rig, id: string, token?: string, payload?: unknown) => + r.app.inject({ method: "POST", url: `/v1/me/identities/${encodeURIComponent(id)}/unlink`, headers: token ? bearer(token) : {}, ...(payload !== undefined ? { payload: payload as object } : {}) }); + +describe("step_up_at on the access claim", () => { + it("a step-up sign-in's claim carries it; an ordinary sign-in and a refresh do not", async () => { + const r = await rig(); + await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]); + const before = Math.floor(Date.now() / 1000); + const stepped = await signIn(r, true); + expect(stepped.claimSet.step_up_at).toBeGreaterThanOrEqual(before); + expect(stepped.claimSet.step_up_at).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + expect((await signIn(r, false)).claimSet.step_up_at).toBeUndefined(); + + const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: stepped.refreshToken } }); + expect(refreshed.statusCode).toBe(201); + expect(refreshed.json().claimSet.step_up_at, "a refresh never renews proof").toBeUndefined(); + }); +}); + +describe("GET /v1/me/identities", () => { + it("is 401 sign_in_required without a live bearer", async () => { + const r = await rig(); + for (const token of [undefined, "not-a-token"]) { + const res = await list(r, token); + expect([res.statusCode, res.json().error]).toEqual([401, "sign_in_required"]); + } + }); + + it("lists the caller's identities and nothing that is a credential, a profile or an email", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]); + await seedAccount(r, [["youtube", "y-other"]]); + const { accessToken } = await signIn(r, false); + const res = await list(r, accessToken); + expect(res.statusCode).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + const body = res.json(); + expect(body.accountId).toBe(account.id); + expect(body.identities).toEqual([ + // The sign-in above refreshed the twitch row from what the provider returned. + { id: rows[0]!.id, provider: "twitch", providerUserId: "t-1", username: "t-1", linkedAt: now, scopes: ["user:read:email"] }, + { id: rows[1]!.id, provider: "discord", providerUserId: "d-9", username: "discord-name", displayName: "discord display", linkedAt: now, scopes: ["identify"] }, + ]); + expect(res.body).not.toMatch(/CIPHERTEXT|PROFILE-CANARY|secret@example|primary_email|primaryEmail/); + }); +}); + +describe("POST /v1/me/identities/:identityId/unlink", () => { + it("is 401 without a live bearer", async () => { + const r = await rig(); + const { rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]); + expect((await unlink(r, rows[1]!.id)).statusCode).toBe(401); + expect((await unlink(r, rows[1]!.id, "garbage")).statusCode).toBe(401); + }); + + it("is 403 step_up_required for a bearer with no proof, and for a refresh-issued one, and removes nothing", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]); + const plain = await signIn(r, false); + const denied = await unlink(r, rows[1]!.id, plain.accessToken); + expect([denied.statusCode, denied.json().error]).toEqual([403, "step_up_required"]); + expect(denied.json().detail).toMatch(/twitch, youtube and google/); + + const stepped = await signIn(r, true); + const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: stepped.refreshToken } }); + const viaRefresh = await unlink(r, rows[1]!.id, refreshed.json().accessToken); + expect([viaRefresh.statusCode, viaRefresh.json().error]).toEqual([403, "step_up_required"]); + expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(2); + }); + + it("is 403 for proof older than the window and for proof dated in the future; 599 s old still passes", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["discord", "d-1"]]); + const issueWith = async (stepUpAt: number) => + (await issueAccessClaim({ + config, + keys: r.keys, + store: r.store, + input: { appSlug: "streampixels", accountId: account.id, linkedIdentities: [], facts: [], stepUpAt }, + })).accessToken; + const nowS = Math.floor(Date.now() / 1000); + for (const at of [nowS - 601, nowS + 120]) { + const res = await unlink(r, rows[1]!.id, await issueWith(at)); + expect([res.statusCode, res.json().error], String(at - nowS)).toEqual([403, "step_up_required"]); + } + expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(3); + const edge = await unlink(r, rows[1]!.id, await issueWith(nowS - 599)); + expect(edge.statusCode).toBe(200); + }); + + it("a step-up sign-in's bearer unlinks: 200 with the remaining list, an audit event without credentials", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]); + const audit = vi.spyOn(r.store, "createAuditEvent"); + const { accessToken } = await signIn(r, true); + const res = await unlink(r, rows[1]!.id, accessToken, null); + expect(res.statusCode).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.json().unlinked).toEqual({ id: rows[1]!.id, provider: "discord", providerUserId: "d-9" }); + expect(res.json().identities.map((i: { id: string }) => i.id)).toEqual([rows[0]!.id]); + expect(res.body).not.toMatch(/CIPHERTEXT|PROFILE-CANARY|secret@example/); + expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(1); + const event = audit.mock.calls.map(([e]) => e).find((e) => e.eventType === "identity_unlinked"); + expect(event?.accountId).toBe(account.id); + expect(event?.eventPayloadJson).toEqual({ provider: "discord", identityId: rows[1]!.id }); + }); + + it("the account's only identity is 409 last_identity and the row stays", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"]]); + const { accessToken } = await signIn(r, true); + const res = await unlink(r, rows[0]!.id, accessToken); + expect([res.statusCode, res.json().error]).toEqual([409, "last_identity"]); + expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(1); + }); + + it("another account's identity and a nonexistent one are the same 404", async () => { + const r = await rig(); + await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]); + const other = await seedAccount(r, [["youtube", "y-other"], ["github", "g-other"]]); + const { accessToken } = await signIn(r, true); + const foreign = await unlink(r, other.rows[0]!.id, accessToken); + const missing = await unlink(r, "no-such-id", accessToken); + expect([foreign.statusCode, foreign.json().error]).toEqual([404, "identity_not_found"]); + expect([missing.statusCode, missing.body]).toEqual([404, foreign.body]); + expect(await r.store.listStoredLinkedIdentitiesForAccount(other.account.id)).toHaveLength(2); + }); + + it("an unlinked identity belongs to no one, and another account can then link it, but not before", async () => { + const r = await rig(); + const mine = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]); + const theirs = await seedAccount(r, [["youtube", "y-other"]]); + const input = { accountId: theirs.account.id, provider: "discord" as const, providerUserId: "d-9", scopes: [], profileJson: {}, createdAt: now, updatedAt: now }; + await expect(r.store.upsertLinkedIdentity(input)).rejects.toBeInstanceOf(IdentityHeldElsewhereError); + const { accessToken } = await signIn(r, true); + expect((await unlink(r, mine.rows[1]!.id, accessToken)).statusCode).toBe(200); + await expect(r.store.upsertLinkedIdentity(input)).resolves.toMatchObject({ accountId: theirs.account.id }); + }); + + it("writes revocation receipts for the provider's live grants when its last identity goes, and not before", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["youtube", "y-2"]]); + const grant = async (provider: Provider) => + (await r.store.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider, createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" })).receipt; + const ytGrant = await grant("youtube"); + const twGrant = await grant("twitch"); + const { accessToken } = await signIn(r, true); + + // A second youtube identity remains: the grant's provider still holds a credential. + expect((await unlink(r, rows[1]!.id, accessToken)).statusCode).toBe(200); + expect((await r.store.listConsentReceipts(account.id)).filter((x) => x.kind === "revocation")).toHaveLength(0); + + // The last youtube identity goes (twitch stays as the sign-in identity): youtube's grant is revoked, twitch's is not. + const audit = vi.spyOn(r.store, "createAuditEvent"); + expect((await unlink(r, rows[2]!.id, accessToken)).statusCode).toBe(200); + const revocations = (await r.store.listConsentReceipts(account.id)).filter((x) => x.kind === "revocation"); + expect(revocations.map((x) => x.kind === "revocation" && x.revokesReceiptId)).toEqual([ytGrant.id]); + expect(revocations.map((x) => x.provider)).toEqual(["youtube"]); + expect(twGrant.id).not.toBe(ytGrant.id); + expect(audit.mock.calls.map(([e]) => e.eventType)).toEqual(expect.arrayContaining(["identity_unlinked", "consent_revoked"])); + }); + + it("proof past the window refuses even a bearer that was fresh when issued", async () => { + const r = await rig(); + const { rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]); + vi.useFakeTimers({ toFake: ["Date"] }); + const { accessToken } = await signIn(r, true); + vi.setSystemTime(Date.now() + 601_000); + const res = await unlink(r, rows[1]!.id, accessToken); + expect([res.statusCode, res.json().error]).toEqual([403, "step_up_required"]); + }); + + it("the proof window's edge: exactly 600 s old still passes, 601 s does not (frozen clock)", async () => { + const r = await rig(); + const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["discord", "d-1"]]); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-11-13T12:00:00.000Z")); + const nowS = Math.floor(Date.now() / 1000); + const issueWith = async (stepUpAt: number) => + (await issueAccessClaim({ config, keys: r.keys, store: r.store, input: { appSlug: "streampixels", accountId: account.id, linkedIdentities: [], facts: [], stepUpAt } })).accessToken; + const tooOld = await unlink(r, rows[1]!.id, await issueWith(nowS - 601)); + expect([tooOld.statusCode, tooOld.json().error]).toEqual([403, "step_up_required"]); + const edge = await unlink(r, rows[1]!.id, await issueWith(nowS - 600)); + expect(edge.statusCode).toBe(200); + }); + + it("a one-identity bearer naming another account's identity gets 404, not 409", async () => { + const r = await rig(); + await seedAccount(r, [["twitch", "t-1"]]); + const other = await seedAccount(r, [["youtube", "y-other"], ["github", "g-other"]]); + const { accessToken } = await signIn(r, true); + const res = await unlink(r, other.rows[0]!.id, accessToken); + expect([res.statusCode, res.json().error]).toEqual([404, "identity_not_found"]); + }); + + it("a step_up state in a non-sign_in mode (forged, signed) yields no step_up_at", async () => { + const r = await rig(); + const { account } = await seedAccount(r, [["twitch", "t-1"]]); + const started = await r.app.inject({ + method: "POST", + url: "/v1/oauth/twitch/start", + headers: spAuth, + payload: { appSlug: "streampixels", mode: "link", initiatingAccountId: account.id, returnTo }, + }); + expect(started.statusCode).toBe(201); + const verified = verifyJwt(stateOf(started.json()), r.keys.publicKey) as { valid: boolean; payload: Record }; + expect(verified.valid).toBe(true); + expect(verified.payload.mode).toBe("link"); + const flow = await launchState(r.app, signJwt({ ...verified.payload, step_up: true }, r.keys)); + 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().stepUpRequested, "the forged flag was read").toBe(true); + const token = called.json().accessToken as string; + expect(token, "the link completion minted an access token to inspect").toBeTruthy(); + expect(decodeJwt(token).payload.step_up_at).toBeUndefined(); + }); +}); diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts index 7982229..4454d4d 100644 --- a/tests/link-binding.test.ts +++ b/tests/link-binding.test.ts @@ -6,7 +6,7 @@ import { buildApp } from "../src/app.js"; import { type HeimdallConfig } from "../src/config.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; -import { InMemoryStore } from "../src/store/index.js"; +import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js"; import { launchFlow } from "./oauth-flow.js"; function createTestConfig(): HeimdallConfig { @@ -166,6 +166,15 @@ describe("link callback holds the provider identity to the initiator", () => { expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id); }); + it("maps a store refusal of the move (a race past the check) to link_initiator_mismatch", async () => { + const r = await rig("d-race"); + vi.spyOn(r.store, "upsertLinkedIdentity").mockRejectedValueOnce(new IdentityHeldElsewhereError()); + const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id }); + const done = await callback(r, started.json()); + expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_mismatch"]); + expect(done.json().completion).toBeUndefined(); + }); + it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => { const dir = mkdtempSync(join(tmpdir(), "heimdall-link-")); try { diff --git a/tests/step-up.test.ts b/tests/step-up.test.ts index 99531e3..6716623 100644 --- a/tests/step-up.test.ts +++ b/tests/step-up.test.ts @@ -150,6 +150,8 @@ describe("the flag is the signed state's, not the redeem side's", () => { const redeemed = await app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: code, stepUp: true } }); expect(redeemed.statusCode).toBe(201); expect(redeemed.json().stepUpRequested).toBe(asked ? true : undefined); + // The proof is the signed state's too: the claim carries step_up_at exactly when the start asked. + expect(typeof redeemed.json().claimSet.step_up_at).toBe(asked ? "number" : "undefined"); } }); }); diff --git a/tests/store.test.ts b/tests/store.test.ts index dc0fbe2..76665cc 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -3,7 +3,7 @@ import { Pool } from "pg"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { newDb } from "pg-mem"; import { type HeimdallConfig } from "../src/config.js"; -import { createStore, InMemoryStore } from "../src/store/index.js"; +import { createStore, IdentityHeldElsewhereError, InMemoryStore, type HeimdallStore } from "../src/store/index.js"; import { createPostgresStore, PostgresStore, @@ -203,6 +203,84 @@ describe("PostgresStore", () => { }); }); +// linked_identities has one writer that can move a row between accounts: none. +// upsert refreshes the holder's own row and refuses another account's; unlink is +// the only delete, and it never takes an account's last identity. +const unlinkStores: Array<[string, () => Promise]> = [ + ["InMemoryStore", async () => new InMemoryStore()], + [ + "PostgresStore on pg-mem", + async () => { + const store = new PostgresStore(new (newDb().adapters.createPg().Pool)()); + await store.ensureSchema(); + return store; + }, + ], +]; +describe.each(unlinkStores)("linked_identities writes, %s", (_name, make) => { + const at = "2026-11-13T12:00:00.000Z"; + const input = (accountId: string, providerUserId: string, extra: Record = {}) => ({ + accountId, + provider: "discord" as const, + providerUserId, + scopes: ["identify"], + profileJson: {}, + createdAt: at, + updatedAt: at, + ...extra, + }); + async function twoAccounts() { + const store = await make(); + const a = await store.createAccount({ createdAt: at, lastSeenAt: at }); + const b = await store.createAccount({ createdAt: at, lastSeenAt: at }); + return { store, a, b }; + } + + // pg-mem has neither ON CONFLICT ... WHERE nor row locks: these two run on real Postgres below. + const realPostgresOnly = _name !== "InMemoryStore"; + + it.skipIf(realPostgresOnly)("upsert on an identity another account holds throws IdentityHeldElsewhereError and leaves the row untouched", async () => { + const { store, a, b } = await twoAccounts(); + const held = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "original", accessTokenEncrypted: "keep" })); + await expect( + store.upsertLinkedIdentity(input(b.id, "u1", { username: "thief", accessTokenEncrypted: "stolen", updatedAt: "2027-01-01T00:00:00.000Z" })) + ).rejects.toBeInstanceOf(IdentityHeldElsewhereError); + expect(await store.findStoredLinkedIdentity("discord", "u1")).toEqual(held); + expect(await store.listStoredLinkedIdentitiesForAccount(b.id)).toEqual([]); + }); + + 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" })); + const again = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "two", updatedAt: "2026-11-14T00:00:00.000Z" })); + expect([again.id, again.accountId, again.username]).toEqual([first.id, a.id, "two"]); + }); + + it("unlink: deletes one of two, refuses the last, reports another account's or a missing id as not_found", async () => { + const { store, a, b } = await twoAccounts(); + const one = await store.upsertLinkedIdentity(input(a.id, "u1")); + const two = await store.upsertLinkedIdentity(input(a.id, "u2")); + const theirs = await store.upsertLinkedIdentity(input(b.id, "u3")); + expect(await store.unlinkLinkedIdentity(a.id, theirs.id)).toEqual({ kind: "not_found" }); + expect(await store.unlinkLinkedIdentity(a.id, "no-such")).toEqual({ kind: "not_found" }); + expect(await store.unlinkLinkedIdentity(b.id, theirs.id)).toEqual({ kind: "last_identity" }); + const done = await store.unlinkLinkedIdentity(a.id, one.id); + expect(done).toMatchObject({ kind: "unlinked", identity: { id: one.id, provider: "discord", providerUserId: "u1" } }); + expect(await store.unlinkLinkedIdentity(a.id, two.id)).toEqual({ kind: "last_identity" }); + expect((await store.listStoredLinkedIdentitiesForAccount(a.id)).map((i) => i.id)).toEqual([two.id]); + expect(await store.findStoredLinkedIdentity("discord", "u3")).toMatchObject({ id: theirs.id, accountId: b.id }); + }); + + it.skipIf(realPostgresOnly)("two concurrent unlinks of an account's last two identities leave exactly one", async () => { + const { store, a } = await twoAccounts(); + const one = await store.upsertLinkedIdentity(input(a.id, "u1")); + const two = await store.upsertLinkedIdentity(input(a.id, "u2")); + const results = await Promise.all([store.unlinkLinkedIdentity(a.id, one.id), store.unlinkLinkedIdentity(a.id, two.id)]); + expect(results.map((r) => r.kind).sort()).toEqual(["last_identity", "unlinked"]); + expect(await store.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1); + }); +}); + describe("consent_receipts foreign key", () => { it("a revocation names a receipt of its own account: the schema says so and the memory store enforces it", async () => { expect(CREATE_SCHEMA_SQL).toContain("FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)"); @@ -496,6 +574,8 @@ async function exerciseStore(store: PostgresStore): Promise> { const linked = await s.upsertLinkedIdentity(identity); await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" })); await s.upsertLinkedIdentity({ ...identity, username: "again" }); + const spare = await s.upsertLinkedIdentity({ ...identity, providerUserId: "u-spare" }); + expect((await s.unlinkLinkedIdentity(account.id, spare.id)).kind).toBe("unlinked"); await s.findAccountByLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentity("discord", "u1"); await s.findStoredLinkedIdentityForAccount(account.id, "discord"); @@ -716,6 +796,41 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD } }); + it("startup refuses a role that lacks DELETE on linked_identities", async () => { + expect(STORE_TABLE_PRIVILEGES.linked_identities).toContain("DELETE"); + const { name, owner } = await appliedDatabase(); + await grant(owner, { ...STORE_TABLE_PRIVILEGES, linked_identities: ["SELECT", "INSERT", "UPDATE"] }); + expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES")); + }); + + 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); + const at = "2026-11-13T12:00:00.000Z"; + const a = await clientA.createAccount({ createdAt: at, lastSeenAt: at }); + const b = await clientA.createAccount({ createdAt: at, lastSeenAt: at }); + const base = { provider: "discord" as const, scopes: [], profileJson: {}, createdAt: at, updatedAt: at }; + const held = await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u1", username: "original" }); + await expect(clientA.upsertLinkedIdentity({ ...base, accountId: b.id, providerUserId: "u1", username: "thief" })).rejects.toBeInstanceOf(IdentityHeldElsewhereError); + expect(await clientA.findStoredLinkedIdentity("discord", "u1")).toEqual(held); + + await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u2" }); + // Two separate pools, as two Heimdall processes would be, race for the last two. + 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 [first, second] = await clientA.listStoredLinkedIdentitiesForAccount(a.id); + expect(first && second, `round ${round} starts with two`).toBeTruthy(); + const results = await Promise.all([clientA.unlinkLinkedIdentity(a.id, first!.id), storeB.unlinkLinkedIdentity(a.id, second!.id)]); + expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["last_identity", "unlinked"]); + expect(await clientA.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1); + await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: `u-next-${round}` }); + } + }); + describe("oauth_state_uses retention", () => { const expMs = Date.parse("2026-04-26T12:10:00.000Z"); const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;