Merge branch 'fix/v1393-review-security' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:59:23 +02:00
commit 3cbfe7f7f9
76 files changed
+2751 -409

No files matched your search

@@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
const requireAuth = vi.fn();
return {
...actual,
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
requireAuth,
// The invitation takes the fresh-proof gate; here it resolves the caller
// the stubbed `requireAuth` names, so these cases stay about the row.
// The gate itself has its own case in `invite-recent-proof.test.ts`.
requireRecentProof: vi.fn(async () => ({
...(await requireAuth()),
commitElevation: async () => {},
})),
};
});
@@ -0,0 +1,114 @@
/**
* Offering access to the record asks for a fresh proof.
*
* An invitation outlives the session that sent it, so a live session alone
* was enough for a stolen one to leave a standing way into the record in the
* thief's own account. The real gate runs here; only the session, the
* database and the headers are stubbed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
user: { findFirst: vi.fn() },
accountGrant: { create: vi.fn(), findFirst: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
recordDelegatedAccess: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
inviteGrant: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { inviteGrant } from "@/lib/sharing/grants";
const OWNER = {
id: "owner-1",
role: "USER",
username: "owner",
totpConfirmedAt: null,
passwordHash: "argon",
};
function invite(): NextRequest {
return new NextRequest("http://localhost/api/account/grants", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identifier: "housemate", access: "READ" }),
});
}
function signedIn(createdAt: Date) {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user: OWNER,
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt,
mfaVerifiedAt: null,
reproofAt: null,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/account/grants — fresh proof", () => {
it("a session that signed in an hour ago is asked to confirm, and nothing is offered", async () => {
signedIn(new Date(Date.now() - 60 * 60 * 1000));
const res = await POST(invite());
expect(res.status).toBe(401);
const body = await res.json();
expect(body.meta.errorCode).toBe("auth.reproof.required");
expect(body.meta.methods).toEqual(["password"]);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
expect(inviteGrant).not.toHaveBeenCalled();
});
it("a session that signed in a minute ago offers the invitation", async () => {
signedIn(new Date(Date.now() - 60 * 1000));
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "invitee-1",
username: "housemate",
displayName: null,
} as never);
vi.mocked(inviteGrant).mockResolvedValue({
id: "grant-1",
access: "READ",
scopeJson: null,
invitedAt: new Date(),
acceptedAt: null,
expiresAt: null,
lastUsedAt: null,
revokedAt: null,
revokedBy: null,
} as never);
const res = await POST(invite());
expect(res.status).toBe(201);
expect(inviteGrant).toHaveBeenCalled();
});
});
@@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
const requireAuth = vi.fn();
return {
...actual,
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
requireAuth,
// The invitation takes the fresh-proof gate; here it resolves the caller
// the stubbed `requireAuth` names, so these cases stay about the row.
// The gate itself has its own case in `invite-recent-proof.test.ts`.
requireRecentProof: vi.fn(async () => ({
...(await requireAuth()),
commitElevation: async () => {},
})),
requireFreshMfaIfEnrolled: vi.fn(),
};
});
+18 -4
View File
@@ -21,9 +21,15 @@
* a code rather than leaving a token caller with an authentication error on a
* request that authenticated fine.
*
* **Neither is delegable, and that is structural.** Both resolve through bare
* `requireAuth()`, which refuses outright while the caller is acting on
* another account. So a delegate cannot invite anybody into the record they
* v1.39.3 — offering any level also asks for a fresh proof
* (`requireRecentProof`), and "sign out everywhere" withdraws the invitations
* nobody has accepted yet. An invitation outlives the session that sent it, so
* one offered from a stolen session would otherwise stay live after the owner
* signed everything else out.
*
* **Neither is delegable, and that is structural.** Both resolve through
* `requireAuth()` (the invitation through `requireRecentProof`, which calls
* it), which refuses outright while the caller is acting on another account. So a delegate cannot invite anybody into the record they
* were given access to, cannot widen their own grant, and cannot pass it on:
* the route does not have a re-delegation check that somebody could soften, it
* has no code path that runs under a switch at all. The grantor is then taken
@@ -45,6 +51,7 @@ import {
apiHandler,
requireAuth,
requireFreshMfaIfEnrolled,
requireRecentProof,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
@@ -122,7 +129,12 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
// Offering access leaves a standing way into the record in somebody else's
// hands, one that outlives the session that offered it. So a live session is
// not enough: a sign-in or re-proof within five minutes on the browser, with
// the second factor on an account that has one, or a step-up elevation on a
// token. No shipped client offers access with a token.
const auth = await requireRecentProof({ bearer: "elevation" });
const user = auth.user;
const rl = await checkRateLimit(
@@ -194,6 +206,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No account with that name or e-mail", 404);
}
await auth.commitElevation();
try {
// `inviteGrant` decides everything about the row except the level: the
// self-grant refusal, and the one-live-grant-per-pair rule the partial
@@ -21,6 +21,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The per-account throttle has its own suite (login-throttle.test.ts); here
// it lets every attempt through and counts nothing.
vi.mock("@/lib/auth/login-throttle", () => ({
beginAccountLoginAttempt: vi.fn(async () => ({
waiting: null,
source: null,
failed: async () => {},
succeeded: async () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -3,6 +3,16 @@ import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
// The per-account throttle has its own suite (login-throttle.test.ts); here
// it lets every attempt through and counts nothing.
vi.mock("@/lib/auth/login-throttle", () => ({
beginAccountLoginAttempt: vi.fn(async () => ({
waiting: null,
source: null,
failed: async () => {},
succeeded: async () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -8,6 +8,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// The per-account throttle has its own suite (login-throttle.test.ts); here
// it lets every attempt through and counts nothing.
vi.mock("@/lib/auth/login-throttle", () => ({
beginAccountLoginAttempt: vi.fn(async () => ({
waiting: null,
source: null,
failed: async () => {},
succeeded: async () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -1,6 +1,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The per-account throttle has its own suite (login-throttle.test.ts); here
// it lets every attempt through and counts nothing.
vi.mock("@/lib/auth/login-throttle", () => ({
beginAccountLoginAttempt: vi.fn(async () => ({
waiting: null,
source: null,
failed: async () => {},
succeeded: async () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
+31 -44
View File
@@ -9,12 +9,8 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
refundRateLimit,
} from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -26,15 +22,6 @@ 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)
@@ -105,27 +92,31 @@ 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) {
// Under an SSO-only policy the one password sign-in allowed is the one that
// confirms the account a pending single sign-on link names. Any other
// account is refused BEFORE its password is checked: refusing after the
// check answered "wrong password" and "right password, wrong account"
// differently, which made this exception an oracle for every other
// account's password.
if (pendingLink && pendingLink.userId !== user?.id) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// The per-account half of the throttle (`@/lib/auth/login-throttle`): a
// place this account has signed in from is neither counted nor held back,
// anywhere else waits longer after each failure past the first few, up to
// fifteen minutes. Decided before the Argon2id verification, so a waiting
// attempt costs no hashing.
const attempt = await beginAccountLoginAttempt({
user,
identifier,
request,
ip: rl.ip,
});
annotate({ meta: { login_known_source: attempt.source ?? "none" } });
if (attempt.waiting) {
await auditLog("auth.login.account_throttled", {
userId: user?.id,
ipAddress: ip,
@@ -137,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: null,
error: "Too many login attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(accountRl) },
{ status: 429, headers: rateLimitHeaders(attempt.waiting) },
);
}
@@ -154,6 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
if (!user || !user.passwordHash) {
await attempt.failed();
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
// happened, and PII must not land in operator artefacts. The
@@ -168,6 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (!valid) {
await attempt.failed();
await auditLog("auth.login.failed", {
userId: user.id,
ipAddress: ip,
@@ -176,14 +169,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid credentials", 401);
}
if (pendingLink && pendingLink.userId !== user.id) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// The password was right: this attempt was not a failed guess.
await refundRateLimit(accountBucket);
await attempt.succeeded();
const ua = request.headers.get("user-agent");
+5 -3
View File
@@ -16,8 +16,10 @@
* factor that later satisfies every step-up gate, so a stolen session must not
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
* — a sign-in or second factor inside the last five minutes, or a password /
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
* carries that proof in its elevation. The recovery-code batch is
* factor proof in the body (the step-up mint's shapes); on an account that
* already has a second factor, only that factor or a passkey. The Bearer arm
* carries the proof in its elevation, under the same rule: on an account with
* a second factor the elevation must come from one. The recovery-code batch is
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
* setup never persists codes.
*/
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const auth = await requireMfaManagementAuth();
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -8,7 +8,10 @@
*
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
* the new key would satisfy every step-up gate from then on.
* the new key would satisfy every step-up gate from then on. On an account that
* already has a second factor, both arms want that factor (or a passkey): a
* password, or a session that skipped the factor on a remembered browser, does
* not add a key.
*/
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (req: Request) => {
const auth = await requireMfaManagementAuth();
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(() => ({ catch: () => undefined })),
},
user: { findUnique: vi.fn() },
accountGrant: { findMany: vi.fn(async () => []) },
},
}));
@@ -165,6 +166,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
@@ -205,6 +207,7 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
accessTokensRevoked: 1,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
@@ -219,6 +222,53 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
});
});
describe("sign out everywhere and record sharing", () => {
it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 2,
});
const party = { id: "user-2", username: "carer", displayName: null };
const live = {
id: "grant-live",
access: "READ",
acceptedAt: new Date("2026-01-01"),
revokedAt: null,
expiresAt: null,
grantee: party,
};
const lapsed = {
...live,
id: "grant-lapsed",
expiresAt: new Date("2026-02-01"),
};
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
live,
lapsed,
] as never);
const res = await DELETE(del());
const body = await res.json();
expect(body.data.pendingInvitesRevoked).toBe(2);
// The lapsed one confers nothing any more and is not listed.
expect(body.data.grantsKept).toEqual([
{ id: "grant-live", account: party, access: "READ" },
]);
expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
grantorId: "user-1",
revokedAt: null,
}),
}),
);
});
});
describe("DELETE /api/auth/me/sessions/[id]", () => {
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
+33 -1
View File
@@ -5,7 +5,10 @@
* connection and token, and (unless
* `?keepShareLinks=1`) every clinician share
* link, keeping the caller's current
* credential. Closes #64.
* credential. Closes #64. Also withdraws
* record-sharing invitations nobody has
* accepted yet, and lists the accepted
* grants it left standing (`grantsKept`).
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
@@ -34,6 +37,8 @@ import {
type CurrentCredential,
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { isGrantActive } from "@/lib/sharing/grants";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
@@ -131,11 +136,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
pendingInvitesRevoked,
} = await destroyOtherSessions(user.id, current, {
reach: "everything",
keepShareLinks,
});
// The accepted grants this account gave, still live. Not ended here: each is
// a person the owner chose, often a carer who still needs the record, and a
// button labelled "sign out" must not quietly cut them off. They are listed
// instead, so the person who just signed everything out can see who can
// still read the record and end any of them with one click.
const now = new Date();
const grantsKept = (
await prisma.accountGrant.findMany({
where: {
grantorId: user.id,
acceptedAt: { not: null },
revokedAt: null,
},
orderBy: { acceptedAt: "desc" },
include: { grantee: { select: GRANT_PARTY_SELECT } },
})
)
.filter((g) => isGrantActive(g, now))
.map((g) => ({ id: g.id, account: g.grantee, access: g.access }));
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -144,6 +170,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
pendingInvitesRevoked,
grantsKept: grantsKept.length,
keepShareLinks,
},
});
@@ -155,6 +183,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
access_tokens_revoked: accessTokensRevoked,
connectors_revoked: connectorsRevoked,
share_links_revoked: shareLinksRevoked,
pending_invites_revoked: pendingInvitesRevoked,
grants_kept: grantsKept.length,
},
});
@@ -163,6 +193,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
accessTokensRevoked,
connectorsRevoked,
shareLinksRevoked,
pendingInvitesRevoked,
grantsKept,
});
});
@@ -5,10 +5,16 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
isSecondFactorProof,
recordReproofFailure,
refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import {
hasSecondFactorEnrolled,
recentProofMethods,
} from "@/lib/auth/second-factor";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
// On an account with a second factor a password does not add a sign-in
// credential, exactly as it does not satisfy the second factor at sign-in:
// a passkey registered on the strength of the password alone would from then
// on sign in without the second factor at all. Refused before a guess is
// spent, with the proofs that would work.
if (
!isSecondFactorProof(parsed.data.method) &&
(await hasSecondFactorEnrolled(user))
) {
annotate({
action: { name: "auth.reproof.method_refused" },
meta: { stage: "passkey_enroll", method: parsed.data.method },
});
return apiError("Confirm with your second factor or a passkey", 422, {
errorCode: "auth.reproof.too_weak",
methods: await recentProofMethods(user, true),
});
}
// Every proof counts against the account's shared re-proof bucket and every
// refusal is audited. Without both, a stolen session could guess the account
// password or a TOTP code here as fast as it could send requests.
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return proofRequired();
}
await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
// The right current password was not a guess: the shared re-proof
// bucket gets its slot back.
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
});
+5
View File
@@ -16,6 +16,7 @@ import {
} from "@/lib/api-response";
import {
recordReproofFailure,
refundReproof,
throttleReproof,
} from "@/lib/auth/existing-factor-proof";
import {
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return apiError("Current password is incorrect", 401);
}
// The current password was right, so this attempt was not a guess. Give the
// shared re-proof budget its slot back; otherwise a password change followed
// by a few confirmations elsewhere runs the owner out of attempts.
await refundReproof(user.id);
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
+3
View File
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await refundReproof(user.id);
const now = new Date();
await prisma.session.update({
where: { id: session.id },
@@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
}
});
});
describe("GET /api/export — the whole record asks for a fresh proof", () => {
const stale = {
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
};
it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export?type=all"));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("no type means the whole record, and is gated the same way", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export"));
expect(res.status).toBe(401);
});
it("type=all right after signing in goes through", async () => {
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export?type=all"));
expect(res.status).toBe(200);
});
it("a single-type export stays on the session alone", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
const { GET } = await import("../route");
const res = await GET(
mkReq("http://localhost/api/export?type=measurements"),
);
expect(res.status).toBe(200);
});
});
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
toJson: (value: unknown) => value,
}));
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
expect(text).toContain("A123456789");
});
});
describe("POST /api/export/health-record — a fresh proof on the browser", () => {
it("a session that signed in an hour ago is asked to confirm first", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -22,7 +22,11 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
// reaches, because it renders a prepared artefact from a declared selection
// rather than handing over the record whole.
const { user, actor } = await requireRecordAuth("manage", "record");
const auth = await requireRecordAuth("manage", "record");
const { user, actor } = auth;
// A whole-record artefact: a browser session must have signed in or
// re-proved within five minutes, the gate every whole-record export takes.
// The proof is the person at the keyboard, so it is the actor's session
// that is asked, whether they export their own record or one they manage.
// A token is its own proof here, as before: the shipped app calls this
// route with its token and no elevation.
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(actor, auth.session.id);
}
annotate({ action: { name: "export.health-record.build" } });
// The whole doctor-report surface is the `doctorReport` module. Refuse with
+18 -4
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
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 { apiError, getClientIp } from "@/lib/api-response";
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
* Export user data.
* Query params:
* format: "csv" | "json" (default: json)
* type: "measurements" | "medications" | "intake" | "all" (default: all)
* type: "measurements" | "medications" | "intake" | "mood" | "all"
* (default: all)
*
* Re-authentication is required via session (user must be logged in).
* `type=all` (the default) is the whole record in one file, so it takes the
* same fresh-proof gate as every other whole-record export
* (`requireRecentProof`): a browser session must have signed in or re-proved
* within five minutes, a token must present a step-up elevation. No shipped
* client calls this route with a token. The single-type exports stay on the
* session alone, like their dedicated routes under `/api/export/*`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const wholeRecord =
(new URL(request.url).searchParams.get("type") ?? "all") === "all";
const auth = wholeRecord
? await requireRecentProof({ bearer: "elevation" })
: { ...(await requireAuth()), commitElevation: async () => {} };
const { user } = auth;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// Spend a Bearer elevation only once the request is about to act.
await auth.commitElevation();
const userId = user.id;
await auditLog("export.download", {
userId,
+17 -3
View File
@@ -13,12 +13,17 @@
* resolves inside the response and the operation can never quietly carry less
* than the document does.
*
* Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
* Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
* recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAuth,
} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,7 +37,16 @@ import {
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
const auth = await requireAuth(FHIR_READ_SCOPE);
const { user } = auth;
// The whole record in one response: a browser session must have signed in
// or re-proved within five minutes, like every other whole-record export.
// A token is its own proof here, as before: the shipped app and the FHIR
// read tokens call this route without an elevation, and a refusal would
// read to the app as a dead session.
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(user, auth.session.id);
}
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
import path from "node:path";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
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/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
import { GET as metadataGet } from "../metadata/route";
import { GET as everythingGet } from "../Patient/$everything/route";
import { prisma } from "@/lib/db";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
});
describe("GET /api/fhir/Patient/$everything", () => {
it("asks a browser session that signed in an hour ago to confirm first", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await everythingGet(req("/api/fhir/Patient/$everything"));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
});
it("returns every resource family the document bundle carries", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
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/modules/operator-availability", async (importOriginal) => {
@@ -1,7 +1,7 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
@@ -21,6 +21,22 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
// The consent reads the session's own proof stamps. By default the session
// signed in a moment ago, which is a recent proof on an account without a
// second factor; the cases below age it.
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) },
},
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
@@ -30,6 +46,7 @@ import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { prisma } from "@/lib/db";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
@@ -327,6 +344,82 @@ describe("POST /authorize — decision", () => {
});
});
describe("authorize — allowing a connection needs a recent proof", () => {
// A queued stale stamp a case did not read must not leak into the next one.
afterEach(() => {
vi.mocked(prisma.session.findUnique).mockReset();
});
function staleSession() {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
}
const allow = {
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
state: "xyz",
resource: RESOURCE,
decision: "allow",
};
it("GET shows a confirm link instead of the form when the session is an hour old", async () => {
signedIn();
staleSession();
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/Confirm it is you/);
expect(body).not.toMatch(/name="decision"/);
const href = body.match(/href="([^"]+)"/)?.[1] ?? "";
const next =
new URL(href.replace(/&amp;/g, "&"), "https://x").searchParams.get(
"next",
) ?? "";
expect(href.startsWith("/confirm-access?next=")).toBe(true);
// Back to this exact consent request.
expect(next).toBe(
new URL(authorizeUrl()).pathname + new URL(authorizeUrl()).search,
);
});
it("POST allow on an hour-old session mints nothing", async () => {
signedIn();
staleSession();
const res = await POST(postReq(allow) as never);
expect(res.status).toBe(401);
expect(res.headers.get("location")).toBeNull();
expect(await res.text()).toMatch(/confirm-access\?next=/);
});
it("an account with a second factor needs the factor, not a young session", async () => {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "s1",
expiresAt: new Date(Date.now() + 1e6),
actingAsUserId: null,
recordEpoch: 0,
},
user: { id: "user-1", totpConfirmedAt: new Date("2026-01-01") } as never,
});
const res = await POST(postReq(allow) as never);
expect(res.status).toBe(401);
});
it("deny needs no proof", async () => {
signedIn();
staleSession();
const res = await POST(postReq({ ...allow, decision: "deny" }) as never);
expect(res.status).toBe(303);
});
});
describe("authorize CSP — the consent form may lead only to the validated redirect", () => {
function csp(res: Response): string {
return res.headers.get("content-security-policy") ?? "";
+63 -1
View File
@@ -11,7 +11,9 @@
* a GET (confused-deputy mitigation: a third party cannot silently
* obtain a grant — an authenticated human must click "Allow").
* POST — the consent decision. Requires a session, re-validates every
* parameter, and on "Allow" mints a single-use, short-lived,
* parameter, and on "Allow" asks for a recent sign-in or re-proof
* (the GET shows a confirm link instead of the form without one),
* then mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 303s back.
*
@@ -30,6 +32,8 @@ import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { assertRecentCookieProof } from "@/lib/api-handler";
import { StepUpRequiredError } from "@/lib/api-errors";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
@@ -88,6 +92,42 @@ function html(body: string, status = 200): Response {
);
}
/**
* Has this session signed in or re-proved recently enough to grant access?
*
* Allowing a connection hands a standing credential to the record to a third
* party, one that outlives the session: the same class of act as minting a
* token or making a share link, and it takes the same gate
* (`assertRecentCookieProof`: a sign-in or re-proof within five minutes, with
* the second factor on an account that has one). Without it, whoever held a
* stolen session could connect their own assistant and keep reading the
* record after the owner signed everything out.
*/
async function hasRecentProof(
session: NonNullable<Awaited<ReturnType<typeof getSession>>>,
): Promise<boolean> {
try {
await assertRecentCookieProof(session.user, session.session.id);
return true;
} catch (err) {
if (err instanceof StepUpRequiredError) return false;
throw err;
}
}
/**
* The page shown instead of the consent form while the session carries no
* recent proof. It links to the confirm page, which runs the same re-proof
* dialog as the rest of the app and then comes back to this exact request.
*/
function reproofPage(clientName: string, returnTo: string, status = 200) {
const confirmHref = `/confirm-access?next=${encodeURIComponent(returnTo)}`;
return html(
`<main><h1>Confirm it is you</h1><p>Before <strong>${htmlEscape(clientName)}</strong> can connect to your HealthLog data, confirm it is you. This is asked when you have not signed in or confirmed in the last five minutes.</p><p><a href="${htmlEscape(confirmHref)}">Confirm and continue</a></p></main>`,
status,
);
}
/**
* The scopes this surface grants for a request. `health:read` is always
* granted (the floor). `health:write` is granted ONLY when explicitly
@@ -359,6 +399,11 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
);
}
if (!(await hasRecentProof(session))) {
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
return reproofPage(v.clientName, url.pathname + url.search);
}
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
@@ -499,6 +544,23 @@ async function authorizePost(request: NextRequest): Promise<Response> {
});
}
// The same gate the consent page asked for, checked again on the decision
// itself: the page may have been left open past the window, or the POST
// built by hand. Denying needs no proof, so only "allow" is gated.
if (!(await hasRecentProof(session))) {
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
const back = new URL("/api/mcp/oauth/authorize", request.url);
back.searchParams.set("response_type", "code");
back.searchParams.set("client_id", v.clientId);
back.searchParams.set("redirect_uri", v.redirectUri);
back.searchParams.set("code_challenge", v.codeChallenge);
back.searchParams.set("code_challenge_method", "S256");
back.searchParams.set("scope", v.scope);
back.searchParams.set("resource", v.resource);
if (v.state !== undefined) back.searchParams.set("state", v.state);
return reproofPage(v.clientName, back.pathname + back.search, 401);
}
const code = signArtifact(
"authCode",
{