Redeem refuses a target the completion did not record #10

Merged
heimdall merged 2 commits from heimdall/redeem-refuse-unexpected-target into main 2026-11-09 12:13:54 +00:00
2 changed files with 12 additions and 2 deletions

View file

@ -2220,7 +2220,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// 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
// held to what the signed state named, both ways: a target the state never named is refused too. 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) {
@ -2228,7 +2228,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
reply.code(401);
return { error: "app_auth_required" };
}
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
if (pending.targetId !== request.body.targetId) {
await store.createAuditEvent({
accountId: pending.accountId,
sessionId: pending.sessionId,

View file

@ -218,6 +218,16 @@ describe("redeem binds caller, target and replay", () => {
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
});
it("refuses a target named against a completion that recorded none, without consuming the code", async () => {
const r = await rig("d-r2b");
const completionCode = await linked(r, undefined);
const refused = await redeem(r, { completionCode, targetId: "target-a" }, appAuth);
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
expect(refused.json().accessToken).toBeUndefined();
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.consumedAt).toBeUndefined();
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
});
it("refuses a replayed completion", async () => {
const r = await rig("d-r3");
const completionCode = await linked(r, "target-a");