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

+15 -4
View File
@@ -72,7 +72,7 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
},
);
it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => {
it("with TRUST_PROXY_HOPS=0 trusts no forwarding header, x-real-ip included", () => {
process.env.TRUST_PROXY_HOPS = "0";
const ip = getClientIp(
makeRequest({
@@ -80,7 +80,17 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
"x-real-ip": "8.8.8.8",
}),
);
expect(ip).toBe("8.8.8.8");
expect(ip).toBeNull();
});
it("x-real-ip rotation — with no declared proxy a caller cannot mint a fresh bucket per request", () => {
process.env.TRUST_PROXY_HOPS = "0";
const seen = new Set(
["1.1.1.1", "2.2.2.2", "3.3.3.3"].map((ip) =>
getClientIp(makeRequest({ "x-real-ip": ip })),
),
);
expect(seen).toEqual(new Set([null]));
});
it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => {
@@ -277,7 +287,8 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
expect(result.ip).toBeNull();
});
it("returns trustViolation=true and x-real-ip when chain is short but x-real-ip is set", () => {
it("returns trustViolation=true and ignores x-real-ip when the chain is short", () => {
// The declared proxies were bypassed, so x-real-ip came from the caller.
process.env.TRUST_PROXY_HOPS = "2";
const result = getClientIpOrTrustWarning(
makeRequest({
@@ -285,7 +296,7 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
"x-real-ip": "9.9.9.9",
}),
);
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: true });
expect(result).toEqual({ ip: null, trustViolation: true });
});
it("returns trustViolation=false when XFF is absent (no chain to validate)", () => {
@@ -176,8 +176,9 @@ describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => {
5,
15 * 60 * 1000,
);
// Falls through to x-real-ip when the chain is too short.
expect(result.ip).toBe("9.9.9.9");
// A short chain means the declared proxies were bypassed, so the
// caller-set x-real-ip is not believed either.
expect(result.ip).toBeNull();
expect(lastKey()).toBe("auth:anon:trust-violation");
} finally {
warn.mockRestore();
@@ -0,0 +1,221 @@
/**
* `requireRecentProof` / `assertRecentCookieProof` — the fresh-proof gate in
* front of whole-record exports, share links, token minting and the admin data
* actions.
*
* Cookie: an account with a second factor needs `mfaVerifiedAt` inside five
* minutes and nothing else; one without accepts a recent sign-in, second-factor
* stamp or password re-proof. Bearer: the rule the route names.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
stepUpElevation: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { requireRecentProof, StepUpRequiredError } from "../api-handler";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { headers } from "next/headers";
import { auditLog } from "@/lib/auth/audit";
const MFA_USER = {
id: "user-1",
role: "USER" as const,
username: "u",
totpConfirmedAt: new Date("2020-01-01"),
passwordHash: "argon",
};
const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null };
const NOW = () => new Date();
const OLD = () => new Date(Date.now() - 10 * 60 * 1000);
beforeEach(() => {
vi.resetAllMocks();
headersGet.mockReturnValue(null);
vi.mocked(headers).mockImplementation(
async () => ({ get: headersGet }) as never,
);
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
});
function cookie(
user: unknown,
stamps: {
mfaVerifiedAt?: Date | null;
createdAt?: Date;
reproofAt?: Date | null;
},
) {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user,
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: stamps.mfaVerifiedAt ?? null,
createdAt: stamps.createdAt ?? OLD(),
reproofAt: stamps.reproofAt ?? null,
} as never);
}
function bearer(user: unknown, elevation: string | null = null) {
vi.mocked(getSession).mockResolvedValue(null as never);
headersGet.mockImplementation((n) => {
const name = n.toLowerCase();
if (name === "authorization") return "Bearer hlk_xyz";
if (name === "x-step-up") return elevation;
return null;
});
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
}
async function refusal(p: Promise<unknown>): Promise<StepUpRequiredError> {
try {
await p;
} catch (err) {
expect(err).toBeInstanceOf(StepUpRequiredError);
return err as StepUpRequiredError;
}
throw new Error("expected a refusal");
}
describe("cookie session, account without a second factor", () => {
it("passes on a sign-in inside five minutes", async () => {
cookie(PLAIN_USER, { createdAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("passes on a password re-proof inside five minutes", async () => {
cookie(PLAIN_USER, { reproofAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("refuses an old session and names the password as the way through", async () => {
cookie(PLAIN_USER, {});
const err = await refusal(requireRecentProof({ bearer: "token" }));
expect(err.errorCode).toBe("auth.reproof.required");
expect(err.extraMeta).toEqual({ methods: ["password"] });
});
it("offers a passkey when the account holds one", async () => {
cookie({ ...PLAIN_USER, passwordHash: null }, {});
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
const err = await refusal(requireRecentProof({ bearer: "token" }));
expect(err.extraMeta).toEqual({ methods: ["passkey"] });
});
});
describe("cookie session, account with a second factor", () => {
it("passes on a second factor inside five minutes", async () => {
cookie(MFA_USER, { mfaVerifiedAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("a password re-proof does not stand in for the second factor", async () => {
cookie(MFA_USER, { reproofAt: NOW() });
const err = await refusal(requireRecentProof({ bearer: "elevation" }));
expect(err.extraMeta).toEqual({ methods: ["totp"] });
});
it("nor does a fresh session without the factor (a remembered device)", async () => {
cookie(MFA_USER, { createdAt: NOW() });
await refusal(requireRecentProof({ bearer: "elevation" }));
});
it("refuses a stale second-factor stamp", async () => {
cookie(MFA_USER, { mfaVerifiedAt: OLD() });
await refusal(requireRecentProof({ bearer: "elevation" }));
});
});
describe("Bearer", () => {
it("`elevation` refuses a token without one", async () => {
bearer(PLAIN_USER);
const err = await refusal(requireRecentProof({ bearer: "elevation" }));
expect(err.errorCode).toBe("auth.stepup.required");
});
it("`elevation-if-enrolled` passes a token on an account without a second factor", async () => {
bearer(PLAIN_USER);
await expect(
requireRecentProof({ bearer: "elevation-if-enrolled" }),
).resolves.toBeTruthy();
});
it("`elevation-if-enrolled` refuses a bare token on an enrolled account", async () => {
bearer(MFA_USER);
await refusal(requireRecentProof({ bearer: "elevation-if-enrolled" }));
});
it("`token` passes on the token alone", async () => {
bearer(MFA_USER);
await expect(requireRecentProof({ bearer: "token" })).resolves.toBeTruthy();
});
it("`elevation` accepts a password-proved elevation on an account without a second factor", async () => {
bearer(PLAIN_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method: "password",
} as never);
const auth = await requireRecentProof({ bearer: "elevation" });
expect(typeof auth.commitElevation).toBe("function");
});
it("`elevation` refuses a password-proved elevation on an enrolled account", async () => {
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method: "password",
} as never);
await refusal(requireRecentProof({ bearer: "elevation" }));
});
});
+4
View File
@@ -9,6 +9,10 @@ describe("looksSecretShaped", () => {
["a share-link token", "see hls_def456"],
["an invite token", "hlv_0a1b2c3d"],
["an elevation token", "hle_0a1b2c3d"],
["a native sign-in handoff code", "hlh_Ab9-xYz_12"],
["an MCP authorization code", "hlac_eyJ0eXAi"],
["an MCP refresh token", "hlrt_eyJ0eXAi"],
["an MCP client id", "hlc_eyJ0eXAi"],
["an OpenAI key", "echoed sk-1234567890"],
["an Anthropic key", "sk-ant-api03-xyz12345"],
])("matches %s", (_label, text) => {
+10 -12
View File
@@ -13,6 +13,10 @@
* a client acted on it. They stay a per-call refusal with their own codes.
*/
import { documentProviderRank } from "@/lib/documents/provider-rank";
import {
isExternalProvider,
isOperatorHeldProvider,
} from "@/lib/ai/provider-egress";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
@@ -70,14 +74,8 @@ export interface AiCapabilityInputs {
recordKind: AiRecordKind;
}
/** Provider tags that egress through a credential the operator holds. */
const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/** The one provider tag that keeps its input on the machine. */
const LOCAL_PROVIDER_TYPE = "local";
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
// the list the consent gate reads too.
const REASON_RANK: ReadonlyMap<AiUnavailableReason, number> = new Map(
AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]),
@@ -208,18 +206,18 @@ function consentNeeded(
// A cascade may fall through to any entry, so one operator-held entry
// anywhere in the chain is enough to need the receipt.
return provider.entries.some((entry) =>
OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType),
isOperatorHeldProvider(entry.providerType),
);
}
if (def.modality === "document") {
// Document reads call the one picked provider, no cascade.
const pick = documentPick(def, provider);
return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE;
return pick !== null && isExternalProvider(pick.providerType);
}
// Text under the document rule cascades like a snapshot does, so any entry
// that leaves the machine needs the receipt.
return provider.entries.some(
(entry) => entry.providerType !== LOCAL_PROVIDER_TYPE,
return provider.entries.some((entry) =>
isExternalProvider(entry.providerType),
);
}
+6 -17
View File
@@ -30,6 +30,10 @@
import { latestActiveReceipt } from "@/lib/consent/receipts";
import type { ConsentKind } from "@/lib/validations/consent";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import {
isExternalProvider,
isOperatorHeldProvider,
} from "@/lib/ai/provider-egress";
/**
* The two AI surfaces that egress PHI through a chain. Each maps to the consent
@@ -39,17 +43,6 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
*/
export type ConsentSurface = "coach" | "insights";
/**
* Provider tags that egress via an operator-managed credential the user did not
* personally contract: the operator's global OpenAI key (`admin-openai`) and the
* operator's shared central Codex / ChatGPT-subscription account (`admin-codex`).
* Both require an active consent receipt before any PHI leaves for them.
*/
const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors the AI capability refusal
@@ -79,9 +72,7 @@ export class ConsentRequiredError extends Error {
export function chainRequiresServerManagedConsent(
chain: ReadonlyArray<ProviderChainResolved>,
): boolean {
return chain.some((entry) =>
SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType),
);
return chain.some((entry) => isOperatorHeldProvider(entry.providerType));
}
/** The consent kinds that satisfy a given surface (the specific + the master). */
@@ -148,13 +139,11 @@ export async function assertConsentForChain(args: {
* reads the receipt kinds from the capability table; the auto-read toggle is a
* trigger, never the consent, so a revoked receipt wins over a toggle left on.
*/
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
/**
* True when reading a document through this provider egresses it OFF the machine
* to a third-party AI service. Only the self-hosted `local` provider keeps the
* document on the operator's own infrastructure.
*/
export function isExternalDocumentEgress(providerType: string): boolean {
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
return isExternalProvider(providerType);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Which AI provider tags send a record off the machine, and through whose
* credential. One definition, read by the consent gate
* (`src/lib/ai/consent-guard.ts`) and the capability resolver
* (`src/lib/ai/capabilities/resolve.ts`). They each kept their own copy
* before, and a provider added to one list but not the other would have been
* consented for in one place and not the other.
*/
/**
* Provider tags that egress through a credential the operator holds, which
* the user did not personally contract: the operator's global OpenAI key
* (`admin-openai`) and the operator's shared Codex / ChatGPT-subscription
* account (`admin-codex`). Both need an active consent receipt before any
* health data leaves for them.
*/
export const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/** The one provider tag that keeps its input on the operator's machine. */
export const LOCAL_PROVIDER_TYPE = "local";
export function isOperatorHeldProvider(providerType: string): boolean {
return OPERATOR_HELD_PROVIDER_TYPES.has(providerType);
}
/**
* True when this provider sends its input OFF the machine to a third-party AI
* service. Only the self-hosted `local` provider keeps it on the operator's
* own infrastructure.
*/
export function isExternalProvider(providerType: string): boolean {
return providerType !== LOCAL_PROVIDER_TYPE;
}
+6 -5
View File
@@ -40,6 +40,7 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
/**
* The whole contract. Three words, no payload.
@@ -68,11 +69,11 @@ export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* The provider tags the operator pays for: the instance-wide OpenAI key and
* the shared central Codex account. These are exactly the two tags
* `consent-guard.ts` gates on, and that is not a coincidence — the offer
* this feeds is the consent for these two and for nothing else.
* the shared central Codex account. The same set `consent-guard.ts` gates on,
* read from the one definition, because the offer this feeds is the consent
* for these and for nothing else.
*/
const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const;
const SHARED_PROVIDER_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export async function readServerProviderHealth(
now: Date = new Date(),
@@ -84,7 +85,7 @@ export async function readServerProviderHealth(
}>;
try {
rows = await prisma.providerHealth.findMany({
where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } },
where: { providerType: { in: SHARED_PROVIDER_TYPES } },
// Three columns, none of which identifies an account. The absence of
// `userId` here is load-bearing, not tidiness.
select: { lastResult: true, lastOkAt: true, nextRetryAt: true },
+14
View File
@@ -153,6 +153,13 @@ export class RecordSessionChangedError extends SharingAuthError {
}
}
/**
* The session carries no proof recent enough for the action, and the request
* brought none. The client asks for one (`POST /api/auth/reproof`, or a proof
* in the body on the enrollment routes) and retries.
*/
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
/**
* Error thrown when a step-up gate is not satisfied. Carries `errorCode` so
* the route can surface a stable machine code (`auth.stepup.required`) the
@@ -163,6 +170,13 @@ export class StepUpRequiredError extends HttpError {
constructor(
public errorCode: string = "auth.stepup.required",
message = "Recent second-factor verification required",
/**
* Extra machine fields for the envelope's `meta`, beside `errorCode`. The
* recent-proof gate names the proofs the account can give here
* (`methods`), so the client offers exactly those and nothing it would
* then refuse.
*/
public extraMeta: Record<string, unknown> = {},
) {
super(401, message);
this.name = "StepUpRequiredError";
+147 -1
View File
@@ -93,6 +93,7 @@ import {
SharingNotPermittedError,
RecordSessionChangedError,
StepUpRequiredError,
REPROOF_REQUIRED_CODE,
} from "./api-errors";
export {
@@ -289,7 +290,7 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
meta: { ...error.extraMeta, errorCode: error.errorCode },
},
{ status: error.statusCode },
);
@@ -1387,6 +1388,151 @@ export async function requireFreshMfaOrElevationIfEnrolled(
return { ...auth, commitElevation };
}
/**
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
* account without a second factor; `totp`, `webauthn` and `passkey` on one
* with a second factor, where a password alone has never been enough.
*/
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
/**
* How the recent-proof gate treats a Bearer caller.
*
* `elevation` — the Bearer twin of the cookie rule: an `X-Step-Up` elevation
* minted by `POST /api/auth/step-up`. On an account with a second factor it
* must come from a second-factor proof; without one, a password-proved
* elevation is enough. For routes no shipped client calls on Bearer.
*
* `elevation-if-enrolled` — the erasure routes' rule
* (`requireFreshMfaOrElevationIfEnrolled`): an account with a second factor
* presents a second-factor elevation, one without passes on the token. For a
* route the shipped app calls, where the second-factor arm was already
* refused before and the other arm must keep working.
*
* `token` — the token is the proof, as before. Only for routes the shipped
* app calls without an elevation, where a refusal would sign the app out (it
* reads a 401 outside its known paths as a dead session). Each one is named
* in the iOS note and in `recent-proof-surface-guard.test.ts`, so tightening
* it later is a visible edit.
*/
export type RecentProofBearerRule =
"elevation" | "elevation-if-enrolled" | "token";
/**
* The fresh-proof gate in front of the actions that hand the record, or a
* standing credential to it, to somebody: exporting the whole record, making a
* clinician share link, minting a token, and the admin backup, restore, wipe
* and reset actions. A live session is not enough for those, because whoever
* holds a stolen session would otherwise walk off with the record or leave a
* credential behind that outlives the session.
*
* Cookie session, account WITH a second factor: `Session.mfaVerifiedAt`
* within five minutes — exactly `requireFreshMfa`. Signing in completes the
* factor; later, `POST /api/auth/reproof` with a TOTP code, security key or
* passkey stamps it again.
*
* Cookie session, account WITHOUT a second factor: any of the session's
* creation (a sign-in), `mfaVerifiedAt` (a passkey sign-in or re-proof) or
* `reproofAt` (a password re-proof) within five minutes. Such an account
* re-enters its password, or uses a passkey.
*
* The refusal is 401 `auth.reproof.required` with `meta.methods`, the proofs
* this account can give; the web opens the re-proof dialog on it and retries.
* An empty list means the account holds nothing to re-prove in place (an
* SSO-only account), and signing in again is the way through.
*
* Returns the caller plus `commitElevation`, which spends a Bearer elevation
* and is a no-op otherwise. Call it right before acting.
*/
export async function requireRecentProof(options: {
bearer: RecentProofBearerRule;
}): Promise<ErasureAuthContext> {
const auth = await requireAuth();
const noop = async () => {};
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(auth.user, auth.session.id);
return { ...auth, commitElevation: noop };
}
const enrolled = await hasSecondFactorEnrolled(auth.user);
if (options.bearer === "token") {
annotate({ meta: { recent_proof: "bearer_token" } });
return { ...auth, commitElevation: noop };
}
if (options.bearer === "elevation-if-enrolled" && !enrolled) {
annotate({ meta: { recent_proof: "bearer_token" } });
return { ...auth, commitElevation: noop };
}
const commitElevation = await resolveBearerElevation(
{ user: auth.user, apiTokenId: auth.session.id },
{ freshFactor: enrolled, proofSource: "second-factor" },
);
return { ...auth, commitElevation };
}
/**
* The cookie arm of {@link requireRecentProof}, for routes that resolve the
* caller themselves (the admin routes, which must stay on `requireAdmin`).
*/
export async function assertRecentCookieProof(
user: User,
sessionId: string,
): Promise<void> {
const enrolled = await hasSecondFactorEnrolled(user);
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
});
const now = Date.now();
const fresh = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= MFA_STEP_UP_MAX_AGE_SECONDS * 1000;
const satisfied = enrolled
? fresh(row?.mfaVerifiedAt)
: fresh(row?.mfaVerifiedAt) ||
fresh(row?.createdAt) ||
fresh(row?.reproofAt);
if (satisfied) {
annotate({ meta: { recent_proof: "session" } });
return;
}
const methods = await recentProofMethods(user, enrolled);
annotate({
action: { name: "auth.reproof.required" },
meta: { second_factor: enrolled, methods: methods.join(",") },
});
throw new StepUpRequiredError(
REPROOF_REQUIRED_CODE,
"Confirm it is you to continue",
{ methods },
);
}
/**
* The proofs that would satisfy {@link assertRecentCookieProof} for this
* account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
* endpoint cannot disagree.
*/
export async function recentProofMethods(
user: Pick<User, "id" | "totpConfirmedAt" | "passwordHash">,
enrolled?: boolean,
): Promise<RecentProofMethod[]> {
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
const [keys, passkeys] = await Promise.all([
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
prisma.passkey.count({ where: { userId: user.id } }),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (user.passwordHash) {
methods.push("password");
}
if (passkeys > 0) methods.push("passkey");
return methods;
}
/**
* Does this account have a SECOND FACTOR at all?
*
+18 -12
View File
@@ -390,9 +390,9 @@ export async function safeJson<T = unknown>(
* IP-based rate-limits.
*
* Trust model (`TRUST_PROXY_HOPS` env):
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
* - "0" → trust no forwarding header at all, XFF or x-real-ip
* (use this if HealthLog is internet-facing without a
* reverse proxy you control)
* reverse proxy you control); the IP resolves to null
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
* Cloudflare-Tunnel single-proxy deployment); read the
* rightmost XFF entry which is the IP your proxy
@@ -540,18 +540,24 @@ export function getClientIpOrTrustWarning(request: Request): {
// configured trust. Without this warning the silent degrade was
// invisible until rate-limits visibly misfired in production.
warnTrustViolationOnce(hops, chain.length);
const realIp = request.headers.get("x-real-ip");
return {
ip: realIp && looksLikeIp(realIp) ? realIp : null,
trustViolation: true,
};
// Not x-real-ip either. A chain shorter than the configured hops means
// the request did not come through the proxies the operator declared,
// so whatever x-real-ip it carries was set by the caller.
return { ip: null, trustViolation: true };
}
// x-real-ip is a forwarding header like XFF and gets the same trust: only
// when the operator declares a proxy in front (hops > 0), and only as the
// single-proxy stand-in for a missing XFF. It used to be read on every
// path, including `TRUST_PROXY_HOPS=0` ("no proxy, trust no header") and
// the broken-chain path above, so a caller sending a fresh x-real-ip per
// request got a fresh per-IP rate-limit bucket each time.
const realIp = request.headers.get("x-real-ip")?.trim();
return {
ip: realIp && looksLikeIp(realIp) ? realIp : null,
trustViolation: false,
};
}
const realIp = request.headers.get("x-real-ip");
return {
ip: realIp && looksLikeIp(realIp) ? realIp : null,
trustViolation: false,
};
return { ip: null, trustViolation: false };
}
export function getClientIp(request: Request): string | null {
+4
View File
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
authChallenge: {
findUnique: vi.fn(),
deleteMany: vi.fn(),
@@ -57,6 +58,9 @@ const VALID_BODY = {
};
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "challenge", user_id: null },
] as never);
vi.mocked(prisma.authChallenge.findUnique).mockResolvedValue({
id: "ch-1",
challenge: "challenge",
+23 -4
View File
@@ -509,10 +509,9 @@ describe("rotateRefreshToken", () => {
expect(legitimate.ok).toBe(true);
});
it("still rotates when the caller sends no device id at all", async () => {
// An older client that never sends `X-Device-Id` must not be locked out
// by the device binding — a presented null is unattributable, not a
// mismatch.
it("refuses a device-bound token presented with no device id", async () => {
// Leaving the header out must not be a way round the device binding: a
// token issued to dev-1 is rotated only by a caller that says dev-1.
const dev1 = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
@@ -525,6 +524,26 @@ describe("rotateRefreshToken", () => {
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("device_mismatch");
expect(dbState.refreshTokens[0].usedAt).toBeNull();
});
it("still rotates a token issued without a device id, header or not", async () => {
// An older client that never sent `X-Device-Id` holds rows with no stored
// id, and nothing to compare means nothing to refuse.
const legacy = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
const result = await rotateRefreshToken({
refreshToken: legacy.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(true);
});
+11
View File
@@ -48,6 +48,9 @@ vi.mock("@/lib/db", () => ({
apiToken: { updateMany: vi.fn() },
refreshToken: { updateMany: vi.fn() },
trustedDevice: { deleteMany: vi.fn() },
mcpOAuthConnection: { updateMany: vi.fn() },
clinicianShareLink: { updateMany: vi.fn() },
stepUpElevation: { deleteMany: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
},
}));
@@ -333,6 +336,14 @@ describe("destroyAllSessions", () => {
where: { userId: "user-rotated", revokedAt: null },
data: { revokedAt: expect.any(Date) },
});
expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenCalledWith({
where: { userId: "user-rotated", revokedAt: null },
data: { revokedAt: expect.any(Date) },
});
expect(prisma.clinicianShareLink.updateMany).toHaveBeenCalledWith({
where: { userId: "user-rotated", revokedAt: null },
data: { revokedAt: expect.any(Date) },
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
});
@@ -63,6 +63,22 @@ vi.mock("@/lib/db", () => {
) => [...table.values()].find((r) => r.credentialId === credentialId) ?? null;
return {
prisma: {
// `claimAuthChallenge`'s guarded DELETE ... RETURNING, over the map:
// values are (id, type) or (id, type, userId).
$queryRaw: async (_sql: TemplateStringsArray, ...values: unknown[]) => {
const [id, type, userId] = values as [string, string, string?];
const row = store.authChallenge.get(id);
if (
!row ||
row.type !== type ||
(userId !== undefined && row.userId !== userId) ||
!((row.expiresAt as Date) > new Date())
) {
return [];
}
store.authChallenge.delete(id);
return [{ challenge: row.challenge, user_id: row.userId ?? null }];
},
authChallenge: {
deleteMany: async () => ({ count: 0 }),
create: async ({ data }: { data: Record<string, unknown> }) => {
+18 -5
View File
@@ -19,6 +19,7 @@
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -164,7 +165,7 @@ export async function recordReproofFailure(
}
/** No proof was presented, and the session carries none recent enough. */
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
export { REPROOF_REQUIRED_CODE };
/** A proof was presented and did not verify. The session is untouched. */
export const REPROOF_FAILED_CODE = "auth.reproof.failed";
@@ -188,6 +189,9 @@ export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
*
* - the session completed a second factor or passkey sign-in within the last
* five minutes (`Session.mfaVerifiedAt`);
* - the session re-proved a credential at `POST /api/auth/reproof` within
* the last five minutes (`Session.reproofAt`), which accepts the same
* proofs this gate takes in the body;
* - the session itself was created by a sign-in within the last five
* minutes (`Session.createdAt`, which no later write touches) — this is
* also what lets an SSO-only account with nothing else to re-prove enroll
@@ -210,12 +214,16 @@ export async function checkCookieEnrollmentProof(args: {
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { mfaVerifiedAt: true, createdAt: true },
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
});
const now = Date.now();
const recent = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) {
if (
recent(row?.mfaVerifiedAt) ||
recent(row?.createdAt) ||
recent(row?.reproofAt)
) {
annotate({ meta: { enrollment_proof: "recent_session" } });
return null;
}
@@ -316,12 +324,17 @@ export async function authorizeSensitiveChange(args: {
if (cookieSessionId) {
const row = await prisma.session.findUnique({
where: { id: cookieSessionId },
select: { mfaVerifiedAt: true, createdAt: true },
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
});
const now = Date.now();
const recent = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) return "ok";
if (
recent(row?.mfaVerifiedAt) ||
recent(row?.createdAt) ||
recent(row?.reproofAt)
)
return "ok";
}
if (!currentPassword) return "required";
+56 -71
View File
@@ -31,6 +31,7 @@ import {
} from "@simplewebauthn/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { claimAuthChallenge } from "@/lib/auth/passkey";
import {
RP_NAME as rpName,
getRpId,
@@ -146,38 +147,30 @@ export async function verifyMfaRegistration(
userId: string,
response: unknown,
): Promise<VerifiedRegistrationResponse> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
});
// The challenge must be live, of the registration type, and bound to the
// calling user — a challenge minted for another account can never be reused.
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_registration" ||
challenge.userId !== userId
) {
// Claimed (deleted) in the same statement that checks it, so two concurrent
// requests cannot both use it.
const challenge = await claimAuthChallenge({
challengeId,
type: "mfa_registration",
userId,
});
if (!challenge) {
throw new Error("Challenge expired or not found");
}
try {
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
return await verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
});
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
const parsed = registrationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Registration response shape invalid");
}
return verifyRegistrationResponse({
response: parsed.data as RegistrationResponseJSON,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
supportedAlgorithmIDs: REGISTRATION_ACCEPTED_ALGORITHM_IDS,
});
}
// ── Authentication (mid-login second factor) ─────────────────────────
@@ -232,16 +225,14 @@ export async function verifyMfaAuthentication(
userId: string,
response: unknown,
): Promise<boolean> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
// Claimed (deleted) in the same statement that checks it: one assertion,
// one verification, even when two requests carry it at once.
const challenge = await claimAuthChallenge({
challengeId,
type: "mfa_authentication",
userId,
});
if (
!challenge ||
challenge.expiresAt < new Date() ||
challenge.type !== "mfa_authentication" ||
challenge.userId !== userId
) {
if (!challenge) {
throw new Error("Challenge expired or not found");
}
@@ -251,43 +242,37 @@ export async function verifyMfaAuthentication(
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
try {
// The presented credential MUST belong to the password-identified user —
// look it up scoped to `userId` so an assertion against another account's
// key can never satisfy this account's second factor.
const credential = await prisma.webauthnMfaCredential.findFirst({
where: { credentialId: typedResponse.id, userId },
// The presented credential MUST belong to the password-identified user —
// look it up scoped to `userId` so an assertion against another account's
// key can never satisfy this account's second factor.
const credential = await prisma.webauthnMfaCredential.findFirst({
where: { credentialId: typedResponse.id, userId },
});
if (!credential) return false;
const verification: VerifiedAuthenticationResponse =
await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: credential.credentialId,
publicKey: credential.credentialPublicKey,
counter: Number(credential.counter),
transports: credential.transports as Transport[],
},
});
if (!credential) return false;
const verification: VerifiedAuthenticationResponse =
await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: credential.credentialId,
publicKey: credential.credentialPublicKey,
counter: Number(credential.counter),
transports: credential.transports as Transport[],
},
});
if (verification.verified) {
await prisma.webauthnMfaCredential.update({
where: { id: credential.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
return verification.verified;
} finally {
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
if (verification.verified) {
await prisma.webauthnMfaCredential.update({
where: { id: credential.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
return verification.verified;
}
+75 -34
View File
@@ -201,6 +201,46 @@ export async function consumeRegistrationChallenge(input: {
};
}
/**
* Claim a WebAuthn challenge: delete it and return its material in one
* statement, or return null when it is unknown, expired, of another ceremony,
* or (when `userId` is given) minted for another account.
*
* Every ceremony verifier goes through here. They used to read the challenge,
* verify, and delete it in a `finally`, so two requests carrying the same
* assertion could both read it before either deleted it and both be
* accepted. The guarded DELETE ... RETURNING gives exactly one of them the
* challenge; the other finds nothing.
*/
export async function claimAuthChallenge(input: {
challengeId: string;
type: string;
userId?: string;
}): Promise<{ challenge: string; userId: string | null } | null> {
const rows = input.userId
? await prisma.$queryRaw<
Array<{ challenge: string; user_id: string | null }>
>`
DELETE FROM auth_challenges
WHERE id = ${input.challengeId}
AND type = ${input.type}
AND user_id = ${input.userId}
AND expires_at > NOW()
RETURNING challenge, user_id
`
: await prisma.$queryRaw<
Array<{ challenge: string; user_id: string | null }>
>`
DELETE FROM auth_challenges
WHERE id = ${input.challengeId}
AND type = ${input.type}
AND expires_at > NOW()
RETURNING challenge, user_id
`;
if (rows.length !== 1) return null;
return { challenge: rows[0].challenge, userId: rows[0].user_id };
}
export async function verifyRegistration(
expectedChallenge: string,
response: unknown,
@@ -271,11 +311,13 @@ export async function verifyAuthentication(
verification: VerifiedAuthenticationResponse;
passkey: { userId: string };
}> {
const challenge = await prisma.authChallenge.findUnique({
where: { id: challengeId },
// Claimed before anything else: a challenge is good for one verification,
// successful or not, and a concurrent second request finds nothing.
const challenge = await claimAuthChallenge({
challengeId,
type: "authentication",
});
if (!challenge || challenge.expiresAt < new Date()) {
if (!challenge) {
throw new Error("Challenge expired or not found");
}
@@ -301,37 +343,36 @@ export async function verifyAuthentication(
throw new Error("Passkey not found");
}
try {
const verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
// A challenge begun for one account (the re-proof arm names the account)
// is answered only by that account's passkey.
if (challenge.userId !== null && challenge.userId !== passkey.userId) {
throw new Error("Challenge expired or not found");
}
const verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
if (verification.verified) {
// Update counter + stamp last-used so the management UI can surface
// when each passkey was last exercised (v1.23 passkey QoL).
await prisma.passkey.update({
where: { id: passkey.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
if (verification.verified) {
// Update counter + stamp last-used so the management UI can surface
// when each passkey was last exercised (v1.23 passkey QoL).
await prisma.passkey.update({
where: { id: passkey.id },
data: {
counter: BigInt(verification.authenticationInfo.newCounter),
lastUsedAt: new Date(),
},
});
}
return { verification, passkey: { userId: passkey.userId } };
} finally {
// Invalidate challenge after first verification attempt (success or failure)
await prisma.authChallenge
.delete({ where: { id: challengeId } })
.catch(() => {});
}
return { verification, passkey: { userId: passkey.userId } };
}
+8 -5
View File
@@ -193,13 +193,16 @@ export async function rotateRefreshToken(input: {
// `row.deviceId ?? input.deviceId` — would sit outside the victim device's
// containment blast radius.
//
// Both ids must be present to compare. A stored null means the row predates
// per-device issuance and cannot be attributed; a presented null means the
// caller sent no `X-Device-Id` header. Neither is treated as a mismatch, so
// this cannot lock out an older client that never sends the header.
// A stored null means the row predates per-device issuance, or the client
// sent no `X-Device-Id` when it signed in: nothing to compare, so it
// rotates. A stored id must be matched exactly, and an absent header does
// not match it. It used to: leaving the header out skipped the check, so a
// thief holding a stolen token rotated it simply by not naming a device. A
// client that sent its id at sign-in sends it on every refresh, so this
// locks out nobody who signed in honestly.
const presentedId = input.deviceId ?? null;
const storedId = row.deviceId ?? null;
if (presentedId !== null && storedId !== null && presentedId !== storedId) {
if (storedId !== null && presentedId !== storedId) {
return { ok: false, reason: "device_mismatch" };
}
+85 -7
View File
@@ -509,6 +509,20 @@ export async function destroyAllSessions(userId: string): Promise<void> {
// trusted-device token: a stolen device cookie must not survive a
// password change or account-credential remediation.
prisma.trustedDevice.deleteMany({ where: { userId } }),
// A connected AI assistant holds a refresh chain of its own: revoking its
// access tokens above left the connection able to mint the next one.
prisma.mcpOAuthConnection.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
// A clinician share link opens the record without signing in. Whoever
// held the old password could have made one, and it would outlive the
// change; the owner makes a fresh link for the people who should keep it.
prisma.clinicianShareLink.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
prisma.stepUpElevation.deleteMany({ where: { userId } }),
]);
}
@@ -526,9 +540,10 @@ export type CurrentCredential =
* v1.23 — "sign out everywhere" for the user-facing active-session surface
* (issue #64). Distinct from `destroyAllSessions`: this keeps the caller's
* CURRENT credential alive so pressing the button does not sign out the device
* it was pressed on, and it leaves the long-lived programmatic `ApiToken`s
* alone — those are credentials the user manages separately under
* /settings/api-tokens, not "sessions" in the device-list sense.
* it was pressed on. Until v1.39.3 it also left the programmatic tokens, the
* AI-assistant connections and the share links alone, as credentials managed
* on their own cards; with `reach: "everything"` it now ends those too (see
* below), because a stolen session could have minted any of them.
*
* It DOES revoke every other native-client `RefreshToken` (each is a device
* login), and, since v1.38.11, the access token paired with each of them. A
@@ -542,13 +557,42 @@ export type CurrentCredential =
* rotation's own consume step refuses a row revoked underneath it and retires
* what it minted.
*
* Returns the number of OTHER web sessions removed and the number of access
* tokens revoked, so the surface and the audit row can say what happened.
* `reach` says how far "everywhere" goes:
*
* `sign-ins` — web sessions, device logins, trusted devices and step-up
* elevations. What turning a second factor on or off ends, because those
* change what a sign-in needs, and a sign-in made under the old rule must
* not outlive it.
*
* `everything` — the above, plus every credential the account handed out
* that works without signing in: connected AI assistants (their OAuth
* connection, not only the current access token, since the connection mints
* the next one), every programmatic token (the connector, measurement and
* document tokens), and, unless `keepShareLinks`, every clinician share
* link. What the user-facing "sign out everywhere" does: someone who presses
* it after losing a device or a session expects nothing to remain that
* reaches the record. Share links default to revoked for the same reason,
* and the web offers to keep them because a link already handed to a
* doctor is often wanted. The caller's own credential is spared either way.
*
* Returns what was removed, so the surface and the audit row can say what
* happened.
*/
export type SignOutReach =
{ reach: "sign-ins" } | { reach: "everything"; keepShareLinks?: boolean };
export interface SignOutResult {
sessionsRevoked: number;
accessTokensRevoked: number;
connectorsRevoked: number;
shareLinksRevoked: number;
}
export async function destroyOtherSessions(
userId: string,
current: CurrentCredential,
): Promise<{ sessionsRevoked: number; accessTokensRevoked: number }> {
options: SignOutReach,
): Promise<SignOutResult> {
// Which rows describe the CALLER depends on how the caller authenticated, and
// the two shapes are not interchangeable. A cookie caller is a `Session` row.
// A Bearer caller is an `ApiToken` whose device login is the `RefreshToken`
@@ -604,9 +648,43 @@ export async function destroyOtherSessions(
// expects nothing to remain that could act on the account; a live elevation
// is exactly that.
await tx.stepUpElevation.deleteMany({ where: { userId } });
if (options.reach === "sign-ins") {
return {
sessionsRevoked: deleted.count,
accessTokensRevoked: access.count,
connectorsRevoked: 0,
shareLinksRevoked: 0,
};
}
// Everything else that reaches the record without a sign-in. Every
// remaining token goes, except the Bearer caller's own access token.
const tokens = await tx.apiToken.updateMany({
where: {
userId,
revoked: false,
...(current.kind === "accessToken"
? { tokenHash: { not: current.accessTokenHash } }
: {}),
},
data: { revoked: true },
});
const connectors = await tx.mcpOAuthConnection.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt },
});
const links = options.keepShareLinks
? { count: 0 }
: await tx.clinicianShareLink.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt },
});
return {
sessionsRevoked: deleted.count,
accessTokensRevoked: access.count,
accessTokensRevoked: access.count + tokens.count,
connectorsRevoked: connectors.count,
shareLinksRevoked: links.count,
};
});
}
+11
View File
@@ -117,6 +117,17 @@ describe("redactSecrets", () => {
).toBe('{"token": "[REDACTED]"}');
});
it.each([
["an invite token", `hlv_${"0a".repeat(32)}`],
["a session secret", `hls_${"ab".repeat(32)}`],
["an elevation", `hle_${"cd".repeat(32)}`],
["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`],
["an MCP refresh token", `hlrt_${"x".repeat(40)}`],
])("redacts %s wherever it appears", (_label, token) => {
expect(redactSecrets(`code=${token} next`)).not.toContain(token);
expect(redactSecrets(`value: ${token}`)).toBe("value: [REDACTED]");
});
it("does NOT redact non-token strings starting with `hl`", () => {
expect(redactSecrets("html_render finished")).toBe("html_render finished");
expect(redactSecrets("healthcheck ok")).toBe("healthcheck ok");
+7 -4
View File
@@ -84,18 +84,21 @@ export function redactSecrets(input: string): string {
// identifiers. Capture group 1 = the leading separator (preserved).
.replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]")
// HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh
// token, `hls_` session cookie secret (v1.30.32), `hle_` step-up
// elevation (v1.30.34). Same boundary-aware shape as the `sk-` rule. The
// token, `hls_` session cookie secret (v1.30.32) and clinician share
// token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite.
// Same boundary-aware shape as the `sk-` rule. The
// idempotency replay-cache already rejects bodies containing
// these prefixes (CLAUDE.md headless-client-API patterns); this
// is the matching egress guard for log/error surfaces.
.replace(/(^|[^A-Za-z0-9])hl[kres]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
.replace(/(^|[^A-Za-z0-9])hl[kresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
// are signed, base64url-bodied, and credential-bearing, so they are
// scrubbed at the egress boundary like the native token formats above.
// `hlh_` is the one-time native sign-in handoff code, base64url too; it
// rides a custom-scheme redirect and is exchanged for a token pair.
.replace(
/(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g,
/(^|[^A-Za-z0-9])hl(?:ac|rt|c|h)_[A-Za-z0-9_.-]{16,}/g,
"$1[REDACTED]",
)
// v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory-
+1
View File
@@ -62,6 +62,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.refresh.revoked",
"auth.reproof.failed",
"auth.reproof.required",
"auth.reproof.too_weak",
"auth.scope.insufficient",
"auth.stepup.required",
"auth.token.expired",
+2 -2
View File
@@ -170,7 +170,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Admin"],
summary: "Download one stored backup as JSON",
description:
"Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
"Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
requestParams: {
path: z.object({
id: z.string().meta({ description: "`DataBackup.id`." }),
@@ -206,7 +206,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Admin"],
summary: "Queue the restore of one stored backup over its owner's record",
description:
"Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
"Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
requestParams: {
path: z.object({
id: z.string().meta({ description: "`DataBackup.id`." }),
+83 -4
View File
@@ -222,6 +222,22 @@ const signOutEverywhereResponse = z
.number()
.int()
.describe("Number of OTHER sessions removed (the current one is kept)."),
accessTokensRevoked: z
.number()
.int()
.describe(
"Tokens revoked: the access tokens of other device logins plus every programmatic token (connector, measurement, document), never the caller's own.",
),
connectorsRevoked: z
.number()
.int()
.describe("Connected AI-assistant (MCP OAuth) connections revoked."),
shareLinksRevoked: z
.number()
.int()
.describe(
"Clinician share links revoked; 0 when `keepShareLinks=1` was sent.",
),
})
.meta({ id: "SignOutEverywhereResponse" });
@@ -817,7 +833,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
requestBody: {
required: true,
@@ -836,6 +852,61 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/reproof": {
post: {
tags: ["Auth"],
summary: "Re-prove a credential on a browser session (cookie only)",
description:
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
requestBody: {
required: true,
content: { "application/json": { schema: stepUpMintSchema } },
},
responses: {
"200": {
description: "Proof accepted; the session is stamped.",
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
method: z.enum(["password", "totp", "webauthn", "passkey"]),
verifiedAt: z.string().datetime(),
})
.meta({ id: "ReproofResponse" }),
"ReproofEnvelope",
),
},
},
},
...stdResponses,
},
},
get: {
tags: ["Auth"],
summary: "Ask whether this browser session's proof is recent",
description:
"The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or re-proved within five minutes (a second factor on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than after it was refused.",
responses: {
"200": {
description: "The session's proof is recent.",
content: {
"application/json": {
schema: dataEnvelope(
z
.object({ recent: z.literal(true) })
.meta({ id: "ReproofStatus" }),
"ReproofStatusEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/mfa/totp/setup": {
post: {
tags: ["Auth"],
@@ -1257,7 +1328,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Sign out everywhere else",
description:
"v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current session. API tokens are not touched (manage those under /settings/api-tokens).",
"Ends every credential except the caller's own: every OTHER web session, every other device login (refresh token and its paired access token), every trusted device and step-up elevation, every connected AI assistant (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement and document tokens), and every clinician share link. Share links are revoked by default because a link made by whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep them. A Bearer caller keeps its own device login.",
requestParams: {
query: z.object({
keepShareLinks: z.enum(["1"]).optional().meta({
description:
"`1` keeps the clinician share links. Anything else, or nothing, revokes them.",
}),
}),
},
responses: {
"200": {
description: "Other sessions revoked.",
@@ -1609,7 +1688,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" +
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
"Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
requestBody: {
required: true,
content: {
@@ -1676,7 +1755,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Mints a Bearer scoped to exactly `documents:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" +
"**What it can do.** `POST /api/documents/inbound` on its owner's own record, and `GET /api/documents/inbound/source`, which answers whether a source key is already held so an importer can skip a download. Nothing else. It is the door another document system pushes through: a Paperless-ngx workflow posting a newly tagged document, or an import script copying an archive. Uploads through it draw on a bucket of their own, keyed on the token (default 120 an hour, operator-tunable via `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`), and get back a receipt (`id`, `duplicate`, and `deleted` when the source key belongs to a document the owner deleted) rather than the stored row.\n\n" +
"**What it cannot do.** List, read, download, preview, edit, delete or bulk-act on a document, reach the document-AI routes, act on a shared record, or mint another token. Every other route names no scope and refuses it 403.\n\n" +
"Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.",
"Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.",
requestBody: {
required: true,
content: {
+2 -2
View File
@@ -198,7 +198,7 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Download the restore-compatible full backup",
description:
"The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup-<userId>-<YYYY-MM-DD>.json`, `Cache-Control: no-store`. Shares the `export:<userId>` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record.",
"The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup-<userId>-<YYYY-MM-DD>.json`, `Cache-Control: no-store`. Shares the `export:<userId>` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it.",
responses: {
"200": {
description:
@@ -292,7 +292,7 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Download the full backup as a passphrase-encrypted archive",
description:
"v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.",
"v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). Asks for a fresh proof, like the plaintext full backup. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. On Bearer, an account without a second factor passes on its token, and one with a second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.",
requestBody: {
required: true,
content: {
+1 -1
View File
@@ -146,7 +146,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Create a clinician share link (v1.11.0)",
description:
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet.",
requestBody: {
required: true,
content: {
+1 -1
View File
@@ -196,7 +196,7 @@ export const mcpPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["MCP"],
summary: "Mint an MCP connector token",
description:
"Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.",
"Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet.",
requestBody: {
required: true,
content: { "application/json": { schema: createMcpTokenRequest } },
+5 -2
View File
@@ -4,7 +4,10 @@
* such text.
*
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
* tokens, `hlv_` registration invite tokens, `hle_` elevation tokens;
* tokens and session secrets, `hlv_` registration invite tokens, `hle_`
* elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth
* artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_`
* (client id);
* `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form
* only: a bare `sk-` substring ("task-id", "risk-management") is not a key,
* and matching it would refuse benign text.
@@ -14,7 +17,7 @@
* that knows the secrets in play must also refuse text containing them.
*/
const SECRET_SHAPED =
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
export function looksSecretShaped(text: string): boolean {
return SECRET_SHAPED.test(text);