pelagia-portal/App/auth.ts
Hardik 56817a7d86 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>
2026-05-28 22:48:37 +05:30

91 lines
2.7 KiB
TypeScript

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";
import type { Role } from "@prisma/client";
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
session: { strategy: "jwt" },
pages: {
signIn: "/login",
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" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const user = await db.user.findUnique({
where: { email: parsed.data.email },
});
if (!user || !user.isActive || !user.passwordHash) return null;
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
callbacks: {
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;
return session;
},
},
});
declare module "next-auth" {
interface Session {
user: {
id: string;
name: string;
email: string;
role: Role;
};
}
}