Skip to main content

Authentication

CashGap uses NextAuth v5 with multiple authentication providers and a MongoDB adapter for session management.

Overview

The authentication system supports:
  • Credentials authentication with email/password
  • Google OAuth for social login
  • Email verification for new registrations
  • JWT-based sessions for stateless authentication
  • Protected routes with middleware

NextAuth Configuration

Auth Setup

The main auth configuration is in auth.ts:
auth.ts
import NextAuth from "next-auth";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { z } from "zod";
import clientPromise from "@/lib/db/mongodb";

const credentialsSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const nextAuth = NextAuth({
  adapter: MongoDBAdapter(clientPromise),
  session: { strategy: "jwt" },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        try {
          const { email, password } = credentialsSchema.parse(credentials);

          const client = await clientPromise;
          const db = client.db();
          const usersCollection = db.collection("users");

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

          if (!user || !user.password) {
            return null;
          }

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

          if (!isValid) {
            return null;
          }

          return {
            id: user._id.toString(),
            email: user.email,
            name: user.name,
          };
        } catch (error) {
          console.error("Auth error:", error);
          return null;
        }
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
});

export const { handlers, signIn, signOut, auth } = nextAuth;

Auth Config

Additional configuration in auth.config.ts:
auth.config.ts
import type { NextAuthConfig } from "next-auth";

export default {
  pages: {
    signIn: "/login",
    newUser: "/register",
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
      
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect to login
      } else if (isLoggedIn) {
        return Response.redirect(new URL("/dashboard", nextUrl));
      }
      return true;
    },
  },
  providers: [], // Providers defined in auth.ts
} satisfies NextAuthConfig;

Authentication Pages

Login Page

The login page uses the shared LoginForm component from @repo/auth:
app/(auth)/login/page.tsx
import { LoginForm, AuthLayout } from "@repo/auth";
import { signIn } from "@/auth";
import Link from "next/link";

export default function LoginPage() {
  const adapter = {
    signIn: async ({ email, password }: { email: string; password: string }) => {
      "use server";
      const result = await signIn("credentials", {
        email,
        password,
        redirect: false,
      });
      if (result?.error) throw new Error(result.error);
    },
  };

  return (
    <AuthLayout>
      <LoginForm
        adapter={adapter}
        signInWithGoogle={async () => {
          "use server";
          await signIn("google", { redirectTo: "/dashboard" });
        }}
      />
      <p className="text-center text-sm text-muted-foreground mt-4">
        Don't have an account?{" "}
        <Link href="/register" className="text-primary hover:underline">
          Sign up
        </Link>
      </p>
    </AuthLayout>
  );
}

Register Page

Registration with email verification:
app/(auth)/register/page.tsx
import { RegisterForm, AuthLayout } from "@repo/auth";
import { signIn } from "@/auth";
import Link from "next/link";

export default function RegisterPage() {
  const adapter = {
    register: async ({
      email,
      password,
      name,
    }: {
      email: string;
      password: string;
      name?: string;
    }) => {
      "use server";
      
      // Call registration API
      const response = await fetch(
        `${process.env.NEXTAUTH_URL}/api/auth/register`,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email, password, name }),
        }
      );

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || "Registration failed");
      }

      // Auto-login after registration
      await signIn("credentials", {
        email,
        password,
        redirectTo: "/dashboard",
      });
    },
  };

  return (
    <AuthLayout>
      <RegisterForm
        adapter={adapter}
        signInWithGoogle={async () => {
          "use server";
          await signIn("google", { redirectTo: "/dashboard" });
        }}
      />
      <p className="text-center text-sm text-muted-foreground mt-4">
        Already have an account?{" "}
        <Link href="/login" className="text-primary hover:underline">
          Sign in
        </Link>
      </p>
    </AuthLayout>
  );
}

Registration API

User Registration Endpoint

The registration endpoint handles user creation:
app/api/auth/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import clientPromise from "@/lib/db/mongodb";

const registerSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  name: z.string().optional(),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { email, password, name } = registerSchema.parse(body);

    const client = await clientPromise;
    const db = client.db();
    const usersCollection = db.collection("users");

    // Check if user already exists
    const existingUser = await usersCollection.findOne({ email });
    if (existingUser) {
      return NextResponse.json(
        { message: "User already exists" },
        { status: 400 }
      );
    }

    // Hash password
    const hashedPassword = await bcrypt.hash(password, 12);

    // Create user
    const result = await usersCollection.insertOne({
      email,
      password: hashedPassword,
      name: name || null,
      emailVerified: null,
      image: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    return NextResponse.json(
      {
        message: "User created successfully",
        userId: result.insertedId.toString(),
      },
      { status: 201 }
    );
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { message: error.errors[0].message },
        { status: 400 }
      );
    }

    console.error("Registration error:", error);
    return NextResponse.json(
      { message: "Internal server error" },
      { status: 500 }
    );
  }
}

Email Verification

Verification Token Model

Email verification tokens are stored in MongoDB:
lib/db/models.ts
export interface EmailVerificationTokenDocument extends Document {
  email: string;
  token: string;
  code: string; // 6-digit verification code
  expiresAt: Date;
  verified: boolean;
  registrationData?: {
    password: string; // Hashed password
    name?: string;
  };
}

const emailVerificationTokenSchema = new Schema<EmailVerificationTokenDocument>({
  email: { type: String, required: true, lowercase: true, trim: true },
  token: { type: String, required: true, unique: true },
  code: { type: String, required: true },
  expiresAt: { type: Date, required: true },
  verified: { type: Boolean, default: false },
  registrationData: {
    password: { type: String },
    name: { type: String },
  },
}, { timestamps: true });

// TTL index: automatically delete expired tokens
emailVerificationTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

Route Protection

Middleware

Protect routes with Next.js middleware:
middleware.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const { nextUrl } = req;
  const isLoggedIn = !!req.auth;

  const isAuthPage = nextUrl.pathname.startsWith("/login") ||
                     nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = nextUrl.pathname.startsWith("/dashboard") ||
                          nextUrl.pathname.startsWith("/income") ||
                          nextUrl.pathname.startsWith("/expenses") ||
                          nextUrl.pathname.startsWith("/subscriptions") ||
                          nextUrl.pathname.startsWith("/settings");

  if (isProtectedRoute && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", nextUrl));
  }

  if (isAuthPage && isLoggedIn) {
    return NextResponse.redirect(new URL("/dashboard", nextUrl));
  }

  return NextResponse.next();
});

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

API Authentication

Request Authentication Helper

Authenticate API requests:
lib/api/utils.ts
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";

export async function authenticateRequest(request: NextRequest) {
  const session = await auth();

  if (!session?.user?.id) {
    return {
      auth: null,
      error: NextResponse.json(
        { error: "Unauthorized" },
        { status: 401 }
      ),
    };
  }

  return {
    auth: {
      userId: session.user.id,
      email: session.user.email,
      name: session.user.name,
    },
    error: null,
  };
}

Using in API Routes

app/api/income/route.ts
import { authenticateRequest } from "@/lib/api/utils";

export async function GET(request: NextRequest) {
  const { auth, error: authError } = await authenticateRequest(request);
  if (authError) return authError;

  // Use auth.userId to fetch user-specific data
  const incomes = await IncomeModel.find({ userId: auth.userId });
  
  return NextResponse.json(incomes);
}

Session Management

Client-Side Session

Access session data in client components:
import { useSession } from "next-auth/react";

export default function Component() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <div>Loading...</div>;
  }

  if (status === "unauthenticated") {
    return <div>Please sign in</div>;
  }

  return <div>Welcome, {session.user.name}!</div>;
}

Server-Side Session

Access session in Server Components:
import { auth } from "@/auth";

export default async function Page() {
  const session = await auth();

  if (!session) {
    redirect("/login");
  }

  return <div>Hello, {session.user.name}</div>;
}

Sign Out

Implement sign out functionality:
import { signOut } from "@/auth";

<button
  onClick={async () => {
    await signOut({ redirectTo: "/login" });
  }}
>
  Sign Out
</button>

Environment Variables

Required environment variables for authentication:
.env.local
# MongoDB
MONGODB_URI=mongodb://localhost:27017/cashgap

# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-here

# Google OAuth (Optional)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
Generate a secure NEXTAUTH_SECRET using: openssl rand -base64 32

Next Steps

Income Tracking

Implement income management features

Expense Management

Build expense tracking functionality

Build docs developers (and LLMs) love