From 449b248323a8296cf408f614b4375486ff49e53c Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Mon, 6 Apr 2026 17:25:58 -0500 Subject: [PATCH] Document SSO seed-user linking pattern for next-auth v5 Captures the allowDangerousEmailAccountLinking pattern for linking pre-seeded users to SSO accounts, org auto-assignment via signIn event, limbo page for unprovisioned users, and DEV_BYPASS_AUTH production guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...uth-v5-sso-seed-user-linking-2026-04-06.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/solutions/best-practices/next-auth-v5-sso-seed-user-linking-2026-04-06.md diff --git a/docs/solutions/best-practices/next-auth-v5-sso-seed-user-linking-2026-04-06.md b/docs/solutions/best-practices/next-auth-v5-sso-seed-user-linking-2026-04-06.md new file mode 100644 index 0000000..7ee8739 --- /dev/null +++ b/docs/solutions/best-practices/next-auth-v5-sso-seed-user-linking-2026-04-06.md @@ -0,0 +1,160 @@ +--- +title: "next-auth v5 SSO with Pre-Seeded Users — allowDangerousEmailAccountLinking Pattern" +date: 2026-04-06 +category: best-practices +module: authentication +problem_type: best_practice +component: authentication +severity: high +applies_when: + - Configuring next-auth v5 SSO with pre-seeded User records + - Linking OAuth accounts to existing users by email match + - Using PrismaAdapter with corporate SSO (Microsoft Entra ID, Google) +tags: + - next-auth + - sso + - microsoft-entra-id + - prisma-adapter + - email-account-linking + - seed-data + - authentication +--- + +# next-auth v5 SSO with Pre-Seeded Users — allowDangerousEmailAccountLinking Pattern + +## Context + +The HP CG Production Tracker had 33 pre-seeded users in the database (from `prisma/seed.ts`) with real `@oliver.agency` emails, assigned roles (PRODUCER, ARTIST), departments, skills, and task assignments. These User records had no Account rows in the Auth.js schema — they were created directly via Prisma, not through OAuth sign-in. + +When a real team member authenticated via Microsoft Entra ID SSO for the first time, PrismaAdapter's default behavior threw `OAuthAccountNotLinked` — it found the User by email but refused to link the new Account because no prior Account row existed. This is a security feature for untrusted providers, but overly restrictive for corporate SSO where email verification is guaranteed. + +## Guidance + +### 1. Enable email account linking on verified providers + +```typescript +// src/lib/auth.ts +MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: `https://login.microsoftonline.com/${process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID}/v2.0`, + allowDangerousEmailAccountLinking: true, +}), +``` + +Despite the alarming name, this is safe when the provider cryptographically verifies email ownership. Microsoft Entra ID and Google both do. The flag is per-provider — it doesn't globally disable security. + +**PrismaAdapter behavior with this flag:** +- Finds existing User by email → links new Account to that User (no duplicate created) +- `events.linkAccount` fires +- `events.signIn` fires with `isNewUser: false` + +**Without this flag:** +- Finds existing User by email → throws `OAuthAccountNotLinked` error +- User sees an error page, cannot sign in + +### 2. Auto-assign organization via signIn event + +```typescript +// src/lib/auth.ts +events: { + async signIn({ user }) { + if (!user.id || !user.email) return; + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { organizationId: true, email: true }, + }); + + if (dbUser?.organizationId || !dbUser?.email) return; // already assigned + + const domain = dbUser.email.split("@")[1]; + if (!domain) return; + + const org = await prisma.organization.findFirst({ where: { domain } }); + if (org) { + await prisma.user.update({ + where: { id: user.id }, + data: { organizationId: org.id }, + }); + } + }, +}, +``` + +This is idempotent — runs every sign-in but only writes when `organizationId` is null. Requires `Organization.domain` to be populated (e.g., `oliver.agency`). + +### 3. Add a limbo page for unprovisioned users + +Users who authenticate but have no `organizationId` should see a branded "pending setup" page, not a JSON 403. The check lives in the `(app)` layout: + +```typescript +// src/app/(app)/layout.tsx +const session = await auth(); +if (session?.user?.id) { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { organizationId: true }, + }); + if (!user?.organizationId) { + redirect("/pending"); + } +} +``` + +### 4. Guard DEV_BYPASS_AUTH against production + +```typescript +// In middleware.ts and api-utils.ts +if (process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production") { + // bypass auth +} +``` + +Both conditions must be true. The health endpoint also flags this as a failure if detected in production. + +## Why This Matters + +**Preserves pre-seeded data.** Real team members sign in and immediately have correct role, department, skills, and task assignments. No re-entry, no admin intervention needed. + +**No duplicate users — ever.** PrismaAdapter never creates a second User for the same email. It either links (with the flag) or errors (without it). This is enforced by the `User.email @unique` constraint. + +**Graceful unknown user handling.** Users who authenticate but aren't in the roster see a friendly page instead of a cryptic 403. An admin can assign them later. + +## When to Apply + +- SSO users should link to pre-seeded User records +- The OAuth provider verifies email ownership (corporate SSO, major cloud providers) +- Users may not have an organization assigned immediately upon sign-in +- Deployment distinguishes dev and production environments + +**Do NOT use when:** +- The provider does not verify email (open OAuth, unverified third-party sign-ups) +- You want to prevent account merging entirely (use Auth.js default behavior) + +## Examples + +**Seeded user signs in for the first time:** +1. User `bohdana.phillips@oliver.agency` (role: PRODUCER, org: Oliver Agency) exists in DB with no Account row +2. User clicks "Sign in with Microsoft" → Entra ID authenticates and returns email +3. PrismaAdapter finds User by email, `allowDangerousEmailAccountLinking` allows linking +4. New Account row created, linked to existing User +5. `signIn` event checks organizationId — already set, no-op +6. User sees dashboard with their producer role and all existing data + +**Unknown user signs in:** +1. User `contractor@oliver.agency` authenticates via Entra ID (valid tenant member) +2. PrismaAdapter creates new User + Account (no existing email match) +3. `signIn` event matches `oliver.agency` domain → auto-assigns organizationId +4. User sees dashboard with default ARTIST role + +**User from wrong tenant:** +1. User `someone@othercorp.com` cannot authenticate — Entra ID rejects the sign-in at the provider level (tenant-locked via issuer URL) +2. Never reaches the app + +## Related + +- Origin requirements: `docs/brainstorms/2026-04-06-sso-bridge-requirements.md` +- Related solution: `docs/solutions/workflow-issues/production-data-migration-purge-reseed-2026-04-06.md` (database prep for rollout) +- Rollout ideation: `docs/ideation/2026-04-06-production-rollout-ideation.md` (ideas #3 and #4) +- Auth.js source: `node_modules/@auth/core/src/lib/actions/callback/handle-login.ts` (account-linking logic)