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

+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,
},
},
},
};