Edupala

Comprehensive Full Stack Development Tutorial: Learn Ionic, Angular, React, React Native, and Node.js with JavaScript

Comprehensive Guide: NextAuth with Separate User and UserProfile Collections

When implementing authentication in a Next.J’s application using NextAuth, it’s common to have separate collections for User and UserProfile data. This separation enables user management to be more flexible, but it can also create complications, particularly when dealing with third-party authentication providers like Google.

NextAuth User Authentication

1. The Setup: Separate Collections

We have, two main collections, related to user, User Collection: Stores essential authentication data and in model/User.ts file we have

import mongoose, { Schema, Document } from "mongoose";

export interface UserAuth extends Document {
  username: string;
  email: string;
  password: string;
  role: string;
  verifyCode: string;
  verifyCodeExpiry: Date;
  isVerified: boolean;
}

const UserAuthSchema: Schema<UserAuth> = new mongoose.Schema({
  username: {
    type: String,
    required: [true, "Username is required"],
  },
  email: {
    type: String,
    required: [true, "Email is required"],
    unique: true,
    match: [/.+\@.+\..+/, "Please use a valid email address"],
  },
  password: {
    type: String,
    required: [true, "Password is required"],
  },
  role: {
    type: String,
    default: "user",
  },
  verifyCode: {
    type: String,
    required: [true, "Verify Code is required"],
  },
  verifyCodeExpiry: {
    type: Date,
    required: [true, "Verify Code Expiry is required"],
  },
  isVerified: {
    type: Boolean,
    default: false,
  },
});

// Check if the model is already registered before registering it
const UserModel =
  mongoose.models.User || mongoose.model<UserAuth>("User", UserAuthSchema);

export default UserModel;

UserProfile Collection: Stores additional user information and we have separate addresses collection as user might want different delivery address. Here is the userProfile collection.

import mongoose, { Schema, Document } from "mongoose";

export interface UserProfile extends Document {
  userId: mongoose.Types.ObjectId;
  email: string;
  firstName?: string;
  lastName?: string;
  phoneNumber?: string;
  dateOfBirth?: Date;
  gender?: string;
  bio?: string;
  avatarUrl?: string;
  lastUpdated: Date;
}

const UserProfileSchema: Schema<UserProfile> = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
    required: true,
    unique: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
  firstName: { type: String, trim: true },
  lastName: { type: String, trim: true },
  phoneNumber: {
    type: String,
    validate: {
      validator: function (v: string) {
        return !v || /^\d{10}$/.test(v);
      },
      message: (props) => `${props.value} is not a valid phone number!`,
    },
  },
  dateOfBirth: { type: Date },
  gender: {
    type: String,
    enum: ["male", "female", "other", "prefer not to say"],
  },
  bio: { type: String, maxlength: 500 },
  avatarUrl: { type: String },
  lastUpdated: { type: Date, default: Date.now },
});

UserProfileSchema.pre("save", function (next) {
  this.lastUpdated = new Date();
  next();
});

const UserProfileModel =
  mongoose.models.UserProfile ||
  mongoose.model<UserProfile>("UserProfile", UserProfileSchema);

export default UserProfileModel;

2. The Challenges

The first issue is that when a user logs in via the Google provider, their record is not saved in the user collection. We need to fix this issue in the NextAuth callback signIn option by checking if the user exists or not, and then creating a new user in the collection if necessary. We will also handle the user profile in this step. The solution will be added in Step 3 of the NextAuth options.

With this setup, we encountered several challenges due to our two login options: credential-based (email and password) and Google provider. When logging in with credentials and visiting the user account, we could successfully fetch the user profile. However, when logging in via the Google provider and accessing the account, it only showed ‘data not found’. Using NextAuth, we faced the following issues:

a) Profile Creation Timing: Ensuring a UserProfile is created immediately after a User is created during Google sign-in.

b) Profile Retrieval: Fetching the correct UserProfile after authentication, particularly with Google sign-in.

c) Data Consistency: Maintaining consistency between User and UserProfile data across different authentication methods.

d) Session Management: Properly populating the session with data from both User and UserProfile collections, regardless of the login method used

3. Step-by-Step Solutions

a) Timing of Profile Creation

We modified the NextAuth options to create a UserProfile right after creating a User. Here is the full code of the NextAuth options, including the signIn callback to check the Google provider and determine if the user exists. If the user does not exist, a new user and UserProfile are created. In app/api/auth/[…nextauth]/options.ts file we have.

import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import UserModel from "@/model/User";
import UserProfileModel from "@/model/UserProfile";
import { signInSchema } from "@/schemas/signInSchema";
import dbConnect from "@/lib/dbConnect";

async function authorize(credentials: any): Promise<any> {
  await dbConnect();

  const parsedCredentials = signInSchema.safeParse(credentials);

  if (!parsedCredentials.success) {
    throw new Error("Invalid credentials");
  }

  const { email, password } = parsedCredentials.data;

  try {
    const user = await UserModel.findOne({ email });

    if (!user) {
      throw new Error("No user found with this email");
    }

    // if (!user.isVerified) {
    //   throw new Error("Please verify your account before logging in");
    // }

    const isPasswordCorrect = await bcrypt.compare(password, user.password);

    if (isPasswordCorrect) {
      return user;
    } else {
      throw new Error("Incorrect password");
    }
  } catch (error) {
    console.error("Login credential error:", error);
    throw new Error("Login credential error");
  }
}

export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.AUTH_GOOGLE_ID as string,
      clientSecret: process.env.AUTH_GOOGLE_SECRET as string,
    }),
    CredentialsProvider({
      id: "credentials",
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials: any): Promise<any> {
        return authorize(credentials);
      },
    }),
  ],
  callbacks: {
    async signIn({ user, account, profile }) {
      if (account?.provider === "google") {
        await dbConnect();
        let existingUser = await UserModel.findOne({ email: user.email });

        if (!existingUser) {
          // Create new user
          existingUser = new UserModel({
            email: user.email,
            username: user.email?.split("@")[0] || `user_${Date.now()}`,
            isVerified: true,
            role: "user",
            // Set a random password for Google users
            password: await bcrypt.hash(
              Math.random().toString(36).slice(-8),
              10
            ),
            verifyCode: "GOOGLE_AUTH",
            verifyCodeExpiry: new Date(),
          });
          await existingUser.save();

          // Create corresponding user profile
          const newUserProfile = new UserProfileModel({
            userId: existingUser._id,
            firstName: user.name?.split(" ")[0] || "",
            lastName: user.name?.split(" ").slice(1).join(" ") || "",
            avatarUrl: user.image,
          });
          await newUserProfile.save();
        } else {
          // Update existing user
          existingUser.isVerified = true;
          await existingUser.save();

          // Update or create user profile
          await UserProfileModel.findOneAndUpdate(
            { userId: existingUser._id },
            {
              $set: {
                firstName: user.name?.split(" ")[0] || "",
                lastName: user.name?.split(" ").slice(1).join(" ") || "",
                avatarUrl: user.image,
                email: user.email, // Add this line
              },
            },
            { upsert: true, new: true }
          );
        }

        // Update the user object with _id, role, and username
       //To avoid unuthorised CRUD operation of undefined _id on session and token           
       user._id = existingUser._id;
       user.role = existingUser.role;
       user.username = existingUser.username;      
      }
      return true;
    },
    async redirect({ url, baseUrl }) {
      // Always redirect to the home page after sign-in
      return baseUrl;
    },
    async jwt({ token, user }) {
      if (user) {
        token._id = user._id?.toString();
        token.isVerified = user.isVerified;
        token.role = user.role;
        token.username = user.username;
      }
      return token;
    },
    async session({ session, token }) {
      if (token) {
        session.user._id = token._id as string;
        session.user.role = token.role as string;
        session.user.username = token.username as string;
      }
      return session;
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 60 * 60, // 1 hour
  },
  jwt: {
    maxAge: 60 * 60, // 1 hour
  },
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: "/auth/sign-in",
    error: "/auth/error", // Add this line
  },
};

b) Profile Retrieval

We updated the getUserProfile function to fetch the profile using the User’s email, in lib/userProfileManager.ts and here it the code

import { getSession } from "next-auth/react";
import axios from "axios";

const getUserProfile = async () => {
  console.log("getUserProfile function called");
  try {
    const session = await getSession();
    console.log("Session data:", session);

    if (!session || !session.user) {
      console.log("No authenticated user in session");
      return null;
    }

    const userId = session.user._id;
    const userEmail = session.user.email;

    if (!userId && !userEmail) {
      console.log("No user identifier found in session");
      return null;
    }

    console.log("Fetching user profile");
    const response = await axios.get("/api/user-profile");
    console.log("API response:", response.data);

    if (response.data.userProfile) {
      console.log(
        "Returning existing user profile:",
        response.data.userProfile
      );
      return response.data.userProfile;
    }

    console.log("No existing profile found, attempting to create one");
    if (userEmail) {
      const newProfileResponse = await axios.post("/api/user-profile", {
        userId: userId,
        email: userEmail,
        firstName: session.user.name?.split(" ")[0] || "",
        lastName: session.user.name?.split(" ").slice(1).join(" ") || "",
        avatarUrl: session.user.image || "",
      });
      console.log("New profile created:", newProfileResponse.data.userProfile);
      return newProfileResponse.data.userProfile;
    }

    console.log("Unable to create user profile");
    return null;
  } catch (error) {
    console.error("Error in getUserProfile:", error);
    return null;
  }
};

export { getUserProfile };

And updated the API route to find the profile:

xport async function GET(req: NextRequest) {
  try {
    await dbConnect();

    const session = await getServerSession(authOptions);
    if (!session?.user?._id && !session?.user?.email) {
      return NextResponse.json(
        createApiResponse<undefined>(false, "Not authenticated", 401)
      );
    }

    let userProfile;
    if (session.user._id) {
      userProfile = await UserProfileModel.findOne({
        userId: session.user._id,
      }).lean();
    }

    if (!userProfile && session.user.email) {
      userProfile = await UserProfileModel.findOne({
        email: session.user.email,
      }).lean();
    }

    if (!userProfile) {
      return NextResponse.json(
        createApiResponse(
          true,
          "User profile not found, but can be created",
          404
        )
      );
    }

    return NextResponse.json(
      {
        success: true,
        message: "User profile retrieved successfully",
        userProfile,
      },
      { status: 200 }
    );
  } catch (error) {
    console.error("Error retrieving user profile:", error);
    return NextResponse.json(
      createApiResponse<undefined>(false, "Internal server error", 500)
    );
  }
}

c) Account Component Setup

In the user account or profile page, we implemented the following setup to manage user profile data:

const Account = () => {
  const [isEditing, setIsEditing] = useState(false);
  const { data: session, status } = useSession();
  const router = useRouter();
  const { toast } = useToast();

  const {
    data: userProfile,
    error: profileError,
    mutate: mutateProfile,
    isLoading: isProfileLoading,
  } = useSWR("userProfile", getUserProfile, {
    revalidateOnFocus: false,
    shouldRetryOnError: false,
    onSuccess: (data) => console.log("SWR success, userProfile:", data),
    onError: (error) => {
      console.error("SWR Error:", error);
      toast({
        title: "Error",
        description: "Failed to fetch user profile. Please try again.",
        variant: "destructive",
      });
    },
  });

  // ... rest of the component
};

This setup utilizes the useSWR hook to fetch and manage the user profile data. It provides:

  • Automatic data fetching and caching
  • Error handling with user-friendly toast notifications
  • Loading state management
  • The ability to manually trigger refetches with mutateProfile

By using SWR, we ensure that the user profile data stays up-to-date and is efficiently managed on the client side, addressing our challenges with profile retrieval and data consistency.

c) Data Consistency

We implemented a mechanism to sync data between User and UserProfile:

// In options.ts
callbacks: {
  async session({ session, token }) {
    if (session.user) {
      const userProfile = await UserProfileModel.findOne({ email: session.user.email });
      if (userProfile) {
        session.user.firstName = userProfile.firstName;
        session.user.lastName = userProfile.lastName;
        // Add other fields as necessary
      }
    }
    return session;
  },
  // ... other callbacks
}

d) Session Management

We ensured the session contains necessary data from both User and UserProfile:

// In options.ts
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.userId = user._id;
      const userProfile = await UserProfileModel.findOne({ userId: user._id });
      if (userProfile) {
        token.firstName = userProfile.firstName;
        token.lastName = userProfile.lastName;
      }
    }
    return token;
  },
  // ... other callbacks
}

Key Concepts Explained

Separate Collections Design
Dividing the User and UserProfile collections offers several advantages:

  • Enhanced data organization
  • Flexible schema evolution
  • Improved performance for authentication-related queries

However, this approach necessitates careful management to maintain data synchronization and ensure efficient retrieval.

NextAuth Callbacks
NextAuth provides several callbacks that allow customization of the authentication process:

  • signIn: Triggered when a user signs in
  • jwt: Triggered whenever a JWT is created or updated
  • session: Triggered whenever a session is verified

These callbacks are essential for handling our separate collections setup.

Best Practices and Lessons Learned

  • Consistent Identifiers: Utilize email as a consistent identifier across both collections to simplify data management.
  • Eager Profile Creation: Generate the UserProfile immediately after creating the User to ensure data consistency.
  • Efficient Data Fetching: Retrieve UserProfile data during session creation to reduce additional database queries.
  • Error Handling: Implement robust error handling, especially for scenarios where a User exists but a UserProfile does not.
  • Type Safety: Utilize TypeScript to ensure type safety throughout your authentication and profile management code.

Conclusion: Managing separate User and UserProfile collections with NextAuth requires careful consideration of data flow and timing. By leveraging NextAuth callbacks and ensuring proper data synchronization, a robust and flexible authentication system can be created. Always weigh the trade-offs between data normalization and query performance, and tailor your approach based on the specific needs of your application.

Comprehensive Guide: NextAuth with Separate User and UserProfile Collections

Leave a Reply

Your email address will not be published. Required fields are marked *

Scroll to top