assessment-model/src/app/api/auth/[...nextauth]/authOptions.ts
2025-11-13 22:30:11 +00:00

274 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import EmailProvider from "next-auth/providers/email";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
import { db } from "@/app/db/db";
import {
user as users,
accounts,
verificationTokens,
} from "@/app/db/schema/users";
import { eq, and } from "drizzle-orm";
// ------------------------------------------------------------------
// Environment variables
// ------------------------------------------------------------------
const {
GOOGLE_CLIENT_ID = "",
GOOGLE_CLIENT_SECRET = "",
AZURE_AD_B2C_TENANT_NAME = "",
AZURE_AD_B2C_CLIENT_ID = "",
AZURE_AD_B2C_CLIENT_SECRET = "",
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
EMAIL_SERVER_HOST = "",
EMAIL_SERVER_PORT = "",
EMAIL_SERVER_USER = "",
EMAIL_SERVER_PASSWORD = "",
EMAIL_FROM = "",
} = process.env;
type OauthProvider = "google" | "azure-ad-b2c";
// ------------------------------------------------------------------
// NextAuth configuration
// ------------------------------------------------------------------
export const AuthOptions: NextAuthOptions = {
adapter: DrizzleEmailAdapter(db, {
user: users,
accounts,
verificationTokens,
}),
providers: [
// ------------------ Google ------------------
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
authorization: {
params: {
access_type: "offline",
prompt: "consent",
response_type: "code",
},
},
}),
// ------------------ Azure AD B2C ------------------
AzureADB2CProvider({
tenantId: AZURE_AD_B2C_TENANT_NAME,
clientId: AZURE_AD_B2C_CLIENT_ID,
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
authorization: {
params: {
scope: "openid profile offline_access",
prompt: "login",
},
},
}),
// ------------------ Email (SES Magic Link) ------------------
EmailProvider({
server: {
host: EMAIL_SERVER_HOST,
port: Number(EMAIL_SERVER_PORT),
auth: {
user: EMAIL_SERVER_USER,
pass: EMAIL_SERVER_PASSWORD,
},
},
from: EMAIL_FROM, // noreply email
maxAge: 60 * 60, // magic link valid for 1 hour
// Slightly extended magic link email sender, to log sends
sendVerificationRequest: async ({ identifier, url, provider }) => {
console.log("EMAIL MAGIC LINK SENT:", {
email: identifier,
url,
timestamp: new Date().toISOString(),
});
await MagicLinksEmail({ identifier, url, provider });
},
}),
],
// ------------------------------------------------------------------
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/", // your landing/login page
verifyRequest: "/auth/verify-request",
error: "/api/auth/error",
},
// ------------------------------------------------------------------
// Callbacks
// ------------------------------------------------------------------
callbacks: {
/**
* Sign in callback — ensures user exists and links OAuth provider if needed
*/
async signIn({ user, account, profile }) {
try {
if (!user?.email) return false;
const normalisedEmail = user.email.toLowerCase();
console.log("SignIn attempt:", {
email: normalisedEmail,
provider: account?.provider,
type: account?.type,
timestamp: new Date().toISOString(),
});
// Fetch the user (NextAuth will have created them already if new)
const [dbUser] = await db
.select()
.from(users)
.where(eq(users.email, normalisedEmail));
// New user - next auth will handle
if (!dbUser) {
console.log("New user created through sign-in:", {
email: normalisedEmail,
provider: account?.provider,
});
return true;
}
// Auto-link provider if same verified email but account not linked yet
if (account?.provider && account.type === "oauth") {
const existingLink = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.userId, dbUser.id),
eq(accounts.provider, account.provider)
)
);
const emailVerified =
(profile as any)?.email_verified ?? account.provider === "google";
if (existingLink.length === 0 && emailVerified) {
// This handles the case where we had not set up accounts but
// signed up users with oauth
console.log(
`Linking ${account.provider} account for user ${normalisedEmail}`
);
await db
.insert(accounts)
.values({
userId: dbUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
id_token: account.id_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
})
.onConflictDoNothing();
}
}
// Link OAuth ID if missing (helps for older accounts)
if (account && !dbUser.oauthId) {
console.log("Backfilling OAuth ID:", {
email: normalisedEmail,
provider: account.provider,
});
const provider = account.provider as OauthProvider;
await db
.update(users)
.set({ oauthId: user.id, oauthProvider: provider })
.where(eq(users.email, normalisedEmail));
}
// Always update last login timestamp
await db
.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
user.onboarded = dbUser.onboarded ?? false;
return true;
} catch (error) {
console.error("Error during sign-in:", error);
return false;
}
},
/**
* Persist dbId in the JWT so its available in sessions
*/
async jwt({ token, user: userFromLogin }) {
// Initial sign-in: attach DB fields
if (userFromLogin) {
console.log("JWT initial login:", {
email: userFromLogin.email,
timestamp: new Date().toISOString(),
});
const existing = await db.query.user.findFirst({
where: eq(users.email, userFromLogin.email!),
});
if (existing) {
token.onboarded = existing.onboarded;
}
} else if (token.email) {
// On subsequent calls, keep token synced from DB
const existing = await db.query.user.findFirst({
where: eq(users.email, token.email),
});
if (existing) {
token.onboarded = existing.onboarded;
}
}
if (userFromLogin?.dbId) {
token.dbId = userFromLogin.dbId;
}
return token;
},
/**
* Attach dbId to session.user
*/
async session({ session, token }) {
if (session.user && token.dbId) {
session.user.dbId = token.dbId;
}
return session;
},
/**
* Redirect users after login
*/
async redirect({ url, baseUrl }) {
// If the user has not onboarded, send them to onboarding
console.log("Redirect triggered:", {
from: url,
to: `${baseUrl}/home`,
timestamp: new Date().toISOString(),
});
return `${baseUrl}/home`;
},
},
// ------------------------------------------------------------------
// General options
// ------------------------------------------------------------------
session: {
strategy: "jwt", // lightweight, avoids DB session writes
},
jwt: {
maxAge: 60 * 60 * 24 * 30, // 30 days
},
debug: false,
};