mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge release v1.38.20
Signing in no longer tells anybody which addresses have an account here.
This commit is contained in:
35 files changed
+1340
-853
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -125,7 +125,8 @@
|
||||
"update": {
|
||||
"nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.",
|
||||
"invalidBody": "프로필 변경 내용을 읽지 못했어요.",
|
||||
"emailInUse": "이미 사용 중인 이메일 주소예요."
|
||||
"emailInUse": "이미 사용 중인 이메일 주소예요.",
|
||||
"emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5508,6 +5509,7 @@
|
||||
"invalidValue": "이 항목이 받는 답변이 아니에요.",
|
||||
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
|
||||
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
|
||||
"rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.",
|
||||
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
|
||||
},
|
||||
"avatar": {
|
||||
|
||||
+3
-1
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user