From 9f5a0e6938fdf6a070ef7844fd18e5a665ce71e9 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 2 Nov 2026 13:38:51 +0000 Subject: [PATCH 1/3] 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 --- docs/service-contract.md | 5 + src/app.ts | 107 ++++++++- src/contracts.ts | 5 + src/store/in-memory.ts | 6 + src/store/postgres.ts | 18 +- src/store/schema.ts | 4 + src/store/types.ts | 5 + tests/caller-identity-attack-soul-r21.test.ts | 2 +- tests/link-binding.test.ts | 210 ++++++++++++++++++ tests/managed-credential.test.ts | 2 +- tests/store.test.ts | 2 +- 11 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 tests/link-binding.test.ts diff --git a/docs/service-contract.md b/docs/service-contract.md index 1567ae5..e356ae2 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -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 diff --git a/src/app.ts b/src/app.ts index 40ceb20..3079694 100644 --- a/src/app.ts +++ b/src/app.ts @@ -91,6 +91,7 @@ function buildOAuthStateToken(options: { mode: OAuthMode; returnTo: string; connection: OAuthConnectionBinding | undefined; + initiatorAccountId: string | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; keys: ReturnType; @@ -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): 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): 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, @@ -1307,12 +1364,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { 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) { diff --git a/src/contracts.ts b/src/contracts.ts index d6cab2a..718523b 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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"; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 6a53dad..b89e7eb 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -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 { + const completion = this.authCompletions.get(code); + return completion && completion.appSlug === appSlug ? clone(completion) : null; + } + async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise { const completion = this.authCompletions.get(code); if (!completion) { diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 487f375..a1224dd 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -82,6 +82,7 @@ interface AuthCompletionRow extends QueryResultRow { account_id: string; session_id: string; return_to: string; + target_id: string | null; payload_json: Record; 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 { + const result = await this.pool.query( + "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 { const result = await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 420f5e6..3433ef8 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -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 diff --git a/src/store/types.ts b/src/store/types.ts index 5307e1f..f5b9873 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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; findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise; createAuthCompletion(input: CreateAuthCompletionInput): Promise; + /** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */ + findAuthCompletion(appSlug: AppSlug, code: string): Promise; consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise; /** * Consume a completion by its correlated attempt handle instead of its diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index de959a8..60cf9a6 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -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 () => { diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts new file mode 100644 index 0000000..1d37e72 --- /dev/null +++ b/tests/link-binding.test.ts @@ -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>[] = []; +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>; + +function start(r: Rig, payload: Record, headers: Record = 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, headers: Record = {}) { + 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 }; + 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"); + }); +}); diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 50ca9ba..5238da2 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -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 }; diff --git a/tests/store.test.ts b/tests/store.test.ts index c5f6445..7f892c8 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -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 }, -- 2.47.3 From 0f2e294fcb093a0576bedda6d850a7b719bce359 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 3 Nov 2026 09:50:34 +0000 Subject: [PATCH 2/3] Pin completion expiry at redeem: 410 at and after expiresAt, for any caller and target Co-Authored-By: Claude Sonnet 5.5 --- tests/link-binding.test.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts index 1d37e72..49f6662 100644 --- a/tests/link-binding.test.ts +++ b/tests/link-binding.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +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"; @@ -78,6 +78,7 @@ function discordRuntime(providerUserId: string): OAuthProviderRuntime { const apps: Awaited>[] = []; afterEach(async () => { + vi.useRealTimers(); await Promise.all(apps.splice(0).map((app) => app.close())); }); @@ -202,6 +203,38 @@ describe("redeem binds caller, target and replay", () => { 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) => { + 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"); -- 2.47.3 From 84c54fbb5ef00136fa99368d28fcb9044cf2ca21 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Tue, 3 Nov 2026 13:30:22 +0000 Subject: [PATCH 3/3] Refuse a connection target on sign_in start Connection targets ride only on connect and link starts, which carry x-heimdall-app-secret. A sign_in start is open to any browser; with a connection it now returns 400 connection_not_allowed_in_sign_in. Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 10 ++++++++++ tests/link-binding.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/app.ts b/src/app.ts index 3079694..a1fe611 100644 --- a/src/app.ts +++ b/src/app.ts @@ -517,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, diff --git a/tests/link-binding.test.ts b/tests/link-binding.test.ts index 49f6662..9f52acb 100644 --- a/tests/link-binding.test.ts +++ b/tests/link-binding.test.ts @@ -120,6 +120,18 @@ describe("link start names its initiator", () => { }); }); +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"); -- 2.47.3