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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:33:16 +01:00
1 parent 69c5c65376
commit e6c709876d
4 files changed
+53 -2

No files matched your search

@@ -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<ReturnType<typeof prisma.apiToken.findUnique>>);
}
@@ -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(
+9 -1
View File
@@ -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");
}