Bind link mode to its initiator and redeem to its target #1

Open
heimdall wants to merge 3 commits from heimdall/link-initiator-target-binding into main
11 changed files with 406 additions and 15 deletions

View file

@ -334,6 +334,11 @@ Request body:
}
```
Binding rules:
- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed.
- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator.
Response:
```json

View file

@ -91,6 +91,7 @@ function buildOAuthStateToken(options: {
mode: OAuthMode;
returnTo: string;
connection: OAuthConnectionBinding | undefined;
initiatorAccountId: string | undefined;
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
@ -111,6 +112,7 @@ function buildOAuthStateToken(options: {
mode: options.mode,
return_to: options.returnTo,
connection: options.connection ?? null,
initiator_account_id: options.initiatorAccountId ?? null,
handoff: options.handoff,
entitlement_policy: options.entitlementPolicy ?? null,
};
@ -168,7 +170,8 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" ||
!handoffIsValid ||
!entitlementPolicyIsValid
!entitlementPolicyIsValid ||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string")
) {
return null;
}
@ -177,6 +180,9 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
if (!normalizedPayload.handoff) {
normalizedPayload.handoff = { kind: "browser_completion" };
}
if (!normalizedPayload.initiator_account_id) {
normalizedPayload.initiator_account_id = null;
}
if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null;
}
@ -253,6 +259,16 @@ function relayedProviderText(value: string | undefined, bound: number): string |
* code, each only when it is a short identifier. Never its message, which
* carries whatever the thrower quoted (a host, a database, a provider's body).
*/
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
class LinkRefusal extends Error {
constructor(
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown",
message: string
) {
super(message);
}
}
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
const code = (error as { code?: unknown } | null)?.code;
return {
@ -501,6 +517,16 @@ export async function startOAuthFlow(
};
}
// A connection target is app-backend authority: it rides only on connect
// and link starts, which are the flows gated by x-heimdall-app-secret.
// A sign_in start is open to any browser and must not carry one.
if (input.mode === "sign_in" && input.connection !== undefined) {
return {
statusCode: 400,
body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." },
};
}
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
return {
statusCode: 400,
@ -511,6 +537,24 @@ export async function startOAuthFlow(
};
}
// Link mode binds an identity to an existing account, so the account must
// be named by someone Heimdall can believe: the authenticated app backend.
// The name rides in the signed state and the callback holds the provider's
// identity to it. No other mode carries an initiator.
if (input.mode === "link") {
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
if (!input.initiatingAccountId) {
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
}
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
}
} else if (input.initiatingAccountId !== undefined) {
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
}
if (!isAllowedReturnOrigin(profile, input.returnTo)) {
return {
statusCode: 400,
@ -574,6 +618,7 @@ export async function startOAuthFlow(
mode: input.mode,
returnTo: input.returnTo,
connection: input.connection,
initiatorAccountId: input.initiatingAccountId,
handoff,
entitlementPolicy,
keys,
@ -822,6 +867,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
summary: { type: "string" },
},
},
initiatingAccountId: { type: "string", minLength: 1 },
handoff: {
type: "object",
required: ["kind"],
@ -1035,6 +1081,25 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
const nowIso = new Date().toISOString();
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
// Link mode attaches the provider identity to the account the app
// named at start, and to no other: an identity already held by
// another account is a refusal, never a quiet sign-in as that account.
const linkInitiatorId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
if (statePayload.mode === "link") {
if (!linkInitiatorId) {
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
}
if (account && account.id !== linkInitiatorId) {
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
}
if (!account) {
account = await store.findAccountById(linkInitiatorId);
if (!account) {
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
}
}
}
if (!account) {
const createAccountInput: CreateAccountInput = {
createdAt: nowIso,
@ -1051,10 +1116,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
} else {
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
// A link adds an identity; it does not rewrite the account's profile.
if (displayName !== undefined && statePayload.mode !== "link") {
accountUpdates.displayName = displayName;
}
if (identity.primaryEmail !== undefined) {
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
accountUpdates.primaryEmail = identity.primaryEmail;
}
await store.touchAccount(account.id, nowIso, accountUpdates);
@ -1246,6 +1312,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
accountId: account.id,
sessionId: issued.session.sessionId,
returnTo: statePayload.return_to,
...(statePayload.connection ? { targetId: statePayload.connection.targetId } : {}),
createdAt: nowIso,
expiresAt: completionExpiresAt,
payloadJson: completionPayload as unknown as Record<string, unknown>,
@ -1307,12 +1374,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// JSON.parse quotes the provider's response body. The audit event
// keeps the error's class, its code, and a provider's HTTP status.
const status = (error as { status?: unknown }).status;
const failure = "The provider callback could not be completed.";
// A link refusal is a typed decision with a static message; every
// other failure keeps the opaque answer above.
const refusal = error instanceof LinkRefusal ? error : null;
const failureCode = refusal?.code ?? "oauth_callback_failed";
const failure = refusal?.message ?? "The provider callback could not be completed.";
const nowIso = new Date().toISOString();
await denyBrowserAttempt("oauth_callback_failed");
await denyBrowserAttempt(failureCode);
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: "oauth_callback_failed",
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
@ -1335,7 +1406,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "oauth_callback_failed",
error: failureCode,
errorDescription: failure,
});
} catch {}
@ -1352,15 +1423,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "oauth_callback_failed",
error: failureCode,
errorDescription: failure,
})
);
}
reply.code(502);
reply.code(refusal ? 403 : 502);
return {
error: "oauth_callback_failed",
error: failureCode,
detail: failure,
provider: request.params.provider,
appSlug: statePayload.app_slug,
@ -1388,12 +1459,38 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
additionalProperties: false,
properties: {
completionCode: { type: "string", minLength: 1 },
targetId: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const nowIso = new Date().toISOString();
// Sign-in and connect completions are redeemed by the browser flow, so
// the route stays open to it. A link completion was minted for an
// initiator the authenticated app named at start; only that app's
// backend redeems it. Redeem is also where the connection target is
// held to what the signed state named. Both refusals read the row
// without consuming it, so a wrong caller or target cannot burn the code.
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
await store.createAuditEvent({
accountId: pending.accountId,
sessionId: pending.sessionId,
appSlug: pending.appSlug,
eventType: "auth_completion_target_refused",
eventPayloadJson: { provider: pending.provider, mode: pending.mode },
createdAt: nowIso,
});
reply.code(403);
return { error: "completion_target_mismatch" };
}
}
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
if (!completion) {

View file

@ -73,6 +73,8 @@ export interface OAuthStartRequest {
mode: OAuthMode;
returnTo: string;
connection?: OAuthConnectionBinding;
/** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */
initiatingAccountId?: string;
handoff?: OAuthHandoff;
requestedScopes?: string[];
entitlementPolicy?: OAuthEntitlementPolicy;
@ -92,6 +94,7 @@ export interface OAuthStatePayload {
mode: OAuthMode;
return_to: string;
connection: OAuthConnectionBinding | null;
initiator_account_id: string | null;
handoff: OAuthHandoff | null;
entitlement_policy: OAuthEntitlementPolicy | null;
}
@ -105,6 +108,8 @@ export interface LinkedIdentityInput {
export interface RedeemAuthCompletionRequest {
completionCode: string;
/** Required when the completion was minted for a connection target; must equal it. */
targetId?: string;
}
export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired";

View file

@ -390,6 +390,7 @@ export class InMemoryStore implements HeimdallStore {
accountId: input.accountId,
sessionId: input.sessionId,
returnTo: input.returnTo,
...(input.targetId ? { targetId: input.targetId } : {}),
createdAt: input.createdAt,
expiresAt: input.expiresAt,
payloadJson: clone(input.payloadJson),
@ -402,6 +403,11 @@ export class InMemoryStore implements HeimdallStore {
return clone(completion);
}
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
const completion = this.authCompletions.get(code);
return completion && completion.appSlug === appSlug ? clone(completion) : null;
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const completion = this.authCompletions.get(code);
if (!completion) {

View file

@ -82,6 +82,7 @@ interface AuthCompletionRow extends QueryResultRow {
account_id: string;
session_id: string;
return_to: string;
target_id: string | null;
payload_json: Record<string, unknown>;
created_at: string | Date;
expires_at: string | Date;
@ -249,6 +250,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
completion.attemptId = row.attempt_id;
}
if (row.target_id) {
completion.targetId = row.target_id;
}
if (row.consumed_at) {
completion.consumedAt = normalizeTimestamp(row.consumed_at);
}
@ -1004,9 +1009,9 @@ export class PostgresStore implements HeimdallStore {
`
INSERT INTO auth_completions (
code, attempt_id, app_slug, provider, mode, account_id, session_id,
return_to, payload_json, created_at, expires_at
return_to, target_id, payload_json, created_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
`,
[
@ -1018,6 +1023,7 @@ export class PostgresStore implements HeimdallStore {
input.accountId,
input.sessionId,
input.returnTo,
input.targetId ?? null,
JSON.stringify(input.payloadJson),
input.createdAt,
input.expiresAt,
@ -1027,6 +1033,14 @@ export class PostgresStore implements HeimdallStore {
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
}
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
"SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2",
[code, appSlug]
);
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null;
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
`

View file

@ -106,6 +106,10 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
-- redeem the completion it created without ever needing the code.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
-- The connection target the signed state named (connection.targetId). Redeem
-- compares it against the target the redeeming app states.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
-- second completion for the same handle would let the by-attempt UPDATE in
-- consumeAuthCompletionByAttempt match more than one row, silently consuming

View file

@ -159,6 +159,8 @@ export interface StoredAuthCompletion {
accountId: string;
sessionId: string;
returnTo: string;
/** The connection target the signed state named, if any; compared at redeem. */
targetId?: string;
createdAt: string;
expiresAt: string;
consumedAt?: string;
@ -171,6 +173,7 @@ export interface CreateAuthCompletionInput {
* always mints `code` itself; a caller cannot choose it.
*/
attemptId?: string;
targetId?: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
@ -263,6 +266,8 @@ export interface HeimdallStore {
createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt>;
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
/** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */
findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null>;
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
/**
* Consume a completion by its correlated attempt handle instead of its

View file

@ -363,7 +363,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => {
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
}
}
expect(consumers).toEqual(["app.ts: reads=2 guards=2"]);
expect(consumers).toEqual(["app.ts: reads=3 guards=3"]);
});
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {

255
tests/link-binding.test.ts Normal file
View file

@ -0,0 +1,255 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
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";
function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
},
};
}
// Link mode binds a provider identity to the account an authenticated app
// named at start. These tests pin the three places that binding is decided:
// the callback (initiator), the completion row (target), and redeem (caller,
// target, replay).
const returnTo = "https://repixelizer.gamecult.org/app/";
const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" };
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
vi.useRealTimers();
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) {
const store = new InMemoryStore();
const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } });
apps.push(app);
const now = new Date().toISOString();
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
return { app, store, initiator };
}
type Rig = Awaited<ReturnType<typeof rig>>;
function start(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = appAuth) {
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
}
function callback(r: Rig, stateToken: string) {
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
}
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload });
}
describe("link start names its initiator", () => {
it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => {
const r = await rig("d-1");
const mode = { mode: "link", initiatingAccountId: r.initiator.id };
expect((await start(r, mode, {})).statusCode).toBe(401);
expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
const missing = await start(r, { mode: "link" });
expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]);
const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" });
expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]);
const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {});
expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]);
});
});
describe("sign_in start carries no connection target", () => {
it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => {
const r = await rig("d-si");
const connection = { kind: "creator", targetId: "victim-target" };
for (const headers of [{}, appAuth]) {
const res = await start(r, { mode: "sign_in", connection }, headers);
expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]);
}
expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201);
});
});
describe("link callback holds the provider identity to the initiator", () => {
it("attaches an unlinked identity to the initiating account, not a new one", async () => {
const r = await rig("d-new");
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
expect(started.statusCode).toBe(201);
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(r.initiator.id);
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
});
it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => {
const r = await rig("d-held");
const now = new Date().toISOString();
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
expect(done.body).not.toContain("accessToken");
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
});
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
try {
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
const r = await rig("d-forged", config);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
const done = await callback(r, stripped);
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("redeem binds caller, target and replay", () => {
async function linked(r: Rig, targetId: string | undefined) {
const started = await start(r, {
mode: "link",
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}
it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => {
const r = await rig("d-r1");
const completionCode = await linked(r, undefined);
const anonymous = await redeem(r, { completionCode });
expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]);
expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
});
it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => {
const r = await rig("d-r2");
const completionCode = await linked(r, "target-a");
for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) {
const refused = await redeem(r, payload, appAuth);
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
expect(refused.json().accessToken).toBeUndefined();
}
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
});
it("refuses a replayed completion", async () => {
const r = await rig("d-r3");
const completionCode = await linked(r, "target-a");
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410);
});
it("refuses a completion at and after its expiry with 410, whatever the caller and target, and honours it just before", async () => {
// completionTtlSeconds is 300. Only Date is faked: the app and store run on real timers.
vi.useFakeTimers({ toFake: ["Date"] });
const t0 = Date.parse("2026-11-03T00:00:00.000Z");
const ttlMs = createTestConfig().completionTtlSeconds * 1000;
const expired = { statusCode: 410, error: "invalid_or_expired_completion_code" };
const outcome = async (r: Rig, payload: Record<string, unknown>) => {
const res = await redeem(r, payload, appAuth);
return { statusCode: res.statusCode, error: res.json().error };
};
vi.setSystemTime(t0);
const r = await rig("d-exp");
const code = await linked(r, "target-a");
const row = await r.store.findAuthCompletion("repixelizer", code);
expect(row?.expiresAt).toBe(new Date(t0 + ttlMs).toISOString());
// Just before expiry the right caller and target redeem it.
const early = await rig("d-exp-early");
const earlyCode = await linked(early, "target-a");
vi.setSystemTime(t0 + ttlMs - 1);
expect((await redeem(early, { completionCode: earlyCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
// At the expiry instant and after it: 410, for the right target and for a wrong one (not 403), unconsumed.
for (const at of [t0 + ttlMs, t0 + ttlMs + 1]) {
vi.setSystemTime(at);
expect(await outcome(r, { completionCode: code, targetId: "target-a" })).toEqual(expired);
expect(await outcome(r, { completionCode: code, targetId: "target-b" })).toEqual(expired);
}
expect((await r.store.findAuthCompletion("repixelizer", code))?.consumedAt).toBeUndefined();
});
it("stores the target on the completion row", async () => {
const r = await rig("d-r4");
const completionCode = await linked(r, "target-a");
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a");
});
});

View file

@ -589,7 +589,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
const redeem = await app.inject({
method: "POST",
url: "/v1/apps/streampixels/auth-completions/redeem",
payload: { completionCode: callback.json().completion.code as string },
payload: { completionCode: callback.json().completion.code as string, ...(mode === "connect" ? { targetId: "creator-1" } : {}) },
});
expect(redeem.statusCode).toBe(201);
return { callback, redeem };

View file

@ -237,7 +237,7 @@ describe("PostgresStore.checkSchema", () => {
"entitlement_snapshots",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(85);
expect(REQUIRED_COLUMNS).toHaveLength(86);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },