diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts new file mode 100644 index 000000000..4e29595c1 --- /dev/null +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -0,0 +1,133 @@ +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() }, + apiToken: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/password", () => ({ + verifyPassword: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ + createSession: vi.fn().mockResolvedValue("session-id"), +})); + +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 }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(() => "deadbeef"), +})); + +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 { verifyPassword } from "@/lib/auth/password"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { auditLog } from "@/lib/auth/audit"; +import { createSession } from "@/lib/auth/session"; + +const FAKE_USER = { + id: "user-1", + username: "marc", + email: "marc@example.com", + passwordHash: "$argon2id$...", +}; + +function makeRequest(headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + reset: 0, + } as never); + vi.mocked(rateLimitHeaders).mockReturnValue({} as never); + vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined); + vi.mocked(auditLog).mockResolvedValue(undefined as never); + vi.mocked(createSession).mockResolvedValue("session-id"); +}); + +describe("POST /api/auth/login — native token issuance", () => { + it("issues a Bearer token when X-Client-Type: native is set", async () => { + const original = process.env.API_TOKEN_HMAC_KEY; + process.env.API_TOKEN_HMAC_KEY = "test-key"; + try { + const res = await POST(makeRequest({ "x-client-type": "native" })); + const body = (await res.json()) as { + data: { user: { id: string }; token?: string; tokenExpiresAt?: string }; + }; + expect(res.status).toBe(200); + expect(body.data.user.id).toBe("user-1"); + expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/); + expect(body.data.tokenExpiresAt).toEqual(expect.any(String)); + expect(prisma.apiToken.create).toHaveBeenCalledTimes(1); + } finally { + process.env.API_TOKEN_HMAC_KEY = original; + } + }); + + it("does NOT issue a token without the native header", async () => { + const res = await POST(makeRequest()); + const body = (await res.json()) as { + data: { user: { id: string }; token?: string }; + }; + expect(res.status).toBe(200); + expect(body.data.user.id).toBe("user-1"); + expect(body.data.token).toBeUndefined(); + expect(prisma.apiToken.create).not.toHaveBeenCalled(); + }); + + it("issues a token when User-Agent starts with HealthLog-iOS", async () => { + const original = process.env.API_TOKEN_HMAC_KEY; + process.env.API_TOKEN_HMAC_KEY = "test-key"; + try { + const res = await POST( + makeRequest({ "user-agent": "HealthLog-iOS/1.0 (iPhone)" }), + ); + const body = (await res.json()) as { data: { token?: string } }; + expect(res.status).toBe(200); + expect(body.data.token).toMatch(/^hlk_/); + } finally { + process.env.API_TOKEN_HMAC_KEY = original; + } + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 12dcbe19e..3937ee13d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; export const POST = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request) ?? "unknown"; @@ -74,6 +75,29 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.login.password" } }); + // Native clients (iOS) additionally receive a long-lived Bearer token in + // the response so they don't need to juggle the cookie jar. + if (isNativeClientRequest(request.headers)) { + const issued = await issueApiToken({ + userId: user.id, + name: `iOS auto-login ${new Date().toISOString()}`, + permissions: ["*"], + expiresInDays: 90, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { tokenId: issued.tokenId, source: "login.password" }, + }); + annotate({ action: { name: "auth.token.autoissue.native" } }); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: issued.token, + tokenExpiresAt: issued.expiresAt.toISOString(), + }); + } + return apiSuccess({ user: { id: user.id, username: user.username }, }); diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts new file mode 100644 index 000000000..c854ec98a --- /dev/null +++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + apiToken: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/passkey", () => ({ + verifyAuthentication: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ + createSession: vi.fn().mockResolvedValue("session-id"), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(() => "deadbeef"), +})); + +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 { verifyAuthentication } from "@/lib/auth/passkey"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const FAKE_USER = { + id: "user-1", + username: "marc", + email: "marc@example.com", +}; + +function makeRequest(headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/auth/passkey/login-verify", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + challengeId: "ch-1", + credential: { id: "cred-1" }, + }), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + reset: 0, + } as never); + vi.mocked(verifyAuthentication).mockResolvedValue({ + verification: { verified: true }, + passkey: { userId: "user-1" }, + } as never); +}); + +describe("POST /api/auth/passkey/login-verify — native token issuance", () => { + it("issues a Bearer token when X-Client-Type: native is set", async () => { + const original = process.env.API_TOKEN_HMAC_KEY; + process.env.API_TOKEN_HMAC_KEY = "test-key"; + try { + const res = await POST(makeRequest({ "x-client-type": "native" })); + const body = (await res.json()) as { + data: { token?: string; tokenExpiresAt?: string }; + }; + expect(res.status).toBe(200); + expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/); + expect(body.data.tokenExpiresAt).toEqual(expect.any(String)); + expect(prisma.apiToken.create).toHaveBeenCalledTimes(1); + } finally { + process.env.API_TOKEN_HMAC_KEY = original; + } + }); + + it("does NOT issue a token without the native header", async () => { + const res = await POST(makeRequest()); + const body = (await res.json()) as { data: { token?: string } }; + expect(res.status).toBe(200); + expect(body.data.token).toBeUndefined(); + expect(prisma.apiToken.create).not.toHaveBeenCalled(); + }); + + it("returns 401 when verification fails", async () => { + vi.mocked(verifyAuthentication).mockResolvedValue({ + verification: { verified: false }, + passkey: { userId: "user-1" }, + } as never); + const res = await POST(makeRequest({ "x-client-type": "native" })); + expect(res.status).toBe(401); + expect(prisma.apiToken.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 47a6912f5..5332f3519 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); @@ -59,6 +60,27 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.login.passkey" } }); + if (isNativeClientRequest(request.headers)) { + const issued = await issueApiToken({ + userId: user.id, + name: `iOS auto-login ${new Date().toISOString()}`, + permissions: ["*"], + expiresInDays: 90, + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { tokenId: issued.tokenId, source: "login.passkey" }, + }); + annotate({ action: { name: "auth.token.autoissue.native" } }); + + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: issued.token, + tokenExpiresAt: issued.expiresAt.toISOString(), + }); + } + return apiSuccess({ user: { id: user.id, username: user.username }, }); diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts new file mode 100644 index 000000000..214cb8732 --- /dev/null +++ b/src/lib/auth/issue-token.ts @@ -0,0 +1,75 @@ +/** + * Helper for issuing an `ApiToken` for a user — currently used by the + * native-client auto-login flow (login + passkey login-verify) so the iOS + * app can talk to bearer-protected routes without juggling the session + * cookie. The raw `hlk_` value is returned exactly once. + */ +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +export interface IssuedToken { + token: string; + expiresAt: Date; + tokenId: string; + name: string; +} + +export interface IssueTokenOptions { + userId: string; + /** Token row name (also surfaced to the user under /api/tokens). */ + name: string; + /** Default permissions. `["*"]` = unrestricted (matches cookie scope). */ + permissions?: string[]; + /** Lifetime in days. */ + expiresInDays?: number; +} + +/** + * Generate a new `hlk_<64 hex>` token, store its hash, and return the raw + * value to the caller. The plaintext token is never persisted and must + * never be logged. + */ +export async function issueApiToken( + opts: IssueTokenOptions, +): Promise { + const rawToken = `hlk_${randomBytes(32).toString("hex")}`; + const tokenHashValue = hashToken(rawToken); + const expiresAt = new Date( + Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000, + ); + + const created = await prisma.apiToken.create({ + data: { + userId: opts.userId, + name: opts.name, + tokenHash: tokenHashValue, + permissions: opts.permissions ?? ["*"], + expiresAt, + }, + select: { id: true }, + }); + + return { + token: rawToken, + expiresAt, + tokenId: created.id, + name: opts.name, + }; +} + +/** + * Detect whether the request is from a native client and should receive a + * bearer token in the login response. Two opt-in signals: + * - `X-Client-Type: native` + * - `User-Agent: HealthLog-iOS/...` + */ +export function isNativeClientRequest(headers: Headers): boolean { + const xClient = headers.get("x-client-type"); + if (xClient && xClient.toLowerCase() === "native") return true; + + const ua = headers.get("user-agent") ?? ""; + if (ua.startsWith("HealthLog-iOS")) return true; + + return false; +}