From ce3c844a58993ca77e5065135065fd44c3e060f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 09:15:11 +0200 Subject: [PATCH] fix(auth): an invitation never registers over a live session `POST /api/auth/register` read no session at all, so a request carrying a valid `healthlog_session` was handled exactly like an anonymous one and the unconditional `createSession` at the end replaced the caller's cookie with the new account's. An admin opening his own invite link to check it was signed out of his admin account and into a stranger's onboarding, his previous session row still valid server-side and no longer reachable. The route now resolves the optional session first and refuses with 409 `already_authenticated`, before the rate-limit bucket is spent and before the invitation is consumed, so a signed-in visitor neither burns a use nor locks the door for the person the invitation is for. The register page renders an explaining panel with a sign-out button in place of the form, so nobody has to fill three inputs to discover the refusal; signing out returns to the same invitation rather than to the login screen. Copy lands in all seven locales. --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/ko.json | 5 + messages/pl.json | 5 + .../api/auth/register/__tests__/route.test.ts | 109 ++++++++++++++++- src/app/api/auth/register/route.ts | 27 +++- src/app/auth/register/__tests__/page.test.tsx | 115 ++++++++++++++++++ src/app/auth/register/page.tsx | 91 +++++++++++++- 11 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/app/auth/register/__tests__/page.test.tsx diff --git a/messages/de.json b/messages/de.json index bc23d2f08..15c2e8336 100644 --- a/messages/de.json +++ b/messages/de.json @@ -413,6 +413,11 @@ "loginFailed": "Anmeldung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen", "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", + "alreadySignedIn": { + "title": "Du bist bereits angemeldet", + "body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.", + "signOut": "Abmelden und Einladung annehmen" + }, "createAccount": "Konto erstellen", "newAccount": "Neues Konto erstellen", "backToPasskey": "Zurück zu Passkey", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..4fa5a0ba7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -413,6 +413,11 @@ "loginFailed": "Login failed", "registerFailed": "Registration failed", "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.", + "alreadySignedIn": { + "title": "You are already signed in", + "body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.", + "signOut": "Sign out and accept the invitation" + }, "createAccount": "Create account", "newAccount": "Create new account", "backToPasskey": "Back to Passkey", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..964f472bf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -413,6 +413,11 @@ "loginFailed": "No se ha podido iniciar sesión", "registerFailed": "No se ha podido completar el registro", "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", + "alreadySignedIn": { + "title": "Ya has iniciado sesión", + "body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.", + "signOut": "Cerrar sesión y aceptar la invitación" + }, "createAccount": "Crear cuenta", "newAccount": "Crear cuenta nueva", "backToPasskey": "Volver a passkey", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..d1c5e8387 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -413,6 +413,11 @@ "loginFailed": "Échec de la connexion", "registerFailed": "Échec de l’inscription", "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.", + "alreadySignedIn": { + "title": "Tu es déjà connecté", + "body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.", + "signOut": "Se déconnecter et accepter l'invitation" + }, "createAccount": "Créer un compte", "newAccount": "Créer un nouveau compte", "backToPasskey": "Revenir à la passkey", diff --git a/messages/it.json b/messages/it.json index d965b3827..c77cff0bc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -413,6 +413,11 @@ "loginFailed": "Accesso non riuscito", "registerFailed": "Registrazione non riuscita", "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.", + "alreadySignedIn": { + "title": "Hai già effettuato l'accesso", + "body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.", + "signOut": "Esci e accetta l'invito" + }, "createAccount": "Crea account", "newAccount": "Crea un nuovo account", "backToPasskey": "Torna a passkey", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..33e6e738a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -413,6 +413,11 @@ "loginFailed": "로그인 실패", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", + "alreadySignedIn": { + "title": "이미 로그인되어 있습니다", + "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.", + "signOut": "로그아웃하고 초대 수락하기" + }, "createAccount": "계정 만들기", "newAccount": "새 계정 만들기", "backToPasskey": "패스키로 돌아가기", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..d4ee7da18 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -413,6 +413,11 @@ "loginFailed": "Logowanie nie powiodło się", "registerFailed": "Rejestracja nie powiodła się", "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.", + "alreadySignedIn": { + "title": "Masz już zalogowaną sesję", + "body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.", + "signOut": "Wyloguj się i przyjmij zaproszenie" + }, "createAccount": "Utwórz konto", "newAccount": "Utwórz nowe konto", "backToPasskey": "Wróć do passkey", diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 870a5e1b2..088f1d040 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn().mockResolvedValue(undefined), })); +// The invite ledger. A refused registration must never reach it — the +// assertion below is that `uses` is left alone, and the only way this route +// can touch it is through `consumeInviteToken`. +vi.mock("@/lib/auth/invite-token", () => ({ + consumeInviteToken: vi + .fn() + .mockResolvedValue({ ok: true, inviteId: "inv-1" }), + recordInviteConsumer: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/auth/password", () => ({ @@ -78,7 +89,8 @@ import { POST } from "../route"; import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; +import { createSession, getSession } from "@/lib/auth/session"; +import { consumeInviteToken } from "@/lib/auth/invite-token"; import { Prisma } from "@/generated/prisma/client"; function postReq(body: unknown): NextRequest { @@ -96,6 +108,7 @@ beforeEach(() => { allowed: true, ip: "1.2.3.4", } as never); + vi.mocked(getSession).mockResolvedValue(null as never); }); describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => { @@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => expect(res.status).toBe(422); }); }); + +/** + * v1.38.19 — an invitation creates a NEW account, so it cannot be accepted + * from inside somebody's live session. + * + * Until this gate the route read no session at all: a request carrying a valid + * `healthlog_session` was treated exactly like an anonymous one, and the + * unconditional `createSession` at the end overwrote the caller's cookie with + * the fresh account's. The admin who clicked his own invite link to check it + * was signed out of his admin account and into a stranger's onboarding, with + * his previous session row left valid server-side and unreachable. + */ +describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => { + const invitedBody = { + email: "new@example.com", + username: "newuser", + password: "a-very-strong-password-123", + inviteToken: `hlv_${"a".repeat(64)}`, + }; + + // Everything the happy path needs is stubbed, so the ONLY reason this + // request can fail is the gate under test. Without it the same request + // answers 201 and mints a session over the caller's cookie. + beforeEach(() => { + vi.mocked(consumeInviteToken).mockResolvedValue({ + ok: true, + inviteId: "inv-1", + } as never); + vi.mocked(prisma.user.count).mockResolvedValue(1 as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + vi.mocked(hashPassword).mockResolvedValue("hashed" as never); + vi.mocked(prisma.$transaction).mockImplementation((( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never); + vi.mocked(prisma.user.create).mockResolvedValue({ + id: "u1", + username: "newuser", + email: "new@example.com", + role: "USER", + } as never); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) }, + user: { id: "u-existing", username: "admin", email: "a@example.com" }, + } as never); + }); + + it("answers 409 already_authenticated", async () => { + const res = await postPathThrough(invitedBody); + expect(res.status).toBe(409); + const body = (await res.json()) as { + data: null; + error: string; + meta?: { errorCode?: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta?.errorCode).toBe("already_authenticated"); + expect(body.error).toMatch(/sign/i); + }); + + it("creates no account", async () => { + await postPathThrough(invitedBody); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it("does not burn a use of the invitation", async () => { + await postPathThrough(invitedBody); + expect(consumeInviteToken).not.toHaveBeenCalled(); + }); + + it("does not touch the caller's own session", async () => { + await postPathThrough(invitedBody); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("refuses before the rate-limit bucket is spent", async () => { + // The gate is a session read, not an attempt: a signed-in visitor + // clicking the link three times must not lock the door for the person + // the invitation is actually for, who shares the IP on a home network. + await postPathThrough(invitedBody); + expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled(); + }); + + it("still registers an anonymous caller", async () => { + // The control. Same request, no session — the invitation works. + vi.mocked(getSession).mockResolvedValue(null as never); + const res = await postPathThrough(invitedBody); + expect(res.status).toBe(201); + }); +}); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 8189b0789..c8b8ca48f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,7 +6,7 @@ import { } from "@/lib/auth/invite-token"; import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; -import { createSession } from "@/lib/auth/session"; +import { createSession, getSession } from "@/lib/auth/session"; import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { @@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // v1.38.19 — an invitation creates a NEW account, so it can never be + // accepted from inside a live session. + // + // The route used to read no session at all: a request carrying a valid + // `healthlog_session` was handled exactly like an anonymous one, and the + // unconditional `createSession` at the end overwrote the caller's cookie + // with the fresh account's. An admin opening his own invite link to check it + // was signed out of his admin account and into a stranger's onboarding, his + // previous session row still valid server-side and no longer reachable. + // + // Refused BEFORE the rate limit on purpose: this is a state, not an attempt, + // and spending the 5-per-15-minutes bucket on it would lock the door for the + // person the invitation is actually for, who may well share the IP. + // `getSession` is the optional resolver — it answers `null` for an anonymous + // caller instead of throwing, which `requireAuth` would. + const existingSession = await getSession(); + if (existingSession) { + annotate({ action: { name: "auth.register.refused_authenticated" } }); + return apiError( + "Already signed in — sign out first to use an invitation", + 409, + { errorCode: "already_authenticated" }, + ); + } + // v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain // is misconfigured; otherwise byte-equivalent per-IP semantics. const rl = await checkAuthSurfaceRateLimit( diff --git a/src/app/auth/register/__tests__/page.test.tsx b/src/app/auth/register/__tests__/page.test.tsx new file mode 100644 index 000000000..47272db5a --- /dev/null +++ b/src/app/auth/register/__tests__/page.test.tsx @@ -0,0 +1,115 @@ +/** + * An invitation opened from inside a live session. + * + * `POST /api/auth/register` refuses that with 409 `already_authenticated` + * (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not + * make the visitor discover it by filling the form: a signed-in caller gets an + * explanation and a way out instead of three inputs that cannot succeed. + * + * SSR-only, per project convention (node environment, no DOM, no + * `@testing-library/react`), so what is asserted is the PAINT — which branch + * the page rendered. The sign-out click itself is driven in + * `e2e/invite-registration.spec.ts`. + * + * Mutation check, run: drop the `isAuthenticated` branch → the two signed-in + * cases go red naming the form inputs they found, the anonymous case stays + * green. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const authRef = { + value: { + user: null as { username: string } | null, + isAuthenticated: false, + isLoading: false, + }, +}; +const inviteRef = { value: null as string | null }; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + useSearchParams: () => ({ + get: (key: string) => (key === "invite" ? inviteRef.value : null), + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authRef.value, + clearCachesForSessionEnd: vi.fn(), +})); + +import RegisterPage from "../page"; + +const TOKEN = `hlv_${"a".repeat(64)}`; + +function render(): string { + return renderToStaticMarkup( + + + + + , + ); +} + +function anonymous(invite: string | null): string { + authRef.value = { user: null, isAuthenticated: false, isLoading: false }; + inviteRef.value = invite; + return render(); +} + +function signedIn(invite: string | null, username = "marc"): string { + authRef.value = { + user: { username }, + isAuthenticated: true, + isLoading: false, + }; + inviteRef.value = invite; + return render(); +} + +describe("register page — an invitation cannot be accepted from a live session", () => { + it("renders the form for an anonymous visitor holding an invitation", () => { + const markup = anonymous(TOKEN); + expect(markup).toContain('data-testid="register-invite-banner"'); + expect(markup).toContain('id="username"'); + expect(markup).not.toContain('data-testid="register-already-signed-in"'); + }); + + it("replaces the form with the explaining panel for a signed-in visitor", () => { + const markup = signedIn(TOKEN); + expect(markup).toContain('data-testid="register-already-signed-in"'); + expect(markup).not.toContain('id="username"'); + expect(markup).not.toContain('id="password"'); + }); + + it("names the account the visitor is signed in as", () => { + const markup = signedIn(TOKEN, "grandma"); + expect(markup).toContain("grandma"); + }); + + it("offers the way out", () => { + const markup = signedIn(TOKEN); + expect(markup).toContain('data-testid="register-sign-out"'); + }); + + it("explains the same thing without an invitation in the URL", () => { + // Reaching /auth/register signed in is the same dead end with or without + // a token — the form below could only ever answer 409. + const markup = signedIn(null); + expect(markup).toContain('data-testid="register-already-signed-in"'); + }); + + it("holds the panel back while the session question is unanswered", () => { + // Fail closed the other way round: an unresolved `/api/auth/me` must not + // flash "you are already signed in" at somebody who is not. + authRef.value = { user: null, isAuthenticated: false, isLoading: true }; + inviteRef.value = TOKEN; + const markup = render(); + expect(markup).not.toContain('data-testid="register-already-signed-in"'); + }); +}); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 73c1f10a6..b47fe23ea 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -3,7 +3,7 @@ import { useId, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, MailOpen, UserPlus } from "lucide-react"; +import { Loader2, LogOut, MailOpen, UserPlus } from "lucide-react"; import { Logo } from "@/components/ui/logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -13,7 +13,8 @@ import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; import { detectBrowserTimezone } from "@/lib/tz/format"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; export default function RegisterPage() { const router = useRouter(); @@ -23,6 +24,15 @@ export default function RegisterPage() { // admits the invited user; an open instance simply ignores it. const inviteToken = searchParams.get("invite"); const queryClient = useQueryClient(); + // v1.38.19 — an invitation creates a NEW account, so it cannot be accepted + // from inside a live session; `POST /api/auth/register` refuses one with + // 409 `already_authenticated`. Rendering the form anyway would let the + // visitor discover that by filling it in. `isLoading` holds the panel back + // until the session question is actually answered — failing closed the + // other way round would flash "you are already signed in" at somebody who + // is not. + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const [signingOut, setSigningOut] = useState(false); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -69,6 +79,83 @@ export default function RegisterPage() { } } + async function handleSignOut() { + setError(null); + setSigningOut(true); + try { + await apiFetchRaw("/api/auth/logout", { method: "POST" }); + // Same session-end wipe the shell and the menu do: the QueryClient + // outlives the navigation on this SPA and the health-data families are + // not user-scoped, so nothing may survive into the next account. + clearCachesForSessionEnd(queryClient); + // Back to this page, invitation intact — NOT to the login screen the + // generic logout goes to. The point of the trip is to accept the + // invitation, and the token only rides the URL. + router.replace( + inviteToken + ? `/auth/register?invite=${encodeURIComponent(inviteToken)}` + : "/auth/register", + ); + router.refresh(); + } catch { + setError(t("common.networkError")); + } finally { + setSigningOut(false); + } + } + + if (isAuthenticated && !authLoading) { + return ( +
+
+
+
+ +
+

+ {t("auth.alreadySignedIn.title")} +

+

+ {t("auth.alreadySignedIn.body", { + username: user?.username ?? "", + })} +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); + } + return (
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the