Authentication with Firebase

Authentication with Firebase

Firebase provides a robust and secure authentication system that integrates seamlessly into your application. This boilerplate leverages Firebase Authentication to handle user login, registration, and session management with ease.

By using Firebase Authentication, you can significantly reduce the amount of code required to implement and maintain a secure authentication system. Firebase offers a pre-built, scalable solution that accelerates development time, allowing you to focus on building core features for your application. Additionally, it provides out-of-the-box support for various authentication methods, robust session management, and enhanced security practices, making it an ideal choice for modern applications.


Key Features

1. Sign In with Email and Password

This boilerplate implements Firebase Authentication using a custom wrapper function called signIn, which abstracts Firebase’s signInWithEmailAndPassword function. This abstraction streamlines the authentication process by adding extra checks, such as verifying the user's email, and improves error handling to ensure seamless integration and enhanced security in your application.

The signIn Function

The signIn function is a custom wrapper around Firebase's signInWithEmailAndPassword method. It abstracts the authentication process by adding extra checks and error handling to ensure that only verified users can proceed. This provides a seamless integration with the application and enhances security.

Here’s the implementation of the signIn function:

import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
 
export const signIn = async (email, password) => {
  try {
    const credential = await signInWithEmailAndPassword(auth, email, password);
    if (!credential?.user || !credential.user?.emailVerified) {
      return false; // Return false if email is not verified
    }
    return credential; // Return the credential if the email is verified
  } catch (error) {
    throw error; // Throw the error to be handled by the calling function
  }
};

File Path: src/services/user.js

The function:

  • Authenticates the user with their email and password.
  • Checks that the user's email is verified before allowing access.
  • Throws an error if something goes wrong, allowing the calling function to handle the error gracefully.

Allowing Unverified Emails

By default, the signIn function checks whether the user's email is verified before granting access. However, in scenarios where email verification is not required, the function can be modified to bypass this check. This adjustment allows users with unverified email addresses to sign in successfully.

Here’s the modified signIn function:

export const signIn = async (email, password) => {
  try {
    const credential = await signInWithEmailAndPassword(auth, email, password);
    return credential; // Allow users to sign in without checking email verification
  } catch (error) {
    throw error;
  }
};

In addition to modifying the signIn function, the email verification check should also be removed from the AuthStateVerifier utility component, located in components/common/AuthStateVerifier/index.js.

Currently, the component checks if the user's email is verified using user?.emailVerified. This logic ensures only verified users can access the application. To allow unverified users, this check needs to be removed.

Here’s the original implementation:

const unsubscribe = auth.onAuthStateChanged(user => {
  if (!user?.emailVerified) {
    signOut(auth);
    setTimeout(() => router.push("/login"), 550);
    onComplete?.();
    return;
  }
 
  onAuthStateChangeSuccess?.();
  setAuthUser(user);
  loadUserProfileById(user.uid);
 
  if (redirectPath) {
    router.push(redirectPath);
  }
 
  onComplete?.();
});

File Path: src/components/common/AuthStateVerifier/index.js

To disable email verification, you should remove the user?.emailVerified condition and adjust the logic accordingly:

const unsubscribe = auth.onAuthStateChanged(user => {
  if (!user) {
    signOut(auth);
    setTimeout(() => router.push("/login"), 550);
    onComplete?.();
    return;
  }
 
  onAuthStateChangeSuccess?.();
  setAuthUser(user);
  loadUserProfileById(user.uid);
 
  if (redirectPath) {
    router.push(redirectPath);
  }
 
  onComplete?.();
});

This modification allows users to proceed regardless of their email verification status, ensuring that the entire authentication flow aligns with the new policy.

Usage in the Login Page

The signIn function is the main function used in the Login Page for authenticating users via email and password. When the user submits the login form, the handleSubmit function is triggered, calling the signIn function with the user's email and password. This ensures that the user is authenticated and only verified users are granted access.

Here’s how the signIn function is used in the Login Page:

const handleSubmit = async e => {
  e.preventDefault();
  setIsLoading(true);
 
  try {
    const credential = await signIn(email, password);
    if (!credential) {
      showNotification(
        "Your email address has not been verified yet. Please check your email for the verification link.",
        "error"
      );
      await logOut();
      return;
    }
    const user = credential?.user;
    onSignInSuccess?.(user);
  } catch (err) {
    showNotification(translateError(err.message), "error");
  } finally {
    setIsLoading(false);
  }
};

File Path: src/components/forms/LoginForm/index.js

2. Social Logins (Sign Up & Sign In)

Enhance user experience by simplifying the login process with social media integrations, allowing users to authenticate via popular third-party providers like Google, Facebook, and more. Firebase makes it effortless to configure and manage these providers. In ShipFast, Google authentication is already integrated, and all that’s needed is to ensure that the Google Provider is enabled in your Firebase Authentication settings. You can also enable additional platforms, such as Facebook, by configuring the respective provider in Firebase, which will be seamlessly integrated into the system.

The signInUserWithGoogleProvider Function

The signInUserWithGoogleProvider function serves as a wrapper around the registerUserWithGoogleProvider function, which leverages Firebase's signInWithPopup method for Google authentication. This function ensures a smooth process by not only authenticating the user but also verifying if they are new or returning. Once authentication is successful, the user is automatically registered within the application, and an organization is created on their behalf, streamlining the sign-up process.

The user’s data is stored in the Firestore users collection, where their details are saved for easy retrieval. Additionally, the new user will receive a Free subscription by default, which is registered via Stripe. This subscription is stored in the subscriptions subcollection within the respective organization’s document (i.e., organizations/{orgId}/subscriptions). The subscription data is sent to Firestore through a Stripe webhook event once the user successfully registers.

For more details about the subscription data synchronization, see Payment Feature Documentation.

By using this method, users enjoy a frictionless experience, logging in or signing up without additional manual steps, while Firebase ensures that user authentication and registration are handled efficiently.

Here’s the implementation of the registerUserWithGoogleProvider function:

import { auth, signInWithPopup, googleProvider } from "@/config/firebase";
 
export const registerUserWithGoogleProvider = () => {
  return new Promise(async (resolve, reject) => {
    try {
      // Trigger Google sign-in popup using Firebase Authentication
      const { user } = await signInWithPopup(auth, googleProvider);
 
      // Check if user is null or undefined, reject if sign-in fails
      if (!user) {
        return reject(new Error("User registration failed."));
      }
 
      const userId = user.uid;
 
      // Check if the user already exists in the database
      const existingUser = await getUserById(userId);
      if (existingUser) {
        return resolve({ message: "User already exists.", user: existingUser });
      }
 
      // Create a default organization for the user
      const orgName = `${user.email || user.displayName} Org`;
 
      // Simultaneously create organization and user records in the database
      const [organization] = await Promise.all([
        createOrganizationAndAttachUser(user.uid, {
          name: orgName,
          billingEmail: user.email || ""
        }),
        addUser({
          userId,
          email: user.email || "",
          name: user.displayName || ""
        })
      ]);
 
      // Retrieve the organization ID
      const orgId = organization?.id || organization?.organizationId;
 
      // Create metadata to be used for subscription registration
      const metadata = {
        organizationId: orgId,
        userId: user.uid,
        email: user.email
      };
 
      // Register the user for a free subscription plan in Stripe
      await createStripeFreeSubscription(user.email, orgId, metadata);
 
      // Resolve the promise indicating successful registration
      resolve({ user, organization });
    } catch (error) {
      // Reject the promise with the error if any step fails
      reject(error);
    }
  });
};

The function:

  • Initiates the Google authentication popup through Firebase's signInWithPopup.
  • Checks if the user exists in the system to avoid duplicates.
  • Creates a new organization for the user and adds them to the database.
  • Sends a welcome notification email to the user.
  • Registers the user for a free subscription in Stripe, and subscription details are stored in the subscriptions subcollection of organizations/{orgId}/subscriptions.

Usage in the Login Page

The signInUserWithGoogleProvider function is used in the Login Page to authenticate users via Google. Upon clicking the "Login with Google" button, the handleGoogleLogin function is triggered, which calls signInUserWithGoogleProvider. This ensures that the user is authenticated and properly registered in the system.

Here’s how the signInUserWithGoogleProvider function is used in the Login Page:

import { signInUserWithGoogleProvider } from "@/services/user";
 
const handleGoogleLogin = async () => {
  setIsLoading(true);
 
  try {
    const { user, organization } = await signInUserWithGoogleProvider();
    if (!user) {
      showNotification("Google login failed. Please try again.", "error");
      return;
    }
 
    // Proceed with any post-login actions
    onSignInSuccess?.(user);
  } catch (err) {
    showNotification(translateError(err.message), "error");
  } finally {
    setIsLoading(false);
  }
};

File Path: src/components/forms/LoginForm/index.js

In this code:

  • The handleGoogleLogin function is triggered when the user clicks the Sign in with Google button.
  • It calls the signInUserWithGoogleProvider function to handle the authentication and registration.
  • If successful, the user is logged in and can proceed to the next steps in the application.
  • Any errors are caught and displayed as notifications.

This approach ensures a smooth authentication process for users, while also integrating essential backend logic for user management and subscription registration.

3. Register using Email and Password

