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) <noreply@anthropic.com>
This commit is contained in:
parent
4149b2cf40
commit
0eaf809bc6
7 changed files with 234 additions and 26 deletions
68
docs/brainstorms/2026-04-06-sso-bridge-requirements.md
Normal file
68
docs/brainstorms/2026-04-06-sso-bridge-requirements.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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 (
|
||||
<QueryProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -56,22 +56,6 @@ export default async function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("google", { redirectTo: "/dashboard" });
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]"
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
|
|
|||
109
src/app/(auth)/pending/page.tsx
Normal file
109
src/app/(auth)/pending/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex min-h-screen bg-[var(--background)]">
|
||||
{/* Left panel — green brand block */}
|
||||
<div className="hidden w-[40%] flex-col justify-between bg-[var(--primary)] p-10 md:flex">
|
||||
<div>
|
||||
<p className="text-[9px] font-bold tracking-[0.2em] uppercase text-[var(--primary-foreground)]/60">
|
||||
Oliver Agency
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-heading text-4xl font-black leading-[1.05] tracking-[-0.03em] text-[var(--primary-foreground)]">
|
||||
HP CG<br />Production<br />Tracker
|
||||
</h1>
|
||||
<p className="mt-4 text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--primary-foreground)]/60">
|
||||
Pipeline management for CG production
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-semibold tracking-[0.15em] uppercase text-[var(--primary-foreground)]/40">
|
||||
Brandtech Group
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — pending message */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-12">
|
||||
<div className="w-full max-w-[400px] text-center">
|
||||
{/* Mobile wordmark */}
|
||||
<div className="mb-10 md:hidden">
|
||||
<h1 className="font-heading text-2xl font-black tracking-[-0.02em]">
|
||||
HP CG Production Tracker
|
||||
</h1>
|
||||
<p className="mt-1 text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--muted-foreground)]">
|
||||
Oliver Agency
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-4xl">
|
||||
<span role="img" aria-label="wave">👋</span>
|
||||
</div>
|
||||
|
||||
<h2 className="font-heading text-lg font-black tracking-[-0.02em]">
|
||||
Account Pending Setup
|
||||
</h2>
|
||||
|
||||
<p className="mt-3 text-sm text-[var(--muted-foreground)]">
|
||||
Welcome, <span className="font-semibold text-[var(--foreground)]">{session.user.name || session.user.email}</span>.
|
||||
Your sign-in was successful, but your account hasn't been added to an organization yet.
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm text-[var(--muted-foreground)]">
|
||||
Contact your production lead to get set up.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-3">
|
||||
<p className="text-[10px] font-semibold tracking-[0.08em] uppercase text-[var(--muted-foreground)]">
|
||||
Signed in as
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{session.user.email}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
className="mt-6"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-10 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase"
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-10 border-t pt-6">
|
||||
<p className="text-[9px] font-semibold tracking-[0.12em] uppercase text-[var(--muted-foreground)]/60">
|
||||
© {new Date().getFullYear()} Oliver Agency · Brandtech Group
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue