Merge release v1.38.20

Signing in no longer tells anybody which addresses have an account here.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-11 16:41:29 +02:00
commit d1bab391fc
35 files changed
+1340 -853

No files matched your search

+42
View File
@@ -1,5 +1,47 @@
# Changelog
## [1.38.20] — 2026-09-11
Signing in no longer tells anybody which addresses have an account here.
### Security
- **Signing in now takes the same time whether or not the account exists.**
A wrong password and an address nobody has registered here have always
given you the same answer on screen, but the server used to reach that
answer much faster when there was no account to check, and the same when
the account signs in with a passkey and has no password at all. Someone
probing the sign-in page could read that difference and learn which
addresses are registered, and which of those have a password. Every
refusal now does the same work before it answers.
- **Changing your email address is limited to ten changes an hour.** The
server tells you when the address you typed already belongs to another
account, which is the only sensible thing to say to someone who typed one.
Nothing stopped a signed-in caller from asking that question over and over
about addresses that were not theirs. Ten an hour per account is far above
any real use of the field, and a save that leaves your address as it is
does not count against it. If you hit the limit while saving other things
on the settings page, those are saved and only the address is held back,
with a note under the field. If you write against the API, a request that
asked for nothing but the address gets a 429 on `PUT /api/auth/profile`
and `PATCH /api/user/profile` with `meta.errorCode` set to
`profile.update.emailRateLimited` and a `Retry-After` header saying how
long to wait; a request that carried other fields gets 200 with `email`
listed in `rejectedFields` under the code `rate_limited`.
### Removed
- **The endpoint that answered whether an address has an account here is
gone.** `POST /api/auth/check-user` took an email address or a username
and said, to anyone who asked and without any sign-in, whether an account
existed and whether it had a passkey or a password. It was built to decide
which field a sign-in screen shows first, which is not worth telling the
world who has an account here. The path now answers 410 and says so in the
API reference rather than disappearing, so anything still calling it gets
a clear "this is gone, stop retrying" instead of a 404 that looks like a
broken server. If you wrote something against it, the sign-in request
itself is the replacement: send the credential and read the answer.
## [1.38.19] — 2026-09-11
The first run tells you what is actually true about your account, an
+244 -247
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.",
"invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.",
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet."
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.",
"emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.",
"invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
"invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
"rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.",
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "Nothing was saved. Check the highlighted field and try again.",
"invalidBody": "The profile update could not be read.",
"emailInUse": "This email address is already in use."
"emailInUse": "This email address is already in use.",
"emailRateLimited": "Too many email-address changes. Try again later."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "That is not one of the answers this field accepts.",
"invalidFormat": "That is not written in a form this field can read.",
"invalidType": "That value did not arrive in a form this field can store.",
"rateLimited": "Changed too often. Wait a little and try again.",
"other": "This value was not accepted. Check it and try again."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.",
"invalidBody": "No se pudo leer la actualización del perfil.",
"emailInUse": "Esta dirección de correo ya está en uso."
"emailInUse": "Esta dirección de correo ya está en uso.",
"emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "Esa no es una de las respuestas que acepta este campo.",
"invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
"invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
"rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.",
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.",
"invalidBody": "La mise à jour du profil n'a pas pu être lue.",
"emailInUse": "Cette adresse e-mail est déjà utilisée."
"emailInUse": "Cette adresse e-mail est déjà utilisée.",
"emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.",
"invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
"invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
"rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.",
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.",
"invalidBody": "Impossibile leggere l'aggiornamento del profilo.",
"emailInUse": "Questo indirizzo email è già in uso."
"emailInUse": "Questo indirizzo email è già in uso.",
"emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "Questa non è una delle risposte accettate da questo campo.",
"invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
"invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
"rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.",
"other": "Questo valore non è stato accettato. Controllalo e riprova."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.",
"invalidBody": "프로필 변경 내용을 읽지 못했어요.",
"emailInUse": "이미 사용 중인 이메일 주소예요."
"emailInUse": "이미 사용 중인 이메일 주소예요.",
"emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "이 항목이 받는 답변이 아니에요.",
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
"rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.",
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
},
"avatar": {
+3 -1
View File
@@ -125,7 +125,8 @@
"update": {
"nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.",
"invalidBody": "Nie udało się odczytać aktualizacji profilu.",
"emailInUse": "Ten adres e-mail jest już używany."
"emailInUse": "Ten adres e-mail jest już używany.",
"emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później."
}
}
},
@@ -5508,6 +5509,7 @@
"invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.",
"invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
"invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
"rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.",
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
},
"avatar": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.38.19",
"version": "1.38.20",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.38.19";
/* @sw-version-fallback */ "v1.38.20";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -1,191 +0,0 @@
/**
* v1.4.41 W-IOS-COORD SB-7 follow-up — pin the four-branch discovery
* contract for `POST /api/auth/check-user`. The iOS onboarding screen
* branches on `branch` to decide which credential form to render; any
* regression that drops a branch silently makes the wrong form appear.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
}),
// v1.4.43 W13 M-4 — the route now routes through the auth-surface
// wrapper. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "@/app/api/auth/check-user/route";
import { prisma } from "@/lib/db";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
interface CheckUserBody {
data: {
branch: "not_found" | "passkey_only" | "email_fallback" | "exists";
hasPasskey: boolean;
hasPassword: boolean;
};
}
function makeRequest(body: unknown) {
return new Request("http://localhost/api/auth/check-user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as unknown as Parameters<typeof POST>[0];
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("POST /api/auth/check-user", () => {
it("returns `not_found` when no account matches the identifier", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
const body = (await res.json()) as CheckUserBody;
expect(res.status).toBe(200);
expect(body.data.branch).toBe("not_found");
expect(body.data.hasPasskey).toBe(false);
expect(body.data.hasPassword).toBe(false);
});
it("returns `passkey_only` when the user has passkeys and no password", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "u1",
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("passkey_only");
expect(body.data.hasPasskey).toBe(true);
expect(body.data.hasPassword).toBe(false);
});
it("returns `email_fallback` when the user has a password (regardless of passkey count)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "u2",
passwordHash: "argon2id$…",
_count: { passkeys: 1 },
} as never);
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("email_fallback");
expect(body.data.hasPasskey).toBe(true);
expect(body.data.hasPassword).toBe(true);
});
it("returns `exists` when the user has neither passkey nor password (recovery path)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "u3",
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("exists");
expect(body.data.hasPasskey).toBe(false);
expect(body.data.hasPassword).toBe(false);
});
it("422s when identifier is missing or blank", async () => {
const res = await POST(makeRequest({}));
expect(res.status).toBe(422);
});
it("queries the identifier exactly as iOS sends it (no case-fold)", async () => {
// Register stores email + username verbatim (no `.toLowerCase()`
// transform in `registerSchema`); folding here would route a
// mixed-case existing account to the sign-up branch.
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
await POST(makeRequest({ identifier: "MixedCase@Example.com" }));
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ username: "MixedCase@Example.com" },
{ email: "MixedCase@Example.com" },
],
},
}),
);
});
it("returns 429 when the per-IP rate-limit is exhausted", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 15 * 60 * 1000,
ip: "203.0.113.1",
} as never);
const res = await POST(makeRequest({ identifier: "anyone@example.com" }));
expect(res.status).toBe(429);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
});
@@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
verifyPasswordOrDummy: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -142,8 +142,8 @@ describe("login MFA branch", () => {
});
it("wrong password never reaches the MFA branch", async () => {
const { verifyPassword } = await import("@/lib/auth/password");
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: new Date(),
@@ -0,0 +1,140 @@
/**
* The profile email-address ceiling is the one the contract describes.
*
* Changing the address asks whether another account already holds it, and the
* answer is a 409 versus a 200 — an existence check over every address on the
* instance, available to any signed-in caller, including the native client's
* own wildcard token. Neither route that reaches it had a ceiling of any kind,
* and `apiHandler` supplies no default, so the sweep was bounded only by the
* network.
*
* A published number is worth reading only if it cannot drift from the code,
* so this pulls the bucket key and the window out of `prisma.$queryRaw` — the
* technique `rate-limit-auth-surface.test.ts` and the record-write contract
* both use — and holds the emitted OpenAPI description to what came back.
* Reading both from the same constant would prove nothing: the sentence is
* hand-written, deliberately, and this is what keeps the hand-written half
* honest.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PROFILE_EMAIL_BUCKET_PREFIX,
PROFILE_EMAIL_LIMIT,
PROFILE_EMAIL_WINDOW_MS,
checkProfileEmailRateLimit,
} from "@/lib/rate-limit";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { profileEmailRateLimitResponse } from "@/lib/openapi/routes/shared";
import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
import { prisma } from "@/lib/db";
/** The published sentence, read from the object the route modules spread. */
const published = profileEmailRateLimitResponse["429"].description;
/** Both doors onto the one handler that answers the conflict. */
const OPERATIONS: Array<[path: string, method: "put" | "patch"]> = [
["/api/auth/profile", "put"],
["/api/user/profile", "patch"],
];
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockReset();
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
] as never);
});
describe("the profile email-address bucket", () => {
it("keys on the acting account under the name the contract publishes", async () => {
await checkProfileEmailRateLimit("account-7");
const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!;
// `$queryRaw`INSERT … VALUES (${key}, …)`` lands the key at index 1 and
// the window interval at index 2. Read from the query rather than from
// the constant, so a renamed prefix that never reached the SQL fails.
expect(call[1]).toBe("profile-email:account-7");
expect(call[2]).toBe(`${PROFILE_EMAIL_WINDOW_MS} milliseconds`);
expect(`${PROFILE_EMAIL_BUCKET_PREFIX}:account-7`).toBe(call[1]);
expect(published).toContain("`profile-email:<accountId>`");
});
it("gives a different account a different bucket, not a shared one", async () => {
await checkProfileEmailRateLimit("account-7");
const first = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1];
await checkProfileEmailRateLimit("account-8");
const second = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1];
expect(first).not.toBe(second);
});
it("refuses at the cap the contract publishes", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ count: PROFILE_EMAIL_LIMIT, reset_at: new Date(Date.now() + 60_000) },
] as never);
await expect(
checkProfileEmailRateLimit("account-7"),
).resolves.toMatchObject({
allowed: true,
limit: PROFILE_EMAIL_LIMIT,
remaining: 0,
});
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{
count: PROFILE_EMAIL_LIMIT + 1,
reset_at: new Date(Date.now() + 60_000),
},
] as never);
await expect(
checkProfileEmailRateLimit("account-7"),
).resolves.toMatchObject({ allowed: false });
const windowSeconds = PROFILE_EMAIL_WINDOW_MS / 1000;
expect(published).toContain(
`${PROFILE_EMAIL_LIMIT} requests per ${windowSeconds} seconds`,
);
});
it("says the headers describe that bucket, and declares them", () => {
expect(Object.keys(profileEmailRateLimitResponse["429"].headers)).toEqual([
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
]);
expect(published).toContain("Retry-After");
});
it("publishes a code a client can branch on, and registers it", () => {
expect(published).toContain("profile.update.emailRateLimited");
expect(ERROR_CODE_CATALOGUE.profile).toContain(
"profile.update.emailRateLimited",
);
});
it("says this response is only for a request that asked for nothing else", () => {
// The refusal narrows to the field when the request carries other
// fields, so a client reading the 429 as "the whole save failed" would
// be wrong. The sentence has to carry that or it is worse than silence.
expect(published).toContain("rejectedFields");
expect(published).toContain("rate_limited");
});
it("is published on both operations that answer it", () => {
const doc = buildOpenApiDocument() as unknown as {
paths: Record<
string,
Record<string, { responses?: Record<string, { description?: string }> }>
>;
};
const unpublished = OPERATIONS.filter(
([path, method]) =>
doc.paths[path]?.[method]?.responses?.["429"]?.description !==
published,
).map(([path]) => path);
expect(unpublished).toEqual([]);
});
});
@@ -1,154 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the
// shape only matters here, so a deterministic stand-in keeps the test
// stable without standing up the env-keyed CipherSuite. The PII check
// below asserts the raw identifier never appears in the audit payload.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }),
// v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface
// wrapper so the trust-violation branch can swap to a tighter global
// bucket. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
function expectedHash(identifier: string): string {
return (hashToken as unknown as (s: string) => string)(identifier);
}
function makeRequest(body: { identifier: string }): NextRequest {
return new NextRequest("http://localhost/api/auth/check-user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.mocked(prisma.user.findFirst).mockReset();
vi.mocked(auditLog).mockReset();
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[0]).toBe("auth.check-user");
expect(args[1]).toEqual({
ipAddress: "203.0.113.1",
details: {
branch: "not_found",
identifier_hash: expectedHash("ghost@example.com"),
},
});
// The raw identifier must not leak into the details — every other
// /api/auth/* audit row already hashes; the check-user route follows
// the same shape.
const details = args[1]?.details as Record<string, unknown> | undefined;
expect(details).not.toHaveProperty("identifier");
expect(JSON.stringify(details)).not.toContain("ghost@example.com");
});
it("writes an audit row for the passkey_only branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "testuser" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({
userId: "user-1",
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("testuser"),
},
});
});
it("writes an audit row for the email_fallback branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$...",
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "user@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("user@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
it("writes an audit row for the exists branch (no passkey + no password)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-3",
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "stub" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "exists",
identifier_hash: expectedHash("stub"),
});
expect(args[1]?.userId).toBe("user-3");
});
});
-149
View File
@@ -1,149 +0,0 @@
/**
* `POST /api/auth/check-user` — four-branch discovery for iOS onboarding.
*
* v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs
* to know, given a typed identifier (username or email), what the next
* UX step should be:
*
* - `not_found` — no account exists; show the sign-up screen.
* - `passkey_only` — account exists, has at least one Passkey, no
* password hash. Show "Sign in with Passkey".
* - `email_fallback` — account exists, has a password hash (with or
* without a Passkey). Show password field plus
* a "Use Passkey" affordance when applicable.
* - `exists` — account exists with no usable credential
* (neither passkey nor password). Treat as
* recovery path; show "Reset access" hint.
*
* The route is intentionally narrow:
*
* - Accepts `{ identifier: string }` (either an email or a username).
* - Returns `{ branch, hasPasskey, hasPassword }` — booleans are
* included so the iOS client can render a "or sign in with Passkey"
* button alongside the password field without a second round-trip.
* - Never leaks PII. The response shape is the same regardless of
* whether the identifier matched (callers learn account-existence
* either way — that is the explicit contract iOS needs). The
* handler does NOT echo the identifier back.
* - Per-IP rate-limited (30 requests / 15 min) so the by-design
* enumeration contract cannot be brute-forced. Mirrors the
* `/api/auth/passkey/login-options` throttle shape.
* - The identifier is queried EXACTLY as iOS sends it (no
* `.toLowerCase()` normalisation). Email + username columns are
* stored verbatim by `/api/auth/register`, so case-folding here
* would route legitimate existing users to the sign-up branch.
* A future server-side normalisation pass on register write is
* the long-term fix.
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const bodySchema = z
.object({
identifier: z.string().trim().min(1).max(254),
})
.strict();
export type CheckUserBranch =
"not_found" | "passkey_only" | "email_fallback" | "exists";
export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:check-user",
30,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Too many requests. Please try again later." },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"identifier required",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const identifier = parsed.data.identifier;
// Match on either username or email — both are unique in the schema.
const user = await prisma.user.findFirst({
where: {
OR: [{ username: identifier }, { email: identifier }],
},
select: {
id: true,
passwordHash: true,
_count: { select: { passkeys: true } },
},
});
// v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin
// can later answer "did anyone enumerate accounts last week?". The
// wide-event `annotate()` log is sampled and short-retention; the
// audit ledger is durable. We hash the identifier (same shape as the
// `auth.login.failed` row) so the literal email/username never lands
// in `AuditLog.details` — the v1.4.20 retroactive PII directive bars
// raw user-typed strings from operator-readable artefacts.
const identifierHash = hashToken(identifier);
if (!user) {
annotate({
action: { name: "auth.check-user" },
meta: { branch: "not_found" },
});
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
});
return apiSuccess({
branch: "not_found" satisfies CheckUserBranch,
hasPasskey: false,
hasPassword: false,
});
}
const hasPasskey = user._count.passkeys > 0;
const hasPassword = Boolean(user.passwordHash);
let branch: CheckUserBranch;
if (hasPasskey && !hasPassword) branch = "passkey_only";
else if (hasPassword) branch = "email_fallback";
else branch = "exists";
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
await auditLog("auth.check-user", {
userId: user.id,
ipAddress: ip,
details: { branch, identifier_hash: identifierHash },
});
return apiSuccess({ branch, hasPasskey, hasPassword });
});
@@ -0,0 +1,155 @@
/**
* Every login outcome reaches the verifier.
*
* The three refusals sign-in can produce — no such account, an account with
* no password hash, a wrong password — return the identical 401 body, and
* used to take three very different amounts of time to produce it, because
* the first two returned before any hashing happened. Argon2id at 19 MiB and
* t=2 against one indexed SELECT is a gap a caller can read over a network.
*
* The passkey-only account is the case that matters most. If it kept the
* cheap path, the channel would still answer "this account exists and has no
* password" — which is exactly what the removed discovery endpoint published
* in a field.
*
* What this proves is that the call happens on all three arms, not that the
* three durations match: the verifier is mocked here, and wall-clock equality
* is not something a unit test can assert. `password-dummy-verify.test.ts`
* carries the other half, that the verifier really does Argon2id work at the
* real cost parameters when there is no stored hash.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(() => "aa".repeat(32)),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "someone@example.com",
password: "a-typed-password",
}),
});
}
beforeEach(() => {
vi.mocked(verifyPasswordOrDummy).mockReset();
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
});
describe("POST /api/auth/login — constant work across the refusals", () => {
it("verifies even when no account matches the identifier", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
undefined,
"a-typed-password",
);
});
it("verifies for a passkey-only account, which carries no password hash", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
totpConfirmedAt: null,
mfaEnforced: false,
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
null,
"a-typed-password",
);
});
it("verifies for an account that does carry a password hash", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$stored",
totpConfirmedAt: null,
mfaEnforced: false,
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
"$argon2id$stored",
"a-typed-password",
);
});
it("answers the three refusals with the identical status and body", async () => {
const bodies: string[] = [];
const statuses: number[] = [];
const accounts = [
null,
{ id: "user-1", passwordHash: null },
{ id: "user-2", passwordHash: "$argon2id$stored" },
];
for (const account of accounts) {
vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never);
const res = await POST(makeRequest());
statuses.push(res.status);
bodies.push(await res.text());
}
expect(new Set(statuses)).toEqual(new Set([401]));
expect(new Set(bodies).size).toBe(1);
});
});
@@ -14,7 +14,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
@@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
@@ -108,7 +108,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
id: "rt-1",
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
@@ -71,7 +71,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import {
checkRateLimit,
@@ -93,7 +93,7 @@ function makeRequest(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
vi.mocked(verifyPassword).mockResolvedValue(false);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
@@ -175,7 +175,7 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// since verifyPasswordOrDummy is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
@@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ verifyPasswordOrDummy: vi.fn() }));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(async () => new Response(null, { status: 200 })),
@@ -58,7 +58,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
@@ -88,7 +88,7 @@ beforeEach(() => {
describe("POST /api/auth/login — trusted device", () => {
it("skips the second factor when the device is trusted (no challenge minted)", async () => {
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(consumeTrustedDevice).mockResolvedValue(true);
const res = await POST(req());
@@ -103,7 +103,7 @@ describe("POST /api/auth/login — trusted device", () => {
});
it("falls back to the MFA challenge when the device is not trusted", async () => {
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(consumeTrustedDevice).mockResolvedValue(false);
const res = await POST(req());
@@ -115,7 +115,7 @@ describe("POST /api/auth/login — trusted device", () => {
});
it("still requires the password — a wrong password never consults the trusted device", async () => {
vi.mocked(verifyPassword).mockResolvedValue(false);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
const res = await POST(req());
expect(res.status).toBe(401);
+17 -7
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
@@ -69,11 +69,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { email, password } = parsed.data;
const identifier = email.trim();
// HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`,
// mirrors the `/api/auth/check-user` pattern. The raw identifier
// stays out of the audit row (H-1 contract); the hash gives a
// future spray-detector a forensic anchor it can correlate across
// IPs without having to look up users by email.
// HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`. The
// raw identifier stays out of the audit row (H-1 contract); the hash
// gives a future spray-detector a forensic anchor it can correlate
// across IPs without having to look up users by email.
const identifierHash = hashToken(identifier);
const user = await prisma.user.findFirst({
@@ -85,6 +84,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// One verification for every outcome. An unknown identifier and an
// account carrying no password hash (a passkey-only account) used to
// return here before any hashing, so the three answers cost one indexed
// SELECT, one indexed SELECT, and a full Argon2id verification
// respectively — identical bodies, three different durations, which is a
// readable answer to "is this address registered, and does it have a
// password". `verifyPasswordOrDummy` verifies against a fixed dummy hash
// minted at the same cost parameters when there is no stored hash, and
// returns false. Keep the call ahead of the branch: moving it back under
// an `if` restores the channel.
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
if (!user || !user.passwordHash) {
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
@@ -99,7 +110,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid credentials", 401);
}
const valid = await verifyPassword(user.passwordHash, password);
if (!valid) {
await auditLog("auth.login.failed", {
userId: user.id,
+10
View File
@@ -143,6 +143,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// Check if email or username already taken (unified message to prevent enumeration)
//
// The ordering is deliberate and load-bearing. This runs BEFORE the password
// strength check, the breach lookup and the Argon2id hash below, so a taken
// address answers after one indexed SELECT and a free one answers after
// zxcvbn plus an outbound round-trip plus tens of milliseconds of hashing.
// The status code already gives the answer away, so today the gap tells a
// caller nothing extra. It would be the whole answer the moment somebody
// unified the 409 into the success shape to close the oracle: the clock
// would then say what the status code no longer does. Anyone doing that has
// to move this block after the hash in the same change.
const [existingEmail, existingUsername] = await Promise.all([
prisma.user.findUnique({ where: { email } }),
prisma.user.findUnique({ where: { username } }),
@@ -146,7 +146,6 @@ describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => {
["auth:passkey-login-options", 10],
["auth:passkey-verify", 10],
["auth:refresh", 60],
["auth:check-user", 30],
];
const seenKeys = new Set<string>();
for (const [prefix, limit] of surfaces) {
@@ -0,0 +1,159 @@
/**
* Signing in must cost the same whether or not the account exists.
*
* The login route used to return the moment it found no account, or an
* account with no password hash, before it reached the verifier. An account
* that did have a password paid a full Argon2id verification. Same status,
* same body, very different durations — so the clock answered "is this
* address registered, and does it carry a password", which is the question
* the removed discovery endpoint used to answer out loud.
*
* What this file pins is the work, not the wall clock: that the empty arms
* reach the verifier at all, that the hash they verify against was minted at
* the same cost parameters every real hash carries, and that no outcome on
* those arms can be read as a successful verification. A unit test cannot
* prove two durations are equal — it can only prove the same call is made.
*
* `@node-rs/argon2` is mocked so the assertions are about which calls happen
* with which arguments, and so the suite does not spend real Argon2 time.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoisted so the mock is in place before `password.ts` is imported.
const { hashMock, verifyMock } = vi.hoisted(() => ({
hashMock: vi.fn(async (input: string) => `hashed:${input}`),
verifyMock: vi.fn(async (_hash: string, _password: string) => false),
}));
vi.mock("@node-rs/argon2", () => ({ hash: hashMock, verify: verifyMock }));
import { ARGON2_HASH_OPTIONS } from "@/lib/auth/argon2-params.mjs";
/**
* The dummy hash is minted once per module instance, so each test gets a
* fresh instance — otherwise the first successful mint is cached and the
* mint-failure cases could never reach the mint at all.
*/
let verifyPasswordOrDummy: (
storedHash: string | null | undefined,
password: string,
) => Promise<boolean>;
beforeEach(async () => {
verifyMock.mockReset();
verifyMock.mockResolvedValue(false);
hashMock.mockReset();
hashMock.mockImplementation(async (input: string) => `hashed:${input}`);
vi.resetModules();
({ verifyPasswordOrDummy } = await import("@/lib/auth/password"));
});
describe("verifyPasswordOrDummy", () => {
it("mints the dummy hash under the same Argon2id parameters real hashes use", async () => {
await verifyPasswordOrDummy(null, "anything");
expect(hashMock).toHaveBeenCalledWith(
expect.any(String),
ARGON2_HASH_OPTIONS,
);
});
it("mints once and reuses it for every later refusal", async () => {
await verifyPasswordOrDummy(null, "anything");
await verifyPasswordOrDummy(null, "anything");
await verifyPasswordOrDummy(undefined, "anything");
// Three refusals, three verifications, one mint.
expect(verifyMock).toHaveBeenCalledTimes(3);
expect(hashMock).toHaveBeenCalledTimes(1);
});
it("verifies against the stored hash when the account has one", async () => {
verifyMock.mockResolvedValueOnce(true);
await expect(
verifyPasswordOrDummy("$argon2id$stored", "correct horse"),
).resolves.toBe(true);
expect(verifyMock).toHaveBeenCalledTimes(1);
expect(verifyMock).toHaveBeenCalledWith(
"$argon2id$stored",
"correct horse",
);
});
it("still verifies when there is no account at all (null hash)", async () => {
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false);
expect(verifyMock).toHaveBeenCalledTimes(1);
// Against the dummy, not against nothing.
expect(verifyMock.mock.calls[0][0]).toMatch(/^hashed:/);
});
it("still verifies for a passkey-only account (undefined hash)", async () => {
// The passkey-only case is the one that matters most: if it took the
// cheap path the channel would still answer "this account has no
// password", which is half of what the removed endpoint published.
await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe(
false,
);
expect(verifyMock).toHaveBeenCalledTimes(1);
});
it("never reports success from the dummy arm, whatever the verifier says", async () => {
verifyMock.mockResolvedValue(true);
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false);
await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe(
false,
);
});
it("swallows a throw from the dummy arm rather than answering differently", async () => {
// A 500 only the unknown-account arm can produce is the same oracle in
// a different field.
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
try {
verifyMock.mockRejectedValueOnce(new Error("malformed hash"));
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(
false,
);
expect(logged).toHaveBeenCalled();
} finally {
logged.mockRestore();
}
});
it("swallows a failed MINT too, not only a failed verify", async () => {
// The mint is awaited as an argument to the verifier, so an unguarded
// `await` on a rejected mint throws before the verifier is reached and
// before any `.catch` on it can apply. That answers 500 on exactly the
// arm that must look ordinary — and a passkey-only account could not
// sign in at all.
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
try {
hashMock.mockRejectedValueOnce(new Error("out of memory"));
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(
false,
);
expect(verifyMock).not.toHaveBeenCalled();
expect(logged).toHaveBeenCalled();
} finally {
logged.mockRestore();
}
});
it("re-mints after a failure instead of caching the rejection forever", async () => {
// A rejected promise stays rejected for the life of the process. Caching
// one would send every later refusal back down the cheap path with the
// timing channel reopened and nothing to notice it by.
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
try {
hashMock.mockRejectedValueOnce(new Error("out of memory"));
await verifyPasswordOrDummy(null, "anything");
expect(verifyMock).not.toHaveBeenCalled();
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(
false,
);
expect(hashMock).toHaveBeenCalledTimes(2);
expect(verifyMock).toHaveBeenCalledTimes(1);
} finally {
logged.mockRestore();
}
});
});
@@ -0,0 +1,240 @@
/**
* The email-address conflict answer is metered, and metered before it answers.
*
* `applyProfileUpdate` is the one place both profile routes reach, and its
* 409 tells any signed-in caller whether a given address belongs to an
* account on this instance. Until this bucket existed neither route had a
* ceiling of any kind — `apiHandler` supplies none — so one account could
* walk an address book at whatever rate the network allowed.
*
* What is pinned here. The charge happens BEFORE the uniqueness probe, so a
* refused request never learns the answer it was refused for. A save that
* carries the address already on file is not charged at all, so a settings
* form that re-posts the whole profile does not spend an honest afternoon of
* edits on the ceiling. A refusal drops the address and keeps the rest of the
* save, so one spent budget cannot block somebody setting their timezone; the
* published 429 is for the request that asked for nothing else. And both
* answers worth detecting leave an audit row, because neither reaches the
* ordinary `profile.update` row written after the save.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkProfileEmailRateLimit: vi.fn(),
}));
import { applyProfileUpdate } from "../profile-update";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
const USER_ID = "user-1";
const ON_FILE = "mine@example.test";
/** The `where` clauses the helper can send, told apart by their shape. */
function selfLookups() {
return vi
.mocked(prisma.user.findUnique)
.mock.calls.filter((c) => "id" in (c[0]!.where as object));
}
function emailProbes() {
return vi
.mocked(prisma.user.findUnique)
.mock.calls.filter((c) => "email" in (c[0]!.where as object));
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.update).mockResolvedValue({
id: USER_ID,
username: "u",
email: ON_FILE,
} as never);
vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({
allowed: true,
limit: 10,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
where: Record<string, unknown>;
}) => {
if ("id" in args.where) return { email: ON_FILE };
return null;
}) as never);
});
describe("applyProfileUpdate — the email conflict is metered", () => {
it("charges the acting account before asking about the address", async () => {
await applyProfileUpdate(USER_ID, { email: "someone@example.test" });
expect(checkProfileEmailRateLimit).toHaveBeenCalledWith(USER_ID);
expect(emailProbes()).toHaveLength(1);
// Ordering, not just presence: the probe must not have run first.
const chargedAt = vi.mocked(checkProfileEmailRateLimit).mock
.invocationCallOrder[0];
const probedAt = vi.mocked(prisma.user.findUnique).mock.invocationCallOrder[
selfLookups().length
];
expect(chargedAt).toBeLessThan(probedAt);
});
it("refuses with 429 and the published code when the address was all that was asked for", async () => {
vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({
allowed: false,
limit: 10,
remaining: 0,
resetAt: Date.now() + 3_600_000,
});
const result = await applyProfileUpdate(USER_ID, {
email: "someone@example.test",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(429);
expect(result.errorCode).toBe("profile.update.emailRateLimited");
}
// The whole point: a refused caller is told nothing about the address.
expect(emailProbes()).toHaveLength(0);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("does not charge a save that carries the address already on file", async () => {
const result = await applyProfileUpdate(USER_ID, {
// Same address, differently typed — normalisation is what decides.
email: " Mine@Example.Test ",
heightCm: 180,
});
expect(result.ok).toBe(true);
expect(checkProfileEmailRateLimit).not.toHaveBeenCalled();
expect(emailProbes()).toHaveLength(0);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("does not charge an update that never touches the address", async () => {
await applyProfileUpdate(USER_ID, { heightCm: 181 });
expect(checkProfileEmailRateLimit).not.toHaveBeenCalled();
});
it("keeps the rest of the save when the budget is spent, dropping only the address", async () => {
// The web form posts the whole profile in one request. Failing all of it
// would stop somebody setting their timezone for an hour and tell them it
// was about email addresses.
vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({
allowed: false,
limit: 10,
remaining: 0,
resetAt: Date.now() + 3_600_000,
});
const result = await applyProfileUpdate(USER_ID, {
email: "someone@example.test",
timezone: "Europe/Lisbon",
heightCm: 180,
});
expect(result.ok).toBe(true);
const written = vi.mocked(prisma.user.update).mock.calls[0]![0]!
.data as Record<string, unknown>;
expect(Object.keys(written).sort()).toEqual(["heightCm", "timezone"]);
expect("email" in written).toBe(false);
if (result.ok) {
expect(result.rejectedFields).toEqual([
expect.objectContaining({ path: "email", code: "rate_limited" }),
]);
}
// Still no probe: the refused caller learns nothing about the address.
expect(emailProbes()).toHaveLength(0);
});
it("records both answers worth detecting in the audit ledger", async () => {
vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({
allowed: false,
limit: 10,
remaining: 0,
resetAt: Date.now() + 3_600_000,
});
await applyProfileUpdate(USER_ID, { email: "someone@example.test" });
expect(auditLog).toHaveBeenCalledWith(
"profile.email.rate_limited",
expect.objectContaining({ userId: USER_ID }),
);
vi.mocked(auditLog).mockClear();
vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({
allowed: true,
limit: 10,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
where: Record<string, unknown>;
}) => {
if ("id" in args.where) return { email: ON_FILE };
return { id: "someone-else" };
}) as never);
await applyProfileUpdate(USER_ID, { email: "taken@example.test" });
expect(auditLog).toHaveBeenCalledWith(
"profile.email.conflict",
expect.objectContaining({ userId: USER_ID }),
);
// Neither path reaches the ordinary post-save row.
expect(auditLog).not.toHaveBeenCalledWith(
"profile.update",
expect.anything(),
);
});
it("does not charge an account whose stored address is spelled differently", async () => {
// Registration writes the address as typed, so an account created as
// `Mine@Example.Test` has that in the column. Comparing it raw against
// the normalised submission would charge its first ordinary save.
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
where: Record<string, unknown>;
}) => {
if ("id" in args.where) return { email: "Mine@Example.Test" };
return null;
}) as never);
const result = await applyProfileUpdate(USER_ID, { email: ON_FILE });
expect(result.ok).toBe(true);
expect(checkProfileEmailRateLimit).not.toHaveBeenCalled();
expect(emailProbes()).toHaveLength(0);
});
it("still answers 409 when the bucket has room and the address is taken", async () => {
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
where: Record<string, unknown>;
}) => {
if ("id" in args.where) return { email: ON_FILE };
return { id: "someone-else" };
}) as never);
const result = await applyProfileUpdate(USER_ID, {
email: "taken@example.test",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(409);
expect(result.errorCode).toBe("profile.update.emailInUse");
}
expect(checkProfileEmailRateLimit).toHaveBeenCalledTimes(1);
});
});
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
update: vi.fn(),
},
// The email-address change is metered before it asks whether another
// account holds the address; the limiter talks to Postgres directly.
$queryRaw: vi.fn(),
},
}));
@@ -34,6 +37,10 @@ const STUB_USER = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.update).mockResolvedValue(STUB_USER as never);
// A bucket with room left, unless a test says otherwise.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
] as never);
});
describe("applyProfileUpdate timezone validation", () => {
+84
View File
@@ -20,6 +20,90 @@ export async function verifyPassword(
return verify(storedHash, password);
}
/**
* A stand-in stored hash for the login arms that have nothing to verify.
*
* Sign-in used to return the moment it found no account, or an account with
* no password — one indexed SELECT — while an account that did have a
* password paid a full Argon2id verification at 19 MiB and t=2. The two
* answers were byte-identical and the two costs were not, so the clock told
* a caller which addresses are registered and which of those carry a
* password. Verifying against this hash on the empty arms makes the work
* comparable.
*
* Minted from `ARGON2_HASH_OPTIONS`, the same parameters every real hash
* carries, because Argon2 encodes its cost in the hash string and the
* verifier pays whatever the string says. A dummy minted at cheaper settings
* would leave the gap it was added to close.
*
* The input is a fixed constant rather than a random string so the module
* has no hidden state, and it is never compared to anything: the arm that
* verifies against it discards the verdict and returns false. There is no
* path on which a caller can make this hash accept.
*/
const DUMMY_VERIFY_INPUT = "healthlog: not a credential, never accepted";
let dummyHashPromise: Promise<string> | null = null;
/**
* The dummy hash, minted on first use and kept for the process afterwards.
*
* Lazy rather than minted at import: every process that imports this module
* would otherwise spend 19 MiB and a thread-pool slot at boot, including the
* queue worker and each build worker, none of which ever authenticates
* anybody. From the second refusal onward the cost profile is identical
* either way.
*
* A rejected promise stays rejected for the life of the process, so caching
* one would degrade every later refusal back to the cheap path and leave the
* timing channel open with no way to notice. A failed mint is therefore
* dropped and the next call mints again.
*/
function dummyHash(): Promise<string> {
if (!dummyHashPromise) {
const pending = hash(DUMMY_VERIFY_INPUT, ARGON2_HASH_OPTIONS);
dummyHashPromise = pending;
pending.catch(() => {
if (dummyHashPromise === pending) dummyHashPromise = null;
});
}
return dummyHashPromise;
}
/**
* Verify `password` against `storedHash`, or against the dummy hash when
* there is no stored hash to verify against.
*
* Returns false whenever `storedHash` is absent — an account without a
* password, or no account at all. The dummy verification's own verdict is
* thrown away, so a false here is never a rejected credential being
* mistaken for anything else, and a true can only come from a real stored
* hash.
*
* Callers on an anonymous surface should use this rather than branching on
* the absent hash themselves: the branch is what the clock reads.
*/
export async function verifyPasswordOrDummy(
storedHash: string | null | undefined,
password: string,
): Promise<boolean> {
if (storedHash) return verify(storedHash, password);
try {
// Same verifier, same cost profile, verdict discarded.
await verify(await dummyHash(), password);
} catch (error) {
// Both the mint and the verify are inside the guard, and the mint has to
// be: an unguarded `await` on a rejected mint would throw out of here and
// answer 500 on exactly the arm this function exists to make ordinary —
// an unknown identifier answering 500 while a real account answers 401 is
// the oracle again, in the status code, and cheaper to read than the
// timing gap was. The breadcrumb makes a degraded instance visible; the
// answer below is the same either way.
console.error("[auth] dummy password verification failed", error);
}
return false;
}
export interface PasswordStrength {
score: number; // 0-4
feedback: string[];
+90 -10
View File
@@ -13,6 +13,8 @@ import {
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
@@ -238,17 +240,95 @@ export async function applyProfileUpdate(
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
// A settings form re-posts the whole profile, so the address arrives
// with every save whether or not it changed. Those saves ask nothing
// about anyone else's account — the address on file already belongs to
// this caller — so they skip both the uniqueness probe and the budget.
// Charging them would spend an honest afternoon of edits on the
// ceiling and lock the account out of its own settings page.
//
// The stored side is normalised for the comparison too. Registration
// writes the address exactly as typed, so an account registered as
// `MixedCase@Example.com` would otherwise fail to match its own address
// and pay for its first ordinary save.
const current = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (existing && existing.id !== userId) {
return {
ok: false,
status: 409,
message: "Email already in use",
errorCode: "profile.update.emailInUse",
};
const addressOnFile = current?.email?.trim().toLowerCase() ?? null;
if (addressOnFile !== normalizedEmail) {
// Charged BEFORE the probe, not after: a refused request must not
// learn the answer it was refused for. The 409 below tells any
// signed-in caller whether an address is registered here, and until
// this bucket existed neither route that reaches it had a ceiling of
// any kind — `apiHandler` supplies no default.
const rl = await checkProfileEmailRateLimit(userId);
if (!rl.allowed) {
annotate({ action: { name: "profile.email.throttled" } });
await auditLog("profile.email.rate_limited", {
userId,
ipAddress: ipAddress ?? null,
});
// Narrow the refusal to the field, the way the demo narrowing above
// does: the web form posts the whole profile in one request, so
// failing all of it would stop somebody setting their timezone for
// an hour and tell them it was about email addresses. The address is
// dropped, everything else is saved, and `rejectedFields` says which
// field did not go through.
const { email: _refusedAddress, ...keepable } = data;
if (Object.keys(keepable).length > 0) {
data = keepable;
rejectedFields = [
...rejectedFields,
{
path: "email",
code: "rate_limited",
message: "Too many email-address changes.",
},
];
} else {
// Nothing else was asked for, so there is nothing to salvage and a
// partial success would be a lie — the same rule the all-fields-
// rejected 422 above follows. This is the arm the published 429
// describes.
return {
ok: false,
status: 429,
message: "Too many email-address changes. Try again later.",
errorCode: "profile.update.emailRateLimited",
};
}
} else {
// Case-sensitive, because the column is: registration writes the
// address as typed, so `Mixed@Example.com` and `mixed@example.com`
// are two rows this probe cannot see as one. Pre-existing and not
// widened here — the fix is a normalisation pass on the register
// write, which is where the two spellings are created.
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing && existing.id !== userId) {
// The informative answer is the one worth recording: it says an
// account holds that address. The ordinary save's `profile.update`
// row is written further down and this path never reaches it, so
// without a row here the only probes in the ledger would be the
// ones that came back negative.
annotate({ action: { name: "profile.email.conflict" } });
await auditLog("profile.email.conflict", {
userId,
ipAddress: ipAddress ?? null,
});
return {
ok: false,
status: 409,
message: "Email already in use",
errorCode: "profile.update.emailInUse",
};
}
}
}
}
+19 -8
View File
@@ -55,14 +55,17 @@
* client's repository, not in this one, and an inference drawn from this side
* would be a guess wearing a citation. Nothing below is a claim about a caller.
*
* How the set was found: every route path under `src/app` at every release tag,
* unioned, then differenced against the current tree. Twenty-seven paths have
* been removed across the project's history. The five here are the ones that
* were published or client-facing. The other twenty-two are admin-console
* routes, operator diagnostics, aggregators superseded in place, and the
* moodLog bridge — surfaces reachable only from the operator's own browser,
* which ships with the server and reads no contract to find out what it talks
* to. `moodlog-removal-guard.test.ts` separately holds that bridge removed.
* How the set was seeded: every route path under `src/app` at every release
* tag, unioned, then differenced against the current tree. Twenty-seven paths
* had been removed across the project's history up to that point. Five of them
* were published or client-facing and are the first five entries below; the
* other twenty-two are admin-console routes, operator diagnostics, aggregators
* superseded in place, and the moodLog bridge — surfaces reachable only from
* the operator's own browser, which ships with the server and reads no
* contract to find out what it talks to. `moodlog-removal-guard.test.ts`
* separately holds that bridge removed. Every entry after those five was
* written by the release that removed the path, which is where a tombstone
* belongs.
*
* Sample tags instead of all of them and this undercounts badly: six evenly
* spaced tags find fourteen of the twenty-seven, because a path added and
@@ -141,6 +144,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [
"The whole-record operation moved under the resource type it operates on, which is where FHIR R4 defines it and where the capability statement points.",
methods: ["GET"],
},
{
path: "/api/auth/check-user",
removedIn: "1.38.20",
replacedBy: null,
reason:
"Took an email address or a username and answered, to any caller and with no credential at all, whether an account existed here and whether it carried a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the account exists.",
methods: ["POST"],
},
{
path: "/api/auth/me/research-mode",
removedIn: "1.37.2",
+1
View File
@@ -250,6 +250,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
practitioner: ["practitioner.invalid"],
profile: [
"profile.update.emailInUse",
"profile.update.emailRateLimited",
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
+3 -61
View File
@@ -38,6 +38,7 @@ import {
} from "@/lib/validations/step-up";
import {
dataEnvelope,
profileEmailRateLimitResponse,
stdResponses,
errorEnvelope,
loginPasswordSchema,
@@ -304,43 +305,6 @@ const successFlagResponse = z
.object({ success: z.boolean() })
.meta({ id: "AuthSuccessFlagResponse" });
// ── iOS onboarding discovery (check-user) ────────────────────────────
const checkUserRequest = z
.object({
identifier: z
.string()
.trim()
.min(1)
.max(254)
.describe(
"The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.",
),
})
.meta({
id: "CheckUserRequest",
description:
"Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step.",
});
const checkUserResponse = z
.object({
branch: z
.enum(["not_found", "passkey_only", "email_fallback", "exists"])
.describe(
"Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — recovery path).",
),
hasPasskey: z
.boolean()
.describe("The account has at least one registered passkey."),
hasPassword: z.boolean().describe("The account has a password hash."),
})
.meta({
id: "CheckUserResponse",
description:
"Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.",
});
/**
* Shared tail for every route the step-up elevation can unlock. Kept in one
* place so the published contract cannot describe the set inconsistently.
@@ -563,7 +527,7 @@ const authProfileUpdateResponse = z
)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save.",
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.",
),
})
.meta({
@@ -620,29 +584,6 @@ const codexDevicePollResponse = z
.meta({ id: "CodexDevicePollResponse" });
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/check-user": {
post: {
tags: ["Auth"],
summary: "Resolve the next sign-in step for a typed identifier",
description:
"Given an email or username, returns which onboarding branch the iOS client should render plus the `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same whether or not the identifier matched, and the identifier is never echoed back.",
requestBody: {
required: true,
content: { "application/json": { schema: checkUserRequest } },
},
responses: {
"200": {
description: "Discovery result.",
content: {
"application/json": {
schema: dataEnvelope(checkUserResponse, "CheckUserEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/auth/login": {
post: {
// No credential: this operation is reachable before one exists.
@@ -1826,6 +1767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...profileEmailRateLimitResponse,
"422": {
description:
"Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`.",
+19
View File
@@ -46,6 +46,7 @@ import {
errorEnvelope,
invalidBaseTokenResponse,
malformedJsonResponse,
profileEmailRateLimitResponse,
recordRefusal,
stdResponses,
updatedAtTokenField,
@@ -482,6 +483,18 @@ const profileUpdateResponse = z
hasInsuranceNumber: z
.boolean()
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.",
),
})
.meta({
id: "ProfileUpdateResponse",
@@ -1476,7 +1489,13 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...profileEmailRateLimitResponse,
},
},
},
+26
View File
@@ -318,6 +318,32 @@ export const recordWriteRateLimitResponse = {
},
};
/**
* The 429 the profile email-address bucket answers with.
*
* Changing the address asks whether another account already holds it, and the
* answer is a 409 versus a 200 — an existence check over every address on the
* instance, available to any signed-in caller. Neither route that reaches it
* had a ceiling of any kind, and `apiHandler` supplies no default, so the
* sweep was bounded only by the network. This is the response that names the
* bucket now capping it.
*
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those two
* operations. The headers block is the one the standard 429 already declares.
*
* `profile-email-rate-limit-contract.test.ts` holds the numbers in this
* sentence to the constants in `src/lib/rate-limit.ts`, so the paragraph
* cannot drift away from the bucket it describes.
*/
export const profileEmailRateLimitResponse = {
"429": {
description:
"Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:<accountId>`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. A request that carries other fields beside the refused address does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},
};
// ── Idempotent writes ────────────────────────────────────────────────
/**
+5
View File
@@ -73,6 +73,11 @@ export function describeRejectedProfileField(
* degrade to something honest instead of to silence.
*/
const REJECTION_REASON_KEYS: Record<string, string> = {
// Not a Zod code: the profile helper refuses an email-address change that
// has spent the account's hourly budget this way, so the rest of the save
// still lands. Without an entry it would fall back to "check it and try
// again", which is wrong — the value was fine, the timing was not.
rate_limited: "settings.profileRejection.rateLimited",
too_big: "settings.profileRejection.tooBig",
too_small: "settings.profileRejection.tooSmall",
invalid_value: "settings.profileRejection.invalidValue",
+41 -1
View File
@@ -160,6 +160,46 @@ export async function checkRecordWriteRateLimit(
);
}
/**
* Per-account ceiling for the profile email-address change.
*
* Changing the address means asking whether another account already holds
* it, and the answer to that question is 409 versus 200 — an existence check
* over every address on the instance, available to any signed-in caller. The
* two routes that reach it (`PUT /api/auth/profile`, `PATCH
* /api/user/profile`) had no ceiling at all and `apiHandler` supplies none,
* so a single account could walk an address book at whatever rate it liked.
*
* Keyed on the acting account rather than on the address, the frozen
* precedent from the record writes: an attacker cannot collect a fresh
* allowance by switching the address they probe, and one account's sweep
* cannot lock a stranger out of their own settings page.
*
* Ten an hour is far above honest use — an address is changed once in a
* while, not ten times a day — and it is only charged when the submitted
* address actually differs from the one on file, so re-saving a settings
* form that carries the unchanged address never spends the budget. What it
* ends is the unbounded sweep: two hundred and forty questions a day per
* account instead of as many as the network will carry. Both answers worth
* detecting write an audit row of their own — `profile.email.conflict` when
* an address turns out to be taken, `profile.email.rate_limited` when the
* budget runs out. The ordinary `profile.update` row is written after the
* save, which neither of those paths reaches.
*/
export const PROFILE_EMAIL_BUCKET_PREFIX = "profile-email";
export const PROFILE_EMAIL_LIMIT = 10;
export const PROFILE_EMAIL_WINDOW_MS = 60 * 60 * 1000;
export async function checkProfileEmailRateLimit(
accountId: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`${PROFILE_EMAIL_BUCKET_PREFIX}:${accountId}`,
PROFILE_EMAIL_LIMIT,
PROFILE_EMAIL_WINDOW_MS,
);
}
/**
* v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`).
* The bucket is keyed by the `<userId>:<tokenId>` binding the Bearer
@@ -240,7 +280,7 @@ const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000;
/**
* Rate-limit helper for anonymous auth surfaces (login, register,
* passkey-login-{options,verify}, refresh, check-user).
* passkey-login-{options,verify}, refresh).
*
* Per-IP path: when the trust chain resolves cleanly, route the request
* to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's