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_SECRET=your-32-char-secret-here-generate-with-openssl
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
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 ──────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────
|
||||||
# Local PostgreSQL or Supabase
|
# Local PostgreSQL or Supabase
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,16 @@ export function LoginForm() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [ssoLoading, setSsoLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
|
|
||||||
|
async function handleSso() {
|
||||||
|
setSsoLoading(true);
|
||||||
|
await signIn("microsoft-entra-id", { callbackUrl });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -34,6 +40,26 @@ export function LoginForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|
@ -50,7 +76,7 @@ export function LoginForm() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -81,11 +107,23 @@ export function LoginForm() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"}
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
if (!data.password) return { error: "Password is required for new users" };
|
|
||||||
|
|
||||||
const exists = await db.user.findFirst({
|
const exists = await db.user.findFirst({
|
||||||
where: { email: data.email },
|
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 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({
|
await db.user.create({
|
||||||
data: {
|
data: {
|
||||||
employeeId,
|
employeeId,
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,9 @@ function UserFormFields({ user }: { user?: UserRow }) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">
|
<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>
|
</label>
|
||||||
<input name="password" type="password" minLength={8}
|
<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" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export async function changePassword(formData: FormData): Promise<Result> {
|
||||||
select: { passwordHash: true },
|
select: { passwordHash: true },
|
||||||
});
|
});
|
||||||
if (!user) return { error: "User not found" };
|
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);
|
const valid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash);
|
||||||
if (!valid) return { error: "Current password is incorrect" };
|
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 NextAuth from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import MicrosoftEntra from "next-auth/providers/microsoft-entra-id";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { loginSchema } from "@/lib/validations/user";
|
import { loginSchema } from "@/lib/validations/user";
|
||||||
|
|
@ -13,6 +14,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
error: "/login",
|
error: "/login",
|
||||||
},
|
},
|
||||||
providers: [
|
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({
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
|
|
@ -25,7 +33,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email: parsed.data.email },
|
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);
|
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
|
||||||
if (!valid) return null;
|
if (!valid) return null;
|
||||||
|
|
@ -35,13 +43,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt({ token, user }) {
|
async signIn({ account, profile }) {
|
||||||
if (user) {
|
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.id = user.id;
|
||||||
token.role = (user as unknown as { role: Role }).role;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
session({ session, token }) {
|
session({ session, token }) {
|
||||||
session.user.id = token.id as string;
|
session.user.id = token.id as string;
|
||||||
session.user.role = token.role as Role;
|
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
|
employeeId String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
passwordHash String
|
passwordHash String?
|
||||||
role Role
|
role Role
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
signatureKey String?
|
signatureKey String?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue