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 351 additions and 15 deletions
Showing only changes of commit 9f5a0e6938 - Show all commits

Bind link mode to its initiator and redeem to its target

Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Heimdall 2026-11-02 13:38:51 +00:00

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 {
@ -511,6 +527,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 +608,7 @@ export async function startOAuthFlow(
mode: input.mode,
returnTo: input.returnTo,
connection: input.connection,
initiatorAccountId: input.initiatingAccountId,
handoff,
entitlementPolicy,
keys,
@ -822,6 +857,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
summary: { type: "string" },
},
},
initiatingAccountId: { type: "string", minLength: 1 },
handoff: {
type: "object",
required: ["kind"],
@ -1035,6 +1071,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 +1106,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 +1302,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 +1364,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 +1396,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 +1413,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 +1449,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 () => {

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

@ -0,0 +1,210 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } 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 () => {
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("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("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 },