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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:15:11 +02:00
1 parent cfca84a294
commit ce3c844a58
11 files changed
+373 -4

No files matched your search

+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -413,6 +413,11 @@
"loginFailed": "로그인 실패",
"registerFailed": "회원가입 실패",
"invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
"alreadySignedIn": {
"title": "이미 로그인되어 있습니다",
"body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.",
"signOut": "로그아웃하고 초대 수락하기"
},
"createAccount": "계정 만들기",
"newAccount": "새 계정 만들기",
"backToPasskey": "패스키로 돌아가기",
+5
View File
@@ -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",
@@ -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);
});
});
+26 -1
View File
@@ -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(
@@ -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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<RegisterPage />
</I18nProvider>
</QueryClientProvider>,
);
}
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"');
});
});
+89 -2
View File
@@ -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 (
<div className="w-full max-w-sm">
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
<div
className="flex flex-col items-center gap-3 text-center"
data-testid="register-already-signed-in"
>
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Logo className="text-primary" size={28} />
</div>
<h1 className="text-xl font-bold tracking-tight">
{t("auth.alreadySignedIn.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("auth.alreadySignedIn.body", {
username: user?.username ?? "",
})}
</p>
</div>
{error && (
<div
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive mt-6 rounded-lg p-3 text-sm"
>
{error}
</div>
)}
<Button
type="button"
size="lg"
className="mt-8 min-h-11 w-full"
onClick={handleSignOut}
disabled={signingOut}
aria-busy={signingOut || undefined}
data-testid="register-sign-out"
>
{signingOut ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<LogOut className="h-4 w-4" />
)}
{t("auth.alreadySignedIn.signOut")}
</Button>
</div>
</div>
);
}
return (
<div className="w-full max-w-sm">
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the