From 0eaf809bc6826fac27ee190fb044e1e8725c83e3 Mon Sep 17 00:00:00 2001 From: Leivur Djurhuus Date: Mon, 6 Apr 2026 14:52:13 -0500 Subject: [PATCH] Add SSO bridge: Microsoft Entra ID auth with seed user linking Configure Microsoft Entra ID as the sole SSO provider with allowDangerousEmailAccountLinking to link SSO accounts to existing seeded user records by email match. Add signIn event for automatic org assignment by domain. Guard DEV_BYPASS_AUTH against production use. Add branded pending page for authenticated users without org membership. Remove Google provider for initial rollout simplicity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-06-sso-bridge-requirements.md | 68 +++++++++++ src/app/(app)/layout.tsx | 18 ++- src/app/(auth)/login/page.tsx | 16 --- src/app/(auth)/pending/page.tsx | 109 ++++++++++++++++++ src/lib/api-utils.ts | 4 +- src/lib/auth.ts | 35 +++++- src/middleware.ts | 10 +- 7 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 docs/brainstorms/2026-04-06-sso-bridge-requirements.md create mode 100644 src/app/(auth)/pending/page.tsx diff --git a/docs/brainstorms/2026-04-06-sso-bridge-requirements.md b/docs/brainstorms/2026-04-06-sso-bridge-requirements.md new file mode 100644 index 0000000..9ce4f87 --- /dev/null +++ b/docs/brainstorms/2026-04-06-sso-bridge-requirements.md @@ -0,0 +1,68 @@ +--- +date: 2026-04-06 +topic: sso-bridge +--- + +# SSO Bridge: Microsoft Entra ID for Production + +## Problem Frame + +Real producers and artists need to log into the HP CG Production Tracker using their Oliver Agency Microsoft accounts. The auth infrastructure exists (next-auth v5 with Microsoft Entra ID provider in `src/lib/auth.ts`) but is unconfigured — env vars are empty placeholders. The database has 33 seeded users with real `@oliver.agency` emails. When a real person signs in via SSO, their Account must link to the existing seeded User record (preserving role, skills, assignments) rather than creating a duplicate. Users who authenticate but aren't in the seed (e.g., someone from a different team) need a graceful experience instead of a raw 403. + +## Requirements + +**SSO Configuration** +- R1. Configure Microsoft Entra ID as the sole SSO provider for the initial rollout. Remove or disable the Google provider to avoid confusion. +- R2. Tenant-lock the Entra ID provider to the Oliver Agency tenant ID. Users from other Microsoft tenants must not be able to create accounts. + +**Seed-to-SSO User Linking** +- R3. When a user signs in via SSO, the PrismaAdapter must match on email and link the new Account/Session to the existing seeded User record. Verify this works correctly — the seeded users have no Account rows, only User rows with matching emails. +- R4. If a user signs in whose email does not match any existing User record, create a new User with `organizationId: null` (do not auto-assign to the org). This covers edge cases where someone outside the known roster authenticates. + +**Organization Auto-Assignment** +- R5. Add a `signIn` or post-authentication callback that checks if a newly created user's email domain matches an Organization's `domain` field. If it matches, auto-set `organizationId` on the User record. For seeded users who already have `organizationId` set, this is a no-op. + +**Limbo Page** +- R6. Users who authenticate via SSO but have no `organizationId` must see a branded "pending setup" page instead of a JSON 403 error. The page should display their name/email and a message like "Your account is pending setup — contact your admin." +- R7. The limbo page must be accessible to authenticated users only (not public). Unauthenticated users still redirect to login. + +**Production Safety** +- R8. Guard `DEV_BYPASS_AUTH` against production use: refuse to honor the flag when `NODE_ENV=production`. Log a warning if the flag is detected in production. + +## Success Criteria + +- A seeded user (e.g., `bohdana.phillips@oliver.agency`) can sign in via Microsoft SSO and immediately see the dashboard with their existing role, skills, and org membership intact. +- A non-seeded `@oliver.agency` user who signs in is auto-assigned to the org and sees the dashboard. +- A user from a different Microsoft tenant cannot sign in. +- A user who authenticates but has no org sees the limbo page, not a 403. +- `DEV_BYPASS_AUTH=true` has no effect when `NODE_ENV=production`. + +## Scope Boundaries + +- **Not in scope:** Google SSO — disabled for now, can be re-enabled later. +- **Not in scope:** Invitation flow — the Invitation model exists but wiring it up is separate work. +- **Not in scope:** Role assignment during SSO — users get whatever role is already in their seed record, or ARTIST by default for new users. +- **Not in scope:** Azure AD app registration — the user will configure this in Azure portal and provide the tenant ID, client ID, and client secret. + +## Key Decisions + +- **Microsoft-only for initial rollout:** Simplifies configuration and avoids needing Google domain restriction. Google can be re-enabled later. +- **Auto-assign by domain, not by invitation:** For a small team where everyone is `@oliver.agency`, domain matching is simpler than requiring invitations. The `Organization.domain` field already exists for this purpose. +- **Limbo page over auto-rejection:** Gracefully handling unknown users builds trust during rollout. An admin can later assign them to the org manually. + +## Dependencies / Assumptions + +- The user will register an Azure AD application in the Oliver Agency tenant and provide: tenant ID, client ID, client secret. +- The `Organization.domain` field is set to `oliver.agency` (done by the clean-slate toolkit). +- PrismaAdapter v5 links SSO accounts to existing users by email match. This is the documented behavior but must be verified with the actual seeded data. + +## Outstanding Questions + +### Deferred to Planning +- [Affects R3][Needs research] Verify PrismaAdapter's exact behavior when a user with a matching email but no Account row signs in via SSO. Does it link automatically, or does it create a duplicate User? +- [Affects R5][Technical] Determine the best next-auth callback for org auto-assignment: `signIn` event, `createUser` event, or `session` callback. +- [Affects R6][Technical] Determine where the limbo page routing should live: middleware redirect, layout-level check, or a dedicated route. + +## Next Steps + +-> `/ce:plan` for structured implementation planning diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 4e3c906..2d66be7 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,11 +1,27 @@ import { Suspense } from "react"; +import { redirect } from "next/navigation"; import { Toaster } from "sonner"; import { Sidebar, MobileSidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; import { QueryProvider } from "@/components/query-provider"; import { LazyCommandPalette } from "@/components/lazy-command-palette"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; -export default function AppLayout({ children }: { children: React.ReactNode }) { +export default async function AppLayout({ children }: { children: React.ReactNode }) { + // Skip org check in dev bypass mode + if (!(process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production")) { + 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"); + } + } + } return (
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index fc83799..40fa25b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -56,22 +56,6 @@ export default async function LoginPage() {
-
{ - "use server"; - await signIn("google", { redirectTo: "/dashboard" }); - }} - > - -
-
{ "use server"; diff --git a/src/app/(auth)/pending/page.tsx b/src/app/(auth)/pending/page.tsx new file mode 100644 index 0000000..98d06b1 --- /dev/null +++ b/src/app/(auth)/pending/page.tsx @@ -0,0 +1,109 @@ +import { auth, signOut } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { Button } from "@/components/ui/button"; + +export default async function PendingPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + // If user already has an org, redirect to dashboard + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { organizationId: true }, + }); + + if (user?.organizationId) { + redirect("/dashboard"); + } + + return ( +
+ {/* Left panel — green brand block */} +
+
+

+ Oliver Agency +

+
+
+

+ HP CG
Production
Tracker +

+

+ Pipeline management for CG production +

+
+
+

+ Brandtech Group +

+
+
+ + {/* Right panel — pending message */} +
+
+ {/* Mobile wordmark */} +
+

+ HP CG Production Tracker +

+

+ Oliver Agency +

+
+ +
+ 👋 +
+ +

+ Account Pending Setup +

+ +

+ Welcome, {session.user.name || session.user.email}. + Your sign-in was successful, but your account hasn't been added to an organization yet. +

+ +

+ Contact your production lead to get set up. +

+ +
+

+ Signed in as +

+

{session.user.email}

+
+ + { + "use server"; + await signOut({ redirectTo: "/login" }); + }} + className="mt-6" + > + + + +
+

+ © {new Date().getFullYear()} Oliver Agency · Brandtech Group +

+
+
+
+
+ ); +} diff --git a/src/lib/api-utils.ts b/src/lib/api-utils.ts index f5c883b..7116cdc 100644 --- a/src/lib/api-utils.ts +++ b/src/lib/api-utils.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; export async function getAuthSession() { - // Dev bypass: return a mock session pointing to the seeded dev user - if (process.env.DEV_BYPASS_AUTH === "true") { + // Dev bypass: return a mock session pointing to the seeded dev user (never in production) + if (process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production") { const devUserId = process.env.DEV_USER_ID ?? "dev-user-001"; return { session: { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index aeef523..20cc9ab 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,4 @@ import NextAuth from "next-auth"; -import Google from "next-auth/providers/google"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from "@/lib/prisma"; @@ -8,19 +7,45 @@ import type { Role } from "@/generated/prisma/client"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [ - Google({ - clientId: process.env.AUTH_GOOGLE_ID, - clientSecret: process.env.AUTH_GOOGLE_SECRET, - }), 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`, + // Safe for Entra ID — Microsoft verifies organizational emails. + // Required to link SSO accounts to pre-seeded User records by email match. + allowDangerousEmailAccountLinking: true, }), ], session: { strategy: "database", }, + events: { + async signIn({ user }) { + if (!user.id || !user.email) return; + + // Auto-assign organization by email domain match + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { organizationId: true, email: true }, + }); + + if (dbUser?.organizationId || !dbUser?.email) return; + + 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 }, + }); + } + }, + }, callbacks: { async session({ session, user }) { // Fetch user with role and org from database diff --git a/src/middleware.ts b/src/middleware.ts index c42183a..25dbe35 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,13 +2,14 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { - // Dev bypass: skip all auth checks for local testing - if (process.env.DEV_BYPASS_AUTH === "true") { + // Dev bypass: skip all auth checks for local testing (never in production) + if (process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production") { return NextResponse.next(); } const { pathname } = request.nextUrl; const isAuthPage = pathname.startsWith("/login"); + const isPendingPage = pathname.startsWith("/pending"); const isApiAuth = pathname.startsWith("/api/auth"); // Always allow auth API routes @@ -28,6 +29,11 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(new URL("/dashboard", request.url)); } + // Allow authenticated users to access the pending page + if (isPendingPage && isLoggedIn) { + return NextResponse.next(); + } + // Redirect unauthenticated users to login if (!isAuthPage && !isLoggedIn) { return NextResponse.redirect(new URL("/login", request.url));