mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #606 from MBombeck/release/v1.32.11
release/v1.32.11 — native web-handoff login for self-hosted iOS
This commit is contained in:
28 files changed
+1976
-197
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user