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:
Hardik 2026-05-28 22:48:37 +05:30
parent 6d8f376949
commit 56817a7d86
8 changed files with 135 additions and 58 deletions

View file

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

View file

@ -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,6 +40,26 @@ export function LoginForm() {
}
return (
<div className="space-y-5">
<button
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"
>
<MicrosoftLogo />
{ssoLoading ? "Redirecting…" : "Sign in with Microsoft 365"}
</button>
<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
@ -50,7 +76,7 @@ export function LoginForm() {
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"
placeholder="you@pelagiamarine.com"
/>
</div>
@ -81,11 +107,23 @@ export function LoginForm() {
<button
type="submit"
disabled={loading}
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>
);
}

View file

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

View file

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

View file

@ -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" };

View file

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

View file

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

View file

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