mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user