Implementing Session-Based Authentication with NestJS, Next.js 14, Firebase, and MongoDB

This article dives into crafting a session-based authentication system for a sports car rental platform. We're leveraging the power of NestJS, Next.js 14, Firebase, and MongoDB to build something both secure and user-friendly. Rather than delving into every line of code, we focus on the essential components and logic that make up a robust authentication system.

GitHub Repo Link

Background: Why I'm Doing It

Starting a project from scratch is always an adventure, right? The sports car rental platform I'm working on was initially running smoothly with Strapi for car listings. However, as the project grew, so did my vision. The next big step? Enabling lessors to upload their cars directly. Strapi's token-based authentication was an option, but I was after something more—more control, more customization. That's where building my own server with NestJS came into play. It was the perfect opportunity to put my recent deep dive into NestJS to the test, especially since I've been working mostly with serverless apps lately.

Session-Based vs. Token-Based Authentication

In web development, picking between session-based and token-based authentication is crucial. Session-based authentication, with its server-side user state storage, is traditionally seen as more secure. Token-based authentication, on the other hand, especially with JWTs, is known for its scalability and ease of use in distributed systems. For this project, session-based authentication was the winner. It just fits right in with our user-focused features, striking that sweet spot of security and simplicity.

Approach and Assumptions

Before diving into our user flows and key code segments, let’s clarify our approach and assumptions. You'll benefit most from this article if you have a basic familiarity with Next.js, NestJS, and MongoDB. While in-depth expertise in these technologies isn't required, a general understanding will help you follow the concepts more easily.

We won’t be covering every single line of code or basic setup steps in extensive detail (you can find the full code in my GitHub repository). Instead, our focus will be on the overarching logic of the system and strategic code snippets that illustrate how our session-based authentication operates. This way, we aim to provide a clear and practical understanding of the system’s functionality, without getting too bogged down in the technical minutiae.

The Authentication Blueprint

Now that we have set our stage, let's dive into the heart of our session-based authentication system. We'll explore the critical user flows - signing up, signing in, signing out, and accessing gated content - to understand how our system handles these fundamental interactions. Along the way, we'll highlight key code segments that are pivotal in making these processes secure and efficient. By dissecting these user flows, we'll gain insights into how the system verifies user credentials, maintains secure sessions, and controls access to restricted resources.

Understanding the Sign-Up Process

The sign-up process in our sports car rental platform involves several key steps, integrating both the front-end and back-end logic seamlessly. Let's walk through this journey, highlighting how each piece of code plays its part.

Initiating Sign-Up on the Front-End

When a user decides to sign up, the front-end initiates the process:

const handleSignUpWithEmailAndPassword = async (e: FormEvent) => {
    e.preventDefault();

    // ... email and password validation

    setLoading(true);

    try {
      const userCredential = await signUpWithForm(email, password);
      const idToken = await userCredential.user.getIdToken();
      const { status } = await createSession(idToken);
      if (status === "success") {
        await createProfile(); // this will create a lessor (user)
        router.push("/dashboard");
      }
    } catch (error: any) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };

Here, we validate the user's email and password, leveraging Firebase for user creation. The signUpWithForm function interacts with Firebase's authentication service:

import { createUserWithEmailAndPassword } from "firebase/auth"

import { auth } from "../firebase-config"

export const signUpWithForm = (email: string, password: string) => {
  return createUserWithEmailAndPassword(auth, email, password)
}

Upon successful sign-up, Firebase returns a token, marking the user's authenticated state. This token is crucial for the next step – creating a session on our server:

const idToken = await userCredential.user.getIdToken();

Establishing a Session: Front-End to Back-End

With the ID token in hand, the front-end makes a POST request to our server. The createSession function sends this token in the authorization header:

export const createSession = async (
  token: string
): Promise<SuccessResponse> => {
  try {
    // Sending a POST request to the /auth endpoint with the token in the Authorization header
    const response = await authApi.post<SuccessResponse>(
      "",
      {},
      { headers: { Authorization: `Bearer ${token}` } }
    );

    return response.data;
  } catch (error) {
    console.error("Error creating session:", error);
    throw error;
  }
};

Once the front-end sends the POST request with the ID token, our server processes it through the sessionLogin endpoint in the AuthController. This is where the session cookie is created and sent back to the client:

// Server: Session Login Endpoint in AuthController
cookieOptions: CookieOptions = {
  httpOnly: true,
  maxAge: 60 * 60 * 24 * 5 * 1000, // 5 days in milliseconds
  secure: false, // In a production environment, this should be set to true
};

... other endpoints

@UseGuards(AuthGuard)
@Post()
async sessionLogin(@Req() req, @Res() res: Response) {
  const cookie = await this.authService.createSessionCookie(
    req.token,
    this.cookieOptions,
  );
  res.cookie(cookie.name, cookie.value, this.cookieOptions);
  res.send(JSON.stringify({ status: 'success' }));
}

In this crucial step, the server creates a session cookie based on the validated ID token and sets it in the client's browser, establishing a secure session. The res.cookie method in Express is instrumental here, taking the cookie's name, value, and a set of options for security and management.

Key aspects of res.cookie in our authentication flow include:

  • Security: The httpOnly option is set to true, making the cookie inaccessible to client-side JavaScript and protecting against XSS attacks.
  • Lifespan: The maxAge option specifies the cookie's validity period, set to 5 days in our configuration.
  • Protocol Security: In development, secure is set to false, but in production, it should be true to ensure the cookie is transmitted over HTTPS.

This method ensures that the client’s browser stores the session cookie, which is included in subsequent requests to the server. This mechanism allows the server to recognize the user across different interactions, facilitating secure access to protected routes on the platform.

Role of the AuthGuard

Before the sessionLogin method is invoked, the AuthGuard plays a critical role:

// Server: Auth Guard Implementation
import { CanActivate, ExecutionContext, Inject } from '@nestjs/common';
import { isString } from 'lodash';
import { FirebaseAuthService } from '../services/firebase.service';

// AuthGuard is a NestJS guard that is used to protect routes
export class AuthGuard implements CanActivate {
  // inject the FirebaseAuthService to verify the token later on
  constructor(
    @Inject(FirebaseAuthService) private firebaseService: FirebaseAuthService,
  ) {}

  // canActivate is a method that is called before a route is accessed to allow or deny access
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest(); // access the HTTP request object

    if (
      req.headers['authorization'] &&
      isString(req.headers['authorization'])
    ) {
      const idToken = req.headers['authorization'].split(' ')[1]; // Bearer <token>

      return this.firebaseService
        .verifyIdToken(idToken)
        .then(() => {
          const request = context.switchToHttp().getRequest();
          request.token = idToken;
          return true;
        });
    } else {
      return false;
    }
  }
}

In NestJS, guards are used to handle authentication and authorization. The AuthGuard in our application is responsible for validating the incoming ID token. It ensures that the token is legitimate and has been issued by Firebase. This validation is crucial for security, as it prevents unauthorized access to the endpoint and subsequent actions.

Here's how the AuthGuard works:

  1. It intercepts the incoming request and extracts the ID token from the authorization header.
  2. The token is then verified using Firebase Admin SDK.
  3. If the token is valid, the request proceeds to the sessionLogin method; otherwise, access is denied.

By using this guard, we ensure that only requests with a valid ID token can initiate session creation, maintaining the integrity and security of our authentication process.

Finalizing the Sign-Up: Creating a Profile

Once the session cookie is securely established by the server, the front-end proceeds to complete the sign-up process. This includes creating a lessor profile in our database, effectively linking the Firebase authenticated user with our application's user model.

// Front-End: Creating Lessor Profile
import axios from "axios";
import { Config } from "@/services/config";
import { Lessor } from "@/core/entities";

const lessorApi = axios.create({
  baseURL: Config.baseUrl + "/lessor",
  withCredentials: true,
});

export const createProfile = async (): Promise<Lessor> => {
  try {
    const response = await lessorApi.post<Lessor>("");
    return response.data;
  } catch (error) {
    console.error("Error creating lessor:", error);
    throw error;
  }
};

This function sends a POST request to our server's /lessor endpoint, which is designed to handle the creation of new lessor profiles. At the server, the request to create a new lessor profile is handled by the createLessor method in the LessorController:

