feat(security): list active sessions, sign out everywhere, and show security activity

Add the user-facing account-security surface in Settings:

- Active sessions (closes #64): GET /api/auth/me/sessions lists each browser
  login with a coarse device label, masked IP, resolved location, the sliding
  last-active stamp, and the current-session marker. DELETE the collection
  signs out everywhere else (keeps the current session, revokes other sessions
  and native refresh tokens, leaves API tokens alone); DELETE one revokes a
  single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
  fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
  the audit log filtered to sign-ins, second-factor changes, password changes,
  session revocations, exports, and deletions, with masked IPs.

OpenAPI regenerated for the new endpoints.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:43 +02:00
1 parent bed6588699
commit 9269043e91
14 files changed
+1379 -1

No files matched your search

+246
View File
@@ -1554,6 +1554,81 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/sessions:
get:
tags:
- Auth
summary: List active web sessions
description: "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device
label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from
/api/auth/me/devices (notification devices)."
responses:
"200":
description: Active sessions for the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/SessionListEnvelope"
"401": *a1
"422": *a3
"429": *a4
delete:
tags:
- Auth
summary: Sign out everywhere else
description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current
session. API tokens are not touched (manage those under /settings/api-tokens).
responses:
"200":
description: Other sessions revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/SignOutEverywhereEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/sessions/{id}:
delete:
tags:
- Auth
summary: Revoke a single web session
description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never
another user's row).
responses:
"200":
description: Session revoked.
content:
application/json:
schema:
$ref: "#/components/schemas/SessionRevokeEnvelope"
"401": *a1
"404":
description: Session not found or not owned by the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/auth/me/security-activity:
get:
tags:
- Auth
summary: List recent account-security activity
description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with
timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the
AuditLog store; no event detail bodies are surfaced."
responses:
"200":
description: Recent security events for the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/SecurityActivityEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/sync/state:
get:
tags:
@@ -11011,6 +11086,177 @@ components:
- data
- error
additionalProperties: false
SessionListEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/SessionListResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SessionListResponse:
type: object
properties:
sessions:
type: array
items:
type: object
properties:
id:
type: string
device:
type: string
description: Coarse device label derived from the User-Agent.
ipMasked:
anyOf:
- type: string
- type: "null"
description: IP with the host portion masked — never the full address.
location:
anyOf:
- type: string
- type: "null"
description: Resolved coarse location, when available.
lastActiveAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
isCurrent:
type: boolean
description: True for the session making this request.
required:
- id
- device
- ipMasked
- location
- lastActiveAt
- createdAt
- isCurrent
additionalProperties: false
required:
- sessions
additionalProperties: false
SignOutEverywhereEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/SignOutEverywhereResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SignOutEverywhereResponse:
type: object
properties:
sessionsRevoked:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Number of OTHER sessions removed (the current one is kept).
required:
- sessionsRevoked
additionalProperties: false
SessionRevokeEnvelope:
type: object
properties:
data:
type: object
properties:
revoked:
type: boolean
required:
- revoked
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SecurityActivityEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/SecurityActivityResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SecurityActivityResponse:
type: object
properties:
events:
type: array
items:
type: object
properties:
action:
type: string
description: Audit action name, e.g. auth.login.password.
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
location:
anyOf:
- type: string
- type: "null"
ipMasked:
anyOf:
- type: string
- type: "null"
description: Host-masked IP.
carrier:
anyOf:
- type: string
- type: "null"
required:
- action
- createdAt
- location
- ipMasked
- carrier
additionalProperties: false
required:
- events
additionalProperties: false
SyncStateEnvelope:
type: object
properties:
@@ -0,0 +1,70 @@
/**
* GET /api/auth/me/security-activity
*
* v1.23 — the SHARED user-facing security-activity feed. Returns the caller's
* recent account-security audit events (logins, MFA, password change, token /
* session revocations, exports, deletions) with timestamp + resolved location
* + MASKED IP. The IP is never returned in full and no event detail body is
* surfaced — only the action name, time, and coarse geo. Consumed by the
* account-security surface and the privacy dashboard.
*/
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";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
export interface SecurityActivityDTO {
action: string;
createdAt: string;
location: string | null;
ipMasked: string | null;
carrier: string | null;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const rows = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: limit,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
});
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
location: r.location ?? null,
ipMasked: maskIp(r.ipAddress),
carrier: r.carrier ?? null,
}));
annotate({
action: { name: "auth.security_activity.list" },
meta: { event_count: events.length },
});
return apiSuccess({ events });
});
@@ -0,0 +1,41 @@
/**
* DELETE /api/auth/me/sessions/[id] — revoke a single active web session.
*
* v1.23 — the deletion is scoped to the authenticated user (the Prisma `where`
* narrows on both id AND userId), so a caller can never revoke another user's
* session by guessing an id. A 404 is returned for a non-existent or
* not-owned id so the surface cannot probe foreign session ids.
*/
import { NextRequest } from "next/server";
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";
export const dynamic = "force-dynamic";
export const DELETE = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user, session } = await requireAuth();
const { id } = await ctx.params;
const removed = await destroySessionById(user.id, id);
if (!removed) {
return apiError("Session not found", 404);
}
await auditLog("auth.session.revoke", {
userId: user.id,
ipAddress: getClientIp(request),
details: { self: id === session.id },
});
annotate({
action: { name: "auth.session.revoke" },
meta: { self: id === session.id },
});
return apiSuccess({ revoked: true });
},
);
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { session: { findMany: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
destroyOtherSessions: vi.fn(),
destroySessionById: vi.fn(),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
}));
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 { GET, DELETE } from "../route";
import { DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import {
getSession,
destroyOtherSessions,
destroySessionById,
} from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) },
user: { id: "user-1", username: "u", role: "USER" as const },
};
function del(): NextRequest {
return new NextRequest("http://localhost/api/auth/me/sessions", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/auth/me/sessions", () => {
it("401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
});
it("lists sessions with masked IP, location, and the current marker", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.session.findMany).mockResolvedValue([
{
id: "sess-current",
ipAddress: "203.0.113.7",
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
lastActiveAt: new Date(),
createdAt: new Date(),
},
{
id: "sess-other",
ipAddress: "198.51.100.4",
userAgent:
"Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36",
lastActiveAt: null,
createdAt: new Date(),
},
] as never);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
const list = body.data.sessions;
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
id: "sess-current",
device: "Firefox on macOS",
ipMasked: "203.0.x.x",
location: "Berlin, DE",
isCurrent: true,
});
expect(list[1].isCurrent).toBe(false);
// The full IP never appears in the response body.
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
});
});
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
it("revokes other sessions and reports the count", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 });
const res = await DELETE(del());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.sessionsRevoked).toBe(3);
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current");
});
});
describe("DELETE /api/auth/me/sessions/[id]", () => {
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
}
function delReq(id: string): NextRequest {
return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, {
method: "DELETE",
});
}
it("revokes a single owned session", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroySessionById).mockResolvedValue(true);
const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other"));
expect(res.status).toBe(200);
expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other");
});
it("404 for a non-existent or not-owned id", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroySessionById).mockResolvedValue(false);
const res = await DELETE_ONE(delReq("nope"), ctx("nope"));
expect(res.status).toBe(404);
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* GET /api/auth/me/sessions — list the user's active web sessions.
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
* session (and native refresh tokens), keeping
* the caller's current session. Closes #64.
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
* this lists the authenticated `Session` rows (one per browser login). The list
* carries the masked IP + resolved coarse location + a coarse device label +
* the sliding `lastActiveAt` — never the full IP, never a credential. The
* caller's own row is flagged `isCurrent` so the UI can mark "this device".
*/
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 { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
export interface SessionDTO {
id: string;
device: string;
ipMasked: string | null;
location: string | null;
lastActiveAt: string | null;
createdAt: string;
isCurrent: boolean;
}
export const GET = apiHandler(async () => {
const { user, session } = await requireAuth();
const sessions = await prisma.session.findMany({
where: { userId: user.id, expiresAt: { gt: new Date() } },
orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } },
select: {
id: true,
ipAddress: true,
userAgent: true,
lastActiveAt: true,
createdAt: true,
},
});
// Resolve location once per distinct IP (the resolver caches internally, but
// de-duping keeps the await count to the number of unique networks).
const uniqueIps = Array.from(
new Set(
sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip),
),
);
const locationByIp = new Map<string, string | null>();
await Promise.all(
uniqueIps.map(async (ip) => {
try {
locationByIp.set(ip, await lookupIpLocation(ip));
} catch {
locationByIp.set(ip, null);
}
}),
);
const result: SessionDTO[] = sessions.map((s) => ({
id: s.id,
device: coarseDeviceLabel(s.userAgent),
ipMasked: maskIp(s.ipAddress),
location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null,
lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null,
createdAt: s.createdAt.toISOString(),
isCurrent: s.id === session.id,
}));
annotate({
action: { name: "auth.session.list" },
meta: { session_count: result.length },
});
return apiSuccess({ sessions: result });
});
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireAuth();
const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id);
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
details: { sessionsRevoked },
});
annotate({
action: { name: "auth.session.revoke_others" },
meta: { sessions_revoked: sessionsRevoked },
});
return apiSuccess({ sessionsRevoked });
});
@@ -39,6 +39,8 @@ import {
} from "./account-section-utils";
import { PasskeyListSection } from "./passkey-list-section";
import { AvatarSection } from "./avatar-section";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -589,6 +591,12 @@ export function AccountSection() {
</div>
</SettingsCard>
{/* v1.23 — active-session management (issue #64) + the security-activity
feed. Both sit in the account/security block under the credential
controls above. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
Erweitert. It is a maintenance / reset action, not a profile or
security control, so it sits beside Research Mode + the danger zone. */}
@@ -0,0 +1,125 @@
"use client";
/**
* v1.23 — Settings → account → "Security activity".
*
* A read-only feed of the user's recent account-security events (sign-ins, MFA
* changes, password changes, session revocations, exports, deletions) from the
* shared `GET /api/auth/me/security-activity` endpoint. Each row shows a
* readable action label, the resolved location, the masked IP, and the time.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
interface ActivityRow {
action: string;
createdAt: string;
location: string | null;
ipMasked: string | null;
carrier: string | null;
}
/** Map an audit action onto a stable, translatable label key. */
function actionLabelKey(action: string): string {
if (action === "auth.login.new_device")
return "settings.security.action.newDevice";
if (action === "auth.login.failed")
return "settings.security.action.loginFailed";
if (action.startsWith("auth.login") || action === "auth.register")
return "settings.security.action.login";
if (action.startsWith("auth.password"))
return "settings.security.action.passwordChange";
if (action.startsWith("auth.mfa")) return "settings.security.action.mfa";
if (action.startsWith("auth.session"))
return "settings.security.action.sessionRevoke";
if (action.startsWith("auth.token") || action.startsWith("auth.bearer"))
return "settings.security.action.token";
if (action === "user.account.delete")
return "settings.security.action.accountDelete";
if (action === "user.data.clear") return "settings.security.action.dataClear";
if (action.includes("export")) return "settings.security.action.export";
return "settings.security.action.other";
}
export function SecurityActivityCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
enabled: isAuthenticated,
});
const events = data?.events ?? [];
return (
<SettingsCard data-slot="settings-security-activity-card">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.security.activityTitle")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<p className="text-muted-foreground text-xs">
{t("settings.security.activityDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.activityLoadError")}
</p>
)}
{!isLoading && !isError && events.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.activityEmpty")}
</p>
)}
{events.length > 0 && (
<ul className="divide-border divide-y">
{events.map((e, idx) => (
<li
key={`${e.action}-${e.createdAt}-${idx}`}
className="flex items-center justify-between gap-3 py-2"
data-slot="security-activity-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{t(actionLabelKey(e.action))}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
e.location ?? t("settings.security.unknownLocation"),
e.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<p className="text-muted-foreground shrink-0 text-xs">
{new Date(e.createdAt).toLocaleString()}
</p>
</li>
))}
</ul>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,183 @@
"use client";
/**
* v1.23 — Settings → account → "Active sessions" (issue #64).
*
* Lists the user's active web sessions (device label, masked IP, resolved
* location, last-active time, current-device marker) and offers a per-session
* "sign out" plus a "sign out everywhere else" that revokes every other
* session (and native device logins) while keeping the current one. All reads
* + writes route through the centralised query-key factory.
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
interface SessionRow {
id: string;
device: string;
ipMasked: string | null;
location: string | null;
lastActiveAt: string | null;
createdAt: string;
isCurrent: boolean;
}
export function SecuritySessionsCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
});
const revokeOne = useMutation({
mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`),
onSuccess: () => {
setStatus(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
onSuccess: (res) => {
setStatus(
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const sessions = data?.sessions ?? [];
const hasOthers = sessions.some((s) => !s.isCurrent);
return (
<SettingsCard data-slot="settings-security-sessions-card">
<SettingsCardHeader
icon={MonitorSmartphone}
title={t("settings.security.sessionsTitle")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.sessionsLoadError")}
</p>
)}
{!isLoading && !isError && sessions.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.sessionsEmpty")}
</p>
)}
{sessions.length > 0 && (
<ul className="divide-border divide-y">
{sessions.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="security-session-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{s.device}
{s.isCurrent && (
<span className="text-success ml-2 text-xs font-normal">
{t("settings.security.currentSession")}
</span>
)}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
s.location ?? t("settings.security.unknownLocation"),
s.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
{s.lastActiveAt && (
<p className="text-muted-foreground text-xs">
{t("settings.security.lastActive", {
time: new Date(s.lastActiveAt).toLocaleString(),
})}
</p>
)}
</div>
{!s.isCurrent && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9 shrink-0"
onClick={() => revokeOne.mutate(s.id)}
disabled={revokeOne.isPending}
>
{t("settings.security.revokeSession")}
</Button>
)}
</li>
))}
</ul>
)}
{hasOthers && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => revokeOthers.mutate()}
disabled={revokeOthers.isPending}
>
{revokeOthers.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.signOutEverywhere")}
</Button>
</div>
)}
{status && (
<p role="status" className="text-muted-foreground text-right text-sm">
{status}
</p>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import {
matchesSecurityActivity,
securityActivityWhere,
} from "../security-activity";
describe("matchesSecurityActivity", () => {
it("includes every auth.* event", () => {
for (const a of [
"auth.login.password",
"auth.login.failed",
"auth.login.new_device",
"auth.mfa.verify",
"auth.password.change",
"auth.session.revoke",
"auth.session.revoke_others",
"auth.token.autoissue.native",
"auth.bearer.success",
"auth.register",
]) {
expect(matchesSecurityActivity(a)).toBe(true);
}
});
it("includes export actions", () => {
for (const a of [
"export.download",
"health-record.export",
"user.export.full-backup",
"user.export.measurements",
]) {
expect(matchesSecurityActivity(a)).toBe(true);
}
});
it("includes the erasure actions", () => {
expect(matchesSecurityActivity("user.account.delete")).toBe(true);
expect(matchesSecurityActivity("user.data.clear")).toBe(true);
});
it("excludes unrelated audit actions", () => {
for (const a of [
"measurement.create",
"medication.intake.bulk",
"coach.budget.exceeded",
"withings.sync.ok",
"consent.ai.web",
]) {
expect(matchesSecurityActivity(a)).toBe(false);
}
});
});
describe("securityActivityWhere", () => {
it("scopes by userId and mirrors the predicate clauses", () => {
const where = securityActivityWhere("user-1");
expect(where.userId).toBe("user-1");
expect(where.OR).toEqual([
{ action: { startsWith: "auth." } },
{ action: { contains: "export" } },
{ action: { in: ["user.account.delete", "user.data.clear"] } },
]);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* v1.23 — security-activity feed. The SHARED read both the account-security
* surface and the data-sovereignty privacy dashboard consume: a user-scoped,
* read-only window onto the `AuditLog` rows that matter for account security —
* logins, MFA events, password changes, token/session revocations, exports,
* and deletions. No new storage: it reuses the existing
* `@@index([userId, action, createdAt(sort: Desc)])`.
*/
/**
* Whether an audit action belongs in the user-facing security-activity feed.
* Pure so the contract can be unit-tested without a DB:
* - every `auth.*` event (login, mfa, password, token, bearer, session,
* register),
* - every export action (`export.download`, `health-record.export`,
* `user.export.*` — all contain "export"),
* - the two erasure actions (`user.account.delete`, `user.data.clear`).
*/
export function matchesSecurityActivity(action: string): boolean {
return (
action.startsWith("auth.") ||
action.includes("export") ||
action === "user.account.delete" ||
action === "user.data.clear"
);
}
/**
* Prisma `where` mirror of `matchesSecurityActivity`, scoped to one user. Kept
* alongside the predicate so the two never drift.
*/
export function securityActivityWhere(userId: string) {
return {
userId,
OR: [
{ action: { startsWith: "auth." } },
{ action: { contains: "export" } },
{ action: { in: ["user.account.delete", "user.data.clear"] } },
],
};
}
+65
View File
@@ -8,6 +8,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// v1.23 — throttle window for the user-facing active-session list's "last
// seen" stamp. A write only happens when the prior stamp is older than this,
// so the high-churn `getSession` path costs at most one extra UPDATE per
// session per window rather than one per request.
const LAST_ACTIVE_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
/**
* v1.4.22 C4 — flag cookie mirroring the user's `onboardingCompletedAt`
* status. Set to `"pending"` while the field is null and cleared once
@@ -147,6 +153,22 @@ export async function getSession(): Promise<{
});
}
// v1.23 — sliding "last seen" stamp for the active-session list. Throttled
// (only when the prior stamp is stale) and fire-and-forget so it never adds
// latency or a failure mode to the request-resolution path.
const lastActive = session.lastActiveAt;
if (
!lastActive ||
lastActive.getTime() < Date.now() - LAST_ACTIVE_THROTTLE_MS
) {
void prisma.session
.update({
where: { id: session.id },
data: { lastActiveAt: new Date() },
})
.catch(() => {});
}
return {
session: { id: session.id, expiresAt: session.expiresAt },
user: session.user,
@@ -191,3 +213,46 @@ export async function destroyAllSessions(userId: string): Promise<void> {
}),
]);
}
/**
* v1.23 — "sign out everywhere" for the user-facing active-session surface
* (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's
* CURRENT cookie session alive so clicking the button doesn't log the user out
* of the device they pressed it on, and it does NOT revoke `ApiToken`s — those
* are long-lived programmatic credentials the user manages separately under
* /settings/api-tokens, not "sessions" in the device-list sense. It DOES revoke
* every native-client `RefreshToken` (each is a device login) so a signed-in
* phone/tablet is dropped too, matching the "everywhere" promise.
*
* Returns the number of OTHER web sessions removed so the surface can confirm.
*/
export async function destroyOtherSessions(
userId: string,
currentSessionId: string,
): Promise<{ sessionsRevoked: number }> {
const [deleted] = await prisma.$transaction([
prisma.session.deleteMany({
where: { userId, id: { not: currentSessionId } },
}),
prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
]);
return { sessionsRevoked: deleted.count };
}
/**
* v1.23 — revoke a single web session by id, scoped to the owning user so a
* caller can never delete another user's session row. Returns whether a row
* was actually removed (false → not found or not owned).
*/
export async function destroySessionById(
userId: string,
sessionId: string,
): Promise<boolean> {
const result = await prisma.session.deleteMany({
where: { id: sessionId, userId },
});
return result.count > 0;
}
+143 -1
View File
@@ -13,7 +13,7 @@ import {
totpConfirmSchema,
mfaDisableSchema,
} from "@/lib/validations/mfa";
import { dataEnvelope, stdResponses } from "./shared";
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -129,6 +129,61 @@ const mfaToggleResponse = z
.object({ enabled: z.boolean() })
.meta({ id: "MfaToggleResponse" });
// ── v1.23 active sessions + security activity shapes ──────────────────
const sessionListResponse = z
.object({
sessions: z.array(
z.object({
id: z.string(),
device: z
.string()
.describe("Coarse device label derived from the User-Agent."),
ipMasked: z
.string()
.nullable()
.describe(
"IP with the host portion masked — never the full address.",
),
location: z
.string()
.nullable()
.describe("Resolved coarse location, when available."),
lastActiveAt: z.iso.datetime({ offset: true }).nullable(),
createdAt: z.iso.datetime({ offset: true }),
isCurrent: z
.boolean()
.describe("True for the session making this request."),
}),
),
})
.meta({ id: "SessionListResponse" });
const signOutEverywhereResponse = z
.object({
sessionsRevoked: z
.number()
.int()
.describe("Number of OTHER sessions removed (the current one is kept)."),
})
.meta({ id: "SignOutEverywhereResponse" });
const securityActivityResponse = z
.object({
events: z.array(
z.object({
action: z
.string()
.describe("Audit action name, e.g. auth.login.password."),
createdAt: z.iso.datetime({ offset: true }),
location: z.string().nullable(),
ipMasked: z.string().nullable().describe("Host-masked IP."),
carrier: z.string().nullable(),
}),
),
})
.meta({ id: "SecurityActivityResponse" });
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/login": {
post: {
@@ -320,4 +375,91 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/sessions": {
get: {
tags: ["Auth"],
summary: "List active web sessions",
description:
"v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from /api/auth/me/devices (notification devices).",
responses: {
"200": {
description: "Active sessions for the caller.",
content: {
"application/json": {
schema: dataEnvelope(sessionListResponse, "SessionListEnvelope"),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Auth"],
summary: "Sign out everywhere else",
description:
"v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).",
responses: {
"200": {
description: "Other sessions revoked.",
content: {
"application/json": {
schema: dataEnvelope(
signOutEverywhereResponse,
"SignOutEverywhereEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/sessions/{id}": {
delete: {
tags: ["Auth"],
summary: "Revoke a single web session",
description:
"v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).",
responses: {
"200": {
description: "Session revoked.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ revoked: z.boolean() }),
"SessionRevokeEnvelope",
),
},
},
},
"404": {
description: "Session not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/auth/me/security-activity": {
get: {
tags: ["Auth"],
summary: "List recent account-security activity",
description:
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.",
responses: {
"200": {
description: "Recent security events for the caller.",
content: {
"application/json": {
schema: dataEnvelope(
securityActivityResponse,
"SecurityActivityEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+15
View File
@@ -33,6 +33,21 @@ export const authKeys = {
passkeys: () => ["passkeys"] as const,
/**
* v1.23 — the user-facing active-session list behind
* `GET /api/auth/me/sessions`. Revoking a single session or signing out
* everywhere invalidates this key so the surface re-reads the live set.
*/
sessions: () => ["auth", "sessions"] as const,
/**
* v1.23 — the shared security-activity feed behind
* `GET /api/auth/me/security-activity` (logins, MFA, password change,
* session revoke, exports, deletions). Consumed by the account-security
* surface and the privacy dashboard.
*/
securityActivity: () => ["auth", "security-activity"] as const,
userAiProvider: () => ["user", "ai-provider"] as const,
userProfile: () => ["user", "profile"] as const,
/**
@@ -0,0 +1,131 @@
/**
* Integration test — v1.23 active-session management + known-device ledger
* against a real Postgres (migration 0203 applied by the container harness).
*
* Covers the DB-layer contracts the unit mocks can't pin:
* - destroyOtherSessions keeps the current session, deletes the rest, revokes
* native refresh tokens, and leaves API tokens untouched.
* - destroySessionById is scoped to the owning user (no cross-user delete).
* - the (userId, deviceHash) unique index enforces the login-alert dedupe.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session";
import { getPrismaClient, truncateAllTables } from "./setup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function makeUser(username: string) {
return getPrismaClient().user.create({
data: { username, email: `${username}@example.test` },
});
}
describe("destroyOtherSessions", () => {
it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
const prisma = getPrismaClient();
const user = await makeUser("sess-owner");
const current = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
const refresh = await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: "rt-hash-1",
expiresAt: new Date(Date.now() + 1e6),
},
});
const apiToken = await prisma.apiToken.create({
data: {
userId: user.id,
name: "automation",
tokenHash: "at-hash-1",
permissions: ["*"],
},
});
const result = await destroyOtherSessions(user.id, current.id);
expect(result.sessionsRevoked).toBe(2);
const remaining = await prisma.session.findMany({
where: { userId: user.id },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(current.id);
const rt = await prisma.refreshToken.findUnique({
where: { id: refresh.id },
});
expect(rt?.revokedAt).not.toBeNull();
const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } });
expect(at?.revoked).toBe(false);
});
});
describe("destroySessionById", () => {
it("revokes an owned session and refuses a cross-user id", async () => {
const prisma = getPrismaClient();
const owner = await makeUser("owner");
const other = await makeUser("intruder");
const ownerSession = await prisma.session.create({
data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) },
});
const otherSession = await prisma.session.create({
data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) },
});
// Cross-user attempt: owner tries to delete the intruder's session.
expect(await destroySessionById(owner.id, otherSession.id)).toBe(false);
expect(
await prisma.session.findUnique({ where: { id: otherSession.id } }),
).not.toBeNull();
// Owned delete succeeds.
expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true);
expect(
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
).toBeNull();
});
});
describe("UserKnownDevice unique index", () => {
it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => {
const prisma = getPrismaClient();
const user = await makeUser("device-owner");
await prisma.userKnownDevice.create({
data: {
userId: user.id,
deviceHash: "hash-abc",
label: "Firefox on macOS",
},
});
await expect(
prisma.userKnownDevice.create({
data: { userId: user.id, deviceHash: "hash-abc" },
}),
).rejects.toThrow();
// A different hash for the same user inserts fine.
await prisma.userKnownDevice.create({
data: { userId: user.id, deviceHash: "hash-def" },
});
const rows = await prisma.userKnownDevice.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(2);
});
});