diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bb61a19..47aa92854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [1.32.11] — 2026-07-24 + +Signing in to the iOS app on a self-hosted domain now runs through your own web +login, so saved passwords and passkeys behave the way they should. + +- **Sign in through your own domain.** On a self-hosted instance the app now + opens your instance's real web login page inside a secure in-app browser. + Because the login happens on your own origin, your password manager files the + credentials under the right site and passkeys can run at all, which the older + native form could not do on a custom domain. +- **A single-use handoff, not a token in a link.** Once you have logged in, the + server hands the app a one-time code that lives for about ninety seconds and + can be redeemed only once. The access and refresh tokens never ride a URL. The + whole exchange mirrors the existing native single sign-on handoff and reuses + its protections, including the per-request PKCE binding. +- **The login has to happen inside the flow.** The app receives a code only when + you actually authenticate during that browser session. A login left open in + the background from before cannot complete the handoff by itself, so a stray + link can never quietly sign the app in as you. +- **Your second factor still applies.** An account with an authenticator app or + a security key completes that step on the web page before any code is issued, + exactly as it does everywhere else. + ## [1.32.9] — 2026-07-23 The Coach output guard now remembers the numbers you were actually shown across diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dfb1ae2d9..9f3624014 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.9 + version: 1.32.11 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1601,6 +1601,79 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/native/login: + get: + tags: + - Auth + summary: Start the first-party web-handoff login (browser navigation) + description: "First leg of the native web-handoff login (iOS #65). On a self-hosted domain the iOS app opens this URL + inside an `ASWebAuthenticationSession` with its PKCE `code_challenge`, so the login runs in the instance's real + web origin (fixing password autofill + passkeys). The endpoint validates the challenge, sets a short-lived + encrypted state cookie carrying the challenge + a DB-clock start time, and 302-redirects to + `/auth/login?flow=native`. It writes no database rows. Every error 302-redirects to + `healthlog://login-callback?error=` (a closed set: `invalid_request`, `rate_limited`) — an error carries + no code or session. Anonymous; rate-limited 10 / 15 min." + parameters: + - in: query + name: code_challenge + schema: + type: string + minLength: 43 + maxLength: 128 + description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported. + required: true + description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported. + responses: + "302": + description: Redirect to `/auth/login?flow=native` (state cookie set) on success, or to + `healthlog://login-callback?error=` on failure. + /api/auth/native/complete: + get: + tags: + - Auth + summary: Complete the web-handoff login and mint the code (browser navigation) + description: "Second leg of the native web-handoff login (iOS #65). After an interactive login on + `/auth/login?flow=native`, the page navigates the browser here as a top-level GET. The endpoint validates the + web session against the database, enforces the freshness binding (`session.createdAt >= startedAt`, both + DB-clock — a pre-existing session is refused), mints a single-use PKCE-locked handoff code, deletes the state + cookie, destroys the scaffold web session, and 302-redirects to `healthlog://login-callback?code=hlh_…`. The + token pair never rides the URL; only the opaque code does. The app never calls this directly. Every failure + 302-redirects to `healthlog://login-callback?error=` (`invalid_state`, `no_session`, `stale_session`, + `rate_limited`). Rate-limited 20 / 15 min." + responses: + "302": + description: Redirect to `healthlog://login-callback?code=hlh_…` on success, or `…?error=` on any failure. + /api/auth/native/token: + post: + tags: + - Auth + summary: Exchange a web-handoff code for the token bundle + description: >- + Third leg of the native web-handoff login (iOS #65). The app exchanges the one-time handoff code from + `healthlog://login-callback?code=hlh_…` plus its PKCE `codeVerifier` for the SAME native bundle password login + issues. + + + Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and + expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. The exchange is + flow-gated: a code minted by the OIDC native leg is not redeemable here (and vice versa). A single generic 401 + covers every invalid / expired / used / PKCE-mismatch / cross-flow case. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NativeHandoffTokenRequest" + responses: + "200": + description: Handoff accepted — native access + refresh bundle. + content: + application/json: + schema: + $ref: "#/components/schemas/NativeHandoffTokenResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/step-up/options: post: tags: @@ -11557,6 +11630,21 @@ components: - code - codeVerifier description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle. + NativeHandoffTokenRequest: + type: object + properties: + code: + type: string + pattern: ^hlh_[A-Za-z0-9_-]{43}$ + description: One-time handoff code from the healthlog://login-callback?code= redirect. + codeVerifier: + type: string + pattern: ^[A-Za-z0-9\-._~]{43,128}$ + description: The app's PKCE code_verifier (RFC 7636). + required: + - code + - codeVerifier + description: Exchange a one-time web-handoff code (+ its PKCE verifier) for the standard native token bundle. StepUpOptionsRequest: type: object properties: @@ -15600,6 +15688,23 @@ components: - data - error additionalProperties: false + NativeHandoffTokenResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false StepUpOptionsEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 9169ab53a..8e1addd18 100644 --- a/messages/de.json +++ b/messages/de.json @@ -360,6 +360,9 @@ "errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.", "errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut." }, + "native": { + "signingInToApp": "Anmeldung bei der HealthLog-App" + }, "mfa": { "title": "Zwei-Faktor-Authentifizierung", "totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", diff --git a/messages/en.json b/messages/en.json index 3bf503cc9..8972c4541 100644 --- a/messages/en.json +++ b/messages/en.json @@ -350,6 +350,9 @@ "errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.", "errorFailed": "Single sign-on failed. Please try again." }, + "native": { + "signingInToApp": "Signing in to the HealthLog app" + }, "mfa": { "title": "Two-factor authentication", "totpHint": "Enter the 6-digit code from your authenticator app.", diff --git a/messages/es.json b/messages/es.json index 001c80b4f..03e2642b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -362,6 +362,9 @@ "errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.", "errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo." }, + "native": { + "signingInToApp": "Iniciando sesión en la app de HealthLog" + }, "mfa": { "title": "Autenticación de dos factores", "totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.", diff --git a/messages/fr.json b/messages/fr.json index 22c3daf65..972b19cc6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -364,6 +364,9 @@ "errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.", "errorFailed": "L'authentification unique a échoué. Veuillez réessayer." }, + "native": { + "signingInToApp": "Connexion à l'application HealthLog" + }, "mfa": { "title": "Authentification à deux facteurs", "totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.", diff --git a/messages/it.json b/messages/it.json index 846ee90e7..016a154c6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -354,6 +354,9 @@ "errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.", "errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova." }, + "native": { + "signingInToApp": "Accesso all'app HealthLog" + }, "mfa": { "title": "Autenticazione a due fattori", "totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.", diff --git a/messages/pl.json b/messages/pl.json index c88b6c14f..2126bc36f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -348,6 +348,9 @@ "errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.", "errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie." }, + "native": { + "signingInToApp": "Logowanie do aplikacji HealthLog" + }, "mfa": { "title": "Uwierzytelnianie dwuskładnikowe", "totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.", diff --git a/package.json b/package.json index c6118fca8..0104458fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.9", + "version": "1.32.11", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0268_native_handoff_flow/migration.sql b/prisma/migrations/0268_native_handoff_flow/migration.sql new file mode 100644 index 000000000..f9733986f --- /dev/null +++ b/prisma/migrations/0268_native_handoff_flow/migration.sql @@ -0,0 +1,14 @@ +-- v1.32.11 — native web-handoff login (iOS #65). +-- +-- The `oidc_native_handoffs` table now backs two flows: the original OIDC SSO +-- native leg and the first-party web-handoff login a self-hoster's iOS app runs +-- against its own domain. Both mint the same single-use, PKCE-locked, hashed-at- +-- rest handoff code through the shared core; the `flow` column records which +-- flow minted a row so the consume path can refuse a cross-flow redemption +-- (an `oidc` code presented at the web-login token route, or vice versa). +-- +-- Additive and backfill-safe: every existing row is an OIDC handoff, so the +-- column defaults to 'oidc' and no data migration is needed. + +ALTER TABLE "oidc_native_handoffs" + ADD COLUMN "flow" TEXT NOT NULL DEFAULT 'oidc'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61e8fff79..4aadf4f78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3415,10 +3415,22 @@ model RefreshToken { // (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes // the token pair the first exchange issued (refresh-token reuse-detection // posture). 90-second TTL enforced at read; a daily sweep prunes stale rows. +// v1.32.11 — this table now backs BOTH native handoff flows: the original +// OIDC SSO leg (`flow = "oidc"`) and the first-party web-handoff login the iOS +// app uses on a self-hosted domain (`flow = "web_login"`, iOS #65). The mint + +// consume core is shared (`src/lib/auth/native-handoff.ts`); the `flow` +// discriminator is written at mint and CHECKED at exchange, so an oidc code can +// never be redeemed at the web-login token route and vice versa. model OidcNativeHandoff { id String @id @default(cuid()) userId String @map("user_id") codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) + /// Which handoff flow minted this code — `oidc` (the SSO native leg) or + /// `web_login` (the first-party web-handoff, iOS #65). Defaults to `oidc` so + /// every pre-existing row keeps its meaning. The consume path filters on it, + /// making a cross-flow redemption structurally impossible (not merely + /// unobserved). + flow String @default("oidc") @map("flow") /// The app's S256 PKCE challenge, bound at mint; the exchange verifies /// s256(code_verifier) against it (constant-time) before consuming. codeChallenge String @map("code_challenge") diff --git a/public/sw.js b/public/sw.js index cf98f70fb..00e69c754 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.9"; + /* @sw-version-fallback */ "v1.32.11"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/native/__tests__/invariants.test.ts b/src/app/api/auth/native/__tests__/invariants.test.ts new file mode 100644 index 000000000..b7dc50415 --- /dev/null +++ b/src/app/api/auth/native/__tests__/invariants.test.ts @@ -0,0 +1,111 @@ +/** + * Structural invariants for the native web-handoff surface (iOS #65). + * + * These are tripwires, not proofs — they assert the load-bearing constructions + * the red-team relied on stay in place: no `redirect_uri` parameter, one + * compile-time callback constant, a trailing-slash public-path entry, the + * cookie-only admin / step-up boundaries, and that the new routes never resolve + * a Bearer token or mint an ApiToken. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SRC = join(process.cwd(), "src"); +const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + +/** + * Strip block + line comments so an invariant asserts against CODE, not against + * a doc comment that legitimately names the thing it forbids (e.g. "there is no + * `redirect_uri` parameter"). + */ +function code(rel: string): string { + return read(rel) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +const ROUTE_FILES = [ + "app/api/auth/native/login/route.ts", + "app/api/auth/native/complete/route.ts", + "app/api/auth/native/token/route.ts", +]; + +describe("T6 — open redirect / redirect_uri removed by construction", () => { + it("no new route or handoff module references a redirect_uri parameter", () => { + const files = [ + ...ROUTE_FILES, + "lib/auth/native-web-handoff.ts", + "lib/auth/native-handoff.ts", + ]; + // Comments legitimately state "there is no redirect_uri parameter"; the + // invariant is that no CODE reads or builds one. + for (const rel of files) { + expect(code(rel)).not.toMatch(/redirect_uri/); + } + }); + + it("the callback scheme is a single compile-time constant", () => { + // Defined exactly once, as a string literal, with no interpolation. + const mod = read("lib/auth/native-web-handoff.ts"); + expect(mod).toMatch( + /NATIVE_WEB_HANDOFF_REDIRECT_URI\s*=\s*"healthlog:\/\/login-callback"/, + ); + // The routes build the Location only through the helper, never a bespoke + // scheme string in code (doc comments naming the scheme are fine). + for (const rel of ROUTE_FILES) { + expect(code(rel)).not.toMatch(/"healthlog:\/\//); + } + }); +}); + +describe("A4 — public-path allowlist carries the trailing slash", () => { + it("proxy admits exactly /api/auth/native/ (slash), never the bare prefix", () => { + const proxy = read("proxy.ts"); + expect(proxy).toMatch(/"\/api\/auth\/native\/"/); + // The slashless literal must not appear as its own allowlist entry. + expect(proxy).not.toMatch(/"\/api\/auth\/native"(?!\/)/); + }); +}); + +describe("T12 — the new routes resolve no Bearer and mint no ApiToken", () => { + it("no native route resolves a Bearer token or hand-rolls a tokenHash lookup", () => { + for (const rel of ROUTE_FILES) { + const src = read(rel); + expect(src).not.toMatch(/resolveBearerToken/); + expect(src).not.toMatch(/apiToken\.findUnique/); + } + }); + + it("no native route creates an ApiToken", () => { + for (const rel of ROUTE_FILES) { + const src = read(rel); + expect(src).not.toMatch(/apiToken\.create\(|issueApiToken\(/); + } + }); +}); + +describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => { + it("the token route never sets a cookie itself", () => { + // The bundle rides the JSON body; a Set-Cookie here would be the browser leak. + expect(read("app/api/auth/native/token/route.ts")).not.toMatch( + /cookies\.set|Set-Cookie/, + ); + }); + + it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => { + const handler = read("lib/api-handler.ts"); + // Both resolve via getSession() (cookie-only) — restated as a tripwire so a + // future edit that threads a Bearer into either trips this test. + const requireAdmin = handler.slice( + handler.indexOf("export async function requireAdmin"), + handler.indexOf("export async function requireAdmin") + 400, + ); + expect(requireAdmin).toMatch(/getSession\(\)/); + const requireFresh = handler.slice( + handler.indexOf("export async function requireFreshMfa"), + handler.indexOf("export async function requireFreshMfa") + 400, + ); + expect(requireFresh).toMatch(/getSession\(\)/); + }); +}); diff --git a/src/app/api/auth/native/complete/__tests__/route.test.ts b/src/app/api/auth/native/complete/__tests__/route.test.ts new file mode 100644 index 000000000..540b12105 --- /dev/null +++ b/src/app/api/auth/native/complete/__tests__/route.test.ts @@ -0,0 +1,193 @@ +/** + * GET /api/auth/native/complete — the freshness-bound code mint (iOS #65). + * + * The load-bearing test is the mandatory-refusal one (red-team A3): a STALE + * session (createdAt < startedAt) is refused with a specific reason AND mints no + * code / row. This file is the mutation-check target for the freshness gate + * (flip the comparison in the route → the stale-refusal case goes RED). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY = "0".repeat(64); + +vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn })); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), +})); + +const validateSessionWithCreatedAt = vi.fn(); +vi.mock("@/lib/auth/session", () => ({ + validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v), + SESSION_COOKIE_NAME: "healthlog_session", +})); + +// Keep the real shared core (buildHandoffCallbackUrl is used by +// native-web-handoff) but stub the mint so we can assert called / not-called. +vi.mock("@/lib/auth/native-handoff", async (importActual) => { + const actual = + await importActual(); + return { ...actual, mintNativeHandoff: vi.fn() }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { session: { delete: vi.fn().mockResolvedValue({}) } }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { GET } from "../route"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { mintNativeHandoff } from "@/lib/auth/native-handoff"; +import { prisma } from "@/lib/db"; +import { + NATIVE_HANDOFF_STATE_COOKIE, + encodeNativeHandoffState, +} from "@/lib/auth/native-web-handoff"; + +const STARTED_AT = new Date("2026-07-24T10:00:00.000Z"); + +/** Build a request carrying a valid encrypted state cookie + a session cookie. */ +function makeRequest(opts: { + withState?: boolean; + stateValue?: string; + withSession?: boolean; +}): NextRequest { + const r = new NextRequest("http://localhost/api/auth/native/complete"); + if (opts.stateValue !== undefined) { + r.cookies.set(NATIVE_HANDOFF_STATE_COOKIE, opts.stateValue); + } else if (opts.withState !== false) { + r.cookies.set( + NATIVE_HANDOFF_STATE_COOKIE, + encodeNativeHandoffState({ + appCodeChallenge: "a".repeat(43), + startedAt: STARTED_AT.toISOString(), + }), + ); + } + if (opts.withSession !== false) { + r.cookies.set("healthlog_session", "hls_sessionsecret"); + } + return r; +} + +function sessionCreatedAt(createdAt: Date) { + return { + session: { + id: "sess-1", + createdAt, + expiresAt: new Date(Date.now() + 1000000), + }, + user: { id: "user-1", username: "alice" }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "1.2.3.4", + } as never); + vi.mocked(mintNativeHandoff).mockResolvedValue({ + code: "hlh_" + "z".repeat(43), + handoffId: "ho-1", + }); +}); + +describe("GET /api/auth/native/complete", () => { + it("fresh session → mints a web_login code and redirects to the scheme", async () => { + // createdAt AFTER startedAt: the user authenticated inside this flow. + validateSessionWithCreatedAt.mockResolvedValue( + sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)), + ); + + const res = await GET(makeRequest({})); + + const location = res.headers.get("location")!; + expect(location.startsWith("healthlog://login-callback?code=hlh_")).toBe( + true, + ); + // The token pair never rides the URL — only the code. + expect(location).not.toContain("token"); + expect(location).not.toContain("refresh"); + + expect(mintNativeHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + appCodeChallenge: "a".repeat(43), + flow: "web_login", + }), + ); + // State cookie deleted + scaffold session destroyed + session cookie cleared. + expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe(""); + expect(prisma.session.delete).toHaveBeenCalledWith({ + where: { id: "sess-1" }, + }); + expect(res.cookies.get("healthlog_session")?.value).toBe(""); + }); + + it("A3 — STALE session (createdAt < startedAt) is REFUSED and mints nothing", async () => { + validateSessionWithCreatedAt.mockResolvedValue( + sessionCreatedAt(new Date(STARTED_AT.getTime() - 1000)), + ); + + const res = await GET(makeRequest({})); + + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=stale_session", + ); + // No code minted, no scaffold session destroyed. + expect(mintNativeHandoff).not.toHaveBeenCalled(); + expect(prisma.session.delete).not.toHaveBeenCalled(); + // The single-use state cookie is still cleared on the failure branch. + expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe(""); + }); + + it("no state cookie → invalid_state, no mint", async () => { + validateSessionWithCreatedAt.mockResolvedValue( + sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)), + ); + const res = await GET(makeRequest({ withState: false })); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=invalid_state", + ); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + }); + + it("tampered / garbage state cookie → invalid_state (auth tag fails)", async () => { + validateSessionWithCreatedAt.mockResolvedValue( + sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)), + ); + const res = await GET(makeRequest({ stateValue: "v1.not-a-real-blob" })); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=invalid_state", + ); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + }); + + it("no valid session → no_session, no mint", async () => { + validateSessionWithCreatedAt.mockResolvedValue(null); + const res = await GET(makeRequest({ withSession: false })); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=no_session", + ); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + }); + + it("rate-limited → scheme error, no mint", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "1.2.3.4", + } as never); + const res = await GET(makeRequest({})); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=rate_limited", + ); + expect(mintNativeHandoff).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/native/complete/route.ts b/src/app/api/auth/native/complete/route.ts new file mode 100644 index 000000000..d2f19d5ce --- /dev/null +++ b/src/app/api/auth/native/complete/route.ts @@ -0,0 +1,152 @@ +/** + * GET /api/auth/native/complete + * + * Completion leg of the first-party web-handoff login (iOS #65). After the user + * signs in on the instance's own web login page (inside the app's + * `ASWebAuthenticationSession`), the page navigates the browser here as a + * top-level GET. This endpoint validates the web session, enforces the FRESHNESS + * BINDING, mints a single-use handoff code, and redirects to the compiled-in + * custom scheme carrying ONLY the opaque code. + * + * Security-critical invariants: + * - `userId` is resolved SOLELY from the DB-validated session cookie — the + * request carries no identity field; the app challenge lives in the + * tamper-authenticated state cookie. + * - FRESHNESS: `session.createdAt >= state.startedAt`. The session completing the + * flow must have been minted AFTER the flow started — i.e. the user actually + * authenticated inside this ASWebAuthenticationSession. A pre-existing browser + * session can NEVER silently complete a handoff. Both timestamps are DB-clock + * values, so there is no app/DB skew (red-team A1). `createdAt` is read from a + * dedicated projection (`validateSessionWithCreatedAt`) so the comparison can + * never be silently dropped (red-team A3). + * - The token pair NEVER rides the URL — only the opaque code does. + * - Every failure redirects to `healthlog://login-callback?error=` and + * deletes the state cookie; no code or row is minted on any failure branch. + */ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { + validateSessionWithCreatedAt, + SESSION_COOKIE_NAME, +} from "@/lib/auth/session"; +import { mintNativeHandoff } from "@/lib/auth/native-handoff"; +import { + NATIVE_HANDOFF_STATE_COOKIE, + NATIVE_HANDOFF_STATE_COOKIE_PATH, + buildWebHandoffCallbackUrl, + decodeNativeHandoffState, + type NativeHandoffErrorReason, +} from "@/lib/auth/native-web-handoff"; + +export const dynamic = "force-dynamic"; + +/** + * The state cookie is path-scoped to `/api/auth/native`; RFC 6265 keys cookies + * by name+domain+path, so the delete must repeat that path or it silently + * targets `/` and leaves the single-use blob alive. + */ +function deleteStateCookie(response: NextResponse): void { + response.cookies.delete({ + name: NATIVE_HANDOFF_STATE_COOKIE, + path: NATIVE_HANDOFF_STATE_COOKIE_PATH, + }); +} + +export const GET = apiHandler(async (req: NextRequest) => { + annotate({ action: { name: "auth.native.complete" } }); + + // Every failure — and success — deletes the single-use state cookie. + const fail = (reason: NativeHandoffErrorReason): NextResponse => { + const response = NextResponse.redirect( + buildWebHandoffCallbackUrl({ error: reason }), + ); + deleteStateCookie(response); + return response; + }; + + const rl = await checkAuthSurfaceRateLimit( + req, + "auth:native:complete", + 20, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + annotate({ meta: { reason: "rate_limited" } }); + return fail("rate_limited"); + } + + // (1) The encrypted, tamper-authenticated state cookie. A missing, forged, or + // tampered blob (AES-256-GCM auth tag) fails to decode → no mint. + const state = decodeNativeHandoffState( + req.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value, + ); + if (!state) { + annotate({ meta: { reason: "invalid_state" } }); + return fail("invalid_state"); + } + + // (2) A genuinely authenticated web session — validated against the database + // (row + expiry), never "a cookie is present". Projects `createdAt` so the + // freshness comparison below is structurally always available. + const authed = await validateSessionWithCreatedAt( + req.cookies.get(SESSION_COOKIE_NAME)?.value, + ); + if (!authed) { + annotate({ meta: { reason: "no_session" } }); + return fail("no_session"); + } + + // (3) FRESHNESS BINDING. The session must have been minted AFTER the flow + // started. A stale (pre-existing) session is refused and mints NOTHING. + const startedAt = new Date(state.startedAt); + if (authed.session.createdAt.getTime() < startedAt.getTime()) { + annotate({ meta: { reason: "stale_session" } }); + return fail("stale_session"); + } + + // (4) Mint the single-use, PKCE-locked, flow-tagged handoff code. `userId` is + // the server-resolved session identity; the app's S256 challenge (from the + // tamper-authenticated cookie) locks the code to the app instance that + // started the flow. + const ua = req.headers.get("user-agent"); + const { code } = await mintNativeHandoff({ + userId: authed.user.id, + appCodeChallenge: state.appCodeChallenge, + flow: "web_login", + ipAddress: ip, + userAgent: ua, + }); + + await auditLog("auth.native.handoff_minted", { + userId: authed.user.id, + ipAddress: ip, + }); + annotate({ action: { name: "auth.native.handoff_minted" } }); + + const response = NextResponse.redirect(buildWebHandoffCallbackUrl({ code })); + deleteStateCookie(response); + + // (5) Destroy the scaffold web session (design §2.6, recommended). The flow's + // product is the native bundle; the browser sheet's residual login is not + // something the user asked for, and the freshness rule already makes a + // retained session worthless for any FUTURE flow. Best-effort — a delete + // hiccup never fails the handoff. + await prisma.session + .delete({ where: { id: authed.session.id } }) + .catch(() => {}); + response.cookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: 0, + path: "/", + }); + + return response; +}); diff --git a/src/app/api/auth/native/login/__tests__/route.test.ts b/src/app/api/auth/native/login/__tests__/route.test.ts new file mode 100644 index 000000000..2f0408ed2 --- /dev/null +++ b/src/app/api/auth/native/login/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * GET /api/auth/native/login — authorize entry (iOS #65). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY = "0".repeat(64); + +vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn })); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), +})); + +// Only `$queryRaw` (the DB clock) is used here; return a fixed instant. +// Hoisted so the mock factory (also hoisted) can reference it. +const { DB_NOW } = vi.hoisted(() => ({ + DB_NOW: new Date("2026-07-24T10:00:00.000Z"), +})); +vi.mock("@/lib/db", () => ({ + prisma: { $queryRaw: vi.fn().mockResolvedValue([{ now: DB_NOW }]) }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { GET } from "../route"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { + NATIVE_HANDOFF_STATE_COOKIE, + decodeNativeHandoffState, +} from "@/lib/auth/native-web-handoff"; + +const VALID_CHALLENGE = "a".repeat(43); + +function req(query = `code_challenge=${VALID_CHALLENGE}`): NextRequest { + return new NextRequest(`http://localhost/api/auth/native/login?${query}`); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "1.2.3.4", + } as never); +}); + +describe("GET /api/auth/native/login", () => { + it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => { + const res = await GET(req()); + const location = res.headers.get("location")!; + expect(location).toContain("/auth/login?flow=native"); + // No custom scheme on the success path. + expect(location.startsWith("healthlog://")).toBe(false); + + const cookie = res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE); + expect(cookie?.value).toBeTruthy(); + expect(cookie?.path).toBe("/api/auth/native"); + expect(cookie?.httpOnly).toBe(true); + + // The state round-trips: challenge bound, startedAt is the DB clock. + const state = decodeNativeHandoffState(cookie!.value); + expect(state?.appCodeChallenge).toBe(VALID_CHALLENGE); + expect(state?.startedAt).toBe(DB_NOW.toISOString()); + }); + + it("invalid (short) challenge → scheme error, no state cookie", async () => { + const res = await GET(req("code_challenge=tooshort")); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=invalid_request", + ); + expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeFalsy(); + // No DB clock read on the reject path. + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + + it("missing challenge → scheme error", async () => { + const res = await GET(req("")); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=invalid_request", + ); + }); + + it("rate-limited → scheme error, no state cookie, no DB write", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "1.2.3.4", + } as never); + const res = await GET(req()); + expect(res.headers.get("location")).toBe( + "healthlog://login-callback?error=rate_limited", + ); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/native/login/route.ts b/src/app/api/auth/native/login/route.ts new file mode 100644 index 000000000..b778d31f6 --- /dev/null +++ b/src/app/api/auth/native/login/route.ts @@ -0,0 +1,82 @@ +/** + * GET /api/auth/native/login + * + * Authorize entry for the first-party web-handoff login (iOS #65). The iOS app, + * on a self-hosted domain, opens this URL inside an `ASWebAuthenticationSession` + * with its PKCE `code_challenge`. This endpoint validates the challenge, folds + * it plus a DB-clock `startedAt` into an AES-256-GCM state cookie, and redirects + * to the instance's own web login at `/auth/login?flow=native` so the login runs + * in the real web origin (fixing password autofill + passkeys for self-hosters). + * + * Pre-auth surface: writes NO database rows (anonymous flooding costs the caller + * a cookie in their own jar). Every error branch redirects to the compiled-in + * custom scheme with a machine-readable `error=` — an error redirect + * carries no code or session, so it leaks nothing. + */ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isValidChallenge } from "@/lib/mcp/oauth/pkce"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { + NATIVE_HANDOFF_STATE_COOKIE, + NATIVE_HANDOFF_STATE_COOKIE_PATH, + NATIVE_HANDOFF_STATE_TTL_MS, + buildWebHandoffCallbackUrl, + encodeNativeHandoffState, + nativeHandoffDbNow, + type NativeHandoffErrorReason, +} from "@/lib/auth/native-web-handoff"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (req: NextRequest) => { + annotate({ action: { name: "auth.native.login" } }); + + // The whole route is a native start — every failure redirects to the scheme. + const loginError = (reason: NativeHandoffErrorReason): NextResponse => + NextResponse.redirect(buildWebHandoffCallbackUrl({ error: reason })); + + const rl = await checkAuthSurfaceRateLimit( + req, + "auth:native:login", + 10, + 15 * 60 * 1000, + ); + if (!rl.allowed) return loginError("rate_limited"); + + // A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is + // structurally unsupported — the exchange only ever verifies S256. + const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge"); + if (!isValidChallenge(appCodeChallenge)) { + annotate({ meta: { reason: "invalid_native_request" } }); + return loginError("invalid_request"); + } + + // Stamp `startedAt` from the DB clock so it is directly comparable to + // `Session.createdAt` (also DB-side) at completion, with no app/DB skew. + const startedAt = await nativeHandoffDbNow(); + + const response = NextResponse.redirect( + new URL("/auth/login?flow=native", req.url), + ); + response.cookies.set( + NATIVE_HANDOFF_STATE_COOKIE, + encodeNativeHandoffState({ + appCodeChallenge, + startedAt: startedAt.toISOString(), + }), + { + httpOnly: true, + secure: shouldEmitSecureCookie(), + // The completion is a top-level GET navigated by the page after login; + // Lax carries the cookie on that same-site top-level navigation. + sameSite: "lax", + maxAge: Math.floor(NATIVE_HANDOFF_STATE_TTL_MS / 1000), + // Only the completion endpoint ever reads it — the delete repeats this path. + path: NATIVE_HANDOFF_STATE_COOKIE_PATH, + }, + ); + return response; +}); diff --git a/src/app/api/auth/native/token/__tests__/route.test.ts b/src/app/api/auth/native/token/__tests__/route.test.ts new file mode 100644 index 000000000..ea0f1361f --- /dev/null +++ b/src/app/api/auth/native/token/__tests__/route.test.ts @@ -0,0 +1,236 @@ +/** + * POST /api/auth/native/token — web-handoff exchange (iOS #65). + * + * Mirrors the OIDC native-token route tests, with the `web_login` flow gate. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn })); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/native-client", () => ({ + isCookielessNativeCaller: vi.fn(() => true), +})); + +vi.mock("@/lib/auth/native-handoff", () => ({ + consumeNativeHandoff: vi.fn(), + stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-response", () => ({ + finishLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/native-handoff"; +import { finishLogin } from "@/lib/auth/login-response"; +import { auditLog } from "@/lib/auth/audit"; + +const VALID_BODY = { + code: "hlh_" + "a".repeat(43), + codeVerifier: "a".repeat(43), +}; + +function makeRequest( + body: unknown = VALID_BODY, + headers: Record = {}, +): NextRequest { + return new NextRequest("http://localhost/api/auth/native/token", { + method: "POST", + headers: { + "content-type": "application/json", + "x-client-type": "native", + ...headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function nativeBundleResponse() { + return NextResponse.json({ + data: { + user: { id: "user-1", username: "alice" }, + token: "hlk_access", + tokenExpiresAt: new Date().toISOString(), + refreshToken: "hlr_refresh", + refreshTokenExpiresAt: new Date().toISOString(), + }, + error: null, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "1.2.3.4", + } as never); + vi.mocked(isCookielessNativeCaller).mockReturnValue(true); +}); + +describe("POST /api/auth/native/token", () => { + it("429 when rate-limited (before any state is touched)", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "1.2.3.4", + } as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(429); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("401 and never consumes when the transport is not cookie-less native", async () => { + vi.mocked(isCookielessNativeCaller).mockReturnValue(false); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("401 when the caller presents no native marker (browser UA path)", async () => { + // isCookielessNativeCaller true, but no x-client-type / native UA → + // isNativeClientRequest false → rejected before any state. + const res = await POST(makeRequest(VALID_BODY, { "x-client-type": "web" })); + expect(res.status).toBe(401); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("422 on a malformed body", async () => { + const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" })); + expect(res.status).toBe(422); + expect(consumeNativeHandoff).not.toHaveBeenCalled(); + }); + + it("consumes with the web_login flow gate", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "not_found", + } as never); + await POST(makeRequest()); + expect(consumeNativeHandoff).toHaveBeenCalledWith( + VALID_BODY.code, + VALID_BODY.codeVerifier, + "web_login", + ); + }); + + it("returns ONE identical generic 401 across every invalid-code class", async () => { + const statuses = [ + { status: "not_found" }, + { status: "replayed", userId: "user-1" }, + { status: "expired" }, + { status: "pkce_mismatch" }, + { status: "race_lost" }, + ] as const; + + const bodies: string[] = []; + for (const s of statuses) { + vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + } + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + const res = await POST(makeRequest()); + expect(res.status).toBe(401); + bodies.push(await res.text()); + + expect(new Set(bodies).size).toBe(1); + }); + + it("does not mint on the deleted-user branch", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + await POST(makeRequest()); + expect(finishLogin).not.toHaveBeenCalled(); + }); + + it("200 on success: mints via the web_handoff source, stamps, audits, no mfaVerified", async () => { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + + const finishArg = vi.mocked(finishLogin).mock.calls[0][0]; + expect(finishArg.source).toBe("login.web_handoff"); + // Never set — the handoff login can't satisfy step-up. + expect(finishArg.mfaVerified).toBeUndefined(); + + expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh"); + expect(auditLog).toHaveBeenCalledWith( + "auth.login", + expect.objectContaining({ + userId: "user-1", + details: { transport: "native", method: "web_handoff" }, + }), + ); + + const body = await res.json(); + expect(body.data.refreshToken).toBe("hlr_refresh"); + }); + + it("is NOT blocked by OIDC_ONLY", async () => { + const prev = process.env.OIDC_ONLY; + process.env.OIDC_ONLY = "true"; + try { + vi.mocked(consumeNativeHandoff).mockResolvedValue({ + status: "ok", + userId: "user-1", + handoffId: "ho-1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + username: "alice", + onboardingCompletedAt: new Date(), + } as never); + vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse()); + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + } finally { + if (prev === undefined) delete process.env.OIDC_ONLY; + else process.env.OIDC_ONLY = prev; + } + }); +}); diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts new file mode 100644 index 000000000..5055a2024 --- /dev/null +++ b/src/app/api/auth/native/token/route.ts @@ -0,0 +1,160 @@ +/** + * POST /api/auth/native/token + * + * The cookie-less native leg of the first-party web-handoff login (iOS #65). The + * app presents the one-time handoff code it received on + * `healthlog://login-callback?code=` plus its PKCE `codeVerifier`; on success it + * receives the SAME native token bundle password login issues + * (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`). + * + * Byte-for-byte the posture of `POST /api/auth/oidc/native/token`, with the + * `flow: "web_login"` gate on the shared consume: + * - It IS the credential mint: no session or Bearer is required to reach it. + * - The transport gate (`isCookielessNativeCaller` + `isNativeClientRequest`) + * makes the endpoint structurally incapable of minting a web session or handing + * a 60-day refresh token into a browser context. + * - `userId` is derived SOLELY from the server-side handoff row. + * - A single generic 401 covers not-found / expired / used / PKCE-mismatch / + * cross-flow / deleted-user so a code-guesser learns nothing. + * - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy + * `requireFreshMfa` step-up. + */ +import { NextRequest } from "next/server"; +import { apiError, safeJson } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { ensureDbCompatibility } from "@/lib/db-compat"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { isCookielessNativeCaller } from "@/lib/auth/native-client"; +import { isNativeClientRequest } from "@/lib/auth/issue-token"; +import { finishLogin } from "@/lib/auth/login-response"; +import { + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/native-handoff"; +import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; + +export const dynamic = "force-dynamic"; + +/** The single generic rejection for every invalid-code class. */ +function invalidCode() { + return apiError("Invalid or expired code", 401); +} + +export const POST = apiHandler(async (request: NextRequest) => { + annotate({ action: { name: "auth.native.token" } }); + + // (1) Rate limit — the MFA-verify surface's posture, incl. the + // trust-violation collapse semantics. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:native:token", + 10, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; + if (!rl.allowed) { + return apiError("Too many attempts. Please try again later.", 429, { + headers: rateLimitHeaders(rl), + }); + } + + // (2) Transport gate — this endpoint only ever hands a native token bundle. A + // browser (Mozilla UA or an inbound session cookie) is rejected, AND the + // caller must present an explicit native marker, so the cookie branch of + // `finishLogin` is structurally unreachable. + if ( + !isCookielessNativeCaller(request.headers) || + !isNativeClientRequest(request.headers) + ) { + annotate({ action: { name: "auth.native.token.wrong_transport" } }); + return invalidCode(); + } + + await ensureDbCompatibility(); + + // (3) Body — bounded, then the exact wire contract. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = nativeHandoffTokenSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid request", 422); + } + const { code, codeVerifier } = parsed.data; + + // (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume. + // The `web_login` flow gate rejects an OIDC-minted code as `not_found`. + const result = await consumeNativeHandoff(code, codeVerifier, "web_login"); + if (result.status !== "ok") { + switch (result.status) { + case "not_found": + annotate({ action: { name: "auth.native.token.not_found" } }); + break; + case "replayed": + annotate({ + action: { name: "auth.native.handoff_replay" }, + meta: { revoked_issued_pair: result.revokedIssuedPair }, + }); + break; + case "expired": + annotate({ action: { name: "auth.native.token.expired" } }); + break; + case "pkce_mismatch": + annotate({ action: { name: "auth.native.pkce_failed" } }); + break; + case "race_lost": + annotate({ action: { name: "auth.native.token.race_lost" } }); + break; + } + return invalidCode(); + } + + // (5) Load the user (may have been deleted mid-flight). + const user = await prisma.user.findUnique({ + where: { id: result.userId }, + select: { id: true, username: true, onboardingCompletedAt: true }, + }); + if (!user) { + annotate({ action: { name: "auth.native.token.user_missing" } }); + return invalidCode(); + } + + // (6) Mint the native bundle. Step (2) guarantees the cookie-less-native + // branch of `finishLogin` (24h access + 60d rotating refresh). `mfaVerified` + // is deliberately unset — the handoff login can never satisfy step-up. + const ua = request.headers.get("user-agent"); + const response = await finishLogin({ + user, + request, + ip, + userAgent: ua, + source: "login.web_handoff", + }); + + // (7) Stamp the issued refresh token's hash for replay-containment reach-back, + // then audit. Best-effort — the code is already single-use-consumed. + try { + const bundle = (await response.clone().json()) as { + data?: { refreshToken?: unknown }; + }; + const refreshToken = bundle.data?.refreshToken; + if (typeof refreshToken === "string") { + await stampIssuedRefreshToken(result.handoffId, refreshToken); + } + } catch { + // Non-fatal — see comment above. + } + + await auditLog("auth.login", { + userId: user.id, + ipAddress: ip, + details: { transport: "native", method: "web_handoff" }, + }); + + return response; +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 274fb870b..fa9d4d09e 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -107,11 +107,28 @@ export default function LoginPage() { ); } + // v1.32.11 — first-party web-handoff login (iOS #65). The app opens this page + // as `/auth/login?flow=native` inside an ASWebAuthenticationSession so the + // login runs in the self-hoster's real web origin (fixing password autofill + + // passkeys). It stays true for the whole component so every success path + // (password, passkey button, passkey autofill, MFA step) routes to completion. + const isNativeHandoff = searchParams.get("flow") === "native"; + // v1.16.6 first-load waterfall fix — the session cookie exists the // moment the login POST resolves, so the dashboard snapshot request // can ride in parallel with the route transition + page-chunk // download instead of queueing behind the dashboard mount. function navigateAfterLogin() { + // Native web-handoff: hand control back to the app via the server's + // single-use code mint. Top-level navigation, not router.push — the + // completion answer is a 302 to `healthlog://login-callback` that only the + // browser/OS can follow. `next` is meaningless to the app and ignored; the + // page always requires a fresh interactive login here (no ambient one-click + // pass), which is exactly what the completion route's freshness rule needs. + if (isNativeHandoff) { + window.location.href = "/api/auth/native/complete"; + return; + } // Start the new session from an empty in-memory cache. The root // QueryClient is created once and outlives every client-side navigation, // so a previous user who closed the tab WITHOUT logging out (no logout @@ -319,6 +336,15 @@ export default function LoginPage() { + {isNativeHandoff && ( +
+ {t("auth.native.signingInToApp")} +
+ )} + {mfaChallenge ? (
({ + prisma: { + oidcNativeHandoff: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn((t: string) => `H(${t.length})`), +})); + +const revokeRefreshTokenByHash = vi.fn(); +vi.mock("@/lib/auth/refresh-token", () => ({ + revokeRefreshTokenByHash: (h: string) => revokeRefreshTokenByHash(h), +})); + +const auditLog = vi.fn(); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: (...a: unknown[]) => auditLog(...a), +})); + +import { mintNativeHandoff, consumeNativeHandoff } from "../native-handoff"; +import { s256Challenge } from "@/lib/mcp/oauth/pkce"; +import { prisma } from "@/lib/db"; + +const VERIFIER = "a".repeat(43); +const CHALLENGE = s256Challenge(VERIFIER); + +function storedRow(overrides: Record = {}) { + return { + id: "ho-1", + userId: "user-1", + codeHash: "H(47)", + flow: "web_login", + codeChallenge: CHALLENGE, + expiresAt: new Date(Date.now() + 90_000), + consumedAt: null, + issuedRefreshTokenHash: null, + createdAt: new Date(), + ipAddress: "1.2.3.4", + userAgent: "HealthLog-iOS/1", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("mintNativeHandoff — flow discriminator", () => { + it("defaults to oidc so the OIDC leg's call site is unchanged", async () => { + vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({ + id: "ho-1", + } as never); + await mintNativeHandoff({ userId: "user-1", appCodeChallenge: CHALLENGE }); + const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0]; + expect(arg.data.flow).toBe("oidc"); + }); + + it("writes flow=web_login when the web-handoff flow mints", async () => { + vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({ + id: "ho-1", + } as never); + await mintNativeHandoff({ + userId: "user-1", + appCodeChallenge: CHALLENGE, + flow: "web_login", + }); + const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0]; + expect(arg.data.flow).toBe("web_login"); + }); +}); + +describe("consumeNativeHandoff — cross-flow rejection", () => { + it("web_login row presented as oidc → not_found (no consume, no revoke)", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ flow: "web_login" }) as never, + ); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "oidc", + ); + expect(res.status).toBe("not_found"); + expect(prisma.oidcNativeHandoff.updateMany).not.toHaveBeenCalled(); + }); + + it("oidc row presented as web_login → not_found", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ flow: "oidc" }) as never, + ); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "web_login", + ); + expect(res.status).toBe("not_found"); + }); + + it("a legacy row with no flow is treated as oidc", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ flow: undefined }) as never, + ); + // As oidc: passes the flow gate (falls through to consume). + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const asOidc = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "oidc", + ); + expect(asOidc.status).toBe("ok"); + + // As web_login: rejected. + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ flow: undefined }) as never, + ); + const asWeb = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "web_login", + ); + expect(asWeb.status).toBe("not_found"); + }); + + it("same-flow (web_login) with the right verifier consumes atomically", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ flow: "web_login" }) as never, + ); + vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "web_login", + ); + expect(res).toEqual({ status: "ok", userId: "user-1", handoffId: "ho-1" }); + }); + + it("a web_login replay audits the web-flow action name", async () => { + vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue( + storedRow({ + flow: "web_login", + consumedAt: new Date(), + issuedRefreshTokenHash: "issued-hash", + }) as never, + ); + const res = await consumeNativeHandoff( + "hlh_" + "x".repeat(43), + VERIFIER, + "web_login", + ); + expect(res.status).toBe("replayed"); + expect(revokeRefreshTokenByHash).toHaveBeenCalledWith("issued-hash"); + expect(auditLog).toHaveBeenCalledWith( + "auth.native.handoff_replay", + expect.objectContaining({ userId: "user-1" }), + ); + }); +}); diff --git a/src/lib/auth/native-handoff.ts b/src/lib/auth/native-handoff.ts new file mode 100644 index 000000000..1cbcb14ef --- /dev/null +++ b/src/lib/auth/native-handoff.ts @@ -0,0 +1,247 @@ +/** + * Shared native-handoff core (iOS #49 + iOS #65). + * + * Two flows bridge a browser-context login to the cookie-less native token + * exchange, and they share this one mint/consume core so the security semantics + * are derived once, not reimplemented: + * + * - OIDC SSO native leg (`flow = "oidc"`): the web OIDC callback mints the code. + * - First-party web-handoff (`flow = "web_login"`): the instance's own web + * login page mints the code via `/api/auth/native/complete`, so password + * autofill and passkeys run in the self-hoster's real web origin. + * + * Security posture (unchanged across both flows): + * - The return address is a compiled-in constant per flow — there is no + * `redirect_uri` parameter, so the open-redirect / scheme-hijack class is + * removed by construction, not validated away. + * - The raw code (`hlh_<…>`) is 256-bit CSPRNG, hashed at rest with the same + * `hashToken` (HMAC-SHA256, `API_TOKEN_HMAC_KEY`) used for Bearer/refresh + * tokens — a DB dump reconstructs nothing. + * - Single-use via a guarded update (`WHERE consumedAt IS NULL`); a failed + * PKCE check also burns the code (no verifier oracle, no retry surface). + * - A presentation of an already-consumed code is treated as an interception + * signal and revokes exactly the token pair the first exchange issued + * (refresh-token reuse-detection reach-back). + * - The token pair NEVER rides the redirect URL — only the opaque code (or an + * MFA ticket) does. + * - The `flow` discriminator is bound at mint and CHECKED at consume, so a code + * minted by one flow can never be exchanged by the other. + */ +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; +import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; +import { revokeRefreshTokenByHash } from "@/lib/auth/refresh-token"; +import { auditLog } from "@/lib/auth/audit"; + +/** + * The two native-handoff flows. Written to `OidcNativeHandoff.flow` at mint and + * verified at consume — a cross-flow redemption resolves to `not_found`. + */ +export type HandoffFlow = "oidc" | "web_login"; + +/** + * 90-second handoff TTL. The gap it must cover is OS-level (scheme redirect → + * app foregrounds → one POST) — sub-second in practice; 90s absorbs a slow + * radio without holding the interception window open. Expiry is enforced at + * read; a daily sweep prunes stale rows. Shared by both flows. + */ +export const NATIVE_HANDOFF_TTL_MS = 90_000; + +/** Distinct prefix — `hlk_` (access) / `hlr_` (refresh) / `hlac_` (MCP) are taken. */ +export const HANDOFF_CODE_PREFIX = "hlh_"; + +/** + * The 43-char base64url body of a 32-byte (256-bit) CSPRNG code. The token + * routes' Zod schema pins `^hlh_[A-Za-z0-9_-]{43}$` so only well-formed codes + * ever reach the hash lookup. + */ +function generateHandoffCode(): string { + return `${HANDOFF_CODE_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +/** + * Build a native callback redirect from a compiled-in constant scheme plus + * query-escaped params only — nothing the client sends is ever echoed into a + * `Location` header. Assembled by string (not via `new URL`) so the target is + * exactly `?…` with no scheme-normalisation surprises. Each flow passes + * its own compile-time `redirectUri`; there is no per-request or per-operator + * redirect target anywhere. + */ +export function buildHandoffCallbackUrl( + redirectUri: string, + params: Record, +): string { + const query = new URLSearchParams(params).toString(); + return `${redirectUri}?${query}`; +} + +export interface MintedHandoff { + /** The raw `hlh_<…>` code — placed ONLY on the custom-scheme redirect. */ + code: string; + handoffId: string; +} + +/** + * Mint a fresh single-use handoff code for a resolved identity. `userId` comes + * from the server-side identity resolution, never from client input; the app's + * S256 challenge is bound here so the exchange can prove the caller is the app + * instance that started the flow. `flow` records which flow minted the row and + * is verified at consume. IP/UA are audit-only (a mobile radio changes address + * between the browser context and the app's socket, so they are NOT validated + * at exchange). + */ +export async function mintNativeHandoff(input: { + userId: string; + appCodeChallenge: string; + /** Defaults to `oidc` so the OIDC leg's existing call site is unchanged. */ + flow?: HandoffFlow; + ipAddress?: string | null; + userAgent?: string | null; +}): Promise { + const code = generateHandoffCode(); + const row = await prisma.oidcNativeHandoff.create({ + data: { + userId: input.userId, + codeHash: hashToken(code), + codeChallenge: input.appCodeChallenge, + flow: input.flow ?? "oidc", + expiresAt: new Date(Date.now() + NATIVE_HANDOFF_TTL_MS), + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + }, + select: { id: true }, + }); + return { code, handoffId: row.id }; +} + +/** + * Closed result union for `consumeNativeHandoff`. Every non-`ok` variant maps + * to the same generic 401 at the route so a code-guesser learns nothing; + * distinct variants keep the wide events diagnosable. + */ +export type ConsumeHandoffResult = + | { status: "ok"; userId: string; handoffId: string } + | { status: "not_found" } + | { status: "replayed"; userId: string; revokedIssuedPair: boolean } + | { status: "expired" } + | { status: "pkce_mismatch" } + | { status: "race_lost" }; + +/** + * Validate + atomically consume a handoff code. + * + * Order: hash-lookup → flow check → replay containment → expiry burn → + * constant-time PKCE verify (burn on mismatch) → guarded single-use consume. + * + * The `expectedFlow` gate is the cross-flow boundary: a row minted by the other + * flow is treated as `not_found` (the same generic 401 the route emits for an + * unknown code), so an OIDC code can never be redeemed at the web-login token + * route and vice versa — structurally, not by observation. + * + * The replay branch revokes exactly the pair the first exchange issued (via + * `issuedRefreshTokenHash`) and audits — the accepted DoS-for-containment trade. + * A failed PKCE check burns the code so an attacker holding the code but not the + * verifier also denies themselves retries. + */ +export async function consumeNativeHandoff( + rawCode: string, + codeVerifier: string, + /** Defaults to `oidc` so the OIDC leg's existing 2-arg call site is unchanged. */ + expectedFlow: HandoffFlow = "oidc", +): Promise { + const codeHash = hashToken(rawCode); + const row = await prisma.oidcNativeHandoff.findUnique({ + where: { codeHash }, + }); + + if (!row) return { status: "not_found" }; + + // Cross-flow boundary. A row minted by the OTHER flow is indistinguishable + // from an unknown code to the caller — never a distinct signal. A missing + // `flow` (a legacy row written before the column existed) is an OIDC row. + if ((row.flow ?? "oidc") !== expectedFlow) { + return { status: "not_found" }; + } + + // Replay: any presentation of an already-consumed row — regardless of + // whether the presented verifier matches — is an interception signal. + // Revoke exactly the pair the first exchange issued and audit; the code + // itself is already spent, so no further consume is needed. + if (row.consumedAt !== null) { + // `issuedRefreshTokenHash` is null only in the sub-millisecond window + // between the consume and the post-`finishLogin` stamp; a replay there + // finds no pair to revoke (the legitimate pair stays live — a later replay + // once stamped triggers containment). Report what actually happened so the + // wide event never claims a revoke that did not occur. + let revokedIssuedPair = false; + if (row.issuedRefreshTokenHash) { + await revokeRefreshTokenByHash(row.issuedRefreshTokenHash); + revokedIssuedPair = true; + } + // Flow-aware audit action so the two flows stay separable in the ledger. + await auditLog( + expectedFlow === "web_login" + ? "auth.native.handoff_replay" + : "auth.oidc.native.handoff_replay", + { + userId: row.userId, + ipAddress: row.ipAddress, + }, + ); + return { status: "replayed", userId: row.userId, revokedIssuedPair }; + } + + // Expiry: enforced at read. Burn the row so a lingering expired code has no + // second life even before the sweep job reaps it. + if (row.expiresAt.getTime() <= Date.now()) { + await burnHandoff(row.id); + return { status: "expired" }; + } + + // PKCE: constant-time S256. A mismatch burns the code (single-presentation + // posture) — no verifier oracle. + if (!verifyPkceS256(codeVerifier, row.codeChallenge)) { + await burnHandoff(row.id); + return { status: "pkce_mismatch" }; + } + + // Single-use consume — the same claim-once shape as `claimChallenge` / + // refresh rotation. Two concurrent exchanges mint at most one bundle. + const consumed = await prisma.oidcNativeHandoff.updateMany({ + where: { id: row.id, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + if (consumed.count === 0) { + // Lost the race — the winner's pair is legitimately in flight; do NOT + // revoke. A genuine later replay is caught by the consumed-at branch above. + return { status: "race_lost" }; + } + + return { status: "ok", userId: row.userId, handoffId: row.id }; +} + +/** Guarded burn: consume a live row without issuing anything. */ +async function burnHandoff(id: string): Promise { + await prisma.oidcNativeHandoff.updateMany({ + where: { id, consumedAt: null }, + data: { consumedAt: new Date() }, + }); +} + +/** + * Stamp the hash of the refresh token minted at exchange onto the consumed + * handoff row, so a later replay can revoke exactly this family member. Called + * after `finishLogin` succeeds. Best-effort: a failure here never fails the + * login (the code is already single-use-consumed), it only forgoes the + * replay-reach-back for this one row. + */ +export async function stampIssuedRefreshToken( + handoffId: string, + refreshToken: string, +): Promise { + await prisma.oidcNativeHandoff.update({ + where: { id: handoffId }, + data: { issuedRefreshTokenHash: hashToken(refreshToken) }, + }); +} diff --git a/src/lib/auth/native-web-handoff.ts b/src/lib/auth/native-web-handoff.ts new file mode 100644 index 000000000..fd854e208 --- /dev/null +++ b/src/lib/auth/native-web-handoff.ts @@ -0,0 +1,131 @@ +/** + * First-party web-handoff login (iOS #65) — server-only support. + * + * The iOS app's `webcredentials` entitlement is compiled to the managed domain, + * so on a self-hosted domain saved passwords mis-file and passkeys cannot run + * (origin mismatch). The fix runs the login in the instance's real web origin + * inside an `ASWebAuthenticationSession`; after a successful web login the + * server hands the app a single-use handoff code on a compiled-in custom scheme, + * which the app exchanges for the standard native token bundle. + * + * This module owns the flow's compile-time constants, its encrypted state-cookie + * codec, and the DB-clock helper the freshness binding depends on. The mint / + * consume core is the shared `./native-handoff.ts`. + * + * Server-only: imports `prisma` + the crypto helpers. The web login PAGE must + * NOT import this (it only navigates to the relative `/api/auth/native/complete` + * path and reads `flow` from the query string). + */ +import { prisma } from "@/lib/db"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { buildHandoffCallbackUrl } from "@/lib/auth/native-handoff"; + +/** + * The ONLY callback target for the web-handoff flow. Compiled-in, not operator- + * or client-supplied — there is no `redirect_uri` parameter anywhere, so the + * open-redirect / scheme-hijack class is removed by construction. Kept next to + * the OIDC leg's `NATIVE_OIDC_REDIRECT_URI` so both live in one named place and + * either can be corrected once before release. Pending iOS confirmation of the + * exact path (`login-callback` vs a param on `oidc-callback`); the scheme stays + * `healthlog` either way. + */ +export const NATIVE_WEB_HANDOFF_REDIRECT_URI = "healthlog://login-callback"; + +/** + * The encrypted state cookie carrying `{ appCodeChallenge, startedAt }` from the + * authorize entry (`/api/auth/native/login`) to the completion endpoint + * (`/api/auth/native/complete`). AES-256-GCM (fresh IV + auth tag per write, via + * `encrypt`), httpOnly, SameSite=Lax, path-scoped, short-lived — the exact + * proven shape of `oidc_auth_state`. + */ +export const NATIVE_HANDOFF_STATE_COOKIE = "native_auth_state"; +/** + * RFC 6265 keys a cookie by (name, domain, path) — a delete must repeat the + * exact path the set used. Only the completion endpoint ever reads it. + */ +export const NATIVE_HANDOFF_STATE_COOKIE_PATH = "/api/auth/native"; +/** + * 10-minute state TTL — a human typing a password (and possibly a TOTP) fits + * comfortably. The 90-second window applies to the minted CODE, not the login. + */ +export const NATIVE_HANDOFF_STATE_TTL_MS = 10 * 60 * 1000; + +/** + * Closed set of machine-readable error reasons the completion / authorize + * endpoints put on `healthlog://login-callback?error=`. Not i18n's + * concern — the app maps them; the web side never renders them. + */ +export type NativeHandoffErrorReason = + | "invalid_request" + | "rate_limited" + | "invalid_state" + | "no_session" + | "stale_session" + | "user_missing"; + +interface NativeHandoffState { + /** The app's S256 PKCE challenge, bound at the authorize entry. */ + appCodeChallenge: string; + /** + * DB-clock instant the flow started (ISO string of `SELECT now()`), so it is + * directly comparable to `Session.createdAt` (`@default(now())`, also DB-side) + * with no app/DB skew — red-team A1. The completion route requires + * `session.createdAt >= startedAt`. + */ + startedAt: string; +} + +/** Encrypt the state blob for the cookie value. */ +export function encodeNativeHandoffState(state: NativeHandoffState): string { + return encrypt(JSON.stringify(state)); +} + +/** + * Decrypt + shape-validate the state cookie. Returns null for a missing, + * undecryptable (tampered / forged — AES-256-GCM is authenticated), or + * malformed blob. A `startedAt` that does not parse to a real date is rejected. + */ +export function decodeNativeHandoffState( + raw: string | undefined | null, +): NativeHandoffState | null { + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(decrypt(raw)); + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as Record).appCodeChallenge !== + "string" || + typeof (parsed as Record).startedAt !== "string" + ) { + return null; + } + const state = parsed as NativeHandoffState; + if (Number.isNaN(new Date(state.startedAt).getTime())) return null; + return state; + } catch { + return null; + } +} + +/** + * Build the web-handoff callback redirect from the compiled-in constant plus + * query-escaped params only. Nothing client-sent is echoed into `Location`. + */ +export function buildWebHandoffCallbackUrl( + params: Record, +): string { + return buildHandoffCallbackUrl(NATIVE_WEB_HANDOFF_REDIRECT_URI, params); +} + +/** + * Read the database clock. The freshness binding compares `startedAt` against + * `Session.createdAt`, which Postgres stamps via the column `DEFAULT now()`; + * stamping `startedAt` from the same clock (rather than the app's `Date.now()`) + * removes the app/DB skew a remote-Postgres self-host could carry — red-team A1. + * Tagged-template `$queryRaw` — parameter-free, no interpolation. + */ +export async function nativeHandoffDbNow(): Promise { + const rows = await prisma.$queryRaw<{ now: Date }[]>`SELECT now() AS now`; + return rows[0].now; +} diff --git a/src/lib/auth/oidc-native-handoff.ts b/src/lib/auth/oidc-native-handoff.ts index b14c7a6e6..35fec970c 100644 --- a/src/lib/auth/oidc-native-handoff.ts +++ b/src/lib/auth/oidc-native-handoff.ts @@ -8,210 +8,49 @@ * back to the app; the app exchanges it at `POST /api/auth/oidc/native/token` * for the standard native token bundle. * - * Security posture (design spec §2–§4, §10): - * - The return address is a compiled-in constant — there is no - * `redirect_uri` parameter, so the open-redirect / scheme-hijack class is - * removed by construction, not validated away. - * - The raw code (`hlh_<…>`) is 256-bit CSPRNG, hashed at rest with the same - * `hashToken` (HMAC-SHA256, `API_TOKEN_HMAC_KEY`) used for Bearer/refresh - * tokens — a DB dump reconstructs nothing. - * - Single-use via a guarded update (`WHERE consumedAt IS NULL`); a failed - * PKCE check also burns the code (no verifier oracle, no retry surface). - * - A presentation of an already-consumed code is treated as an interception - * signal and revokes exactly the token pair the first exchange issued - * (refresh-token reuse-detection reach-back). - * - The token pair NEVER rides the redirect URL — only the opaque code (or an - * MFA ticket) does. + * v1.32.11 — the mint/consume core moved to `./native-handoff.ts` so the + * first-party web-handoff login (iOS #65) shares the exact same single-use / + * short-TTL / PKCE-S256 / replay-revoke semantics. This module keeps the OIDC + * flow's compile-time constant + its callback builder and re-exports the shared + * core so every existing OIDC call site is unchanged. The OIDC leg is `flow: + * "oidc"`, which is the shared core's default — so nothing in the OIDC callback + * or token route had to change. */ -import { randomBytes } from "node:crypto"; -import { prisma } from "@/lib/db"; -import { hashToken } from "@/lib/auth/hmac"; -import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; -import { revokeRefreshTokenByHash } from "@/lib/auth/refresh-token"; -import { auditLog } from "@/lib/auth/audit"; +import { + buildHandoffCallbackUrl, + NATIVE_HANDOFF_TTL_MS, +} from "@/lib/auth/native-handoff"; /** - * The ONLY callback target for the native flow. Compiled-in, not operator- or - * client-supplied — see design spec §2. The `healthlog` scheme is an iOS-app - * dependency the app must register (iOS-coord §9.2). If the shipped app owns a - * different scheme, this constant is corrected once before release; it remains - * a compile-time constant either way. + * The ONLY callback target for the OIDC native flow. Compiled-in, not operator- + * or client-supplied. The `healthlog` scheme is an iOS-app dependency the app + * must register. If the shipped app owns a different scheme, this constant is + * corrected once before release; it remains a compile-time constant either way. */ export const NATIVE_OIDC_REDIRECT_URI = "healthlog://oidc-callback"; -/** - * 90-second handoff TTL. The gap it must cover is OS-level (scheme redirect → - * app foregrounds → one POST) — sub-second in practice; 90s absorbs a slow - * radio without holding the interception window open. Expiry is enforced at - * read; a daily sweep prunes stale rows. - */ -export const OIDC_NATIVE_HANDOFF_TTL_MS = 90_000; - -/** Distinct prefix — `hlk_` (access) / `hlr_` (refresh) / `hlac_` (MCP) are taken. */ -export const HANDOFF_CODE_PREFIX = "hlh_"; +/** Back-compat alias — the OIDC leg's TTL is the shared handoff TTL. */ +export const OIDC_NATIVE_HANDOFF_TTL_MS = NATIVE_HANDOFF_TTL_MS; /** - * The 43-char base64url body of a 32-byte (256-bit) CSPRNG code. The token - * route's Zod schema pins `^hlh_[A-Za-z0-9_-]{43}$` so only well-formed codes - * ever reach the hash lookup. - */ -function generateHandoffCode(): string { - return `${HANDOFF_CODE_PREFIX}${randomBytes(32).toString("base64url")}`; -} - -/** - * Build the native callback redirect from the compiled-in constant plus + * Build the OIDC native callback redirect from the compiled-in constant plus * query-escaped params only — nothing the client sends is ever echoed into a - * `Location` header on the native branch. Assembled by string (not via `new - * URL`) so the target is exactly `healthlog://oidc-callback?…` with no - * scheme-normalisation surprises. + * `Location` header on the native branch. */ export function buildNativeCallbackUrl(params: Record): string { - const query = new URLSearchParams(params).toString(); - return `${NATIVE_OIDC_REDIRECT_URI}?${query}`; + return buildHandoffCallbackUrl(NATIVE_OIDC_REDIRECT_URI, params); } -export interface MintedHandoff { - /** The raw `hlh_<…>` code — placed ONLY on the custom-scheme redirect. */ - code: string; - handoffId: string; -} - -/** - * Mint a fresh single-use handoff code for a resolved identity. `userId` comes - * from the server-side identity resolution, never from client input; the app's - * S256 challenge is bound here so the exchange can prove the caller is the app - * instance that started the flow. IP/UA are audit-only (a mobile radio changes - * address between the browser context and the app's socket, so they are NOT - * validated at exchange). - */ -export async function mintNativeHandoff(input: { - userId: string; - appCodeChallenge: string; - ipAddress?: string | null; - userAgent?: string | null; -}): Promise { - const code = generateHandoffCode(); - const row = await prisma.oidcNativeHandoff.create({ - data: { - userId: input.userId, - codeHash: hashToken(code), - codeChallenge: input.appCodeChallenge, - expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS), - ipAddress: input.ipAddress ?? null, - userAgent: input.userAgent ?? null, - }, - select: { id: true }, - }); - return { code, handoffId: row.id }; -} - -/** - * Closed result union for `consumeNativeHandoff`. Every non-`ok` variant maps - * to the same generic 401 at the route so a code-guesser learns nothing; - * distinct variants keep the wide events diagnosable. - */ -export type ConsumeHandoffResult = - | { status: "ok"; userId: string; handoffId: string } - | { status: "not_found" } - | { status: "replayed"; userId: string; revokedIssuedPair: boolean } - | { status: "expired" } - | { status: "pkce_mismatch" } - | { status: "race_lost" }; - -/** - * Validate + atomically consume a handoff code (design spec §4 steps 4–8). - * - * Order: hash-lookup → replay containment → expiry burn → constant-time PKCE - * verify (burn on mismatch) → guarded single-use consume. The replay branch - * revokes exactly the pair the first exchange issued (via - * `issuedRefreshTokenHash`) and audits — the accepted DoS-for-containment trade - * (spec §10 T7). A failed PKCE check burns the code so an attacker holding the - * code but not the verifier also denies themselves retries (spec §10 T1). - */ -export async function consumeNativeHandoff( - rawCode: string, - codeVerifier: string, -): Promise { - const codeHash = hashToken(rawCode); - const row = await prisma.oidcNativeHandoff.findUnique({ - where: { codeHash }, - }); - - if (!row) return { status: "not_found" }; - - // Replay: any presentation of an already-consumed row — regardless of - // whether the presented verifier matches — is an interception signal. - // Revoke exactly the pair the first exchange issued and audit; the code - // itself is already spent, so no further consume is needed. - if (row.consumedAt !== null) { - // `issuedRefreshTokenHash` is null only in the sub-millisecond window - // between the consume and the post-`finishLogin` stamp; a replay there - // finds no pair to revoke (the legitimate pair stays live — a later replay - // once stamped triggers containment). Report what actually happened so the - // wide event never claims a revoke that did not occur. - let revokedIssuedPair = false; - if (row.issuedRefreshTokenHash) { - await revokeRefreshTokenByHash(row.issuedRefreshTokenHash); - revokedIssuedPair = true; - } - await auditLog("auth.oidc.native.handoff_replay", { - userId: row.userId, - ipAddress: row.ipAddress, - }); - return { status: "replayed", userId: row.userId, revokedIssuedPair }; - } - - // Expiry: enforced at read. Burn the row so a lingering expired code has no - // second life even before the sweep job reaps it. - if (row.expiresAt.getTime() <= Date.now()) { - await burnHandoff(row.id); - return { status: "expired" }; - } - - // PKCE: constant-time S256. A mismatch burns the code (single-presentation - // posture) — no verifier oracle. - if (!verifyPkceS256(codeVerifier, row.codeChallenge)) { - await burnHandoff(row.id); - return { status: "pkce_mismatch" }; - } - - // Single-use consume — the same claim-once shape as `claimChallenge` / - // refresh rotation. Two concurrent exchanges mint at most one bundle. - const consumed = await prisma.oidcNativeHandoff.updateMany({ - where: { id: row.id, consumedAt: null }, - data: { consumedAt: new Date() }, - }); - if (consumed.count === 0) { - // Lost the race — the winner's pair is legitimately in flight; do NOT - // revoke. A genuine later replay is caught by the consumed-at branch above. - return { status: "race_lost" }; - } - - return { status: "ok", userId: row.userId, handoffId: row.id }; -} - -/** Guarded burn: consume a live row without issuing anything. */ -async function burnHandoff(id: string): Promise { - await prisma.oidcNativeHandoff.updateMany({ - where: { id, consumedAt: null }, - data: { consumedAt: new Date() }, - }); -} - -/** - * Stamp the hash of the refresh token minted at exchange onto the consumed - * handoff row, so a later replay can revoke exactly this family member. Called - * after `finishLogin` succeeds. Best-effort: a failure here never fails the - * login (the code is already single-use-consumed), it only forgoes the - * replay-reach-back for this one row. - */ -export async function stampIssuedRefreshToken( - handoffId: string, - refreshToken: string, -): Promise { - await prisma.oidcNativeHandoff.update({ - where: { id: handoffId }, - data: { issuedRefreshTokenHash: hashToken(refreshToken) }, - }); -} +// Re-export the shared mint/consume core so the OIDC callback + token route +// keep importing from here unchanged. +export { + HANDOFF_CODE_PREFIX, + mintNativeHandoff, + consumeNativeHandoff, + stampIssuedRefreshToken, +} from "@/lib/auth/native-handoff"; +export type { + HandoffFlow, + MintedHandoff, + ConsumeHandoffResult, +} from "@/lib/auth/native-handoff"; diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 0c0a55755..f86f7a0bd 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -114,6 +114,42 @@ export async function validateSessionFromCookieValue( }; } +/** + * Like `validateSessionFromCookieValue`, but ALSO projects `Session.createdAt`. + * + * Exists for the native web-handoff completion (`/api/auth/native/complete`), + * whose load-bearing freshness binding compares `session.createdAt` against the + * DB-clock `startedAt` stamped when the flow began. `validateSessionFromCookieValue` + * deliberately does not return `createdAt`; wiring the completion route to it + * would leave `createdAt` undefined and either refuse every completion or, worse, + * tempt a caller to drop the comparison and delete the whole defence (red-team + * A3). So the projection lives here, next to the primitive it mirrors, and the + * completion route reads `createdAt` from a value that is structurally always + * present. + * + * `createdAt` is immutable per login: `createSession` is insert-only and the + * sliding-expiry / last-active writes never touch it, so it is reliably the + * instant of THIS interactive login. + */ +export async function validateSessionWithCreatedAt( + cookieValue: string | undefined | null, +): Promise<{ + session: { id: string; createdAt: Date; expiresAt: Date }; + user: User; +} | null> { + if (!cookieValue) return null; + const session = await findSessionByCookie(cookieValue).catch(() => null); + if (!session || session.expiresAt < new Date()) return null; + return { + session: { + id: session.id, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }, + user: session.user, + }; +} + // v1.23 — throttle window for the user-facing active-session list's "last // seen" stamp. A write only happens when the prior stamp is older than this, // so the high-churn `getSession` path costs at most one extra UPDATE per diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index ac408777c..e96ea4d38 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -18,6 +18,7 @@ import { mfaWebauthnLoginVerifySchema, } from "@/lib/validations/mfa"; import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; +import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; import { stepUpMintSchema, stepUpOptionsSchema, @@ -434,6 +435,73 @@ export const authPaths: NonNullable = { }, }, }, + "/api/auth/native/login": { + get: { + tags: ["Auth"], + summary: "Start the first-party web-handoff login (browser navigation)", + description: + "First leg of the native web-handoff login (iOS #65). On a self-hosted domain the iOS app opens this URL inside an `ASWebAuthenticationSession` with its PKCE `code_challenge`, so the login runs in the instance's real web origin (fixing password autofill + passkeys). The endpoint validates the challenge, sets a short-lived encrypted state cookie carrying the challenge + a DB-clock start time, and 302-redirects to `/auth/login?flow=native`. It writes no database rows. Every error 302-redirects to `healthlog://login-callback?error=` (a closed set: `invalid_request`, `rate_limited`) — an error carries no code or session. Anonymous; rate-limited 10 / 15 min.", + requestParams: { + query: z.object({ + code_challenge: z + .string() + .min(43) + .max(128) + .describe( + "The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported.", + ), + }), + }, + responses: { + "302": { + description: + "Redirect to `/auth/login?flow=native` (state cookie set) on success, or to `healthlog://login-callback?error=` on failure.", + }, + }, + }, + }, + "/api/auth/native/complete": { + get: { + tags: ["Auth"], + summary: + "Complete the web-handoff login and mint the code (browser navigation)", + description: + "Second leg of the native web-handoff login (iOS #65). After an interactive login on `/auth/login?flow=native`, the page navigates the browser here as a top-level GET. The endpoint validates the web session against the database, enforces the freshness binding (`session.createdAt >= startedAt`, both DB-clock — a pre-existing session is refused), mints a single-use PKCE-locked handoff code, deletes the state cookie, destroys the scaffold web session, and 302-redirects to `healthlog://login-callback?code=hlh_…`. The token pair never rides the URL; only the opaque code does. The app never calls this directly. Every failure 302-redirects to `healthlog://login-callback?error=` (`invalid_state`, `no_session`, `stale_session`, `rate_limited`). Rate-limited 20 / 15 min.", + responses: { + "302": { + description: + "Redirect to `healthlog://login-callback?code=hlh_…` on success, or `…?error=` on any failure.", + }, + }, + }, + }, + "/api/auth/native/token": { + post: { + tags: ["Auth"], + summary: "Exchange a web-handoff code for the token bundle", + description: + "Third leg of the native web-handoff login (iOS #65). The app exchanges the one-time handoff code from `healthlog://login-callback?code=hlh_…` plus its PKCE `codeVerifier` for the SAME native bundle password login issues.\n\n" + + "Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. The exchange is flow-gated: a code minted by the OIDC native leg is not redeemable here (and vice versa). A single generic 401 covers every invalid / expired / used / PKCE-mismatch / cross-flow case.", + requestBody: { + required: true, + content: { "application/json": { schema: nativeHandoffTokenSchema } }, + }, + responses: { + "200": { + description: "Handoff accepted — native access + refresh bundle.", + content: { + "application/json": { + schema: dataEnvelope( + accessRefreshBundle, + "NativeHandoffTokenResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/step-up/options": { post: { tags: ["Auth"], diff --git a/src/lib/validations/native-handoff.ts b/src/lib/validations/native-handoff.ts new file mode 100644 index 000000000..88e771551 --- /dev/null +++ b/src/lib/validations/native-handoff.ts @@ -0,0 +1,45 @@ +/** + * Request schema for the first-party web-handoff token exchange + * (`POST /api/auth/native/token`, iOS #65). Single-sourced here so the runtime + * `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/auth.ts`) + * describe the identical wire contract. Same shape as the OIDC native exchange + * — the code prefix and PKCE verifier grammar are shared across both flows. + */ +import { z } from "zod/v4"; + +/** + * `{ code, codeVerifier }`. + * + * - `code` is the opaque handoff code from the + * `healthlog://login-callback?code=` redirect: the `hlh_` prefix + the 43-char + * base64url body of a 256-bit CSPRNG value. Pinned exactly so only well-formed + * codes reach the hash lookup. + * - `codeVerifier` is the app's PKCE verifier (RFC 7636 §4.1 — 43–128 chars of + * the unreserved set); the server checks `S256(codeVerifier)` against the + * challenge bound at mint (constant-time) before consuming the code. + */ +export const nativeHandoffTokenSchema = z + .object({ + code: z + .string() + .regex(/^hlh_[A-Za-z0-9_-]{43}$/, "Malformed handoff code.") + .describe( + "One-time handoff code from the healthlog://login-callback?code= redirect.", + ), + codeVerifier: z + .string() + .regex( + /^[A-Za-z0-9\-._~]{43,128}$/, + "The PKCE code_verifier must be 43–128 chars of the RFC 7636 unreserved set.", + ) + .describe("The app's PKCE code_verifier (RFC 7636)."), + }) + .meta({ + id: "NativeHandoffTokenRequest", + description: + "Exchange a one-time web-handoff code (+ its PKCE verifier) for the standard native token bundle.", + }); + +export type NativeHandoffTokenRequest = z.infer< + typeof nativeHandoffTokenSchema +>; diff --git a/src/proxy.ts b/src/proxy.ts index fa76eb302..ea7e0e998 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -23,6 +23,14 @@ const PUBLIC_PATHS = [ "/api/auth/oidc/login", "/api/auth/oidc/callback", "/api/auth/oidc/status", + // v1.32.11 — first-party web-handoff login (iOS #65). `login` sets the state + // cookie and redirects to the web login page, `complete` mints the handoff + // code after an interactive login, `token` exchanges it. All three + // authenticate the flow themselves (state cookie / session / handoff code), + // never by a pre-existing session, so they bypass the page auth gate. The + // TRAILING SLASH is load-bearing: `isPublicPath` is a bare `startsWith`, so + // `/api/auth/native/` admits exactly this family and never `/api/auth/native-…`. + "/api/auth/native/", "/api/health", "/api/version", "/api/notifications/vapid",