mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
274 lines
8.2 KiB
TypeScript
274 lines
8.2 KiB
TypeScript
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,
|
||
};
|