chore(release): v1.32.11 — native web-handoff login for self-hosted iOS

Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.

Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):

  - GET  /api/auth/native/login    validates the PKCE challenge, sets a
                                   short-lived encrypted state cookie, and
                                   redirects to /auth/login?flow=native.
  - GET  /api/auth/native/complete validates the web session, enforces the
                                   freshness binding, mints the code, and
                                   redirects to healthlog://login-callback.
  - POST /api/auth/native/token    exchanges the code under the cookie-less
                                   native transport gate for the native bundle.

The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.

The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 02:28:51 +02:00
1 parent d2d79b70a2
commit 0b3280c604
28 files changed
+1976 -197

No files matched your search

+23
View File
@@ -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
+106 -1
View File
@@ -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=<reason>` (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=<reason>` 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=<reason>` (`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=<reason>` 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:
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+1 -1
View File
@@ -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",
@@ -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';
+12
View File
@@ -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")
+1 -1
View File
@@ -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/*`
@@ -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\(\)/);
});
});
@@ -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<typeof import("@/lib/auth/native-handoff")>();
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();
});
});
+152
View File
@@ -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=<reason>` 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;
});
@@ -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();
});
});
+82
View File
@@ -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=<reason>` — 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;
});
@@ -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<string, string> = {},
): 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;
}
});
});
+160
View File
@@ -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;
});
+26
View File
@@ -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() {
</div>
</div>
{isNativeHandoff && (
<div
data-slot="native-handoff-banner"
className="bg-primary/10 text-foreground mt-6 rounded-lg p-3 text-center text-sm"
>
{t("auth.native.signingInToApp")}
</div>
)}
{mfaChallenge ? (
<div className="mt-8">
<MfaLoginStep
@@ -0,0 +1,178 @@
/**
* Shared native-handoff core — the `flow` discriminator (iOS #65).
*
* The mint writes the flow; the consume filters on it. A code minted by one
* flow presented at the other's token route must resolve to the SAME generic
* `not_found` an unknown code gets — the cross-flow boundary is structural, not
* observational. This file is the mutation-check target for that boundary
* (remove the flow filter in `consumeNativeHandoff` → the cross-flow cases here
* go RED).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
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<string, unknown> = {}) {
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" }),
);
});
});
+247
View File
@@ -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 `<scheme>?…` 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, string>,
): 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<MintedHandoff> {
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<ConsumeHandoffResult> {
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<void> {
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<void> {
await prisma.oidcNativeHandoff.update({
where: { id: handoffId },
data: { issuedRefreshTokenHash: hashToken(refreshToken) },
});
}
+131
View File
@@ -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=<reason>`. 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<string, unknown>).appCodeChallenge !==
"string" ||
typeof (parsed as Record<string, unknown>).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, string>,
): 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<Date> {
const rows = await prisma.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
return rows[0].now;
}
+33 -194
View File
@@ -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, string>): 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<MintedHandoff> {
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<ConsumeHandoffResult> {
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<void> {
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<void> {
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";
+36
View File
@@ -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
+68
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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=<reason>` (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=<reason>` 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=<reason>` (`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=<reason>` 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"],
+45
View File
@@ -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
>;
+8
View File
@@ -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",