feat(auth): add Microsoft 365 SSO via Azure Entra ID
Adds the Microsoft Entra ID provider to NextAuth alongside the existing credentials provider. Sign-in is restricted to Pelagia Marine's M365 tenant via the issuer URL; access is further gated by requiring a matching active user record in the DB (DB-managed roles remain unchanged). - auth.ts: add MicrosoftEntra provider, signIn callback (DB lookup), async jwt callback to populate id/role on first SSO sign-in - login-form.tsx: add primary "Sign in with Microsoft 365" button with Microsoft logo; credentials form kept as a fallback below a divider - prisma: make passwordHash nullable (migration applied) to allow SSO-only users without a local password - admin/users: password is now optional when creating a user — leave blank for SSO-only accounts - profile/actions: return a clear error if an SSO user (no passwordHash) attempts to use the change-password form - .env.example: document AZURE_AD_CLIENT_ID/SECRET/TENANT_ID Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d8f376949
commit
56817a7d86
8 changed files with 135 additions and 58 deletions
|
|
@ -15,6 +15,14 @@
|
|||
NEXTAUTH_SECRET=your-32-char-secret-here-generate-with-openssl
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# ── Microsoft Entra ID (Azure AD) SSO ────────────────────────
|
||||
# Register an app at https://entra.microsoft.com
|
||||
# Required redirect URI: {NEXTAUTH_URL}/api/auth/callback/microsoft-entra-id
|
||||
# Grant: openid, profile, email (Microsoft Graph delegated permissions)
|
||||
AZURE_AD_CLIENT_ID=your-azure-app-client-id
|
||||
AZURE_AD_CLIENT_SECRET=your-azure-app-client-secret
|
||||
AZURE_AD_TENANT_ID=your-azure-tenant-id
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────
|
||||
# Local PostgreSQL or Supabase
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
|
||||
|
|
|
|||
|
|
@ -9,10 +9,16 @@ export function LoginForm() {
|
|||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoLoading, setSsoLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
|
||||
async function handleSso() {
|
||||
setSsoLoading(true);
|
||||
await signIn("microsoft-entra-id", { callbackUrl });
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
|
@ -34,58 +40,90 @@ export function LoginForm() {
|
|||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
type="button"
|
||||
onClick={handleSso}
|
||||
disabled={ssoLoading || loading}
|
||||
className="w-full flex items-center justify-center gap-3 rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
<MicrosoftLogo />
|
||||
{ssoLoading ? "Redirecting…" : "Sign in with Microsoft 365"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-neutral-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-white px-3 text-neutral-400">or sign in with password</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
placeholder="you@pelagiamarine.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || ssoLoading}
|
||||
className="w-full rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MicrosoftLogo() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ export async function createUser(formData: FormData): Promise<ActionResult> {
|
|||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
|
||||
const data = parsed.data;
|
||||
if (!data.password) return { error: "Password is required for new users" };
|
||||
|
||||
const exists = await db.user.findFirst({
|
||||
where: { email: data.email },
|
||||
|
|
@ -48,7 +47,7 @@ export async function createUser(formData: FormData): Promise<ActionResult> {
|
|||
});
|
||||
const employeeId = nextId(prefix, existingIds.map((u) => u.employeeId));
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : null;
|
||||
await db.user.create({
|
||||
data: {
|
||||
employeeId,
|
||||
|
|
|
|||
|
|
@ -62,10 +62,9 @@ function UserFormFields({ user }: { user?: UserRow }) {
|
|||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">
|
||||
{user ? "New Password (leave blank to keep current)" : "Password *"}
|
||||
{user ? "New Password (leave blank to keep current)" : "Password (leave blank for SSO-only users)"}
|
||||
</label>
|
||||
<input name="password" type="password" minLength={8}
|
||||
required={!user}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export async function changePassword(formData: FormData): Promise<Result> {
|
|||
select: { passwordHash: true },
|
||||
});
|
||||
if (!user) return { error: "User not found" };
|
||||
if (!user.passwordHash) return { error: "Password change is not available for accounts that sign in via Microsoft 365." };
|
||||
|
||||
const valid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash);
|
||||
if (!valid) return { error: "Current password is incorrect" };
|
||||
|
|
|
|||
35
App/auth.ts
35
App/auth.ts
|
|
@ -1,5 +1,6 @@
|
|||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import MicrosoftEntra from "next-auth/providers/microsoft-entra-id";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "@/lib/db";
|
||||
import { loginSchema } from "@/lib/validations/user";
|
||||
|
|
@ -13,6 +14,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
error: "/login",
|
||||
},
|
||||
providers: [
|
||||
MicrosoftEntra({
|
||||
clientId: process.env.AZURE_AD_CLIENT_ID!,
|
||||
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
|
||||
// Restricts sign-in to Pelagia Marine's M365 tenant. Without this,
|
||||
// any Microsoft account (personal or other org) could authenticate.
|
||||
issuer: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}/v2.0`,
|
||||
}),
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
|
|
@ -25,7 +33,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
const user = await db.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
});
|
||||
if (!user || !user.isActive) return null;
|
||||
if (!user || !user.isActive || !user.passwordHash) return null;
|
||||
|
||||
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
|
||||
if (!valid) return null;
|
||||
|
|
@ -35,13 +43,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
async signIn({ account, profile }) {
|
||||
if (account?.provider !== "microsoft-entra-id") return true;
|
||||
|
||||
const email = profile?.email ?? (profile as Record<string, unknown>)?.preferred_username as string;
|
||||
if (!email) return false;
|
||||
|
||||
const dbUser = await db.user.findUnique({ where: { email } });
|
||||
return !!(dbUser?.isActive);
|
||||
},
|
||||
|
||||
async jwt({ token, user, account }) {
|
||||
if (account?.provider === "credentials" && user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as unknown as { role: Role }).role;
|
||||
}
|
||||
if (account?.provider === "microsoft-entra-id") {
|
||||
const email = token.email;
|
||||
if (email) {
|
||||
const dbUser = await db.user.findUnique({ where: { email } });
|
||||
if (dbUser) {
|
||||
token.id = dbUser.id;
|
||||
token.role = dbUser.role;
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
session({ session, token }) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as Role;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- Make passwordHash nullable to support SSO users who authenticate via Microsoft Entra
|
||||
-- and do not have a local password.
|
||||
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||
|
|
@ -62,7 +62,7 @@ model User {
|
|||
employeeId String @unique
|
||||
email String @unique
|
||||
name String
|
||||
passwordHash String
|
||||
passwordHash String?
|
||||
role Role
|
||||
isActive Boolean @default(true)
|
||||
signatureKey String?
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue