From c1eb618dcc29008d11a302a7403b3b8078c6ff9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:32:19 +0200 Subject: [PATCH 1/2] Add invite universal-link landing for app deep-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode the admin invite QR as a clean /invite/ universal-link that the iOS app intercepts via the AASA wildcard matcher. Add the matching web route as a thin, shape-validated redirect onto the existing register flow, and open the path on the public allowlist so an unauthenticated scanner reaches it. A malformed token segment is indistinguishable from no invite — no database hit, no enumeration oracle. The token is never logged. --- docs/api/openapi.yaml | 3 +- src/__tests__/proxy-invite-public.test.ts | 58 +++++++++++++++ .../invite/[token]/__tests__/page.test.tsx | 73 +++++++++++++++++++ src/app/invite/[token]/page.tsx | 47 ++++++++++++ src/lib/auth/__tests__/invite-token.test.ts | 38 +++++++++- src/lib/auth/invite-token.ts | 13 +++- src/lib/openapi/routes/admin.ts | 2 +- src/proxy.ts | 6 ++ 8 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/proxy-invite-public.test.ts create mode 100644 src/app/invite/[token]/__tests__/page.test.tsx create mode 100644 src/app/invite/[token]/page.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58887df82..14045abca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13451,7 +13451,8 @@ components: description: Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext. url: type: string - description: Composed registration deep link (`/auth/register?invite=…`). + description: Composed invite universal-link (`/invite/`). On iOS it opens the app onto onboarding + registration; in a browser it redirects to `/auth/register?invite=…`. required: - id - createdAt diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts new file mode 100644 index 000000000..32ef0ed7b --- /dev/null +++ b/src/__tests__/proxy-invite-public.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.17.0 — `/invite/` is the invite universal-link landing + * (iOS #16). It is a thin shape-validated redirect onto + * `/auth/register?invite=…` and carries no session, so an unauthenticated + * visitor (scanning the admin QR) must reach it without the auth-gate + * bounce to `/auth/login`. This guard locks the allowlist entry in place + * so a future refactor of `src/proxy.ts` cannot silently re-gate it. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +const VALID_TOKEN = `hlv_${"a".repeat(64)}`; + +function makeRequest( + pathname: string, + cookies: Record = {}, +): NextRequest { + const cookieHeader = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + return new NextRequest(`http://localhost${pathname}`, { + headers: cookieHeader ? { cookie: cookieHeader } : {}, + }); +} + +describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { + it("does not 307 an unauthenticated /invite/ to /auth/login", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + }); + + it("admits a pending-onboarding session onto the invite landing", () => { + const res = proxy( + makeRequest(`/invite/${VALID_TOKEN}`, { + healthlog_session: "sess-1", + hl_onboarding: "pending", + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + }); + + it("still redirects unauthenticated requests for protected pages", () => { + // Negative check — confirms the fixture would catch a real + // regression. `/insights` is not public. + const res = proxy(makeRequest("/insights")); + expect([307, 308]).toContain(res.status); + expect(res.headers.get("location")).toMatch(/\/auth\/login/); + }); +}); diff --git a/src/app/invite/[token]/__tests__/page.test.tsx b/src/app/invite/[token]/__tests__/page.test.tsx new file mode 100644 index 000000000..2615afd64 --- /dev/null +++ b/src/app/invite/[token]/__tests__/page.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.17.0 — invite universal-link landing (iOS #16). + * + * `/invite/` is a thin, server-side redirect: it validates the + * `hlv_` shape, then bounces a browser onto the existing + * `/auth/register?invite=` flow. These tests pin the security + * contract — the token shape gate, the carry-through to register, and the + * non-oracle behaviour where a malformed segment is indistinguishable + * from "no invite". The route never touches the database. + */ +const redirectMock = vi.fn((href: string) => { + // next/navigation `redirect()` throws a sentinel inside the + // server-component renderer to short-circuit rendering. Mimic it by + // throwing an error tagged with the href so each test can assert the + // exact redirect target. + const err = new Error(`__redirect__:${href}`); + (err as Error & { __redirect__: string }).__redirect__ = href; + throw err; +}); + +vi.mock("next/navigation", () => ({ + redirect: (href: string) => redirectMock(href), +})); + +import InviteLandingPage from "../page"; + +beforeEach(() => { + redirectMock.mockClear(); +}); + +const VALID_TOKEN = `hlv_${"a".repeat(64)}`; + +async function runRedirect(token: string): Promise { + try { + await InviteLandingPage({ params: Promise.resolve({ token }) }); + } catch (e) { + const tagged = e as Error & { __redirect__?: string }; + if (tagged.__redirect__) return tagged.__redirect__; + throw e; + } + throw new Error("expected redirect, none thrown"); +} + +describe(" universal-link landing", () => { + it("carries a well-formed token through to the register flow", async () => { + const href = await runRedirect(VALID_TOKEN); + expect(href).toBe(`/auth/register?invite=${VALID_TOKEN}`); + }); + + it("encodes the token segment into the register query string", async () => { + const href = await runRedirect(VALID_TOKEN); + const url = new URL(href, "https://h.example.com"); + expect(url.pathname).toBe("/auth/register"); + expect(url.searchParams.get("invite")).toBe(VALID_TOKEN); + }); + + it("drops a malformed token to plain register (no enumeration oracle)", async () => { + // Wrong prefix, wrong length, and outright garbage all land on the + // identical target — the page never reveals whether a token exists. + for (const bad of [ + "not-a-token", + `hlk_${"a".repeat(64)}`, + `hlv_${"a".repeat(63)}`, + `hlv_${"G".repeat(64)}`, + ]) { + redirectMock.mockClear(); + const href = await runRedirect(bad); + expect(href).toBe("/auth/register"); + } + }); +}); diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 000000000..236f6be7b --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,47 @@ +import { redirect } from "next/navigation"; + +import { looksLikeInviteToken } from "@/lib/auth/invite-token"; + +/** + * v1.17.0 — invite universal-link landing (iOS #16). + * + * `https:///invite/` is the URL encoded in the admin + * invite QR and the path the AASA `["*"]` matcher hands to the iOS app: + * - On an installed iOS app the scene delegate intercepts the + * Universal Link before this page ever renders and starts onboarding + * registration with the token prefilled. This server component is the + * BROWSER fallback only. + * - In a browser, the page is a thin, safe redirect onto the existing + * `/auth/register?invite=` flow, where the invite banner shows + * and the token rides the signup POST exactly as before. + * + * Security: + * - The `hlv_` shape is validated before the token is ever reflected + * into the redirect target — a malformed segment lands on plain + * `/auth/register` with no `?invite`, so the page can never echo + * attacker-controlled text into the URL. + * - The route does NOT touch the database and is NOT an enumeration + * oracle: a well-formed-but-unknown token and a well-formed-and-valid + * token redirect to the identical target. Whether a token is real is + * only ever decided at `POST /api/auth/register`, which keeps its + * existing uniform error semantics and rate limit. + * - The token is never logged here; the redirect is the only effect. + * + * The route is listed in `PUBLIC_PATHS` (`/invite/`) so an unauthenticated + * visitor reaches it without the auth-gate bounce to `/auth/login`. + */ +export default async function InviteLandingPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + + if (looksLikeInviteToken(token)) { + redirect(`/auth/register?invite=${encodeURIComponent(token)}`); + } + + // Malformed token: behave exactly like a visitor with no invite. No DB + // hit, no leak of whether any token exists. + redirect("/auth/register"); +} diff --git a/src/lib/auth/__tests__/invite-token.test.ts b/src/lib/auth/__tests__/invite-token.test.ts index 4fc6a7691..d1bf45711 100644 --- a/src/lib/auth/__tests__/invite-token.test.ts +++ b/src/lib/auth/__tests__/invite-token.test.ts @@ -8,7 +8,7 @@ * - `recordInviteConsumer` writes the `usedBy` stamp AND the * redemption-ledger row in one transaction. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const findUnique = vi.fn(); const updateMany = vi.fn(); @@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({ })); import { + buildInviteUrl, consumeInviteToken, generateInviteToken, looksLikeInviteToken, @@ -129,3 +130,38 @@ describe("looksLikeInviteToken", () => { expect(looksLikeInviteToken("hlv_" + "G".repeat(64))).toBe(false); }); }); + +describe("buildInviteUrl", () => { + const SAVED = { + APP_URL: process.env.APP_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + }; + + beforeEach(() => { + delete process.env.APP_URL; + delete process.env.NEXT_PUBLIC_APP_URL; + }); + + afterEach(() => { + if (SAVED.APP_URL === undefined) delete process.env.APP_URL; + else process.env.APP_URL = SAVED.APP_URL; + if (SAVED.NEXT_PUBLIC_APP_URL === undefined) + delete process.env.NEXT_PUBLIC_APP_URL; + else process.env.NEXT_PUBLIC_APP_URL = SAVED.NEXT_PUBLIC_APP_URL; + }); + + it("encodes the invite universal-link path the iOS app intercepts", () => { + const token = generateInviteToken(); + const url = buildInviteUrl(token, "https://health.example.com/api/x"); + // The AASA-matched path shape — NOT the legacy ?invite query form. + expect(url).toBe(`https://health.example.com/invite/${token}`); + expect(new URL(url).pathname).toBe(`/invite/${token}`); + }); + + it("prefers the operator-configured origin over the request URL", () => { + process.env.APP_URL = "https://app.example.org"; + const token = generateInviteToken(); + const url = buildInviteUrl(token, "http://internal-host:3000/api/x"); + expect(url).toBe(`https://app.example.org/invite/${token}`); + }); +}); diff --git a/src/lib/auth/invite-token.ts b/src/lib/auth/invite-token.ts index c579ea9e5..ecc15be0b 100644 --- a/src/lib/auth/invite-token.ts +++ b/src/lib/auth/invite-token.ts @@ -36,7 +36,16 @@ export function looksLikeInviteToken(value: string): boolean { } /** - * Compose the registration deep link for a freshly minted invite. + * Compose the invite universal-link for a freshly minted invite. + * + * The URL is `https:///invite/` — a clean path the iOS + * scene delegate intercepts via the AASA `["*"]` matcher (issue #16): an + * installed app opens straight onto onboarding registration with the + * token prefilled; a browser hits the `/invite/` web route, which + * is a thin redirect onto `/auth/register?invite=`. The token is + * `hlv_` + 64 hex, so it is already path-safe; `encodeURIComponent` + * stays as a defensive belt against any future prefix change. + * * Origin precedence mirrors the passkey RP-origin resolution: the * operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the * request origin (which may be an internal hostname behind the proxy). @@ -59,7 +68,7 @@ export function buildInviteUrl(rawToken: string, requestUrl: string): string { // try the next candidate } } - return `${origin}/auth/register?invite=${encodeURIComponent(rawToken)}`; + return `${origin}/invite/${encodeURIComponent(rawToken)}`; } export type InviteConsumeResult = diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index c0cdda382..e2e013a03 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -152,7 +152,7 @@ export const adminInvitePaths: NonNullable = { url: z .string() .describe( - "Composed registration deep link (`/auth/register?invite=…`).", + "Composed invite universal-link (`/invite/`). On iOS it opens the app onto onboarding registration; in a browser it redirects to `/auth/register?invite=…`.", ), }), "AdminInviteCreated", diff --git a/src/proxy.ts b/src/proxy.ts index f2c8664cf..754892f5b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -42,6 +42,12 @@ const PUBLIC_PATHS = [ // by a session cookie, so it must reach the page without an auth gate. // The page renders a flat 404 for any unknown / revoked / expired token. "/c/", + // v1.17.0 — `/invite/` is the invite universal-link landing + // (iOS #16). It is a thin shape-validated redirect onto + // `/auth/register?invite=…`, carries no session, touches no database, + // and is not an enumeration oracle — so it must reach the page without + // an auth gate, like the `/auth/` register surface it forwards to. + "/invite/", // `/onboarding` itself + its subroutes are matched exactly via // `isPublicPath()` so we don't admit `/onboarding-export` etc. "/robots.txt", From 03bde8d40cc8dd1a3c5e3d9d034e85d96cf12869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:34:14 +0200 Subject: [PATCH 2/2] Harden invite token-in-path against cache and index leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the /c/ edge defence (no-store, noindex, no-referrer) to the /invite/ surface — the invite secret rides in the path, so no CDN may cache it and no crawler may index the URL. --- src/__tests__/proxy-invite-public.test.ts | 11 +++++++++++ src/proxy.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts index 32ef0ed7b..feb7aee54 100644 --- a/src/__tests__/proxy-invite-public.test.ts +++ b/src/__tests__/proxy-invite-public.test.ts @@ -48,6 +48,17 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { expect(res.headers.get("location")).toBeNull(); }); + it("hardens the token-in-path surface against cache / index / referrer leaks", () => { + // Mirror the `/c/` edge defence — the secret rides in the path, + // so no CDN may cache it and no crawler may index the URL. + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.headers.get("Cache-Control")).toBe( + "no-store, no-cache, must-revalidate", + ); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); + }); + it("still redirects unauthenticated requests for protected pages", () => { // Negative check — confirms the fixture would catch a real // regression. `/insights` is not public. diff --git a/src/proxy.ts b/src/proxy.ts index 754892f5b..c93c7a666 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -279,7 +279,12 @@ export function proxy(request: NextRequest) { // the header, and the token must never reach a search index. // - `Referrer-Policy: no-referrer` so the token-bearing URL is not // leaked in the `Referer` of any outbound navigation from the page. - if (pathname.startsWith("/c/")) { + // + // v1.17.0 — `/invite/` (iOS #16) carries an equally sensitive + // secret in the path, so it earns the same edge defence even though it is + // a pure server redirect: no shared proxy / CDN may cache it and no + // crawler may index the token-bearing URL. + if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) { response.headers.set( "Cache-Control", "no-store, no-cache, must-revalidate",