feat(auth-check-user): four-branch discovery endpoint for iOS onboarding

Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:

- not_found       no account
- passkey_only    passkey present, no password
- email_fallback  password present (passkey optional)
- exists          neither credential (recovery path)

The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.

Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 17:13:34 +02:00
1 parent cb8f74e4fa
commit 99a801f993
3 files changed
+212

No files matched your search

@@ -0,0 +1,117 @@
/**
* 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("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";
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();
});
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: "marc" }));
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: "marc" }));
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: "marc" }));
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);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* `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.
* - No rate-limit middleware added here; the higher-level edge limit
* on `/api/auth/*` covers brute-force enumeration concerns, and the
* route is functionally equivalent in information leak to the
* existing `/api/auth/passkey/login-options` request that already
* accepts an identifier.
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const bodySchema = z.object({
identifier: z.string().trim().min(1).max(254),
});
export type CheckUserBranch =
| "not_found"
| "passkey_only"
| "email_fallback"
| "exists";
export const POST = apiHandler(async (request: NextRequest) => {
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiError("identifier required", 422);
}
const identifier = parsed.data.identifier.toLowerCase();
// 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 } },
},
});
if (!user) {
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
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 } });
return apiSuccess({ branch, hasPasskey, hasPassword });
});