diff --git a/apps/web/src/app/login/auth-error.ts b/apps/web/src/app/login/auth-error.ts new file mode 100644 index 00000000..f6369a4c --- /dev/null +++ b/apps/web/src/app/login/auth-error.ts @@ -0,0 +1,16 @@ +export const INVITATION_REQUIRED_MESSAGE = + "You need a team invitation to create an account on this instance."; +export const GENERIC_AUTH_ERROR_MESSAGE = + "Unable to sign in. Please try again."; + +export function getAuthErrorMessage(error?: string | null) { + if (!error) { + return null; + } + + if (error === "AccessDenied") { + return INVITATION_REQUIRED_MESSAGE; + } + + return GENERIC_AUTH_ERROR_MESSAGE; +} diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx index 46617f16..b9753952 100644 --- a/apps/web/src/app/login/login-page.tsx +++ b/apps/web/src/app/login/login-page.tsx @@ -25,8 +25,8 @@ import { Input } from "@usesend/ui/src/input"; import { BuiltInProviderType } from "next-auth/providers/index"; import Spinner from "@usesend/ui/src/spinner"; import Link from "next/link"; -import { useTheme } from "@usesend/ui"; import { useSearchParams as useNextSearchParams } from "next/navigation"; +import { GENERIC_AUTH_ERROR_MESSAGE, getAuthErrorMessage } from "./auth-error"; const emailSchema = z.object({ email: z @@ -82,11 +82,40 @@ export default function LoginPage({ async function onEmailSubmit(values: z.infer) { setEmailStatus("sending"); - await signIn("email", { - email: values.email.toLowerCase(), - redirect: false, - }); - setEmailStatus("success"); + emailForm.clearErrors("email"); + + try { + const result = await signIn("email", { + email: values.email.toLowerCase(), + redirect: false, + }); + + if (!result || result.error) { + setEmailStatus("idle"); + emailForm.setError( + "email", + { + type: "server", + message: + getAuthErrorMessage(result?.error) ?? GENERIC_AUTH_ERROR_MESSAGE, + }, + { shouldFocus: true }, + ); + return; + } + + setEmailStatus("success"); + } catch { + setEmailStatus("idle"); + emailForm.setError( + "email", + { + type: "server", + message: GENERIC_AUTH_ERROR_MESSAGE, + }, + { shouldFocus: true }, + ); + } } async function onOTPSubmit(values: z.infer) { @@ -98,12 +127,12 @@ export default function LoginPage({ ? `/join-team?inviteId=${inviteId}` : `${callbackUrl}/dashboard`; window.location.href = `/api/auth/callback/email?email=${encodeURIComponent( - email.toLowerCase() + email.toLowerCase(), )}&token=${values.otp.toLowerCase()}&callbackUrl=${encodeURIComponent(finalCallbackUrl)}`; } const emailProvider = providers?.find( - (provider) => provider.type === "email" + (provider) => provider.type === "email", ); const [submittedProvider, setSubmittedProvider] = @@ -111,6 +140,7 @@ export default function LoginPage({ const searchParams = useNextSearchParams(); const inviteId = searchParams.get("inviteId"); + const authErrorMessage = getAuthErrorMessage(searchParams.get("error")); const handleSubmit = (provider: LiteralUnion) => { setSubmittedProvider(provider); @@ -120,8 +150,6 @@ export default function LoginPage({ signIn(provider, { callbackUrl }); }; - const { resolvedTheme } = useTheme(); - return (
@@ -148,6 +176,14 @@ export default function LoginPage({
+ {authErrorMessage ? ( +

+ {authErrorMessage} +

+ ) : null} {providers && Object.values(providers).map((provider) => { if (provider.type === "email") return null; diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index ccd9d388..10650a2c 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -4,7 +4,7 @@ import { type DefaultSession, type NextAuthOptions, } from "next-auth"; -import { type Adapter } from "next-auth/adapters"; +import { type Adapter, type AdapterUser } from "next-auth/adapters"; import GitHubProvider from "next-auth/providers/github"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; @@ -16,6 +16,58 @@ import { db } from "~/server/db"; const GITHUB_OAUTH_ISSUER = "https://github.com/login/oauth"; +/** + * PostgreSQL advisory-lock namespace for self-hosted user creation. + * + * The lock serializes only transactions that request this same key; it does not + * lock the User table or any rows. Because pg_advisory_xact_lock is scoped to + * the current transaction, PostgreSQL releases it automatically on commit, + * rollback, or connection loss. A concurrent registration may wait briefly for + * the active registration transaction to finish. + */ +const SELF_HOSTED_REGISTRATION_LOCK_ID = 1431520590; + +export class SelfHostedRegistrationError extends Error { + constructor() { + super("A team invitation is required to create an account"); + this.name = "SelfHostedRegistrationError"; + } +} + +export async function canRegisterSelfHostedUser(email?: string | null) { + if (env.NEXT_PUBLIC_IS_CLOUD) { + return true; + } + + if (!email) { + return false; + } + + const existingUser = await db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (existingUser) { + return true; + } + + const firstUser = await db.user.findFirst({ + select: { id: true }, + }); + + if (!firstUser) { + return true; + } + + const invite = await db.teamInvite.findFirst({ + where: { email }, + select: { id: true }, + }); + + return Boolean(invite); +} + /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * object and keep type safety. @@ -64,7 +116,7 @@ function getProviders() { scope: "read:user user:email", }, }, - }) + }), ); } @@ -74,7 +126,7 @@ function getProviders() { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, allowDangerousEmailAccountLinking: true, - }) + }), ); } @@ -88,7 +140,7 @@ function getProviders() { async generateVerificationToken() { return Math.random().toString(36).substring(2, 7).toLowerCase(); }, - }) + }), ); } @@ -106,6 +158,7 @@ function getProviders() { */ export const authOptions: NextAuthOptions = { callbacks: { + signIn: async ({ user }) => canRegisterSelfHostedUser(user.email), session: ({ session, user }) => ({ ...session, user: { @@ -117,7 +170,57 @@ export const authOptions: NextAuthOptions = { }, }), }, - adapter: PrismaAdapter(db) as Adapter, + adapter: (() => { + const prismaAdapter = PrismaAdapter(db); + + return { + ...prismaAdapter, + async createUser(user: AdapterUser) { + if (env.NEXT_PUBLIC_IS_CLOUD) { + if (!prismaAdapter.createUser) { + throw new Error("Prisma adapter does not support user creation"); + } + + return prismaAdapter.createUser(user); + } + + if (!user.email) { + throw new SelfHostedRegistrationError(); + } + + return db.$transaction(async (tx) => { + // Acquire the lock before checking for the first user. Without this, + // two concurrent callbacks could both observe an empty User table and + // both create an account without an invitation. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${SELF_HOSTED_REGISTRATION_LOCK_ID})`; + + const firstUser = await tx.user.findFirst({ + select: { id: true }, + }); + + if (firstUser) { + const invite = await tx.teamInvite.findFirst({ + where: { email: user.email }, + select: { id: true }, + }); + + if (!invite) { + throw new SelfHostedRegistrationError(); + } + } + + return tx.user.create({ + data: { + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + }, + }); + }); + }, + } as Adapter; + })(), pages: { signIn: "/login", }, diff --git a/apps/web/src/server/auth.unit.test.ts b/apps/web/src/server/auth.unit.test.ts index 50682cb1..2ef03b14 100644 --- a/apps/web/src/server/auth.unit.test.ts +++ b/apps/web/src/server/auth.unit.test.ts @@ -1,40 +1,115 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const env = { + GITHUB_ID: "github-client-id", + GITHUB_SECRET: "github-client-secret", + NEXT_PUBLIC_IS_CLOUD: true, + }; + + const baseCreateUser = vi.fn(); + const userFindUnique = vi.fn(); + const userFindFirst = vi.fn(); + const inviteFindFirst = vi.fn(); + const transactionUserFindFirst = vi.fn(); + const transactionInviteFindFirst = vi.fn(); + const transactionUserCreate = vi.fn(); + const executeRaw = vi.fn(); + const transaction = vi.fn(async (callback) => + callback({ + $executeRaw: executeRaw, + user: { + findFirst: transactionUserFindFirst, + create: transactionUserCreate, + }, + teamInvite: { + findFirst: transactionInviteFindFirst, + }, + }), + ); + + return { + env, + baseCreateUser, + userFindUnique, + userFindFirst, + inviteFindFirst, + transactionUserFindFirst, + transactionInviteFindFirst, + transactionUserCreate, + executeRaw, + transaction, + }; +}); vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); vi.mock("@auth/prisma-adapter", () => ({ - PrismaAdapter: vi.fn(() => ({})), + PrismaAdapter: vi.fn(() => ({ createUser: mocks.baseCreateUser })), +})); + +vi.mock("next-auth/providers/github", () => ({ + default: vi.fn((options) => ({ id: "github", options })), })); vi.mock("next-auth/providers/google", () => ({ - default: vi.fn(), + default: vi.fn((options) => ({ id: "google", options })), })); vi.mock("next-auth/providers/email", () => ({ - default: vi.fn(), + default: vi.fn((options) => ({ id: "email", options })), })); vi.mock("~/server/db", () => ({ - db: {}, + db: { + user: { + findUnique: mocks.userFindUnique, + findFirst: mocks.userFindFirst, + }, + teamInvite: { + findFirst: mocks.inviteFindFirst, + }, + $transaction: mocks.transaction, + }, })); vi.mock("~/server/mailer", () => ({ sendSignUpEmail: vi.fn(), })); -vi.mock("~/env", () => ({ - env: { - GITHUB_ID: "github-client-id", - GITHUB_SECRET: "github-client-secret", - NEXT_PUBLIC_IS_CLOUD: true, - }, -})); +vi.mock("~/env", () => ({ env: mocks.env })); + +import { + authOptions, + canRegisterSelfHostedUser, + SelfHostedRegistrationError, +} from "~/server/auth"; -import { authOptions } from "~/server/auth"; +const newUser = { + id: "new-user", + name: "New User", + email: "new@example.com", + emailVerified: null, + image: null, + isBetaUser: false, + isWaitlisted: false, + isAdmin: false, +}; describe("authOptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.env.NEXT_PUBLIC_IS_CLOUD = true; + mocks.userFindUnique.mockResolvedValue(null); + mocks.userFindFirst.mockResolvedValue(null); + mocks.inviteFindFirst.mockResolvedValue(null); + mocks.transactionUserFindFirst.mockResolvedValue(null); + mocks.transactionInviteFindFirst.mockResolvedValue(null); + mocks.transactionUserCreate.mockResolvedValue({ ...newUser, id: 1 }); + }); + it("configures the GitHub provider with an explicit issuer", () => { const githubProvider = authOptions.providers.find( (provider) => provider.id === "github", @@ -49,4 +124,142 @@ describe("authOptions", () => { }, }); }); + + describe("self-hosted registration policy", () => { + beforeEach(() => { + mocks.env.NEXT_PUBLIC_IS_CLOUD = false; + }); + + it("allows the first user without an invite", async () => { + await expect( + canRegisterSelfHostedUser("first@example.com"), + ).resolves.toBe(true); + + expect(mocks.inviteFindFirst).not.toHaveBeenCalled(); + }); + + it("allows an existing user to sign in without an invite", async () => { + mocks.userFindUnique.mockResolvedValue({ id: 1 }); + + await expect( + canRegisterSelfHostedUser("existing@example.com"), + ).resolves.toBe(true); + + expect(mocks.userFindFirst).not.toHaveBeenCalled(); + expect(mocks.inviteFindFirst).not.toHaveBeenCalled(); + }); + + it("allows a new user with a matching invite", async () => { + mocks.userFindFirst.mockResolvedValue({ id: 1 }); + mocks.inviteFindFirst.mockResolvedValue({ id: "invite_1" }); + + await expect( + canRegisterSelfHostedUser("invited@example.com"), + ).resolves.toBe(true); + + expect(mocks.inviteFindFirst).toHaveBeenCalledWith({ + where: { email: "invited@example.com" }, + select: { id: true }, + }); + }); + + it("rejects a new user without a matching invite", async () => { + mocks.userFindFirst.mockResolvedValue({ id: 1 }); + + await expect( + canRegisterSelfHostedUser("random@example.com"), + ).resolves.toBe(false); + }); + + it("rejects a new account that has no email", async () => { + await expect(canRegisterSelfHostedUser(null)).resolves.toBe(false); + expect(mocks.userFindUnique).not.toHaveBeenCalled(); + }); + }); + + describe("adapter user creation", () => { + const createUser = authOptions.adapter?.createUser; + + if (!createUser) { + throw new Error("Expected the auth adapter to support user creation"); + } + + it("keeps cloud user creation unchanged", async () => { + mocks.baseCreateUser.mockResolvedValue({ ...newUser, id: 1 }); + + await createUser(newUser); + + expect(mocks.baseCreateUser).toHaveBeenCalledWith(newUser); + expect(mocks.transaction).not.toHaveBeenCalled(); + }); + + it("atomically creates the first self-hosted user", async () => { + mocks.env.NEXT_PUBLIC_IS_CLOUD = false; + + await expect(createUser(newUser)).resolves.toMatchObject({ + id: 1, + email: newUser.email, + }); + + expect(mocks.transaction).toHaveBeenCalledOnce(); + expect(mocks.executeRaw).toHaveBeenCalledOnce(); + expect(mocks.executeRaw.mock.invocationCallOrder[0]!).toBeLessThan( + mocks.transactionUserFindFirst.mock.invocationCallOrder[0]!, + ); + expect(mocks.transactionInviteFindFirst).not.toHaveBeenCalled(); + expect(mocks.transactionUserCreate).toHaveBeenCalledWith({ + data: { + name: newUser.name, + email: newUser.email, + emailVerified: newUser.emailVerified, + image: newUser.image, + }, + }); + }); + + it("atomically creates an invited self-hosted user", async () => { + mocks.env.NEXT_PUBLIC_IS_CLOUD = false; + mocks.transactionUserFindFirst.mockResolvedValue({ id: 1 }); + mocks.transactionInviteFindFirst.mockResolvedValue({ id: "invite_1" }); + + await expect(createUser(newUser)).resolves.toMatchObject({ + id: 1, + email: newUser.email, + }); + + expect(mocks.transactionInviteFindFirst).toHaveBeenCalledWith({ + where: { email: newUser.email }, + select: { id: true }, + }); + expect(mocks.transactionUserCreate).toHaveBeenCalledWith({ + data: { + name: newUser.name, + email: newUser.email, + emailVerified: newUser.emailVerified, + image: newUser.image, + }, + }); + }); + + it("does not create an uninvited self-hosted user", async () => { + mocks.env.NEXT_PUBLIC_IS_CLOUD = false; + mocks.transactionUserFindFirst.mockResolvedValue({ id: 1 }); + + await expect(createUser(newUser)).rejects.toBeInstanceOf( + SelfHostedRegistrationError, + ); + + expect(mocks.transactionUserCreate).not.toHaveBeenCalled(); + }); + + it("does not create a self-hosted user without an email", async () => { + mocks.env.NEXT_PUBLIC_IS_CLOUD = false; + + await expect( + createUser({ ...newUser, email: "" }), + ).rejects.toBeInstanceOf(SelfHostedRegistrationError); + + expect(mocks.transaction).not.toHaveBeenCalled(); + }); + }); });