// Server: Lessor Profile Creation Endpoint
@UseGuards(SessionGuard)
@Post()
async createLessor(@Req() req) {
  return this.lessorService.createLessor(req.auth);
}

This endpoint is protected by the SessionGuard to ensure that only authenticated sessions can create new profiles. The guard checks the session cookie and, upon verification, allows the request to proceed.

Role of the SessionGuard

The SessionGuard plays a crucial role in verifying the authenticity of the session:

// Server: Session Guard Implementation
import { CanActivate, ExecutionContext, Inject } from '@nestjs/common';
import { FirebaseAuthService } from '../services/firebase.service';

export class SessionGuard implements CanActivate {
  constructor(
    @Inject(FirebaseAuthService) private firebaseService: FirebaseAuthService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest();

    const session = req.cookies.session;

    if (!session) {
      return false;
    } else {
      return this.firebaseService
        .verifySessionCookie(session)
        .then((decodedToken) => {
          const request = context.switchToHttp().getRequest();
          request.auth = decodedToken;
          return true;
        })
        .catch(() => {
          return false;
        });
    }
  }
}

Upon receiving a request, the guard checks for the presence of a session cookie. If found, it uses Firebase Admin SDK to verify the session. Upon successful verification, the decoded token (containing user information like UID and email) is attached to the request object (req.auth). This verified information is then used to create the lessor profile.

Database Profile Creation

Finally, with the verified user information in req.auth, the LessorService creates the new profile in the database:

// Server: Lessor Service - Creating a New Lessor
async createLessor(auth: AuthDto) {
  return this.modelService.create({
    uid: auth.uid,
    email: auth.email,
  });
}

Here, we're linking the Firebase UID with our application’s user data, ensuring a consistent and secure connection between the Firebase authentication and our internal user model. With the sign-up process complete, our system now seamlessly integrates new users, balancing security with user-friendliness. This careful orchestration of user registration sets the stage for their ongoing experience on the platform, particularly for the subsequent sign-in process. As we transition from onboarding new users to welcoming back returning ones, the next section will focus on the sign-in flow. We'll explore how our platform ensures a smooth and secure re-entry for existing users, maintaining the integrity and ease of use that defines our user experience.

Sign In Process: A Smooth Re-Entry

The sign-in process mirrors the sign-up flow in many ways, but without the need to create a new profile. It involves validating user credentials and establishing a session:

// Front-End: Sign-In Function
const handleSignInWithEmailAndPassword = async (e: FormEvent) => {
    e.preventDefault();

    // ... validate email

    setLoading(true);

    try {
      const userCredential = await signInWithForm(email, password);
      const idToken = await userCredential.user.getIdToken();
      const { status } = await createSession(idToken);
      if (status === "success") {
        router.push("/dashboard");
      }
    } catch (error: any) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };

In this function, the user signs in using their email and password. We utilize Firebase's signInWithEmailAndPassword method, similar to the sign-up process:

// Firebase: Sign In with Email and Password
import { signInWithEmailAndPassword } from "firebase/auth"

import { auth } from "../firebase-config"

export const signInWithForm = (email: string, password: string) => {
  return signInWithEmailAndPassword(auth, email, password)
}

Once signed in, Firebase returns an ID token, which we send to the server to establish a new session. The user is then redirected to the dashboard, signifying a successful sign-in.

Accessing Gated Content: The Dashboard

Once a user successfully signs in, the next crucial phase is interacting with gated content on the dashboard. The dashboard in our application serves as a prime example of how gated content is managed and displayed, demonstrating the effective use of session validation and secure data retrieval. This process involves several layers of security and validation to ensure that only authenticated users can access sensitive information.

Middleware: Protecting Gated Content

Our application employs Next.js Middleware to safeguard specific routes, including the dashboard. The middleware acts as a gatekeeper, determining the validity of the user session before granting access:

