From e6c709876dfd12356855c551ffefff3fca11621e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:33:16 +0100 Subject: [PATCH] fix(auth): the ingest and idempotency Bearer resolvers refuse a pending account Both resolve a Bearer token outside resolveBearerToken and checked only revoked and expiry. The deletion revokes every token in its own transaction, so only a token issued in that same instant could get through, but the rule should hold everywhere a token is resolved: both now refuse an account pending deletion. --- .../ingest/medication/__tests__/route.test.ts | 23 +++++++++++++++++++ src/app/api/ingest/medication/route.ts | 10 +++++++- src/lib/__tests__/idempotency.test.ts | 12 ++++++++++ src/lib/idempotency.ts | 10 +++++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 1170ba482..c1fa13ee5 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -98,6 +98,7 @@ beforeEach(() => { revoked: false, expiresAt: null, lastUsedAt: null, + user: { deletionRequestedAt: null }, } as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); }); @@ -209,6 +210,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. revoked: false, expiresAt: null, lastUsedAt: null, + user: { deletionRequestedAt: null }, } as never); vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -373,6 +375,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. revoked: false, expiresAt: null, lastUsedAt: null, + user: { deletionRequestedAt: null }, } as Awaited>); } @@ -536,6 +539,7 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { revoked: false, expiresAt: null, lastUsedAt: null, + user: { deletionRequestedAt: null }, } as never) .mockResolvedValueOnce({ id: "tok-button", @@ -544,6 +548,7 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { revoked: false, expiresAt: null, lastUsedAt: null, + user: { deletionRequestedAt: null }, } as never); await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" })); @@ -600,6 +605,24 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled(); }); + // The deletion revokes every token in its own transaction; one issued in + // the same instant would slip past that, and is refused here as well. + it("refuses the token of an account pending deletion", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "tok-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + lastUsedAt: null, + user: { deletionRequestedAt: new Date() }, + } as never); + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-pending-001" }), + ); + expect(res.status).toBe(401); + }); + it("falls back to the per-IP bucket when the token does not resolve", async () => { vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never); const res = await POST( diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index fafd5f3c3..7d0076d1b 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -103,10 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => { revoked: true, expiresAt: true, lastUsedAt: true, + user: { select: { deletionRequestedAt: true } }, }, }); - if (!apiToken || apiToken.revoked) { + // A deletion revokes the account's tokens in its own transaction; a token + // issued in the same instant would slip past that, so the pending account + // is refused here too, as `resolveBearerToken` does. + if ( + !apiToken || + apiToken.revoked || + apiToken.user.deletionRequestedAt !== null + ) { return refuseUnauthenticated(request, "Invalid or revoked token"); } diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 089047d44..f325a6c8e 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -426,6 +426,18 @@ describe("defaultUserIdResolver (audit C-4)", () => { expect(await defaultUserIdResolver()).toBeNull(); }); + it("rejects the Bearer token of an account pending deletion", async () => { + vi.mocked(getSession).mockResolvedValue(null); + mockHeader("Bearer hlk_abcdef"); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + userId: "u-bearer", + revoked: false, + expiresAt: null, + user: { deletionRequestedAt: new Date() }, + } as never); + expect(await defaultUserIdResolver()).toBeNull(); + }); + it("rejects expired Bearer tokens", async () => { vi.mocked(getSession).mockResolvedValue(null); mockHeader("Bearer hlk_abcdef"); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 278f76875..3429c4210 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -558,11 +558,19 @@ export async function defaultUserIdResolver(): Promise { const apiToken = await prisma.apiToken .findUnique({ where: { tokenHash: tokenHashValue }, - select: { userId: true, revoked: true, expiresAt: true }, + select: { + userId: true, + revoked: true, + expiresAt: true, + user: { select: { deletionRequestedAt: true } }, + }, }) .catch(() => null); + // An account pending deletion replays nothing, as `resolveBearerToken` + // refuses it. if (!apiToken || apiToken.revoked) return null; + if (apiToken.user?.deletionRequestedAt) return null; if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null; return apiToken.userId; }