Member identity list and step-up-gated unlink; a link never moves an identity #14
14 changed files with 811 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
195
src/app.ts
195
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<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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
330
tests/identity-unlink.test.ts
Normal file
330
tests/identity-unlink.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue