diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ba15e063..36f4dfe69 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29943,19 +29943,19 @@ components: carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, - auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, - auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, - coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, - coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, - coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — - coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — - cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, - cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, - cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — - cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; - documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, + auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required, + auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing; + coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, + documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, diff --git a/e2e/invite-registration.spec.ts b/e2e/invite-registration.spec.ts new file mode 100644 index 000000000..aefaeb362 --- /dev/null +++ b/e2e/invite-registration.spec.ts @@ -0,0 +1,118 @@ +/** + * The journey that would have caught the invite defect. + * + * Every layer of the invite chain had unit coverage and the chain was broken + * anyway: the proxy test proved the edge admits `/invite/`, the landing + * page test proved it redirects onto `/auth/register?invite=…`, and neither + * could see that in the real runtime the redirect arrives in an RSC flight + * payload AFTER the shell has flushed — so the shell mounted on `/invite/…`, + * read it as a protected route and raced its own `router.replace( + * "/auth/login")` against it. The operator scanned his own QR in a private + * window and landed on the sign-in page holding a one-time invitation the + * sign-in page cannot use. + * + * So this spec is deliberately end to end and deliberately about the SEAM: + * mint a real invitation in the admin UI, open the real URL in a genuinely + * cookie-less context, and assert both where the visitor ends up and where + * they never went. + * + * The second case is the other half of the same defect: the same link opened + * in the admin's own window used to render the bare form, and submitting it + * replaced his session cookie with the new account's — signing him out of his + * admin account and into a stranger's onboarding. + * + * Neither case completes a signup: that would add a user to the shared e2e + * database and spend the 5-per-15-minutes registration bucket every other + * spec shares. What the signup does with the token is pinned in + * `src/app/api/auth/register/__tests__/route.test.ts`. + */ +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** Mint an invitation in the admin UI and return its `/invite/` path. */ +async function mintInvite(page: import("@playwright/test").Page) { + await page.goto("/admin/invites"); + await page.getByTestId("admin-invites-open-create").click(); + await page.getByTestId("admin-invites-submit-create").click(); + const minted = page.getByTestId("admin-invites-minted"); + await expect(minted).toBeVisible(); + const url = await page.getByTestId("admin-invites-minted-url").textContent(); + expect(url).toMatch(/\/invite\/hlv_[0-9a-f]{64}$/); + // The minted URL carries whatever origin the instance is configured with + // (`APP_URL` / `NEXT_PUBLIC_APP_URL`), which is not necessarily the one + // Playwright is driving. The path is the part under test. + return new URL(url!.trim()).pathname; +} + +test.describe("invitation links", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("opens registration in a cookie-less window and never passes login", async ({ + page, + browser, + }) => { + const invitePath = await mintInvite(page); + + // The operator's private window: a context with no cookies at all, not a + // page that merely has not logged in. + const fresh = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const guest = await fresh.newPage(); + const visited: string[] = []; + guest.on("framenavigated", (frame) => { + if (frame === guest.mainFrame()) visited.push(frame.url()); + }); + + try { + await guest.goto(invitePath); + await expect(guest).toHaveURL( + /\/auth\/register\?invite=hlv_[0-9a-f]{64}/, + ); + await expect(guest.getByTestId("register-invite-banner")).toBeVisible(); + // The form is reachable, not just the URL. + await expect(guest.locator("#username")).toBeVisible(); + // The defect, named: the visitor must never have been bounced through + // the sign-in page on the way. + expect(visited.filter((u) => u.includes("/auth/login"))).toEqual([]); + } finally { + // Contexts a spec creates itself are invisible to the route-cleanup + // fixture — see the header of `e2e/setup/test.ts`. + await fresh.close(); + } + }); + + test("shows the signed-in panel in the admin's own window and keeps the session", async ({ + page, + }) => { + const invitePath = await mintInvite(page); + + await page.goto(invitePath); + await expect(page).toHaveURL(/\/auth\/register\?invite=hlv_[0-9a-f]{64}/); + await expect(page.getByTestId("register-already-signed-in")).toBeVisible(); + // No form to submit, so no way to register over the live session. + await expect(page.locator("#username")).toHaveCount(0); + await expect(page.getByTestId("register-sign-out")).toBeVisible(); + + // The admin is still the admin. + const me = await page.request.get("/api/auth/me"); + expect(me.status()).toBe(200); + }); + + test("refuses a registration POST carried on a live session", async ({ + page, + }) => { + // The server half of the same rule, asserted through the wire rather than + // through the UI: the panel above is an explanation, not the enforcement. + const res = await page.request.post("/api/auth/register", { + data: { + email: "invite-over-session@healthlog.test", + username: "invite-over-session", + password: "ZJ4hN8x!Pq3vMr2C", + }, + }); + expect(res.status()).toBe(409); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("auth.already_authenticated"); + }); +}); diff --git a/messages/de.json b/messages/de.json index bc23d2f08..bb63290f5 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Jemanden einladen", "description": "Vor der Zustimmung wird nichts geteilt.", + "needsAccount": "Die Person braucht ein eigenes HealthLog-Konto auf dieser Instanz und findet die Einladung dort unter Einstellungen → Geteilter Zugriff.", "identifierLabel": "Benutzername oder E-Mail", "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", diff --git a/messages/en.json b/messages/en.json index dc66aa61b..2ecd85fdf 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Invite someone", "description": "Nothing is shared until they accept.", + "needsAccount": "The person needs their own HealthLog account on this instance and will find the invitation under Settings → Shared access.", "identifierLabel": "Username or e-mail", "identifierPlaceholder": "The name they sign in with", "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", diff --git a/messages/es.json b/messages/es.json index e5fddd66f..527610e38 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Invitar a alguien", "description": "No se comparte nada hasta que acepte.", + "needsAccount": "La persona necesita su propia cuenta de HealthLog en esta instancia y encontrará la invitación en Ajustes → Acceso compartido.", "identifierLabel": "Usuario o correo electrónico", "identifierPlaceholder": "El nombre con el que inicia sesión", "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", diff --git a/messages/fr.json b/messages/fr.json index 1640aa7e9..b8c3b73d3 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Inviter quelqu'un", "description": "Rien n'est partagé avant son acceptation.", + "needsAccount": "La personne a besoin de son propre compte HealthLog sur cette instance et trouvera l'invitation dans Paramètres → Accès partagé.", "identifierLabel": "Nom d'utilisateur ou e-mail", "identifierPlaceholder": "Le nom avec lequel elle se connecte", "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", diff --git a/messages/it.json b/messages/it.json index d965b3827..a1ceb9da9 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Invita qualcuno", "description": "Nulla viene condiviso finché non accetta.", + "needsAccount": "La persona ha bisogno di un proprio account HealthLog su questa istanza e troverà l'invito in Impostazioni → Accesso condiviso.", "identifierLabel": "Nome utente o e-mail", "identifierPlaceholder": "Il nome con cui accede", "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", diff --git a/messages/ko.json b/messages/ko.json index 4dabc847b..7772b8576 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -413,6 +413,11 @@ "loginFailed": "로그인 실패", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", + "alreadySignedIn": { + "title": "이미 로그인되어 있습니다", + "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.", + "signOut": "로그아웃하고 초대 수락하기" + }, "createAccount": "계정 만들기", "newAccount": "새 계정 만들기", "backToPasskey": "패스키로 돌아가기", @@ -10322,6 +10327,7 @@ "invite": { "title": "다른 사람 초대", "description": "상대가 수락하기 전에는 아무것도 공유되지 않아요.", + "needsAccount": "상대방에게는 이 인스턴스의 HealthLog 계정이 필요하며, 초대는 설정 → 공유 접근에서 확인할 수 있습니다.", "identifierLabel": "사용자 이름 또는 이메일", "identifierPlaceholder": "로그인에 쓰는 이름", "identifierHint": "이 인스턴스에 계정이 있어야 해요. 가입하지 않은 이메일 주소로는 초대를 보낼 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index 9769c0838..cff8897c2 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", @@ -10322,6 +10327,7 @@ "invite": { "title": "Zaproś kogoś", "description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje.", + "needsAccount": "Ta osoba potrzebuje własnego konta HealthLog na tej instancji i znajdzie zaproszenie w Ustawienia → Udostępniony dostęp.", "identifierLabel": "Nazwa użytkownika lub e-mail", "identifierPlaceholder": "Nazwa, którą się loguje", "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", diff --git a/src/__tests__/proxy-invite-public.test.ts b/src/__tests__/proxy-invite-public.test.ts index feb7aee54..d7c2df685 100644 --- a/src/__tests__/proxy-invite-public.test.ts +++ b/src/__tests__/proxy-invite-public.test.ts @@ -8,6 +8,16 @@ import { NextRequest } from "next/server"; * 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. + * + * v1.38.19 — the redirect moved here, to the edge. It used to be emitted by + * `src/app/invite/[token]/page.tsx`, and Next does not answer that with a + * server 307: it streams the root layout first and puts the redirect in the + * flight payload, so `` mounted on `/invite/…`, read the pathname + * as a protected route and raced its own `router.replace("/auth/login")` + * against the redirect. Answering at the edge means there is no HTML, no + * hydration and no race — the behaviour the page's doc comment already + * claimed. The page stays as the fallback for any path that bypasses the + * proxy matcher. */ vi.mock("@/lib/process-type", () => ({ @@ -33,19 +43,18 @@ function makeRequest( 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(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); - it("admits a pending-onboarding session onto the invite landing", () => { + it("does not bounce a pending-onboarding session off 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(); + expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); }); it("hardens the token-in-path surface against cache / index / referrer leaks", () => { @@ -67,3 +76,64 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => { expect(res.headers.get("location")).toMatch(/\/auth\/login/); }); }); + +describe("proxy.ts /invite edge redirect (v1.38.19)", () => { + it("307s a shape-valid token straight onto the registration form", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it("307s a malformed segment onto a bare registration form", () => { + // Never echo an attacker-controlled segment into the target — a + // malformed token behaves exactly like a visitor with no invite. + const res = proxy(makeRequest("/invite/not-a-token")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("ignores anything after the token segment", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}/extra`)); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); + + it.each([ + ["an uppercase hex body", `hlv_${"A".repeat(64)}`], + ["a short body", `hlv_${"a".repeat(63)}`], + ["a long body", `hlv_${"a".repeat(65)}`], + ["the wrong prefix", `hlk_${"a".repeat(64)}`], + ["an empty segment", ""], + ])("drops %s rather than echoing it into the target", (_label, segment) => { + const res = proxy(makeRequest(`/invite/${segment}`)); + expect(res.headers.get("location")).toBe("http://localhost/auth/register"); + }); + + it("keeps the token hardening on the malformed redirect too", () => { + const res = proxy(makeRequest("/invite/not-a-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("carries the baseline transport headers on the redirect hop", () => { + const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`)); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("redirects a signed-in caller the same way, with no session read", () => { + const res = proxy( + makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1" }), + ); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/register?invite=${VALID_TOKEN}`, + ); + }); +}); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 855b7c650..324af37db 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => { // the freshness comparison, which `requireAuth` does not project. "app/api/auth/native/complete/route.ts": "reads createdAt for the handoff freshness check", + // Anonymous self-registration. Probes for an already-valid session so an + // invitation opened from inside one is refused instead of minting a second + // account over the caller's own cookie. `requireAuth` is the wrong gate + // here twice over: it would throw 401 at the anonymous caller the route + // exists for, and this route wants the ABSENCE of a session. + "app/api/auth/register/route.ts": "refuses to register over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 870a5e1b2..d25cca103 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("auth.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..d3221afbc 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: "auth.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 diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0e8a741ed..9047a519b 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -619,7 +619,10 @@ export function InviteTokensSection() { height={176} className="rounded-lg bg-white p-2" /> -

+

{minted.url}