diff --git a/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts b/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..d8a5628f3 --- /dev/null +++ b/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts @@ -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, + }); + }); +}); diff --git a/src/app/api/auth/me/devices/[id]/route.ts b/src/app/api/auth/me/devices/[id]/route.ts new file mode 100644 index 000000000..a5661d7a0 --- /dev/null +++ b/src/app/api/auth/me/devices/[id]/route.ts @@ -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, + }); + }, +); diff --git a/src/app/api/auth/me/devices/__tests__/route.test.ts b/src/app/api/auth/me/devices/__tests__/route.test.ts new file mode 100644 index 000000000..3462c6a7e --- /dev/null +++ b/src/app/api/auth/me/devices/__tests__/route.test.ts @@ -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 = {}; + 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" }); + }); +}); diff --git a/src/app/api/auth/me/devices/route.ts b/src/app/api/auth/me/devices/route.ts new file mode 100644 index 000000000..5df0f6992 --- /dev/null +++ b/src/app/api/auth/me/devices/route.ts @@ -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 }); +}); diff --git a/src/app/api/devices/[id]/__tests__/route.test.ts b/src/app/api/devices/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..e2661f97c --- /dev/null +++ b/src/app/api/devices/[id]/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/devices/[id]/route.ts b/src/app/api/devices/[id]/route.ts new file mode 100644 index 000000000..d84533628 --- /dev/null +++ b/src/app/api/devices/[id]/route.ts @@ -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, + }); + }, +);