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

@@ -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 });
});