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..a1fe611 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 { @@ -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, @@ -1307,12 +1374,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..9f52acb --- /dev/null +++ b/tests/link-binding.test.ts @@ -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>[] = []; +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>; + +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("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 }; + 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) => { + 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"); + }); +}); 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 },