mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'feat/v1462-auth' into release/v1.46.2
This commit is contained in:
commit
d0be3ae106
74 files changed
+3645
-901
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user