Merge branch 'feat/v1462-auth' into release/v1.46.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 09:45:37 +01:00
commit d0be3ae106
74 files changed
+3645 -901

No files matched your search

+140 -1
View File
@@ -3,13 +3,21 @@ import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAdmin,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { adminDeleteUserSchema } from "@/lib/validations/admin";
const updateUserSchema = z
.object({
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
});
},
);
/** A refusal decided under the account's row lock, inside the transaction. */
class DeletionRefusal extends Error {
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
super(`admin deletion refused: ${kind}`);
this.name = "DeletionRefusal";
}
}
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
if (kind === "not_found") return apiError("User not found", 404);
if (kind === "managed_profile") {
return apiError("A managed profile is deleted by its Guardians", 409, {
errorCode: "admin.users.delete.managed_profile",
});
}
return apiError("This account is already pending deletion", 409, {
errorCode: "account.deletion.already_pending",
});
}
/**
* DELETE /api/admin/users/{id} — an admin deletes another account.
*
* The same request the account's own deletion makes
* (`requestAccountDeletion`): with a grace window the account is locked at
* once (every credential revoked, sign-in refused) and erased by the purge job
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
*
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
* the gate in front of the other admin acts on someone's data), with the typed
* confirmation the self-deletion asks for. Refused for the caller's own
* account (that is the account settings' deletion, with its own step-up), for
* the last admin who can sign in, for a managed profile (its Guardians delete
* it), for an account already pending deletion, and for the last Guardian of
* a managed profile. Audit-logged for the admin and on the account itself.
*/
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin, session } = await requireAdmin();
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({
action: {
name: "admin.users.delete",
entity_type: "user",
entity_id: id,
},
});
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
maxBytes: 4 * 1024,
});
if (!parsed.ok) return parsed.error;
if (id === admin.id) {
return apiError(
"Delete your own account from your account settings",
400,
{ errorCode: "admin.users.delete.self" },
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { role: true, username: true },
});
if (!target) return apiError("User not found", 404);
let purgeAt: Date | null;
try {
({ purgeAt } = await requestAccountDeletion({
userId: id,
targetIsAdmin: target.role === "ADMIN",
requestedById: admin.id,
inTransaction: async (tx, scheduledPurgeAt) => {
const state = await lockPendingAccount(tx, id);
if (!state) throw new DeletionRefusal("not_found");
if (state.managedProfile)
throw new DeletionRefusal("managed_profile");
if (state.pending) throw new DeletionRefusal("pending");
const details = {
targetUserId: id,
username: target.username,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
};
await auditLog("admin.user.delete", {
userId: admin.id,
ipAddress: getClientIp(request),
details,
client: tx,
});
// On the account itself too, so its own activity shows who asked.
// An immediate erasure takes this row with the account's history.
await auditLog("user.account.delete", {
userId: id,
actorUserId: admin.id,
details: { ...details, requestedBy: "admin" },
client: tx,
});
},
}));
} catch (error) {
if (error instanceof DeletionRefusal) {
return deletionRefusalResponse(error.kind);
}
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
}
if (error instanceof LastManagedGuardianError) {
return apiError(
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
409,
{ errorCode: "managed_profile.guardian.required" },
);
}
throw error;
}
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
return apiSuccess({
deleted: true,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
});
},
);
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
// BigInt in Prisma, plain number on the wire.
documentQuotaBytes: 2_147_483_648,
passkeyCount: 2,
managedProfile: false,
// Not pending deletion.
deletion: null,
});
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+9 -3
View File
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
deletionRequestedAt: true,
deletionPurgeAt: true,
deletionRequestedById: true,
managedProfileAt: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
documentQuotaBytes:
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
passkeyCount: u._count.passkeys,
// A record somebody else runs. Its Guardians delete it, so the list
// offers no admin deletion on it.
managedProfile: u.managedProfileAt != null,
// An account pending deletion: when it was asked for, when the
// purge job erases it, and whether the account asked itself or a
// Guardian deleted the managed profile. Null on every other account.
// purge job erases it, and who asked: the account itself, a Guardian
// (a managed profile), or an admin. Null on every other account.
// The list offers "Undo deletion" on these rows until `purgeAt`.
deletion: u.deletionRequestedAt
? {
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
requestedBy:
u.deletionRequestedById === u.id
? ("self" as const)
: ("guardian" as const),
: u.managedProfileAt != null
? ("guardian" as const)
: ("admin" as const),
}
: null,
})),
+7 -1
View File
@@ -22,19 +22,21 @@ import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
import { recentProofMethods } from "@/lib/auth/second-factor";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
recentProofMethods(user),
]);
annotate({ action: { name: "auth.mfa.status" } });
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
// The ways this account can confirm it is you, the list the recent-proof
// refusal names. The passkey card offers exactly these before adding one,
// so an account without a password is not handed a password field.
proofMethods,
});
});
+19 -12
View File
@@ -93,6 +93,21 @@ const SERVER_FEATURES = {
lifeCircumstances: true,
} as const;
/**
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
* 500s the whole profile fetch (the field just reads null and the user
* re-enters it).
*/
function readInsuranceNumber(encrypted: string | null | undefined) {
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -289,22 +304,14 @@ export const GET = apiHandler(async () => {
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
const insuranceNumber = readInsuranceNumber(
identity?.insuranceNumberEncrypted,
);
return apiSuccess({
id: user.id,
username: user.username,
createdAt: user.createdAt,
email: user.email,
role: user.role ?? "USER",
heightCm: user.heightCm,
+116 -33
View File
@@ -47,6 +47,11 @@ import {
} from "@/lib/managed-profiles/claim-sso";
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
import {
isReproofSession,
recordOidcReproof,
reproofReturnUrl,
} from "@/lib/auth/oidc-reproof";
const LOGIN_ERROR_URL = "/auth/login";
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
return response;
}
/**
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
* the person is still signed in, and the session they came from is the one
* thing that leg may touch. `reason` null is the success.
*/
function reproofRedirect(next: string, reason: string | null): NextResponse {
if (reason) annotate({ meta: { reason } });
const response = NextResponse.redirect(
reproofReturnUrl(next, reason ? "failed" : "done"),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
const isNative = stored?.native === true;
const isClaim = stored?.intent === "claim";
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
const isReproof = stored?.intent === "reproof";
const reproofBack = (reason: string | null) =>
reproofRedirect(stored?.next ?? "/", reason);
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
};
const failRedirect = (reason: string): NextResponse => {
if (isReproof) return reproofBack(reason);
if (isClaim) return claimRedirect(reason);
if (isNative) {
const response = NextResponse.redirect(
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) {
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (!stored) {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
if (!stored) return stateMissing(req);
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
if (isReproof) return reproofBack("oidc_state");
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
}
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
// v1.43 (#959) — a claim is never finished over a live session (the claim
// routes' rule: a Guardian finishing their own link would otherwise be
// signed out of their account and into the person's), and is admitted only
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
return failRedirect("oidc_failed");
}
});
/**
* #1296 — the re-proof leg. The browser must still carry the session the
* start bound into the state, checked before the code is redeemed; then the
* provider's answer must name this account's linked identity and an
* authentication inside the gate's window. Only then is `reproofAt` stamped.
* No session is created, and every refusal lands back on the page it left.
*/
async function finishReproof(
req: NextRequest,
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
config: NonNullable<ReturnType<typeof getOidcConfig>>,
ip: string,
back: (reason: string | null) => NextResponse,
): Promise<NextResponse> {
const live = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (!isReproofSession(stored, live)) return back("session_mismatch");
try {
const metadata = await discoverOidcMetadata(config);
const tokens = await exchangeCodeForTokens({
metadata,
config,
code: req.nextUrl.searchParams.get("code") as string,
codeVerifier: stored.codeVerifier,
redirectUri: getOidcRedirectUri(),
});
if (!tokens.id_token) return back("oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
const outcome = await recordOidcReproof({
userId: stored.reproofUserId as string,
sessionId: stored.reproofSessionId as string,
issuer: metadata.issuer,
identity,
ipAddress: ip,
});
return back(outcome.ok ? null : outcome.reason);
} catch (err) {
getEvent()?.setError(err);
return back("exchange_or_verify_failed");
}
}
/**
* A callback that arrives without its state blob: the duplicate of a sign-in
* that already completed, or a forged, replayed or expired one.
*/
async function stateMissing(req: NextRequest): Promise<NextResponse> {
// Defence-in-depth idempotency for a duplicated callback. The single-use
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
// first callback the moment it succeeds. A second callback for the same
// sign-in (a client/proxy retry, a double navigation, or the
// service-worker double-request this release also fixes) therefore arrives
// with the state cookie already gone — `stored` is null — and, because the
// first callback set the session, it carries a live session cookie.
// Redeeming the now-consumed authorization code again fails at the IdP and
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
// when a VALIDATED session is present, treat this as a duplicate of a
// completed login and send the user into the app instead of erroring.
//
// Fail-closed is preserved for the real attack case: the code is NEVER
// exchanged on this branch, and the session is resolved against the
// database + its expiry (not merely "a cookie is present"). A forged,
// replayed, or expired state from a caller with no valid session gets the
// unchanged `oidc_state` error; a caller who already holds their own valid
// session gains nothing here but a redirect to a page they could already
// reach — no code redemption, no session mint, no identity change.
const existingSession = await validateSessionFromCookieValue(
readSessionCookie(req.cookies)?.value,
);
if (existingSession) {
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
const response = NextResponse.redirect(oidcAppUrl("/"));
// The state cookie is already consumed; delete defensively so a stray
// blob can never outlive this response.
deleteStateCookie(response);
return response;
}
return errorRedirect("oidc_state");
}
+118
View File
@@ -0,0 +1,118 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { recentProofMethods } from "@/lib/auth/second-factor";
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
export const dynamic = "force-dynamic";
/**
* Start confirming it is you at the identity provider (#1296).
*
* A top-level navigation from the re-proof dialog, cookie session only: a
* Bearer token never re-proves this way, because a stamp on a session row
* means nothing to a token (`requireCookieAuth` reads the session cookie and
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
* is an account without a second factor whose identity is linked while a
* provider is configured; anything else goes straight back with
* `reproof=failed`.
*
* The round trip asks for a fresh authentication (`prompt=login`,
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
* same single-use cookie the sign-in uses, together with the account and the
* session it confirms. The callback stamps that session's `reproofAt` and
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
* a session.
*
* Draws on the account's shared re-proof budget, like every other proof.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.reproof.start" } });
const { user, session } = await requireCookieAuth();
const next = sanitizeOidcNextPath(
req.nextUrl.searchParams.get("next"),
req.url,
);
const back = (reason: string): NextResponse => {
annotate({ meta: { reason } });
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
};
const config = getOidcConfig();
if (!config) return back("oidc_disabled");
const methods = await recentProofMethods(user);
if (!methods.includes("oidc")) return back("not_offered");
const limited = await throttleReproof(
user.id,
getClientIp(req),
OIDC_REPROOF_STAGE,
);
if (limited) return back("rate_limited");
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return back("oidc_failed");
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const response = NextResponse.redirect(
buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge: s256Challenge(codeVerifier),
redirectUri: getOidcRedirectUri(),
reauthenticate: true,
}),
);
response.cookies.set(
OIDC_STATE_COOKIE,
encrypt(
JSON.stringify({
state,
nonce,
codeVerifier,
next,
intent: "reproof" as const,
reproofUserId: user.id,
reproofSessionId: session.id,
}),
),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
path: OIDC_STATE_COOKIE_PATH,
},
);
return response;
});
@@ -5,6 +5,8 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
RECENT_PROOF_MAX_AGE_MS,
REPROOF_REQUIRED_CODE,
isSecondFactorProof,
recordReproofFailure,
refundReproof,
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ ...result, reauth: true });
}
// #1296 — an account whose only credential is its identity provider proves
// itself there (`GET /api/auth/oidc/reproof`), which stamps
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
// stamp as the proof: only where `oidc` is on the account's list (no second
// factor, a linked identity, a configured provider) and only inside the
// five-minute window. Like a password, it clears the MFA stamp rather than
// setting it.
if (Object.keys(body).length === 1 && body.method === "oidc") {
if (
!(await recentProofMethods(user)).includes("oidc") ||
!(await hasRecentReproof(session.id))
) {
annotate({
action: { name: "auth.reproof.required" },
meta: { stage: "passkey_enroll", method: "oidc" },
});
return apiError("Confirm it is you before adding a passkey", 401, {
errorCode: REPROOF_REQUIRED_CODE,
methods: await recentProofMethods(user),
});
}
return issueRegistrationOptions(user, session.id, null);
}
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
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,
// session-bound challenge but cannot upgrade password-only authentication.
return issueRegistrationOptions(
user,
session.id,
parsed.data.method === "password" ? null : new Date(),
);
});
/** A password or provider re-proof on this session inside the window. */
async function hasRecentReproof(sessionId: string): Promise<boolean> {
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { reproofAt: true },
});
return (
row?.reproofAt != null &&
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
);
}
/**
* A strong existing factor refreshes the session's MFA stamp. A password or
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
* is authorized by this single-use, session-bound challenge but cannot upgrade
* first-factor authentication.
*/
async function issueRegistrationOptions(
user: { id: string; email: string | null; username: string },
sessionId: string,
mfaVerifiedAt: Date | null,
): Promise<Response> {
await prisma.session.update({
where: { id: session.id },
data: {
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
},
where: { id: sessionId },
data: { mfaVerifiedAt },
});
const { options, challengeId } = await createRegistrationOptions(
user.id,
user.email ?? user.username,
session.id,
sessionId,
);
annotate({ action: { name: "auth.passkey.register-options" } });
return apiSuccess({ options, challengeId });
});
}
+16 -58
View File
@@ -8,24 +8,10 @@ import {
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
import {
deleteGuardianAccountWithLifecycle,
LastManagedGuardianError,
} from "@/lib/managed-profiles/lifecycle";
import {
accountDeletionGraceDays,
accountDeletionPurgeAt,
} from "@/lib/account-deletion/grace";
import {
eraseAccount,
lockAccountForDeletion,
} from "@/lib/account-deletion/lifecycle";
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
import { requestAccountDeletion } from "@/lib/account-deletion/request";
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import {
assertAnotherAdminStays,
LastAdminError,
} from "@/lib/auth/admin-roster";
import { LastAdminError } from "@/lib/auth/admin-roster";
export const dynamic = "force-dynamic";
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// the elevation would stay redeemable while the erasure was already running.
await auth.commitElevation();
const requestedAt = new Date();
const purgeAt = accountDeletionPurgeAt(
requestedAt,
accountDeletionGraceDays(),
);
let purgeAt: Date | null;
try {
await deleteGuardianAccountWithLifecycle(
({ purgeAt } = await requestAccountDeletion({
userId,
async (tx) => {
// Checked again under the roster lock: two admins deleting at the
// same moment must not each count the other.
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
// The invariant has now passed, so this success event cannot survive
// a refusal. On an immediate erasure it is purged with the rest of the
// account's audit history; during a grace window it is the account's
// own record of the request.
await auditLog("user.account.delete", {
targetIsAdmin: user.role === "ADMIN",
requestedById: userId,
// The invariants have passed by the time this runs, so this success
// event cannot survive a refusal. On an immediate erasure it is purged
// with the rest of the account's audit history; during a grace window
// it is the account's own record of the request.
inTransaction: (tx, scheduledPurgeAt) =>
auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
details: {
username,
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
},
client: tx,
});
if (purgeAt) {
// Locked, not erased: credentials revoked in this transaction,
// the record untouched until the purge job reaches `purgeAt`.
await lockAccountForDeletion(tx, {
userId,
requestedById: userId,
requestedAt,
purgeAt,
});
return;
}
// No window: erase now, inside the same record lifecycle transaction
// so a refusal leaves every account state intact (sessions, the
// account's audit history — GDPR Art. 17 — the off-host purge
// request, then the row with everything cascading from it).
await eraseAccount(tx, userId, "account_deleted");
},
{ erasing: purgeAt === null },
);
}),
}));
} catch (error) {
if (error instanceof LastAdminError) {
return apiError("The last admin account cannot be deleted", 400);
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
throw error;
}
if (!purgeAt) await kickOffhostPurge();
annotate({
action: { name: "settings.account.delete" },
meta: { username, deletion_scheduled: purgeAt !== null },