The registerUser function allows users to create a new account using their email and password, while performing the following operations:

  1. Firebase Authentication: Registers the user using Firebase's built-in createUserWithEmailAndPassword method. This method securely stores the user's email and password in Firebase Authentication, a service that handles authentication and user management. By using this service, the user's credentials are stored securely, and the system ensures that only valid users can access their accounts.

  2. Organization Creation and Attach User: Creates a new organization and attaches the user to it using the createOrganizationAndAttachUser function. The organization is stored in the organizations collection, while the organization ID and the user ID from Firebase Authentication are stored in the user_organizations collection. For details of the fields and structure, see the Database Diagram.

  3. User Record Creation: Adds the user to the Firestore users collection using the addUser function. This ensures that the user's details are securely stored and can be easily retrieved for future reference. For more information on the fields and structure of the user record, see the Database Diagram.

  4. Email Verification: Sends a verification email to the user through Firebase's sendEmailVerification method. This step ensures that the email address provided by the user is valid and that they have access to it. The user must click the verification link in the email to confirm their account, helping to prevent fraud and ensuring the accuracy of the user’s contact details.

  5. Stripe Subscription: Creates a free subscription on Stripe for the new user's organization. Upon successful creation of the subscription, Stripe triggers an API call to the designated ShipFast API route, /api/stripe/events, which handles syncing the subscription data. (For more details about this API route, see /Stripe Event Setup.) This data, which includes a summary of the subscription, is then stored in ShipFast's Firestore under the subscriptions subcollection within the respective organization's document (i.e., organizations/{orgId}/subscriptions).

Here’s the function code:

export const registerUser = (email, password) => {
  return new Promise(async (resolve, reject) => {
    try {
      // Create a Firebase Auth account using the provided email and password
      const { user } = await createUserWithEmailAndPassword(
        auth,
        email,
        password
      );
 
      if (!user) {
        return reject(new Error("User creation failed")); // Reject if user creation is unsuccessful
      }
 
      const orgName = `${user.email || user.displayName} Org`; // Default organization name based on user info
 
      // Execute multiple asynchronous operations concurrently
      const [organization] = await Promise.all([
        // Create an organization in the database linked to the user
        createOrganizationAndAttachUser(user.uid, {
          name: orgName,
          billingEmail: user.email || ""
        }),
        // Create a user record in the database
        addUser({
          userId: user.uid,
          email: user.email || "",
          name: user.displayName || ""
        }),
        // Send an email verification to the user
        sendEmailVerification(user)
      ]);
 
      const orgId = organization?.id || organization?.organizationId; // Retrieve the organization ID
      const metadata = {
        organizationId: orgId,
        userId: user.uid,
        email: user.email
      };
 
      await createStripeFreeSubscription(user.email, orgId, metadata);
 
      resolve(); // Resolve the promise upon successful registration
    } catch (error) {
      reject(new Error(`Registration failed: ${error.message}`)); // Reject the promise with an error message
    }
  });
};

4. Change Password

ShipFast provides a built-in feature to allow users to update their passwords easily. This utilizes Firebase's updatePassword method, which securely handles password changes for Firebase Authentication accounts. The following operations are performed:

  1. Firebase Authentication: Uses Firebase's built-in updatePassword method to update the user's password. This method securely updates the password associated with the user's Firebase Authentication account, ensuring that only authenticated users can modify their password.

  2. Password Security: Ensures that the new password meets security standards, such as length and complexity, to prevent weak passwords from being set. Firebase handles all necessary validation for security.

Here’s how it is used in the component:

const handleSave = async e => {
  e.preventDefault();
  const user = authUser; // Get the authenticated user
 
  // Validate password fields
  const validationError = validatePasswords(password, confirmPassword);
  if (validationError) {
    showNotification(validationError, "error");
    return;
  }
 
  try {
    setIsSaving(true);
    await updatePassword(user, password);
    showNotification(
      "Password updated successfully! Your changes have been saved."
    );
    resetPasswordFields();
  } catch (err) {
    setIsReAuthOpen(
      Boolean(err?.message?.includes("auth/requires-recent-login"))
    ); // Opens a modal for re-login if the user hasn't logged in recently
  } finally {
    setIsSaving(false);
  }
};

File Path: src/components/forms/ChangePasswordForm/index.js

In this implementation:

  • The handleSave function validates the password fields and ensures that the passwords match.
  • The updatePassword function is called to update the password for the authenticated user.
  • If the update is successful, a success notification is displayed and the password fields are reset.
  • If the user is required to re-authenticate, the setIsReAuthOpen state will open a modal for the user to log in again. The app requires a re-login for users who haven't logged in recently.

Important Notes

  1. Authorized Domains: To ensure proper functioning of Firebase Authentication, you must add the domain you are using to the Authorized Domains section in your Firebase Authentication settings. This is necessary to allow Firebase to handle authentication requests from your application.

  2. Sign-In Providers: Make sure to enable and configure the Sign-In Providers you want to use in your Firebase Authentication settings. These include options like Email/Password, Google, Facebook, or other supported providers. Proper configuration ensures a seamless authentication experience for users.