From 2dbc405f0fb6eadf62e66e23c7dc577d6a5d8001 Mon Sep 17 00:00:00 2001 From: RisingOrange Date: Sat, 2 May 2026 23:07:16 +0200 Subject: [PATCH] fix(auth): unblock invited users and bootstrap first admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related sign-in failures, both stemming from "user row exists in DB but auth flow refuses to proceed": 1. Invited users could not sign in. Auth.js throws OAuthAccountNotLinked before the signIn callback runs whenever an email matches an existing user with no linked account row — exactly the state every invited user is in. Re-enable allowDangerousEmailAccountLinking on the Google provider and compensate with a profile.email_verified === true guard plus the existing invite-only check. Trade-off documented in BUGS.md #24; structural fix tracked in #3. 2. First-admin chicken-and-egg. ADMIN_EMAILS auto-promotes existing users to admin in the jwt callback, but if no row exists the signIn callback rejects them as not-invited. Auto-create the user row when the email is in ADMIN_EMAILS, and add them to the Global workspace so hasWorkspaceMembership() callers don't treat them as non-members. Race-safe via onConflictDoNothing + re-SELECT. The ADMIN_EMAILS parser is hoisted to module scope so signIn and jwt callbacks share it instead of duplicating the same parse chain. --- BUGS.md | 6 ++--- src/lib/auth.ts | 70 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/BUGS.md b/BUGS.md index 4ad9722..b07d06c 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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` diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fb9f3fa..a7625f1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 @@ -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 @@ -197,13 +247,7 @@ 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) @@ -211,7 +255,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (dbUser) { const isEnvAdmin = dbUser.email - ? adminEmails.includes(dbUser.email.toLowerCase()) + ? ADMIN_EMAILS.includes(dbUser.email.toLowerCase()) : false; if (isEnvAdmin && dbUser.role !== "admin") {