Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 20:40:59 +02:00
commit 03ccb21227
96 files changed
+3238 -495

No files matched your search

@@ -18,7 +18,12 @@ import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -42,7 +47,10 @@ export const GET = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
@@ -20,7 +20,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -45,7 +50,10 @@ const handler = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
+10 -2
View File
@@ -35,7 +35,12 @@ import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
@@ -143,7 +148,10 @@ async function* decodedBytes(
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
+9 -2
View File
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
);
}
const { user } = await requireAdmin();
const { user, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "admin.data.delete" } });
// v1.18.1 — the documented convention buckets authenticated admin
+6 -2
View File
@@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
export const dynamic = "force-dynamic";
@@ -43,8 +44,11 @@ interface ProviderHealthSummary {
lastFailureStatus: number | null;
}
/** The two operator-managed tags sort first — they affect every user on the chain. */
const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
/**
* The operator-managed tags sort first — they affect every user on the chain.
* Read from the one definition, in its order (the OpenAI key, then Codex).
*/
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export const GET = apiHandler(async () => {
await requireAdmin();
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -31,7 +35,10 @@ export const POST = apiHandler(
);
}
const { user } = await requireAdmin();
const { user, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(user, session.id);
const { id } = await params;
annotate({
@@ -44,6 +44,8 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
checkRateLimit: vi.fn(async () => ({ allowed: true, remaining: 9 })),
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
@@ -48,6 +48,7 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
@@ -39,6 +39,7 @@ vi.mock("@/lib/rate-limit", () => ({
.fn()
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
rateLimitHeaders: vi.fn(() => ({})),
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
+54 -1
View File
@@ -9,7 +9,12 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
refundRateLimit,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -21,6 +26,15 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
/**
* Failed password sign-ins one account takes per window, from any number of
* addresses. Twice the per-IP ceiling, so the owner mistyping from one network
* meets that one first, and small enough that guessing is hopeless.
*/
const LOGIN_ACCOUNT_BUCKET = "auth:login:account";
const LOGIN_ACCOUNT_LIMIT = 10;
const LOGIN_ACCOUNT_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
@@ -91,6 +105,42 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// A per-account ceiling beside the per-IP one. The IP bucket alone is only
// as good as the address it is keyed on, and a guesser with many addresses
// (or a proxy that forwards whatever address the caller claims) could try
// one account's password without end. This bucket follows the account
// wherever the guesses come from.
//
// Keyed on the account when the identifier resolves and on the identifier's
// hash when it does not, with the same ceiling, so a locked answer says
// nothing about whether the account exists. Charged before the Argon2id
// verification (a locked account costs no hashing) and refunded on a
// successful sign-in, so it counts failures: the owner signing in does not
// use it up. While it is spent, a passkey or single sign-on still works.
const accountBucket = user
? `${LOGIN_ACCOUNT_BUCKET}:u:${user.id}`
: `${LOGIN_ACCOUNT_BUCKET}:i:${hashToken(identifier.toLowerCase())}`;
const accountRl = await checkRateLimit(
accountBucket,
LOGIN_ACCOUNT_LIMIT,
LOGIN_ACCOUNT_WINDOW_MS,
);
if (!accountRl.allowed) {
await auditLog("auth.login.account_throttled", {
userId: user?.id,
ipAddress: ip,
details: { identifierHash },
});
annotate({ action: { name: "auth.login.account_throttled" } });
return NextResponse.json(
{
data: null,
error: "Too many login attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(accountRl) },
);
}
// One verification for every outcome. An unknown identifier and an
// account carrying no password hash (a passkey-only account) used to
// return here before any hashing, so the three answers cost one indexed
@@ -132,6 +182,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// The password was right: this attempt was not a failed guess.
await refundRateLimit(accountBucket);
const ua = request.headers.get("user-agent");
await auditLog("auth.login.password", {
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
const res = await POST(req());
expect(res.status).toBe(200);
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
kind: "session",
sessionId: "sess-current",
});
expect(destroyOtherSessions).toHaveBeenCalledWith(
"u1",
{
kind: "session",
sessionId: "sess-current",
},
{ reach: "sign-ins" },
);
});
it("spares the CALLER's own device login on the Bearer path", async () => {
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
const res = await POST(req());
expect(res.status).toBe(200);
expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
kind: "accessToken",
accessTokenHash: "hash-of-caller-access-token",
});
expect(destroyOtherSessions).toHaveBeenCalledWith(
"u1",
{
kind: "accessToken",
accessTokenHash: "hash-of-caller-access-token",
},
{ reach: "sign-ins" },
);
});
it("spends the elevation before tearing the factor down", async () => {
+1
View File
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
auth.transport === "cookie"
? { kind: "session", sessionId: auth.session.id }
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
{ reach: "sign-ins" },
);
await auditLog("auth.mfa.disabled", {
@@ -33,6 +33,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
}
const recoveryCodes = generateRecoveryCodes();
// A confirmed TOTP secret was refused above, so the only second factor the
// account can already hold is a security key.
const hadSecondFactor =
(await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
})) > 0;
// The code verified and the activation is next — spend the elevation here so
// a wrong code above costs the caller nothing.
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
// admin-enforced forced-enrollment redirect must clear immediately.
await setMfaEnrollCookie(false);
// Turning on the first second factor ends every other sign-in, as a password
// change does: a session opened before the factor existed, perhaps by
// whoever made the owner turn it on, must not keep going without it.
if (!hadSecondFactor) {
await destroyOtherSessions(
user.id,
auth.transport === "cookie"
? { kind: "session", sessionId: auth.session.id }
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
{ reach: "sign-ins" },
);
}
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
@@ -19,6 +19,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// failed ceremony above must not have cost the caller their elevation.
await auth.commitElevation();
const hadSecondFactor =
user.totpConfirmedAt !== null ||
(await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
})) > 0;
const { registrationInfo } = verification;
const created = await prisma.webauthnMfaCredential.create({
data: {
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
// so clear any forced-enrollment redirect immediately.
await setMfaEnrollCookie(false);
// Turning on the first second factor ends every other sign-in, as a password
// change does: a session opened before the factor existed, perhaps by
// whoever made the owner turn it on, must not keep going without it.
if (!hadSecondFactor) {
await destroyOtherSessions(
user.id,
auth.transport === "cookie"
? { kind: "session", sessionId: auth.session.id }
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
{ reach: "sign-ins" },
);
}
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -163,16 +163,21 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 3,
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
});
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", {
kind: "session",
sessionId: "sess-current",
});
expect(destroyOtherSessions).toHaveBeenCalledWith(
"user-1",
{ kind: "session", sessionId: "sess-current" },
// Everything, share links included, when the client says nothing: the
// shipped app sends no parameter and gets the safe default.
{ reach: "everything", keepShareLinks: false },
);
});
it("names a Bearer caller by its access token so its own device login is spared", async () => {
@@ -198,15 +203,18 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 1,
connectorsRevoked: 0,
shareLinksRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
kind: "accessToken",
accessTokenHash: hashToken(raw),
});
expect(destroyOtherSessions).toHaveBeenCalledWith(
"user-1",
{ kind: "accessToken", accessTokenHash: hashToken(raw) },
{ reach: "everything", keepShareLinks: false },
);
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
});
});
+38 -8
View File
@@ -1,8 +1,11 @@
/**
* 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.
* session, device login, AI-assistant
* connection and token, and (unless
* `?keepShareLinks=1`) every clinician share
* link, keeping the caller's current
* credential. Closes #64.
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
@@ -114,15 +117,35 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
: { kind: "session", sessionId: auth.session.id };
const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
user.id,
current,
);
// "Everywhere" reaches every credential that works without signing in: AI
// assistant connections, programmatic tokens and, unless the caller asks to
// keep them, clinician share links. The default is to revoke, because the
// person pressing this is often doing it after losing a device or a session,
// and a share link made by whoever held it would otherwise keep working. A
// client that sends nothing (the shipped app) gets the safe default.
const keepShareLinks =
new URL(request.url).searchParams.get("keepShareLinks") === "1";
const {
sessionsRevoked,
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
} = await destroyOtherSessions(user.id, current, {
reach: "everything",
keepShareLinks,
});
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
details: { sessionsRevoked, accessTokensRevoked },
details: {
sessionsRevoked,
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
keepShareLinks,
},
});
annotate({
@@ -130,10 +153,17 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
meta: {
sessions_revoked: sessionsRevoked,
access_tokens_revoked: accessTokensRevoked,
connectors_revoked: connectorsRevoked,
share_links_revoked: shareLinksRevoked,
},
});
return apiSuccess({ sessionsRevoked });
return apiSuccess({
sessionsRevoked,
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
});
});
/**
+139
View File
@@ -0,0 +1,139 @@
/**
* POST /api/auth/reproof
*
* A browser session re-proves a credential the account holds, so that for the
* next five minutes it may export the whole record, make a share link, mint a
* token, or run an admin backup, restore, wipe or reset. Those routes answer
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
* recent proof; the web opens the re-proof dialog, posts the proof here, and
* retries.
*
* What a proof stamps on the session row:
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
* same stamp a completed second factor or passkey sign-in writes, from the
* same factor, so it also clears the fresh-factor routes, exactly as
* signing in again would.
* - `password`: `reproofAt` only. Accepted only on an account without a
* second factor; an account with one is told which proofs it can give
* instead (422 `auth.reproof.too_weak`), because a password has never
* stood in for the second factor.
*
* Cookie-only. The Bearer transport has its own proof flow (`POST
* /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
* session row means nothing to a token.
*
* GET asks the same question the gated routes ask, without acting: 200 when
* the session's proof is recent, otherwise the same 401 `auth.reproof.required`
* with `meta.methods`. The web calls it before a large upload, so the file is
* not sent twice around a re-proof.
*
* Proofs draw on the account's shared re-proof budget (five per fifteen
* minutes, the same bucket as the step-up mint and the enrollment routes) and
* every refusal is audited. Failures are indistinguishable on the wire.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
recentProofMethods,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import { stepUpMintSchema } from "@/lib/validations/step-up";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
const ip = getClientIp(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const proof = parsed.data;
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
// it: a password on an account with a second factor would verify and still
// leave every gated action refused.
const methods = await recentProofMethods(user);
if (!methods.includes(proof.method)) {
annotate({
action: { name: "auth.reproof.method_refused" },
meta: { method: proof.method },
});
return apiError("Use one of the listed ways to confirm it is you", 422, {
errorCode: "auth.reproof.too_weak",
methods,
});
}
const limited = await throttleReproof(user.id, ip, "reproof");
if (limited) return limited;
const outcome = await verifyExistingFactorProof(user, proof);
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ip,
"reproof",
proof.method,
outcome.reason,
);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const now = new Date();
await prisma.session.update({
where: { id: session.id },
data:
proof.method === "password"
? { reproofAt: now }
: { reproofAt: now, mfaVerifiedAt: now },
});
await auditLog("auth.reproof.succeeded", {
userId: user.id,
ipAddress: ip,
details: { method: proof.method },
});
annotate({
action: { name: "auth.reproof.succeeded" },
meta: { method: proof.method },
});
return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
});
export const GET = apiHandler(async () => {
const { user, session } = await requireCookieAuth();
await assertRecentCookieProof(user, session.id);
return apiSuccess({ recent: true });
});
@@ -12,6 +12,15 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
@@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({
consentReceipt: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) },
apiToken: { count: vi.fn() },
session: { count: vi.fn() },
session: {
count: vi.fn(),
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
appSettings: { findUnique: vi.fn() },
userAchievement: {
findMany: vi.fn(),
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -114,6 +115,14 @@ beforeEach(() => {
// Default: no registered security key. Tests that exercise the
// WebAuthn-only cohort override this.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
// Default: a session that signed in a moment ago. The cases about the
// recent-proof gate set their own stamps.
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
});
describe("POST /api/export/encrypted", () => {
@@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => {
});
});
it("refuses a single-factor account whose session is not recent (401 reproof)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
session: { id: "sess-1" },
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.meta.errorCode).toBe("auth.reproof.required");
});
it("allows a single-factor account after a password re-proof", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
session: { id: "sess-1" },
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: new Date(),
} as never);
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
expect(res.status).toBe(200);
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
+15 -18
View File
@@ -9,10 +9,9 @@
* `application/octet-stream`.
*
* SECURITY:
* - Step-up: when the account has any second factor enrolled (TOTP OR a
* security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
* whole record is a sensitive action. Single-factor accounts fall back to a
* normal authenticated session.
* - Fresh proof: `requireRecentProof`, the gate every whole-record export
* shares. A second factor within five minutes on an account that has one;
* otherwise a recent sign-in or a password re-proof.
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
* body, passed straight into the KDF, and never `annotate()`d. The egress
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
@@ -20,19 +19,16 @@
* - There is NO server-side recovery — the passphrase is not stored. A
* forgotten passphrase means the archive is unrecoverable; the UI says so.
*
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
* second factor for MFA accounts (Bearer can never satisfy step-up).
* Auth: cookie session with a recent proof; Bearer on the token alone for an
* account without a second factor, with a second-factor `X-Step-Up`
* elevation for one with.
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
* plaintext export so the encrypted variant cannot be used to bypass the cap.
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
*/
import { prisma } from "@/lib/db";
import { z } from "zod/v4";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -92,13 +88,13 @@ const encryptedExportSchema = z
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
// Resolve the session (cookie or Bearer), then escalate to a fresh second
// factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
// covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
// security key — so a security-key-only account is gated too. A single-factor
// account cannot produce a fresh-MFA proof, so it passes straight through
// rather than being locked out of its own encrypted export.
const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
// A fresh proof, like every whole-record export (`requireRecentProof`). On
// the cookie path: a second factor within five minutes on an account that
// has one, otherwise a recent sign-in or password re-proof. On Bearer the
// shipped app calls this route without an elevation, so an account without
// a second factor still passes on its token; one with a second factor
// presents a second-factor elevation (before, it could not export at all).
const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" });
const user = auth.user;
annotate({ action: { name: "user.export.encrypted" } });
@@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error);
}
const { passphrase } = parsed.data;
await auth.commitElevation();
// Sealed as it is produced and spooled to a temporary file, then sent: the
// format puts the tag in front of the ciphertext, so the archive cannot go
+9 -3
View File
@@ -25,12 +25,16 @@
* included; the payload's `manifest` field discloses both, and the export UI
* states this too.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
* plain text, so a live session alone is not enough: a cookie session needs a
* recent sign-in or re-proof (a second factor on an account that has one), a
* Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
* on Bearer.
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const auth = await requireRecentProof({ bearer: "elevation" });
const { user } = auth;
annotate({ action: { name: "user.export.full-backup" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
await auth.commitElevation();
// Written into the response as it is produced (`streamFullBackupJson`), not
// built and stringified first: for an account of 1.25 million measurements
@@ -10,6 +10,15 @@ vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findMany: vi.fn() },
auditLog: { create: vi.fn() },
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -29,6 +38,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { issueApiToken } from "@/lib/auth/issue-token";
@@ -57,6 +67,20 @@ beforeEach(() => {
} as never);
});
describe("POST /api/mcp/tokens — fresh proof", () => {
it("refuses a browser session with no recent sign-in or re-proof", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(postReq({ name: "laptop" }));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(issueApiToken).not.toHaveBeenCalled();
});
});
describe("POST /api/mcp/tokens — scope minting", () => {
it("defaults to read-only [health:read]", async () => {
const res = await POST(postReq({ name: "laptop" }));
+6 -2
View File
@@ -19,7 +19,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -71,7 +71,11 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// A connector token outlives the session that mints it, so the cookie path
// needs a fresh proof. The shipped app mints these on its token without an
// elevation and reads a 401 here as a dead session, so the Bearer arm stays
// on the token until it sends one (see the iOS note).
const { user } = await requireRecentProof({ bearer: "token" });
annotate({ action: { name: "mcp.tokens.create" } });
if (!(await isApiGloballyEnabled())) {
@@ -19,6 +19,15 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
},
auditLog: { create: vi.fn() },
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
@@ -101,6 +110,22 @@ beforeEach(() => {
} as never);
});
describe("POST /api/share-links — fresh proof", () => {
it("refuses a browser session with no recent sign-in or re-proof", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(postReq(validBody()));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.meta.errorCode).toBe("auth.reproof.required");
expect(body.meta.methods).toEqual([]);
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
});
describe("POST /api/share-links — create", () => {
it("returns the raw token once and persists ONLY its hash", async () => {
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+7 -2
View File
@@ -15,7 +15,7 @@
import { randomBytes } from "node:crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
@@ -136,7 +136,12 @@ function toSummary(row: {
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// A share link is a standing credential to the record that works without
// signing in, so making one needs a fresh proof on the cookie path, not only
// a live session. The shipped app creates links on its token without an
// elevation, and reads any 401 here as a dead session, so the Bearer arm
// stays on the token until the app sends one (see the iOS note).
const { user } = await requireRecentProof({ bearer: "token" });
annotate({ action: { name: "share-link.create" } });
const rl = await checkRateLimit(`share-link:${user.id}`, 20, 60 * 60 * 1000);
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
}));
import { POST } from "../route";
import { requireCookieAuth } from "@/lib/api-handler";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(requireCookieAuth).mockResolvedValue({
user: USER,
session: { id: "sess-1" },
} as never);
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
@@ -77,6 +82,26 @@ beforeEach(() => {
} as never);
});
describe("the fresh proof in front of the mint", () => {
it("mints nothing when the session carries no recent proof", async () => {
vi.mocked(assertRecentCookieProof).mockRejectedValue(
new Error("auth.reproof.required"),
);
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
"auth.reproof.required",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("asks about the caller's own session", async () => {
await POST(req({ name: "Scale" }) as never);
expect(assertRecentCookieProof).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"sess-1",
);
});
});
describe("what it mints", () => {
it("names exactly the upload scope, and no wildcard", async () => {
const res = await POST(req({ name: "Paperless workflow" }) as never);
+10 -2
View File
@@ -23,7 +23,11 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -55,7 +59,11 @@ const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const MAX_LIVE_TOKENS = 10;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireCookieAuth();
const { user, session } = await requireCookieAuth();
// And a fresh proof on top of the session: the token lives far longer than
// a stolen session would, so minting one needs the person, not just the
// browser.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "tokens.documents.create" } });
if (!(await isApiGloballyEnabled())) {
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
}));
import { POST } from "../route";
import { requireCookieAuth } from "@/lib/api-handler";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(requireCookieAuth).mockResolvedValue({
user: USER,
session: { id: "sess-1" },
} as never);
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
@@ -77,6 +82,26 @@ beforeEach(() => {
} as never);
});
describe("the fresh proof in front of the mint", () => {
it("mints nothing when the session carries no recent proof", async () => {
vi.mocked(assertRecentCookieProof).mockRejectedValue(
new Error("auth.reproof.required"),
);
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
"auth.reproof.required",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("asks about the caller's own session", async () => {
await POST(req({ name: "Scale" }) as never);
expect(assertRecentCookieProof).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"sess-1",
);
});
});
describe("what it mints", () => {
it("names exactly the ingest scope, and no wildcard", async () => {
const res = await POST(req({ name: "Home Assistant" }) as never);
+10 -2
View File
@@ -30,7 +30,11 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -94,7 +98,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// front of, and it is the surface this endpoint is reached from anyway. The
// same argument the passkey-registration and trusted-device routes make, and
// `requireCookieAuth` is the helper they share.
const { user } = await requireCookieAuth();
const { user, session } = await requireCookieAuth();
// And a fresh proof on top of the session: the token lives far longer than
// a stolen session would, so minting one needs the person, not just the
// browser.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "tokens.measurements.create" } });
if (!(await isApiGloballyEnabled())) {