From 09f088e8a76b52ca9224a5bc5c67ba12eedc8971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:31:51 +0200 Subject: [PATCH] fix(auth): record a self sign-out as one in the session revoke audit DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC of the row id, and compared that handle with the caller's row id to decide whether the caller signed out the session it was using. The two never match, so every revoke was audited and annotated with self: false, including the ones where a person ended their own session. The handle is compared with the handle of the caller's session now. --- src/app/api/auth/me/sessions/[id]/route.ts | 12 +++++++++--- .../routes/auth-me-sessions.test.ts | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/app/api/auth/me/sessions/[id]/route.ts b/src/app/api/auth/me/sessions/[id]/route.ts index 4a98aed9d..5b9dfd040 100644 --- a/src/app/api/auth/me/sessions/[id]/route.ts +++ b/src/app/api/auth/me/sessions/[id]/route.ts @@ -11,7 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { destroySessionById } from "@/lib/auth/session"; +import { destroySessionById, sessionHandle } from "@/lib/auth/session"; export const dynamic = "force-dynamic"; @@ -25,15 +25,21 @@ export const DELETE = apiHandler( return apiError("Session not found", 404); } + // The path carries the public handle, never the row id, so "is this the + // caller's own session" compares handles. A Bearer caller's `session.id` + // is a token id, whose handle matches no session, which is the right + // answer for it too. + const self = id === sessionHandle(session.id); + await auditLog("auth.session.revoke", { userId: user.id, ipAddress: getClientIp(request), - details: { self: id === session.id }, + details: { self }, }); annotate({ action: { name: "auth.session.revoke" }, - meta: { self: id === session.id }, + meta: { self }, }); return apiSuccess({ revoked: true }); diff --git a/tests/integration/routes/auth-me-sessions.test.ts b/tests/integration/routes/auth-me-sessions.test.ts index 2113336b7..c04f0ebff 100644 --- a/tests/integration/routes/auth-me-sessions.test.ts +++ b/tests/integration/routes/auth-me-sessions.test.ts @@ -449,11 +449,12 @@ describe("DELETE /api/auth/me/sessions/{id}", () => { ).toBeNull(); expect((await listSessions(laptop)).status).toBe(401); expect((await listSessions(current)).status).toBe(200); - expect( - await prisma.auditLog.count({ - where: { userId: owner.id, action: "auth.session.revoke" }, - }), - ).toBe(1); + const audits = await prisma.auditLog.findMany({ + where: { userId: owner.id, action: "auth.session.revoke" }, + }); + expect(audits.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([ + { self: false }, + ]); }); it("revokes the caller's own session when it names its own handle", async () => { @@ -469,6 +470,13 @@ describe("DELETE /api/auth/me/sessions/{id}", () => { expect(res.status).toBe(200); expect((await listSessions(current)).status).toBe(401); + // The audit row says the person signed out the session they were using. + const audits = await prisma.auditLog.findMany({ + where: { userId: owner.id, action: "auth.session.revoke" }, + }); + expect(audits.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([ + { self: true }, + ]); }); it("answers 404 for another account's session and leaves it signed in", async () => {