From 8ca14c39adb09d12440b9cf91501a3b4c65f7afd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Oct 2025 11:49:19 +0000 Subject: [PATCH] Fixing auth options export issue --- src/app/api/auth/[...nextauth]/authOptions.ts | 243 +++++++++++++++++ src/app/api/auth/[...nextauth]/route.ts | 245 +----------------- src/app/api/user/onboarded/route.ts | 2 +- src/app/api/user/profile/route.ts | 2 +- src/app/home/page.tsx | 2 +- src/app/layout.tsx | 2 +- src/app/page.tsx | 2 +- 7 files changed, 250 insertions(+), 248 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/authOptions.ts diff --git a/src/app/api/auth/[...nextauth]/authOptions.ts b/src/app/api/auth/[...nextauth]/authOptions.ts new file mode 100644 index 00000000..2d08ebaa --- /dev/null +++ b/src/app/api/auth/[...nextauth]/authOptions.ts @@ -0,0 +1,243 @@ +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 + sendVerificationRequest: MagicLinksEmail, + }), + ], + + // ------------------------------------------------------------------ + // Pages + // ------------------------------------------------------------------ + pages: { + signIn: "/", // your landing/login page + verifyRequest: "/auth/verify-request", + error: "/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(); + + // 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 sign up for email:", normalisedEmail); + 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("Linking OAuth ID for user:", normalisedEmail); + 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) { + 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({ baseUrl }) { + // If the user has not onboarded, send them to onboarding + return `${baseUrl}/home`; + }, + }, + + // ------------------------------------------------------------------ + // General options + // ------------------------------------------------------------------ + session: { + strategy: "jwt", // lightweight, avoids DB session writes + }, + jwt: { + maxAge: 60 * 60 * 24 * 30, // 30 days + }, + debug: false, +}; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 9aeccbe2..bdd45b94 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,246 +1,5 @@ -import NextAuth, { 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 - sendVerificationRequest: MagicLinksEmail, - }), - ], - - // ------------------------------------------------------------------ - // Pages - // ------------------------------------------------------------------ - pages: { - signIn: "/", // your landing/login page - verifyRequest: "/auth/verify-request", - error: "/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(); - - // 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 sign up for email:", normalisedEmail); - 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("Linking OAuth ID for user:", normalisedEmail); - 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) { - 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({ baseUrl }) { - // If the user has not onboarded, send them to onboarding - return `${baseUrl}/home`; - }, - }, - - // ------------------------------------------------------------------ - // General options - // ------------------------------------------------------------------ - session: { - strategy: "jwt", // lightweight, avoids DB session writes - }, - jwt: { - maxAge: 60 * 60 * 24 * 30, // 30 days - }, - debug: false, -}; +import NextAuth from "next-auth"; +import { AuthOptions } from "./authOptions"; const handler = NextAuth(AuthOptions); export { handler as GET, handler as POST }; diff --git a/src/app/api/user/onboarded/route.ts b/src/app/api/user/onboarded/route.ts index eaed7506..b4676a4e 100644 --- a/src/app/api/user/onboarded/route.ts +++ b/src/app/api/user/onboarded/route.ts @@ -4,7 +4,7 @@ import { db } from "@/app/db/db"; import { user } from "@/app/db/schema/users"; import { eq } from "drizzle-orm"; import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; const OnboardedSchema = z.object({ onboarded: z.boolean(), diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 218426bb..fb922e35 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -4,7 +4,7 @@ import { db } from "@/app/db/db"; import { userProfiles, user } from "@/app/db/schema/users"; import { eq } from "drizzle-orm"; import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; const UserProfileSchema = z.object({ firstName: z.string().min(1), diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 5d873ab5..f82a9829 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,6 +1,6 @@ import CardTiles from "../components/home/CardTiles"; import { getPortfolios } from "./utils"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 610ab057..c5f03c0b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import "./globals.css"; import Provider from "./components/Provider"; import Nav from "./components/Navbar"; import { ReactQueryProvider } from "./ReactQueryProvider"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth/next"; import { cache } from "react"; import { Inter } from "next/font/google"; diff --git a/src/app/page.tsx b/src/app/page.tsx index 26962d27..fcabe67d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import { getServerSession } from "next-auth/next"; -import { AuthOptions } from "./api/auth/[...nextauth]/route"; +import { AuthOptions } from "./api/auth/[...nextauth]/authOptions"; import GoogleSignInButton from "./components/signin/GoogleSignInButton"; import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton"; import EmailSignInButton from "./components/signin/EmailSignInButton";