mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(invite): open invites through a deep-linkable path
Emit invite links as a /invite/<token> path the iOS app intercepts as a universal link to start a prefilled registration, with a browser fallback that redirects to the register flow once the token shape checks out. The path is a thin, side-effect-free redirect — no lookup, no logging, no enumeration oracle — and carries the same no-store, no-index, no-referrer hardening as the other secret-bearing path since the token rides in the URL.
This commit is contained in:
commit
52bf985734
8 files changed
+252
-6
No files matched your search
@@ -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/<hlv_token>`). On iOS it opens the app onto onboarding
|
||||
registration; in a browser it redirects to `/auth/register?invite=…`.
|
||||
required:
|
||||
- id
|
||||
- createdAt
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.17.0 — `/invite/<hlv_token>` 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<string, string> = {},
|
||||
): 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/<token> 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("hardens the token-in-path surface against cache / index / referrer leaks", () => {
|
||||
// Mirror the `/c/<token>` 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.
|
||||
const res = proxy(makeRequest("/insights"));
|
||||
expect([307, 308]).toContain(res.status);
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.17.0 — invite universal-link landing (iOS #16).
|
||||
*
|
||||
* `/invite/<hlv_token>` is a thin, server-side redirect: it validates the
|
||||
* `hlv_` shape, then bounces a browser onto the existing
|
||||
* `/auth/register?invite=<token>` 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<string> {
|
||||
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("<InviteLandingPage> 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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://<host>/invite/<hlv_token>` 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=<token>` 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");
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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://<host>/invite/<hlv_token>` — 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/<token>` web route, which
|
||||
* is a thin redirect onto `/auth/register?invite=<token>`. 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 =
|
||||
|
||||
@@ -152,7 +152,7 @@ export const adminInvitePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
"Composed registration deep link (`/auth/register?invite=…`).",
|
||||
"Composed invite universal-link (`/invite/<hlv_token>`). On iOS it opens the app onto onboarding registration; in a browser it redirects to `/auth/register?invite=…`.",
|
||||
),
|
||||
}),
|
||||
"AdminInviteCreated",
|
||||
|
||||
+12
-1
@@ -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/<hlv_token>` 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",
|
||||
@@ -273,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/<hlv_token>` (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",
|
||||
|
||||
Reference in new issue
Block a user