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");
}
+12
View File
@@ -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");
+9 -1
View File
@@ -558,11 +558,19 @@ export async function defaultUserIdResolver(): Promise<string | null> {
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;
}