Member identity list and step-up-gated unlink; a link never moves an identity #14

Merged
heimdall merged 5 commits from heimdall/identity-unlink into main 2026-11-13 19:22:35 +00:00
14 changed files with 811 additions and 49 deletions

View file

@ -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

View file

@ -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:

View file

@ -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<string, unknown>;
}
/**
* 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<HeimdallRuntimeContext, "config" | "keys" | "store">,
appSlug: AppSlug,
authorization: string | undefined
): Promise<string | null> {
authorization: string | undefined,
appSlug?: AppSlug
): Promise<ResolvedBearer | null> {
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<HeimdallRuntimeContext, "config" | "keys" | "store">,
appSlug: AppSlug,
authorization: string | undefined
): Promise<string | null> {
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<HeimdallRuntimeContext, "config" | "keys" | "store">,
authorization: string | undefined
): Promise<string | null> {
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<FastifyIn
await store.touchAccount(account.id, nowIso, accountUpdates);
}
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
try {
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) {
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
}
throw error;
}
// Heimdall now holds a credential the person was asked about: the
// receipt is the record of that agreement, written with the credential.
@ -1904,6 +1924,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
...(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() } : {}),
};
if (account.displayName !== undefined) {
issueInput.displayName = account.displayName;
@ -2214,7 +2236,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
reply.code(409);
return { error: "identity_linked_elsewhere" };
}
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
try {
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
} catch (error) {
// The holder check above is the fast path; a second account may win the race to the identity.
if (!(error instanceof IdentityHeldElsewhereError)) throw error;
reply.code(409);
return { error: "identity_linked_elsewhere" };
}
await store.createAuditEvent({
accountId,
appSlug: pending.appSlug,
@ -2669,6 +2698,109 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "sign_in_required", detail: "Send a Heimdall access token as a Bearer token, or open a link from POST /v1/me/grants/link." };
};
/**
* Writes a revocation receipt for each live grant the account holds on
* `provider` (whichever app asked: the credential is one row per account and
* provider), and the audit event naming them. The credential is gone or
* stripped first, so a failure here leaves a receipt that still reads active
* over a credential that is gone, never the reverse. Returns the grants this
* call revoked.
*/
const revokeLiveGrants = async (accountId: string, provider: Provider, receipts: Parameters<typeof grantViews>[0], at: string): Promise<string[]> => {
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<FastifyIn
if (identity) {
await store.updateLinkedIdentityCredential(identity.id, async () => ({ 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");

View file

@ -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;
}

View file

@ -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;

View file

@ -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<UnlinkResult> {
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<CredentialDecision>,

View file

@ -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<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE" | "DELETE")[]>> = {
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<UnlinkResult> {
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<LinkedIdentityRow>(
"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(

View file

@ -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<StoredLinkedIdentity | null>;
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>;
/** Writes the holder's own row; throws IdentityHeldElsewhereError rather than move another account's identity. */
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
/**
* 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<UnlinkResult>;
/**
* 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

@ -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."}

View file

@ -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();

View file

@ -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<ReturnType<typeof buildApp>>[] = [];
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<ReturnType<typeof rig>>;
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<string, unknown> };
}
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<string, unknown> };
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();
});
});

View file

@ -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 {

View file

@ -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");
}
});
});

View file

@ -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<HeimdallStore>]> = [
["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<string, unknown> = {}) => ({
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<Set<string>> {
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;