mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
585b55fd49
commit
6ef139c7b2
8 files changed
+5
-640
No files matched your search
@@ -1809,31 +1809,6 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/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:
|
||||
$ref: "#/components/schemas/CheckUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Discovery result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckUserEnvelope"
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
/api/auth/login:
|
||||
post:
|
||||
security: []
|
||||
@@ -21129,17 +21104,6 @@ components:
|
||||
server-side recovery.
|
||||
required:
|
||||
- passphrase
|
||||
CheckUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 254
|
||||
description: The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.
|
||||
required:
|
||||
- identifier
|
||||
description: "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step."
|
||||
LoginPasswordRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -36587,49 +36551,6 @@ components:
|
||||
omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have
|
||||
neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which.
|
||||
`schemaVersion` is `2` on everything written today; the parser still accepts `1`."
|
||||
CheckUserEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CheckUserResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CheckUserResponse:
|
||||
type: object
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
enum:
|
||||
- not_found
|
||||
- passkey_only
|
||||
- email_fallback
|
||||
- exists
|
||||
description: "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:
|
||||
type: boolean
|
||||
description: The account has at least one registered passkey.
|
||||
hasPassword:
|
||||
type: boolean
|
||||
description: The account has a password hash.
|
||||
required:
|
||||
- branch
|
||||
- hasPasskey
|
||||
- hasPassword
|
||||
additionalProperties: false
|
||||
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.
|
||||
LoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -304,43 +304,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.
|
||||
@@ -620,29 +583,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.
|
||||
|
||||
@@ -240,7 +240,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