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
6 changes: 3 additions & 3 deletions BUGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ Changed to static import alongside other drizzle-orm imports.

### Critical

#### 24. ~~`allowDangerousEmailAccountLinking` enables account takeover~~FIXED
**File:** `src/lib/auth.ts:21`
The Google OAuth provider was configured with `allowDangerousEmailAccountLinking: true`, allowing automatic account linking when a new Google sign-in matches an existing user's email. An attacker who controls a Google account with a victim's email could log in as the victim. Removed the flag.
#### 24. `allowDangerousEmailAccountLinking` enables account takeover — REOPENED & MITIGATED (2026-05-01)
**File:** `src/lib/auth.ts`
Originally removed the flag, which broke the invite flow: Auth.js throws `OAuthAccountNotLinked` before the `signIn` callback runs whenever a user row exists by email but has no linked OAuth `account` row — exactly the state every invited user is in. Re-enabled `allowDangerousEmailAccountLinking: true` on the Google provider and added two compensating controls in the `signIn` callback: (a) reject unless `profile.email_verified === true` (Google's verified-ownership signal), (b) keep the invite-only check (email must already exist in `users`). Residual risks for invitees on custom domains: expired-domain takeover (attacker registers the domain after expiry, creates a Google Workspace, recreates the email) and Workspace mailbox reassignment (org admin transfers an email to a different person). Gmail addresses aren't vulnerable (Google doesn't recycle them); other public providers depend on that provider's account-reuse policy. Issue #3 tracks the structural fix (move invite state to `pending_invites` so the dangerous flag isn't needed).

#### 25. ~~Unauthenticated Mailersend webhook — campaign metric poisoning~~ — FIXED
**File:** `src/app/api/webhooks/mailersend/route.ts`
Expand Down
70 changes: 57 additions & 13 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ import { eq } from "drizzle-orm";
import type { UserRole } from "@/db/schema/users";
import type { Provider } from "next-auth/providers";
import { userWorkspaces, workspaces } from "@/db/schema/workspaces";
import { addUserToWorkspace } from "@/lib/workspaces";

const isDev = process.env.NODE_ENV === "development";

const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);

const providers: Provider[] = [
Google({}),
// Required so invited users (no `account` row yet) can link via Google
// on first sign-in; the email_verified guard + invite-only check below
// are the compensating controls. See BUGS.md #24.
Google({ allowDangerousEmailAccountLinking: true }),
];

// Dev-only Credentials provider — lets you sign in as any email
Expand Down Expand Up @@ -162,18 +171,59 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return true;
}

// Invite-only: only allow sign-in if the user's email already exists in the DB
// Pairs with allowDangerousEmailAccountLinking on the Google provider
// — Google's email_verified flag is what makes the auto-link safe.
if (account?.provider === "google" && profile?.email_verified !== true) {
return "/login?error=email_not_verified";
}

const email = user.email || profile?.email;
if (!email) return false;

const [existingUser] = await db
const normalizedEmail = email.toLowerCase();
let [existingUser] = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.email, email.toLowerCase()));
.where(eq(users.email, normalizedEmail));

if (!existingUser) {
// Redirect to login with error message
return "/login?error=not_invited";
if (!ADMIN_EMAILS.includes(normalizedEmail)) {
return "/login?error=not_invited";
}
// Fail closed if Global is missing — otherwise we'd create a user row
// with no workspace membership that subsequent sign-ins won't repair
// (existingUser would be truthy, skipping this block entirely).
const [globalWs] = await db
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.type, "global"))
.limit(1);
if (!globalWs) {
console.error(
`[auth] Cannot bootstrap admin ${normalizedEmail}: no Global workspace exists. Run \`npm run db:seed\`.`
);
return "/login?error=server_misconfigured";
}
// onConflictDoNothing covers concurrent first sign-ins for the same
// email (e.g. double-click). RETURNING gives us the row directly when
// we won the race; the SELECT fallback picks it up otherwise.
const [created] = await db
.insert(users)
.values({ email: normalizedEmail, role: "admin" })
.onConflictDoNothing({ target: users.email })
.returning({ id: users.id, name: users.name });
if (created) {
existingUser = created;
} else {
[existingUser] = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.email, normalizedEmail));
}

if (existingUser) {
await addUserToWorkspace(existingUser.id, globalWs.id, "admin");
}
}

// Update name and image from Google profile on first sign-in
Expand All @@ -197,21 +247,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.id = user.id;
}

// Always refresh role from DB + ADMIN_EMAILS env var
if (token.id) {
const adminEmails = (process.env.ADMIN_EMAILS ?? "")
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);

const [dbUser] = await db
.select({ role: users.role, email: users.email })
.from(users)
.where(eq(users.id, token.id as string));

if (dbUser) {
const isEnvAdmin = dbUser.email
? adminEmails.includes(dbUser.email.toLowerCase())
? ADMIN_EMAILS.includes(dbUser.email.toLowerCase())
: false;

if (isEnvAdmin && dbUser.role !== "admin") {
Expand Down