diff --git a/.env.example b/.env.example index b957ddc0..7c0b70df 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ REDIS_URL=redis://localhost:6379 RESEND_API_KEY=re_... EMAIL_FROM=OpenReply +# Optional: restrict who can sign in. Without it, anyone who reaches your +# public URL can request a magic link and gets their own workspace. Comma +# separated, case insensitive. +# ALLOWED_EMAILS=you@example.com,teammate@example.com + # Optional: use your own SMTP server instead of Resend. When EMAIL_SERVER is # set it takes over and RESEND_API_KEY is not needed. URL-encode special # characters in user and password (@ becomes %40). diff --git a/__tests__/env.test.ts b/__tests__/env.test.ts index d0648999..2bfc79d2 100644 --- a/__tests__/env.test.ts +++ b/__tests__/env.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { getEncryptionKeyHex, getMetaGraphApiVersion, + isEmailAllowedToSignIn, requireEnv, } from "../lib/env"; @@ -29,3 +30,33 @@ describe("environment helpers", () => { expect(getMetaGraphApiVersion()).toBe("v26.0"); }); }); + +describe("sign-in allowlist", () => { + it("allows everyone when ALLOWED_EMAILS is unset", () => { + expect(isEmailAllowedToSignIn("anyone@example.com")).toBe(true); + }); + + it("allows everyone when ALLOWED_EMAILS is empty or only separators", () => { + vi.stubEnv("ALLOWED_EMAILS", " , , "); + expect(isEmailAllowedToSignIn("anyone@example.com")).toBe(true); + }); + + it("only allows listed addresses once ALLOWED_EMAILS is set", () => { + vi.stubEnv("ALLOWED_EMAILS", "owner@example.com,team@example.com"); + expect(isEmailAllowedToSignIn("owner@example.com")).toBe(true); + expect(isEmailAllowedToSignIn("team@example.com")).toBe(true); + expect(isEmailAllowedToSignIn("stranger@example.com")).toBe(false); + }); + + it("ignores case and surrounding whitespace on both sides", () => { + vi.stubEnv("ALLOWED_EMAILS", " Owner@Example.com , team@example.com "); + expect(isEmailAllowedToSignIn("OWNER@example.COM")).toBe(true); + }); + + it("rejects a missing address when the list is set", () => { + vi.stubEnv("ALLOWED_EMAILS", "owner@example.com"); + expect(isEmailAllowedToSignIn(null)).toBe(false); + expect(isEmailAllowedToSignIn(undefined)).toBe(false); + expect(isEmailAllowedToSignIn("")).toBe(false); + }); +}); diff --git a/docs/setup.md b/docs/setup.md index 3ce249ae..6d54bc19 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -94,6 +94,7 @@ Copy `.env.example` to `.env` for local work, or set these in Vercel and Railway | `REDIS_URL` | Redis connection string. Must support blocking commands, so an HTTP-only Redis will not work with BullMQ. | | `RESEND_API_KEY` | Resend key. Login is email magic links only, so without this nobody can sign in. | | `EMAIL_FROM` | A sender on a domain you verified in Resend. The placeholder will not deliver. | +| `ALLOWED_EMAILS` | Optional. Comma-separated allowlist of addresses that may sign in, case insensitive. Unset, anyone who reaches your public URL can request a magic link and gets their own workspace, which is worth closing on an instance you run for yourself. | | `EMAIL_SERVER` | Optional. An SMTP URL, for example `smtps://login%40example.com:password@mail.example.com:465`. Set it to send magic links through your own mail server instead of Resend; then `RESEND_API_KEY` is not needed. URL-encode special characters in the user and password (`@` becomes `%40`). Port 465 with `smtps://` is implicit TLS, port 587 with `smtp://` is STARTTLS. | | `META_GRAPH_API_VERSION` | Graph API version, for example `v25.0`. | | `INSTAGRAM_APP_ID` | From the Meta app, see Step 6. | diff --git a/lib/auth.ts b/lib/auth.ts index bfee5c63..2b69660b 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -4,6 +4,7 @@ import Resend from "next-auth/providers/resend"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from "@/lib/db/client"; import { ensureWorkspaceForUser, getPrimaryWorkspace } from "@/lib/workspace"; +import { isEmailAllowedToSignIn } from "@/lib/env"; type AdapterPrismaClient = Parameters[0]; @@ -30,6 +31,11 @@ export const authConfig = { }), ], callbacks: { + // Runs before the magic link is sent, so a blocked address never receives + // one, and again when the link is verified. + async signIn({ user }) { + return isEmailAllowedToSignIn(user?.email); + }, async session({ session, user }) { if (session.user) { session.user.id = user.id; diff --git a/lib/env.ts b/lib/env.ts index a4994492..1e980bb8 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -50,6 +50,28 @@ export function getMetaGraphApiVersion(): string { return process.env.META_GRAPH_API_VERSION ?? "v25.0"; } +/** + * Optional sign-in allowlist. + * + * A self-hosted instance on a public domain is open to signup: the email + * provider creates an account for whoever asks for a magic link, and that + * account gets its own workspace. ALLOWED_EMAILS closes it to a comma-separated + * list of addresses. Left unset, sign-in behaves exactly as before, so an + * existing deployment is unaffected. + */ +export function isEmailAllowedToSignIn( + email: string | null | undefined +): boolean { + const allowed = (process.env.ALLOWED_EMAILS ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + if (allowed.length === 0) return true; + if (!email) return false; + return allowed.includes(email.toLowerCase()); +} + export const serverEnvSchema = z.object({ NEXTAUTH_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(16),