feat(api): device management endpoints for native + web settings

Adds GET /api/auth/me/devices to list a user's paired devices
(label, lastSeen, channels, isCurrent marker) plus matching DELETE
routes at /api/auth/me/devices/[id] and /api/devices/[id]. Revoke
cascades: every refresh token bound to the device is killed alongside
its paired access tokens, then the device row is deleted.

Cross-user attempts return 404 (no enumeration leak). The duplicated
/api/devices/[id] endpoint is the native-friendly URL the iOS client
calls on APNs token rotation, mirroring the W3 open question.

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 01:11:30 +02:00
1 parent 05fe89575f
commit 1bb5a45521
6 files changed
+624

No files matched your search

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findUnique: vi.fn(),
delete: vi.fn(),
},
refreshToken: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
apiToken: {
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(): NextRequest {
return new NextRequest("http://localhost/api/auth/me/devices/dev-x", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
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);
});
describe("DELETE /api/auth/me/devices/[id]", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
expect(res.status).toBe(401);
});
it("returns 404 when the device doesn't exist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
expect(res.status).toBe(404);
});
it("returns 404 when the device belongs to another user (no enumeration)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-x",
userId: "user-2",
model: "iPhone",
bundleId: "io.healthlog.app",
} as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
expect(res.status).toBe(404);
expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled();
});
it("revokes refresh tokens + access tokens + deletes the device row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "user-1",
model: "iPhone",
bundleId: "io.healthlog.app",
} as never);
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([
{ accessTokenHash: "hash:a1" },
{ accessTokenHash: "hash:a2" },
{ accessTokenHash: null },
] as never);
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 3 } as never);
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 2 } as never);
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.revoked).toBe(true);
expect(body.data.refreshTokensRevoked).toBe(3);
expect(body.data.accessTokensRevoked).toBe(2);
expect(vi.mocked(prisma.device.delete)).toHaveBeenCalledWith({
where: { id: "dev-1" },
});
// The refresh-token revoke filter MUST scope to the deviceId, not
// every refresh row the user has.
const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock.calls[0]?.[0];
expect(updateCall?.where).toMatchObject({
userId: "user-1",
deviceId: "dev-1",
revokedAt: null,
});
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* DELETE /api/auth/me/devices/[id]
*
* v1.4.23 W4 F7b — revoke a single device.
*
* Behaviour:
* 1. Confirms the device row belongs to the current user (ownership
* boundary — 404 on cross-user attempts so callers can't probe
* another tenant's device ids).
* 2. Revokes every active refresh token bound to this `deviceId` and
* the access tokens those refresh rows pair with.
* 3. Deletes the `Device` row outright. Future re-pairing will create
* a fresh row.
*
* Note: this route does NOT touch the user's browser session cookie —
* the session lives on `Session.id`, not `Device.id`. Browser logouts
* keep using /api/auth/logout. The two surfaces address different
* blast radii.
*/
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";
interface RouteContext {
params: Promise<{ id: string }>;
}
export const DELETE = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
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) {
// 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,
},
});
annotate({
action: { name: "auth.me.devices.revoke", entity_type: "device", entity_id: device.id },
meta: {
refresh_tokens_revoked: liveRefreshTokens.length,
access_tokens_revoked: accessHashes.length,
},
});
return apiSuccess({
id: device.id,
revoked: true,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
});
},
);
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(deviceIdHeader?: string): NextRequest {
const headers: Record<string, string> = {};
if (deviceIdHeader) headers["x-device-id"] = deviceIdHeader;
return new NextRequest("http://localhost/api/auth/me/devices", {
method: "GET",
headers,
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([]);
});
describe("GET /api/auth/me/devices", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req());
expect(res.status).toBe(401);
});
it("lists devices owned by the current user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
userId: "user-1",
platform: "ios",
token: "tok-1",
bundleId: "io.healthlog.app",
locale: "en-US",
appVersion: "1.0",
model: "iPhone 14 Pro",
apnsToken: "aaaa",
apnsEnvironment: "production",
lastSeen: new Date("2026-05-10T12:00:00Z"),
createdAt: new Date("2026-05-01T12:00:00Z"),
updatedAt: new Date("2026-05-10T12:00:00Z"),
},
{
id: "dev-2",
userId: "user-1",
platform: "ios",
token: "tok-2",
bundleId: "io.healthlog.app",
locale: null,
appVersion: null,
model: "iPad Pro",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date("2026-05-09T12:00:00Z"),
createdAt: new Date("2026-04-01T12:00:00Z"),
updatedAt: new Date("2026-05-09T12:00:00Z"),
},
] as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.devices).toHaveLength(2);
expect(body.data.devices[0].id).toBe("dev-1");
expect(body.data.devices[0].channels).toEqual(["web_push", "apns"]);
expect(body.data.devices[1].channels).toEqual(["web_push"]);
});
it("marks the matching device as isCurrent when X-Device-Id is present", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
userId: "user-1",
platform: "ios",
token: "tok-1",
bundleId: "io.healthlog.app",
locale: null,
appVersion: null,
model: "iPhone",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "dev-2",
userId: "user-1",
platform: "ios",
token: "tok-2",
bundleId: "io.healthlog.app",
locale: null,
appVersion: null,
model: "iPad",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
const res = await GET(req("dev-2"));
const body = await res.json();
const current = body.data.devices.find((d: { isCurrent: boolean; id: string }) => d.isCurrent);
expect(current?.id).toBe("dev-2");
});
it("only queries devices for the authenticated user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req());
const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where;
expect(where).toEqual({ userId: "user-1" });
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* GET /api/auth/me/devices
*
* v1.4.23 W4 F7b — list active devices for the current user. Drives the
* settings → security → devices surface in the v1.5 iOS app and the
* matching web view planned for a follow-up. The list ships
* device-identifying metadata only (label, lastSeen, channels) — no
* tokens, no APNs identifiers, no IPs.
*
* The "current device" marker is best-effort: the route inspects the
* X-Device-Id header (which the native client always sends) and marks
* the matching row with `isCurrent: true`. Browser callers don't have a
* device row and therefore see `isCurrent: false` across the board.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
interface DeviceDTO {
id: string;
label: string;
platform: string;
appVersion: string | null;
locale: string | null;
channels: Array<"web_push" | "apns">;
lastSeenAt: string;
createdAt: string;
isCurrent: boolean;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const currentDeviceId = request.headers.get("x-device-id");
const devices = await prisma.device.findMany({
where: { userId: user.id },
orderBy: { lastSeen: "desc" },
});
const result: DeviceDTO[] = devices.map((d) => {
const channels: DeviceDTO["channels"] = [];
// The legacy `token` column always carries the generic device
// identifier — for v1.4.23 it doubles as the web-push channel
// anchor when a VAPID subscription exists.
if (d.token) channels.push("web_push");
if (d.apnsToken) channels.push("apns");
return {
id: d.id,
// Prefer the `model` field as the label; fall back to bundleId or
// a generic platform string so the row never paints blank.
label: d.model ?? d.bundleId ?? d.platform,
platform: d.platform,
appVersion: d.appVersion,
locale: d.locale,
channels,
lastSeenAt: d.lastSeen.toISOString(),
createdAt: d.createdAt.toISOString(),
isCurrent: currentDeviceId !== null && d.id === currentDeviceId,
};
});
annotate({
action: { name: "auth.me.devices.list" },
meta: { device_count: result.length },
});
return apiSuccess({ devices: result });
});
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findUnique: vi.fn(),
delete: vi.fn(),
},
refreshToken: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
apiToken: {
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(): NextRequest {
return new NextRequest("http://localhost/api/devices/dev-x", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
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);
});
describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
expect(res.status).toBe(401);
});
it("returns 404 on cross-user attempt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-x",
userId: "user-2",
model: "iPhone",
bundleId: "io.healthlog.app",
} as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
expect(res.status).toBe(404);
});
it("revokes the device + paired tokens", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "user-1",
model: "iPhone",
bundleId: "io.healthlog.app",
} as never);
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([
{ accessTokenHash: "hash:a1" },
] as never);
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
expect(res.status).toBe(200);
expect(vi.mocked(prisma.device.delete)).toHaveBeenCalled();
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* DELETE /api/devices/[id]
*
* v1.4.23 W4 F7b alternate iOS endpoint — mirror of
* `DELETE /api/auth/me/devices/[id]` for the v1.5 iOS app's
* token-rotation cleanup path. Same ownership boundary, same audit
* trail, same cascade.
*
* 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.
*/
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";
interface RouteContext {
params: Promise<{ id: string }>;
}
export const DELETE = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
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) {
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,
via: "ios.rotation",
},
});
annotate({
action: { name: "devices.revoke", entity_type: "device", entity_id: device.id },
meta: {
refresh_tokens_revoked: liveRefreshTokens.length,
access_tokens_revoked: accessHashes.length,
},
});
return apiSuccess({
id: device.id,
revoked: true,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
});
},
);