// Next.js Middleware for Route Protection
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Config } from "@/services/config";

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  const session = request.cookies.get("session");

  if (!session || !session.value) {
    if (pathname.startsWith("/auth")) return NextResponse.next();
    return NextResponse.redirect(new URL("/auth", request.url));
  }

  const response = await fetch(`${Config.baseUrl}/auth`, {
    method: "GET",
    credentials: "include",
    headers: {
      Cookie: `session=${session?.value}`,
    },
  });

  if (response.status !== 200 && !pathname.startsWith("/auth")) {
    return NextResponse.redirect(new URL("/auth", request.url));
  }

  if (response.status === 200 && pathname.startsWith("/auth")) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/auth/:path*", "/dashboard/:path*"],
};

This middleware checks for a session cookie and validates it by making a GET request to the /auth server endpoint. Based on the response status and the requested path, it decides whether to redirect the user to the login page, dashboard, or proceed with the request.

Server-Side Session Validation

The GET request to the server endpoint /auth is responsible for validating the session cookie:

// Server: Session Validation Endpoint in AuthController
@Get()
async getSession(@Req() req, @Res() res) {
  const session = req.cookies.session;
  if (!session) {
    res.status(401).send({ isLogged: false });
  }
  const decodedToken = await this.authService.verifySessionCookie(session);

  if (!decodedToken) {
    res.status(401).send({ isLogged: false });
  }
  res.status(200).send(decodedToken);
}

This endpoint checks the session cookie and uses Firebase Admin SDK for validation. If the validation is successful, it confirms the user's authenticated status, allowing access to the dashboard.

Rendering Gated Content on the Dashboard

In the Dashboard component of our application, we demonstrate the retrieval and display of gated content, specifically user and lessor information. This process is facilitated by a combination of front-end requests and server-side validations.

The Dashboard component is responsible for displaying personalized information to the authenticated user:

// Front-End: Dashboard Component
import { getSession } from "@/lib/api/auth";
import { getLessor } from "@/lib/api/lessor";
import { SignOut } from "./_components/SignOut";

export default async function Dashboard() {
  const user = await getSession();
  const lessor = await getLessor();

  return (
    <main className="flex min-h-screen flex-col items-center p-24">
      <div className="sm:mx-auto sm:w-full sm:max-w-sm">
        <h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
          Dashboard of {user?.uid}
        </h2>
        <p className="mt-2 text-center text-sm text-gray-600 max-w">
          {lessor?.email}
        </p>
      </div>
      <SignOut />
    </main>
  );
}

This component first retrieves the current user session using the getSession function, and then fetches the lessor data using the getLessor function. Both functions make requests to the server, utilizing server-side rendered (SSR) pages.

The getSession function is essential for obtaining the authenticated user's session information:

// Front-End: getSession Function for Fetching User Session
import { cookies } from "next/headers";
import { Config } from "@/services/config";
import { Auth } from "@/core/entities";

/**
 * Use these functions to interact with the backend server when working with server side rendered pages.
 * Reason: Axios will not set the cookies automatically when using SSR. Thats why we use fetch here.
 */

/**
 * Get the current session from the backend server.
 * @returns {Promise<Auth>} - The decoded session data.
 */
export const getSession = async (): Promise<Auth> => {
  const session = cookies().get("session")?.value;

  try {
    // Sending a GET request to the /auth endpoint
    const response = await fetch(Config.baseUrl + "/auth", {
      method: "GET",
      credentials: "include",
      headers: {
        Cookie: `session=${session}`,
      },
    });

    return response.json();
  } catch (error) {
    // Error Handling
    console.error("Error getting session:", error);
    throw error;
  }
};

This function sends a GET request to the server's /auth endpoint. It includes the session cookie in the request headers to validate the user's session.

Similarly, the getLessor function is used to fetch specific data related to the authenticated user, such as their lessor details:

// Front-End: getLessor Function for Fetching Lessor Data
import { cookies } from "next/headers";
import { Config } from "@/services/config";
import { Lessor } from "@/core/entities";

/**
 * Use these functions to interact with the backend server when working with server side rendered pages.
 */

/**
 * Get the current lessor from the backend server using the current session.
 * @returns {Promise<Lessor>} - The lessor data.
 */
export const getLessor = async (): Promise<Lessor> => {
  const session = cookies().get("session")?.value;
  try {
    const response = await fetch(Config.baseUrl + "/lessor", {
      method: "GET",
      credentials: "include",
      headers: {
        Cookie: `session=${session}`,
      },
    });
    return response.json();
  } catch (error) {
    console.error("Error getting lessor:", error);
    throw error;
  }
};

It also makes a GET request to the server, this time to the /lessor endpoint, with the session cookie included for session validation.

On the server, the getLessor endpoint is protected by the SessionGuard, ensuring that the request is from an authenticated session:

// Server: getLessor Endpoint Protected by SessionGuard
@UseGuards(SessionGuard)
@Get()
async getLessor(@Req() req) {
  return this.lessorService.getLessor(req.auth);
}

The SessionGuard verifies the session cookie and attaches the decoded token (containing user information like UID) to the request object (req.auth). This mechanism ensures that the lessor data is fetched securely and is specific to the authenticated user.

The integration of these components and functions in the Dashboard illustrates how gated content is securely accessed and rendered in our application. By combining front-end requests with server-side session validation, we provide a secure and personalized user experience, ensuring that sensitive data is accessible only to authenticated users.

Sign Out: Completing the User Journey

The sign-out process is an essential aspect of the user journey, providing a secure and convenient way for users to end their session. Let's break down how the SignOut component in the client works in tandem with the server to securely terminate a session.

Front-End: Initiating the Sign Out

The SignOut component on the client side initiates the sign-out process:

// Front-End: SignOut Component
"use client";
import { useRouter } from "next/navigation";
import { signOutWithFirebase } from "@/services/firebase/auth";
import { removeSession } from "@/services/auth";

export function SignOut() {
  const router = useRouter();

  const handleSignOut = async () => {
    await signOutWithFirebase(); // Sign out from Firebase
    const { status } = await removeSession(); // Request to server to remove session
    if (status === "success") {
      router.push("/auth"); // Redirect to the auth page
    }
  };

  // Return button for sign out
  return (
    // ... JSX for sign-out button ...
  );
}

This component performs two main actions:

  1. Firebase Sign Out: It uses signOutWithFirebase, which leverages Firebase's signOut function to end the Firebase session.
// Firebase: Sign Out Function
import { signOut } from "firebase/auth";

import { auth } from "../firebase-config";

export const signOutWithFirebase = () => {
  return signOut(auth);
};

  1. Server Session Removal: It then invokes removeSession to notify the server to clear the session.
// Axios: removeSession Function
import axios from "axios";

import { Config } from "@/services/config";
import { SuccessResponse } from "@/core/entities";

const authApi = axios.create({
  baseURL: Config.baseUrl + "/auth",
  withCredentials: true,
});

export const removeSession = async (): Promise<SuccessResponse> => {
  try {
    const response = await authApi.delete<SuccessResponse>("");
    return response.data;
  } catch (error) {
    console.error("Error removing session:", error);
    throw error;
  }
};

Back-End: Terminating the Session

Upon receiving the request from the front-end, the server endpoint handles the session termination:

// Server: Endpoint to Remove Session
@UseGuards(SessionGuard)
@Delete()
removeSession(@Req() req, @Res() res: Response) {
  res.clearCookie('session', this.cookieOptions); // Clear the session cookie
  res.send(JSON.stringify({ status: 'success' })); // Send success response
}

This endpoint, protected by SessionGuard, clears the session cookie, thus finalizing the sign-out process on the server side.

This complete sign-out mechanism, spanning from the front-end to the back-end, ensures that users can securely and easily end their sessions. By integrating Firebase's authentication system with our server's session management, we provide a robust and user-friendly way for users to sign out, maintaining the security and integrity of our application.

Wrapping Up

We've journeyed through the intricacies of implementing a session-based authentication system using NestJS, Next.js 14, Firebase, and MongoDB. From the initial user signup to the secure access of gated content and the final sign out, we've covered the essential aspects of creating a robust and user-friendly authentication flow. I hope this exploration has provided valuable insights into building efficient, secure authentication systems.

For those who are keen to dive deeper or replicate similar functionalities, the entire codebase for this project is available on GitHub. Feel free to explore it, fork it, or use it as a reference for your projects:

GitHub Repo Link

If you have any questions, suggestions, or just want to discuss more on this topic, don't hesitate to reach out.