Files
HealthLog/src/app/api/auth/login/__tests__/route.test.ts
T
Marc-André Bombeck 995bd2385a fix(auth): wrong passwords from elsewhere can no longer lock the owner out
The per-account password limit refused every attempt once ten failures
had landed in fifteen minutes, counted before the password was checked.
Ten guesses from two addresses therefore locked a password-only account,
the app's password sign-in included, and repeating them kept it locked
for as long as anyone cared to.

A place the account has signed in from before is now neither counted nor
held back: a remembered browser, a phone presenting the device id a login
of this account was issued to, or an address a session or device login of
this account came from in the last thirty days. Anywhere else gets five
free failures a day and then a wait that doubles with each failure, from
thirty seconds to at most fifteen minutes, instead of a wall. The per-IP
limit is unchanged, and an unknown identifier is limited the same way on
its hash.

Under an SSO-only policy, a password sign-in to an account other than the
one a pending link names is now refused before the password is checked,
so that exception no longer tells right passwords from wrong ones.
2026-09-26 22:42:40 +02:00

193 lines
6.0 KiB
TypeScript

/**
* v1.4.43 W3-SECURITY (H-1) — `auth.login.failed` audit row must not
* leak the typed identifier (email / username) into operator
* artefacts. Per the v1.4.20 PII directive, the `reason` field is the
* only signal operators need; the identifier itself is PII and stays
* out of the row.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// The per-account throttle has its own suite (login-throttle.test.ts); here
// it lets every attempt through and counts nothing.
vi.mock("@/lib/auth/login-throttle", () => ({
beginAccountLoginAttempt: vi.fn(async () => ({
waiting: null,
source: null,
failed: async () => {},
succeeded: async () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
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", () => ({
// Return a deterministic hex string that does NOT echo the raw
// value — mirrors the real HMAC-SHA256 shape. The audit row's
// privacy contract is "the typed identifier never serialises into
// the row's body", so the mock must not embed the raw input.
hashToken: vi.fn(
(raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
),
}));
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";
import { auditLog } from "@/lib/auth/audit";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
const TYPED_EMAIL = "leaked-identifier@example.com";
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: TYPED_EMAIL, password: "supersecret" }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
});
describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
it("does NOT include the typed identifier in the audit details", async () => {
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({
details: expect.objectContaining({
reason: "user_not_found_or_no_password",
}),
}),
);
// Pin the absence: neither the literal identifier string nor an
// `identifier` key may appear anywhere in the audit-row JSON.
const calls = vi.mocked(auditLog).mock.calls;
const failedCall = calls.find((c) => c[0] === "auth.login.failed");
expect(failedCall).toBeDefined();
const payload = failedCall![1] as { details?: Record<string, unknown> };
expect(payload.details).toBeDefined();
expect(Object.keys(payload.details!)).not.toContain("identifier");
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
});
});
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// 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);
});
});