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:
parent
ffbc5a2e31
commit
449b248323
1 changed files with 160 additions and 0 deletions
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue