Files
HealthLog/src/app/api/auth/me/mfa/disable/route.ts
T
Marc-André Bombeck 92c5b0a60d fix(auth): end connected assistants, tokens and share links on sign-out everywhere
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.

- Sign out everywhere now ends every other sign-in, every AI-assistant
  connection, every programmatic token except the caller's own, and every
  clinician share link. Share links are revoked by default; the web offers
  a checkbox to keep them (keepShareLinks=1), and a client that sends
  nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
  connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
  other sign-in, as a password change does. Adding a further factor does
  not.

destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
2026-09-26 20:06:36 +02:00

133 lines
4.1 KiB
TypeScript

/**
* POST /api/auth/me/mfa/disable
*
* Tear down the second factor. Double-gated:
* 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that
* completed a second factor within the step-up window, OR a Bearer token
* presenting a single-use elevation it minted by re-proving a factor.
* 2. A current TOTP or recovery code in the body — proves live possession at
* the moment of the destructive action, not just a stale fresh-session.
*
* On success it clears `totpSecretEncrypted` / `totpConfirmedAt` /
* `totpLastStep` and deletes every recovery code in one transaction.
*/
import {
apiHandler,
AUTH_ERROR_CODES,
requireMfaManagementAuth,
HttpError,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { destroyOtherSessions } from "@/lib/auth/session";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { mfaDisableSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
const DISABLE_RATE_LIMIT = 5;
const DISABLE_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
// session is not freshly second-factor-verified.
const auth = await requireMfaManagementAuth({ freshFactor: true });
const { user } = auth;
const rl = await checkRateLimit(
`mfa:disable:${user.id}`,
DISABLE_RATE_LIMIT,
DISABLE_WINDOW_MS,
);
if (!rl.allowed) {
const res = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
res.headers.set(k, v);
}
return res;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4096,
});
if (jsonError) {
throw new HttpError(400, "Invalid JSON body");
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const factor = await verifyMfaFactor(
user,
parsed.data.method,
parsed.data.code,
);
if (!factor.ok) {
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
stage: "disable",
method: parsed.data.method,
replay: factor.replay,
},
});
annotate({ action: { name: "auth.mfa.disable.invalid_factor" } });
throw new HttpError(401, "Invalid code", AUTH_ERROR_CODES.mfaCode);
}
// Every cheap check has passed and the teardown is next, so spend the
// elevation now. Placing it here rather than at the gate means a wrong code or
// a 429 above costs the caller nothing.
await auth.commitElevation();
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: null,
totpConfirmedAt: null,
totpLastStep: null,
},
});
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
});
// v1.23 (M-review L3) — removing the second factor is a security-state
// change: drop every OTHER web session, every native refresh token, and
// every "remember this device" trusted device (all handled by
// destroyOtherSessions), keeping only the caller's current session. A stale
// session or a trusted-device cookie must not survive the factor's removal.
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.disabled", {
userId: user.id,
ipAddress: getClientIp(req),
details: { factor: parsed.data.method },
});
annotate({ action: { name: "auth.mfa.disabled" } });
return apiSuccess({ enabled: false });
});