fix(devices): wrap revoke cascade in a single transaction

Both DELETE /api/devices/[id] and DELETE /api/auth/me/devices/[id]
performed the same four-write cascade (refresh-token lookup, refresh
revoke, access-token revoke, device delete) as separate Prisma calls.
A partial failure between the third and fourth write left the device
row alive with all its tokens revoked - the user's iPad showed in the
device list as "still registered" but every request returned 401.

Extract the cascade into a shared revokeDeviceCascade() helper and
wrap the writes in prisma.$transaction so they commit atomically.
Both routes now call the helper; the audit-log via tag and the wide
event action name stay route-local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 02:27:47 +02:00
1 parent dfffe6bb3b
commit d25e50eaf3
6 files changed
+281 -80

No files matched your search

@@ -14,6 +14,10 @@ vi.mock("@/lib/db", () => ({
apiToken: {
updateMany: vi.fn(),
},
// v1.4.23 W6 — the cascade now runs through `prisma.$transaction`
// (HIGH 6 fix). The mock returns the resolved batch payload so the
// route handler walks past the transaction step.
$transaction: vi.fn().mockResolvedValue([] as never),
},
}));
@@ -58,6 +62,7 @@ beforeEach(() => {
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
});
describe("DELETE /api/auth/me/devices/[id]", () => {
+18 -40
View File
@@ -16,13 +16,18 @@
* the session lives on `Session.id`, not `Device.id`. Browser logouts
* keep using /api/auth/logout. The two surfaces address different
* blast radii.
*
* v1.4.23 W6 — the four-write cascade now runs through the shared
* `revokeDeviceCascade` helper so the writes commit atomically inside
* a single `prisma.$transaction`. Previously a partial failure could
* leave the device row alive with all its tokens revoked.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { revokeDeviceCascade } from "@/lib/devices/revoke";
interface RouteContext {
params: Promise<{ id: string }>;
@@ -33,62 +38,35 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
const device = await prisma.device.findUnique({
where: { id },
select: { id: true, userId: true, model: true, bundleId: true },
});
if (!device || device.userId !== user.id) {
const result = await revokeDeviceCascade(user.id, id);
if (!result) {
// 404 on cross-user attempts — leaking "this id exists but isn't
// yours" would let an attacker enumerate device ids.
return apiError("Device not found", 404);
}
// Revoke every refresh token bound to this device + its paired
// access tokens.
const liveRefreshTokens = await prisma.refreshToken.findMany({
where: { userId: user.id, deviceId: device.id, revokedAt: null },
select: { accessTokenHash: true },
});
const revokedAt = new Date();
await prisma.refreshToken.updateMany({
where: { userId: user.id, deviceId: device.id, revokedAt: null },
data: { revokedAt },
});
const accessHashes = liveRefreshTokens
.map((r) => r.accessTokenHash)
.filter((v): v is string => Boolean(v));
if (accessHashes.length > 0) {
await prisma.apiToken.updateMany({
where: { tokenHash: { in: accessHashes }, revoked: false },
data: { revoked: true },
});
}
await prisma.device.delete({ where: { id: device.id } });
await auditLog("devices.revoke", {
userId: user.id,
details: {
deviceId: device.id,
label: device.model ?? device.bundleId ?? null,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
deviceId: result.id,
label: result.label,
refreshTokensRevoked: result.refreshTokensRevoked,
accessTokensRevoked: result.accessTokensRevoked,
},
});
annotate({
action: { name: "auth.me.devices.revoke", entity_type: "device", entity_id: device.id },
action: { name: "auth.me.devices.revoke", entity_type: "device", entity_id: result.id },
meta: {
refresh_tokens_revoked: liveRefreshTokens.length,
access_tokens_revoked: accessHashes.length,
refresh_tokens_revoked: result.refreshTokensRevoked,
access_tokens_revoked: result.accessTokensRevoked,
},
});
return apiSuccess({
id: device.id,
id: result.id,
revoked: true,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
refreshTokensRevoked: result.refreshTokensRevoked,
accessTokensRevoked: result.accessTokensRevoked,
});
},
);
@@ -14,6 +14,10 @@ vi.mock("@/lib/db", () => ({
apiToken: {
updateMany: vi.fn(),
},
// v1.4.23 W6 — the cascade now runs through `prisma.$transaction`
// (HIGH 6 fix). The mock returns the resolved batch payload so the
// route handler walks past the transaction step.
$transaction: vi.fn().mockResolvedValue([] as never),
},
}));
@@ -58,6 +62,7 @@ beforeEach(() => {
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
});
describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
+17 -40
View File
@@ -9,15 +9,17 @@
* The duplication is deliberate: the W3 open question called out
* needing a native-friendly URL for the iOS client's APNs rotation
* loop, and the `/api/auth/me/...` path is built for the settings-
* surface listing. Two URLs, one implementation: the underlying
* Prisma calls are identical to keep the audit trail consistent.
* surface listing. Two URLs share one transactional helper
* (`revokeDeviceCascade`) so the cascade stays atomic — the W6 audit
* collapsed the previous duplicated four-write sequence into a single
* `prisma.$transaction` call.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { revokeDeviceCascade } from "@/lib/devices/revoke";
interface RouteContext {
params: Promise<{ id: string }>;
@@ -28,59 +30,34 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
const device = await prisma.device.findUnique({
where: { id },
select: { id: true, userId: true, model: true, bundleId: true },
});
if (!device || device.userId !== user.id) {
const result = await revokeDeviceCascade(user.id, id);
if (!result) {
return apiError("Device not found", 404);
}
const liveRefreshTokens = await prisma.refreshToken.findMany({
where: { userId: user.id, deviceId: device.id, revokedAt: null },
select: { accessTokenHash: true },
});
const revokedAt = new Date();
await prisma.refreshToken.updateMany({
where: { userId: user.id, deviceId: device.id, revokedAt: null },
data: { revokedAt },
});
const accessHashes = liveRefreshTokens
.map((r) => r.accessTokenHash)
.filter((v): v is string => Boolean(v));
if (accessHashes.length > 0) {
await prisma.apiToken.updateMany({
where: { tokenHash: { in: accessHashes }, revoked: false },
data: { revoked: true },
});
}
await prisma.device.delete({ where: { id: device.id } });
await auditLog("devices.revoke", {
userId: user.id,
details: {
deviceId: device.id,
label: device.model ?? device.bundleId ?? null,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
deviceId: result.id,
label: result.label,
refreshTokensRevoked: result.refreshTokensRevoked,
accessTokensRevoked: result.accessTokensRevoked,
via: "ios.rotation",
},
});
annotate({
action: { name: "devices.revoke", entity_type: "device", entity_id: device.id },
action: { name: "devices.revoke", entity_type: "device", entity_id: result.id },
meta: {
refresh_tokens_revoked: liveRefreshTokens.length,
access_tokens_revoked: accessHashes.length,
refresh_tokens_revoked: result.refreshTokensRevoked,
access_tokens_revoked: result.accessTokensRevoked,
},
});
return apiSuccess({
id: device.id,
id: result.id,
revoked: true,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
refreshTokensRevoked: result.refreshTokensRevoked,
accessTokensRevoked: result.accessTokensRevoked,
});
},
);