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 it’s 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, };