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) <noreply@anthropic.com>
This commit is contained in:
Leivur Djurhuus 2026-04-06 17:25:58 -05:00
parent ffbc5a2e31
commit 449b248323

View file

@ -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)