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

@@ -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(