Remove the anonymous account-discovery endpoint

`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 13:56:49 +02:00
1 parent 585b55fd49
commit 6ef139c7b2
8 files changed
+5 -640

No files matched your search

@@ -1,154 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the
// shape only matters here, so a deterministic stand-in keeps the test
// stable without standing up the env-keyed CipherSuite. The PII check
// below asserts the raw identifier never appears in the audit payload.
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(
(raw: string) =>
`hash-${Buffer.from(raw, "utf-8")
.toString("hex")
.slice(0, 16)
.padStart(16, "0")}`,
),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }),
// v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface
// wrapper so the trust-violation branch can swap to a tighter global
// bucket. Default to a clean per-IP result.
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
function expectedHash(identifier: string): string {
return (hashToken as unknown as (s: string) => string)(identifier);
}
function makeRequest(body: { identifier: string }): NextRequest {
return new NextRequest("http://localhost/api/auth/check-user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.mocked(prisma.user.findFirst).mockReset();
vi.mocked(auditLog).mockReset();
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[0]).toBe("auth.check-user");
expect(args[1]).toEqual({
ipAddress: "203.0.113.1",
details: {
branch: "not_found",
identifier_hash: expectedHash("ghost@example.com"),
},
});
// The raw identifier must not leak into the details — every other
// /api/auth/* audit row already hashes; the check-user route follows
// the same shape.
const details = args[1]?.details as Record<string, unknown> | undefined;
expect(details).not.toHaveProperty("identifier");
expect(JSON.stringify(details)).not.toContain("ghost@example.com");
});
it("writes an audit row for the passkey_only branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "testuser" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({
userId: "user-1",
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("testuser"),
},
});
});
it("writes an audit row for the email_fallback branch with userId attached", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$...",
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "user@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("user@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
it("writes an audit row for the exists branch (no passkey + no password)", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-3",
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "stub" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "exists",
identifier_hash: expectedHash("stub"),
});
expect(args[1]?.userId).toBe("user-3");
});
});
-149
View File
@@ -1,149 +0,0 @@
/**
* `POST /api/auth/check-user` — four-branch discovery for iOS onboarding.
*
* v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs
* to know, given a typed identifier (username or email), what the next
* UX step should be:
*
* - `not_found` — no account exists; show the sign-up screen.
* - `passkey_only` — account exists, has at least one Passkey, no
* password hash. Show "Sign in with Passkey".
* - `email_fallback` — account exists, has a password hash (with or
* without a Passkey). Show password field plus
* a "Use Passkey" affordance when applicable.
* - `exists` — account exists with no usable credential
* (neither passkey nor password). Treat as
* recovery path; show "Reset access" hint.
*
* The route is intentionally narrow:
*
* - Accepts `{ identifier: string }` (either an email or a username).
* - Returns `{ branch, hasPasskey, hasPassword }` — booleans are
* included so the iOS client can render a "or sign in with Passkey"
* button alongside the password field without a second round-trip.
* - Never leaks PII. The response shape is the same regardless of
* whether the identifier matched (callers learn account-existence
* either way — that is the explicit contract iOS needs). The
* handler does NOT echo the identifier back.
* - Per-IP rate-limited (30 requests / 15 min) so the by-design
* enumeration contract cannot be brute-forced. Mirrors the
* `/api/auth/passkey/login-options` throttle shape.
* - The identifier is queried EXACTLY as iOS sends it (no
* `.toLowerCase()` normalisation). Email + username columns are
* stored verbatim by `/api/auth/register`, so case-folding here
* would route legitimate existing users to the sign-up branch.
* A future server-side normalisation pass on register write is
* the long-term fix.
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const bodySchema = z
.object({
identifier: z.string().trim().min(1).max(254),
})
.strict();
export type CheckUserBranch =
"not_found" | "passkey_only" | "email_fallback" | "exists";
export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:check-user",
30,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Too many requests. Please try again later." },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"identifier required",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const identifier = parsed.data.identifier;
// Match on either username or email — both are unique in the schema.
const user = await prisma.user.findFirst({
where: {
OR: [{ username: identifier }, { email: identifier }],
},
select: {
id: true,
passwordHash: true,
_count: { select: { passkeys: true } },
},
});
// v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin
// can later answer "did anyone enumerate accounts last week?". The
// wide-event `annotate()` log is sampled and short-retention; the
// audit ledger is durable. We hash the identifier (same shape as the
// `auth.login.failed` row) so the literal email/username never lands
// in `AuditLog.details` — the v1.4.20 retroactive PII directive bars
// raw user-typed strings from operator-readable artefacts.
const identifierHash = hashToken(identifier);
if (!user) {
annotate({
action: { name: "auth.check-user" },
meta: { branch: "not_found" },
});
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
});
return apiSuccess({
branch: "not_found" satisfies CheckUserBranch,
hasPasskey: false,
hasPassword: false,
});
}
const hasPasskey = user._count.passkeys > 0;
const hasPassword = Boolean(user.passwordHash);
let branch: CheckUserBranch;
if (hasPasskey && !hasPassword) branch = "passkey_only";
else if (hasPassword) branch = "email_fallback";
else branch = "exists";
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
await auditLog("auth.check-user", {
userId: user.id,
ipAddress: ip,
details: { branch, identifier_hash: identifierHash },
});
return apiSuccess({ branch, hasPasskey, hasPassword });
});
+4 -5
View File
@@ -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({