Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ REDIS_URL=redis://localhost:6379
RESEND_API_KEY=re_...
EMAIL_FROM=OpenReply <login@example.com>

# 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).
Expand Down
31 changes: 31 additions & 0 deletions __tests__/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import {
getEncryptionKeyHex,
getMetaGraphApiVersion,
isEmailAllowedToSignIn,
requireEnv,
} from "../lib/env";

Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 6 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof PrismaAdapter>[0];

Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down