mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
cd3c0b7de2
74 files changed
+4140
-508
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
|
||||
*
|
||||
* The admin states that the active encryption key is backed up. The body
|
||||
* names the key the admin was shown; when that is no longer the active key
|
||||
* (rotated, or the server restarted with another key since the page loaded)
|
||||
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
|
||||
* No step-up: confirming is a low-risk write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
confirmKeyBackup,
|
||||
KEY_BACKUP_STALE_CODE,
|
||||
} from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const confirmSchema = z
|
||||
.object({
|
||||
keyId: z.string().min(1).max(32),
|
||||
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = confirmSchema.safeParse(body.data);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error);
|
||||
|
||||
const result = await confirmKeyBackup({
|
||||
keyId: parsed.data.keyId,
|
||||
fingerprint: parsed.data.fingerprint,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { key_backup_stale: true } });
|
||||
return apiError(
|
||||
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
|
||||
409,
|
||||
{
|
||||
errorCode: KEY_BACKUP_STALE_CODE,
|
||||
activeKeyId: result.activeKeyId,
|
||||
fingerprint: result.fingerprint,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("encryption.keyBackup.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keyId: result.status.activeKeyId,
|
||||
fingerprint: result.status.fingerprint,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.status);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/key-backup
|
||||
*
|
||||
* Whether the "Back up your encryption key" step is due, for the admin step,
|
||||
* the overview tile and the dashboard banner. A light read (one settings row),
|
||||
* separate from `/status`, which walks the whole encrypted corpus. Key id and
|
||||
* fingerprint only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
|
||||
const status = await readKeyBackupStatus();
|
||||
annotate({ meta: { key_backup_due: status.due } });
|
||||
return apiSuccess(status);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
|
||||
*
|
||||
* "Check my copy": the admin pastes the backed-up key and learns whether it
|
||||
* is the active key. The value is compared in memory (constant time on the
|
||||
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
|
||||
* the Zod issue list is not returned for this body (a validation failure is a
|
||||
* plain 422), and the field name is on the observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KEY_BACKUP_VERIFY_LIMIT = 5;
|
||||
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({ encryptionKey: z.string().min(1).max(256) })
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`key-backup-verify:${user.id}`,
|
||||
KEY_BACKUP_VERIFY_LIMIT,
|
||||
KEY_BACKUP_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key as 64 hex characters.", 422);
|
||||
}
|
||||
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
|
||||
annotate({ meta: { key_backup_verify_matches: matches } });
|
||||
await auditLog("encryption.keyBackup.verified", {
|
||||
userId: user.id,
|
||||
details: { keyId: activeKeyId, matches },
|
||||
});
|
||||
return apiSuccess({ matches, keyId: activeKeyId });
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
const keyBackup = await readKeyBackupStatus();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -233,6 +235,8 @@ export const GET = apiHandler(async () => {
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// The "Back up your encryption key" step, bound to the active key.
|
||||
keyBackup,
|
||||
// Every row rotated AND no backup still needs a retired key: the one
|
||||
// state in which dropping an old key loses nothing. Copies that recorded
|
||||
// no key ids count against it, because they may hold any key that
|
||||
|
||||
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A browser page on plain http:// with Secure-only cookies cannot keep the
|
||||
// session. Refuse before the password is verified (the account throttle
|
||||
// never counts it) and before an MFA ticket is minted.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded, an attempt reserved, or a one-time code
|
||||
// spent: a sign-in whose cookie cannot stick must not consume either.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
// The SSO button states the page's transport. A browser on plain http://
|
||||
// with Secure-only cookies would come back from the provider and drop the
|
||||
// session cookie, so it is stopped here instead of after the round trip.
|
||||
if (!isNative) {
|
||||
const clientProto = req.nextUrl.searchParams.get("client_proto");
|
||||
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
|
||||
if (
|
||||
(clientProto === "http" || clientProto === "https") &&
|
||||
transportVerdict(shouldEmitSecureCookie(), {
|
||||
protocol: clientProto,
|
||||
host: clientHost,
|
||||
}) === "blocked"
|
||||
) {
|
||||
annotate({ meta: { reason: "insecure_transport" } });
|
||||
return loginError("insecure_transport");
|
||||
}
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:login",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login.
|
||||
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// The body is optional (the web client sends its page transport; nothing
|
||||
// else needs to send anything). A missing or unreadable body is no body, as
|
||||
// before; only a stated plain-http page refuses, before a challenge exists.
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions();
|
||||
|
||||
annotate({ action: { name: "auth.passkey.login-options" } });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
// Whether this server issues `Secure` session cookies. The login page
|
||||
// compares it with its own scheme before any credential is sent.
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed on backend errors.
|
||||
annotate({ action: { name: "auth.registration-status" } });
|
||||
return apiSuccess({ registrationEnabled: false });
|
||||
return apiSuccess({
|
||||
registrationEnabled: false,
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
|
||||
expect(ready.body).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/health while the encryption key does not match", () => {
|
||||
it("answers 503 with the reason, for everyone, even with every component up", async () => {
|
||||
const { setKeyMismatchState } =
|
||||
await import("@/lib/boot/key-mismatch-state");
|
||||
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
|
||||
getGlobalBoss.mockReturnValue({});
|
||||
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
||||
try {
|
||||
const { response, body } = await health();
|
||||
expect(response.status).toBe(503);
|
||||
expect(body).toEqual({
|
||||
status: "degraded",
|
||||
reason: "encryption_key_mismatch",
|
||||
});
|
||||
} finally {
|
||||
setKeyMismatchState(null);
|
||||
}
|
||||
const { response, body } = await health();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isKeyMismatch,
|
||||
KEY_MISMATCH_HEALTH_REASON,
|
||||
} from "@/lib/boot/key-mismatch-state";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,7 +26,13 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const producerReady = getGlobalBoss() !== null;
|
||||
// A process whose encryption key cannot open the database refuses every
|
||||
// other route; the probe says so in a word every caller may see, because
|
||||
// it is the one place a catalog install's status page reads.
|
||||
const keyMismatch = isKeyMismatch();
|
||||
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
|
||||
const status =
|
||||
!keyMismatch &&
|
||||
dbOk &&
|
||||
(!shouldRunWeb() || producerReady) &&
|
||||
(!shouldRunWorker() || worker.running)
|
||||
@@ -40,6 +50,7 @@ export const GET = apiHandler(async () => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
...(reason ? { reason } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbOk ? "connected" : "disconnected",
|
||||
worker: worker.running ? "running" : "stopped",
|
||||
@@ -52,7 +63,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ status },
|
||||
{ status, ...(reason ? { reason } : {}) },
|
||||
{ status: statusCode, headers: cacheHeaders },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user