Authentication in monorepo(NextJs, Astro) with Lucia and MongoDB

skorphil

Philipp Rich

Posted on June 6, 2024

Authentication in monorepo(NextJs, Astro) with Lucia and MongoDB

This guide will walk you through setting up a simple authentication in a monorepo environment. It covers the common scenario when multiple applications (e.g. landing page and web app), built with different frameworks need to share the same authentication mechanism.

  1. Create a monorepo mockup (with turborepo)
  2. Create a shared package to work with MongoDB database (with mongoose)
  3. Create a shared package to manage auth across monorepo (with lucia-auth)
  4. Set up user validation in Astro.js
  5. Set up user validation in Next.js

For all NPM packages, I explicitly specified the latest versions by the moment of writing (instead of @latest) so this guide can be reproduced in a future. It is recommended to use @latest version of packages since they should be more secure and stable.


Project overview

  • mysite.com – landing page built with Astro
    Publicly available
    Provides login/signup page
    Redirects authenticated users to app.mysite.com

  • app.mysite.com – web application built with NextJs (app Router)
    Available only for authenticated users
    Provides sign-out feature
    Redirects unauthenticated users to mysite.com

Stack

  • Astro js
  • Next.js (app router)
  • Lucia-auth
  • Mongoose
  • TurboRepo
  • npm
  • dotenv

Source code

GitHub - skorphil/monorepo-auth


Prerequisites

  • MongoDB atlas(free account will do)

Part 1. Create monorepo mockup

For simplicity starter packages of TurboRepo(with NextJs) and Astro will be used.

Monorepo structure

Monorepo structure graph

  • db-utils - provides simple db methods to work with MongoDB: createUser(), getUser(). These methods are used by auth-utils.
  • auth-utils - provides methods to create users and user sessions. Used by web and landing
  • web - web application, accessible only for authenticated users. Provides log-out function
  • landing - public landing page. Provides logout and login form. Inaccessible for authenticated users

Install Turborepo

Install Turborepo starter package:

npx create-turbo@1.13.3

# ? Where would you like to create your turborepo? ./monorepo-auth
# ? Which package manager do you want to use? npm workspaces
Enter fullscreen mode Exit fullscreen mode

Create landing page (@monorepo-auth/landing)

Install Astro starter package inside {monorepo}/apps/landing

npm create astro@4.8.0

# Where should we create your new project? ./apps/landing
# How would you like to start your new project? Include sample files
# Do you plan to write TypeScript? Yes
# How strict should TypeScript be? Strict
# Install dependencies? Yes
# Initialize a new git repository? No
Enter fullscreen mode Exit fullscreen mode

Rename the package to maintain consistency:

// apps/landing/package.json

- "name": "monorepo-auth-apps-landing",
+ "name": "@monorepo-auth/landing",
Enter fullscreen mode Exit fullscreen mode

Create web app (@monorepo-auth/web)

Next.js starter package is already being created with a turborepo, so just rename it:

// apps/web/package.json

- "name": "web",
+ "name": "@monorepo-auth/web",
Enter fullscreen mode Exit fullscreen mode

Delete {monorepo}/apps/docs package, so there is only 2 packages left in apps directory:

# Monorepo structure so far

monorepo-auth/
└── apps/
    ├── web # @monorepo-auth/web
    └── landing # @monorepo-auth/landing
Enter fullscreen mode Exit fullscreen mode

Test run npm run dev to make sure everything works as expected. In my case landing runs at localhost:4321 and web runs at localhost:3000.
If everything is working it's time to set up an authentication.


Part 2. Create database utilities (@monorepo-auth/db-utils)

Database methods are usually used among multiple packages inside the project, this is why it is better to create them in a separate package. Only a few methods are needed for now: createUser() method for the sign-up form and getUser() for the login form. Also, lucia mongodb adapter needs dbConnect() method.

Create a db-utils package. I created it in {monorepo}/packages

mkdir packages/db-utils && touch packages/db-utils/package.json && touch packages/db-utils/.env
Enter fullscreen mode Exit fullscreen mode

Get connection string(URI) for your ModgoDB Atlas: Connection Strings - MongoDB Manual v7.0

Add URI to the created .env file.

# monorepo-auth/packages/db-utils/.env

MONGO_URI="mongodb_uri_here"
Enter fullscreen mode Exit fullscreen mode

Set up Turborepo to use created .env. I used dotenv-cli to make global .env file accessible by all packages. Install it to the monorepo root:

npm install dotenv-cli@7.4.2
Enter fullscreen mode Exit fullscreen mode

Add globalDotEnv to turbo.json config:

// monorepo-auth/turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
+ "globalDotEnv": [".env"],
Enter fullscreen mode Exit fullscreen mode

Edit global package.json to run turbo with dotenv

// monorepo-auth/package.json

"scripts": {
    "build": "turbo build",
+   "dev": "dotenv -- turbo dev",
Enter fullscreen mode Exit fullscreen mode

Continue creating db-utils. Edit db-utils package.json:

// monorepo-auth/packages/db-utils/package.json

{
    "name": "@monorepo-auth/db-utils",
    "type": "module",
    "exports": "./index.js",
    "version": "0.0.1"
}
Enter fullscreen mode Exit fullscreen mode

Install necessary packages to @monorepo-auth/db-utils

npm install mongoose@8.4.0 @lucia-auth/adapter-mongodb@1.0.3 --workspace="@monorepo-auth/db-utils"
Enter fullscreen mode Exit fullscreen mode

Create dbConnect() method is used to connect to a specified mongo database.

// monorepo-auth/packages/db-utils/lib/dbConnect.js

import { connect } from "mongoose";

export async function dbConnect() {
  try {
    await connect(process.env.MONGO_URI);
    console.debug("Database connected");
  } catch (error) {
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Create User and Session models.

I followed recommendations from Lucia docs and expanded userSchema to include username and hashed_password along with _id:

// monorepo-auth/packages/db-utils/user.model.js

import { Schema, model, models } from "mongoose";

const userSchema = new Schema(
  {
    _id: {
      type: String,
      required: true,
    },
    username: {
      type: String,
      required: true,
    },
    password_hash: {
      type: String,
      required: true,
    },
  },
  { _id: false } // default mongodb _id will be replaced by custom _id, which is being generated from entropy as Lucia docs suggesting
);

export default models.User || model("User", userSchema);


Enter fullscreen mode Exit fullscreen mode
// monorepo-auth/packages/db-utils/lib/session.model.js

import { Schema, model, models } from "mongoose";

const sessionSchema = new Schema(
  {
    _id: {
      type: String,
      required: true,
    },
    user_id: {
      type: String,
      required: true,
    },
    expires_at: {
      type: Date,
      required: true,
    },
  },
  { _id: false }
);

export default models.Record || model("Session", sessionSchema);
Enter fullscreen mode Exit fullscreen mode

Create createUser() and getUser() methods.

// monorepo-auth/packages/db-utils/lib/createUser.js

import { dbConnect } from "./dbConnect";
import User from "../models/user.model";

export async function createUser(userData) {
  const user = await new User(userData);
  try {
    await dbConnect();
    await user.save();
    console.debug("User saved to db");
  } catch (error) {
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode
// monorepo-auth/packages/db-utils/lib/createUser.js

import User from "../models/user.model";

export async function getUser(userData) {
  const user = await User.findOne(userData, {
    _id: 1,
    password_hash: 1,
    username: 1,
  });
  if (user) {
    return user;
  } else return false;
}
Enter fullscreen mode Exit fullscreen mode

Create Lucia adapter

// monorepo-auth/packages/db-utils/lib/adapter.js

import { dbConnect } from "./dbConnect";
import { MongodbAdapter } from "@lucia-auth/adapter-mongodb";
import mongoose from "mongoose";

await dbConnect();

export const adapter = new MongodbAdapter(
  mongoose.connection.collection("sessions"),
  mongoose.connection.collection("users")
);
Enter fullscreen mode Exit fullscreen mode

Create interface for db-utils

To export created methods, create index.js in the root of db-utils package:

// monorepo-auth/packages/db-utils/index.js

import { dbConnect } from "./lib/dbConnect";
import { createUser } from "./lib/createUser";
import { getUser } from "./lib/checkUser";
import { adapter } from "./lib/adapter";

export { createUser, adapter, dbConnect, getUser };
Enter fullscreen mode Exit fullscreen mode

db-utils package ready and can be used by auth-utils.

# db-utils package structure

db-utils/
├── lib/
│   ├── dbConnect.js
│   ├── createUser.js
│   └── getUser.js
├── models/
│   ├── session.model.js
│   └── user.model.js
├── package.json
└── index.js
Enter fullscreen mode Exit fullscreen mode

Part 3. Setup Lucia-auth (@monorepo-auth/auth-utils)

Since both apps will use auth, it is better to define auth methods in a separate package.

Create an auth-utils package. I created it in {monorepo}/packages:

mkdir packages/auth-utils && touch packages/auth-utils/package.json && touch packages/auth-utils/tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Edit created package.json and tsconfig.json

// monorepo-auth/packages/auth-utils/package.json

{
    "name": "@monorepo-auth/auth-utils",
    "type": "module",
    "exports": "./index.js",
    "version": "0.0.1"
}
Enter fullscreen mode Exit fullscreen mode
// monorepo-auth/packages/auth-utils/tsconfig.json

{
    "compilerOptions": {
        "noImplicitAny": false, // i specified this to allow imports of undeclared js modules (db-utils)
        "module": "ESNext",
        "target": "ESNext",
        "moduleResolution":"Bundler"
    }
}
Enter fullscreen mode Exit fullscreen mode

Install necessary packages to @monorepo-auth/auth-utils

npm install lucia@3.2.0 --workspace="@monorepo-auth/auth-utils"
Enter fullscreen mode Exit fullscreen mode

Create lucia module

I've followed Lucia docs here, performing some decomposition.

// monorepo-auth/packages/auth-utils/auth.ts

import { adapter } from "@monorepo-auth/db-utils";
import { Lucia } from "lucia";

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: /* import.meta.env.PROD */ false,
    },
  },
  getUserAttributes: (attributes) => {
    return {
      username: attributes.username,
    };
  },
});

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
  }
}

interface DatabaseUserAttributes {
  username: string;
}
Enter fullscreen mode Exit fullscreen mode

Create auth-utils interface

There is only a single export needed so far.

// monorepo-auth/packages/auth-utils/index.ts

export { lucia } from "./auth";
Enter fullscreen mode Exit fullscreen mode

auth-utils package is ready and it is time to implement auth in web and landing packages.

# auth-utils package structure

auth-utils/
├── tsconfig.json
├── package.json
├── index.ts
└── auth.ts
Enter fullscreen mode Exit fullscreen mode

Part 4. Implement auth in @monorepo-auth/landing

Create middleware

Astro middleware use lucia to manage user sessions. It defines session and user in context.locals making it accessible by other parts of an app.

// monorepo-auth/landing/src/middleware.ts

import { lucia, verifyRequestOrigin } from "@monorepo-auth/auth-utils";
import { defineMiddleware } from "astro:middleware";

export const onRequest = defineMiddleware(async (context, next) => {
  if (context.request.method !== "GET") {
    const originHeader = context.request.headers.get("Origin");
    const hostHeader = context.request.headers.get("Host");
    if (
      !originHeader ||
      !hostHeader ||
      !verifyRequestOrigin(originHeader, [hostHeader])
    ) {
      return new Response(null, {
        status: 403,
      });
    }
  }

  const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null;
  if (!sessionId) {
    context.locals.user = null;
    context.locals.session = null;
    return next();
  }

  const { session, user } = await lucia.validateSession(sessionId);
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  context.locals.session = session;
  context.locals.user = user;
  return next();
});

Enter fullscreen mode Exit fullscreen mode

Declare session and user types

// monorepo-auth/landing/src/env.d.ts

/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    session: import("lucia").Session | null;
    user: import("lucia").User | null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Lucia works only in Astro server mode, so edit astro.config.mjs:

// monorepo-auth/landing/astro.config.mjs

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  output: "server",
  adapter: node({
    mode: "standalone",
  }),
});
Enter fullscreen mode Exit fullscreen mode

Enabling server mode requires to install @astrojs/node adapter

npm install @astrojs/node@8.2.5 --workspace="@monorepo-auth/landing"
Enter fullscreen mode Exit fullscreen mode

Create signup form and API

I strictly followed lucia docs to make it more simple, so I created login and signup pages in landing package. However, to achieve modular and flexible architecture they can be created as a part of separate auth package with respective redirects.
API and signup form are copies from lucia docs, but imports shared db-utils and auth-utils:

// monorepo-auth/landing/src/pages/api/signup.ts

import { lucia } from "@monorepo-auth/auth-utils";
import { createUser } from "@monorepo-auth/db-utils";

import { hash } from "@node-rs/argon2";
import { generateIdFromEntropySize } from "lucia";

import type { APIContext } from "astro";

export async function POST(context: APIContext): Promise<Response> {
  const formData = await context.request.formData();
  const username = formData.get("username");
  // username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _
  // keep in mind some database (e.g. mysql) are case insensitive
  if (
    typeof username !== "string" ||
    username.length < 3 ||
    username.length > 31 ||
    !/^[a-z0-9_-]+$/.test(username)
  ) {
    return new Response("Invalid username", {
      status: 400,
    });
  }
  const password = formData.get("password");
  if (
    typeof password !== "string" ||
    password.length < 6 ||
    password.length > 255
  ) {
    return new Response("Invalid password", {
      status: 400,
    });
  }

  const userId = generateIdFromEntropySize(10); // 16 characters long
  const passwordHash = await hash(password, {
    // recommended minimum parameters
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  // TODO: check if username is already used
  await createUser({
    _id: userId,
    username: username,
    password_hash: passwordHash,
  });

  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  context.cookies.set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return context.redirect("/");
}
Enter fullscreen mode Exit fullscreen mode

Create signup form:

<!--monorepo-auth/landing/src/pages/signup.astro-->

<html lang="en">
    <body>
        <h1>Signup Page</h1>
        <form method="post" action="/api/signup">
            <label for="username">Username</label>
            <input id="username" name="username" />
            <label for="password">Password</label>
            <input id="password" name="password" />
            <button>Continue</button>
        </form>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Add signup form link to index.astro to simplify navigation. I deleted original content of index.astro to make it simpler:

// monorepo-auth/landing/src/pages/index.astro

<Layout title="Welcome to Astro.">
  <main>
    <h1>Landing page</h1>
+   <a href="/signup">Signup</a>
  </main>
</Layout>

Enter fullscreen mode Exit fullscreen mode

To check if sign up feature is working:

  1. Launch project npm run dev
  2. Create new user on http://localhost:4321/signup In MongoDB atlas there should be a new user in users collection as well as a corresponding session in sessions collection.

Mongodb screenshot
In browser there should be auth_session cookie
Browser devtools screenshot

Create login form and API

// monorepo-auth/landing/src/pages/api/login.ts

import { lucia } from "@monorepo-auth/auth-utils";
import { getUser } from "@monorepo-auth/db-utils";

import { verify } from "@node-rs/argon2";
import type { APIContext } from "astro";

interface UserDocument extends Document {
  _id: string;
  username: string;
  password_hash: string;
}

export async function POST(context: APIContext): Promise<Response> {
  const formData = await context.request.formData();
  const username = formData.get("username");
  if (
    typeof username !== "string" ||
    username.length < 3 ||
    username.length > 31 ||
    !/^[a-z0-9_-]+$/.test(username)
  ) {
    return new Response("Invalid username", {
      status: 400,
    });
  }
  const password = formData.get("password");
  if (
    typeof password !== "string" ||
    password.length < 6 ||
    password.length > 255
  ) {
    return new Response("Invalid password", {
      status: 400,
    });
  }

  const existingUser = await getUser({ username: username });
  console.log(existingUser);
  if (!existingUser) {
    // NOTE:
    // Returning immediately allows malicious actors to figure out valid usernames from response times,
    // allowing them to only focus on guessing passwords in brute-force attacks.
    // As a preventive measure, you may want to hash passwords even for invalid usernames.
    // However, valid usernames can be already be revealed with the signup page among other methods.
    // It will also be much more resource intensive.
    // Since protecting against this is non-trivial,
    // it is crucial your implementation is protected against brute-force attacks with login throttling etc.
    // If usernames are public, you may outright tell the user that the username is invalid.
    return new Response("Incorrect username or password", {
      status: 400,
    });
  }

  const validPassword = await verify(existingUser.password_hash, password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });
  if (!validPassword) {
    return new Response("Incorrect username or password", {
      status: 400,
    });
  }

  const session = await lucia.createSession(existingUser._id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  context.cookies.set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return context.redirect("/");
}
Enter fullscreen mode Exit fullscreen mode
<!--monorepo-auth/landing/src/pages/login.astro-->

<html lang="en">
    <body>
        <h1>Login Page</h1>
        <form method="post" action="/api/login">
            <label for="username">Username</label>
            <input id="username" name="username" />
            <label for="password">Password</label>
            <input id="password" name="password" />
            <button>Continue</button>
        </form>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Add login form link to index.astro:

// landing/src/pages/index.astro

<Layout title="Welcome to Astro.">
  <main>
    <h1>Landing page</h1>
    <a href="/signup">Signup</a>
+   <a href="/login">Login</a>
  </main>
</Layout>

Enter fullscreen mode Exit fullscreen mode

Redirect authenticated user to web app

For convenience create environment variables in root .env file with urls on which they run. In my case:

# monorepo-auth/packages/db-utils/.env

MONGO_URI="mongodb_uri_here"
+ WEB_URL="http://localhost:3000"
+ LANDING_URL="http://localhost:4321"
Enter fullscreen mode Exit fullscreen mode

After middleware created user in context.locals, it can be checked in astro pages within frontmatter:

---
const user = Astro.locals.user;
if (user) {
  return Astro.redirect(process.env.WEB_URL);
}
---
Enter fullscreen mode Exit fullscreen mode

Now if the user is authenticated it will be redirected to web.


Part 5. Implement auth in @monorepo-auth/web

The last part of this guide covers setting up web package to redirect unauthenticated users to the landing page and provide log-out feature.

Validate users in server components

Create validateRequest() function in auth.ts. It is a copy from Lucia documentation with a different lucia import.

// web/utils/auth.ts

import { cookies } from "next/headers";
import { cache } from "react";
import { lucia } from "@monorepo-auth/auth-utils"; // lucia instance from shared auth-utils

import type { Session, User } from "lucia";

export const validateRequest = cache(
  async (): Promise<
    { user: User; session: Session } | { user: null; session: null }
  > => {
    const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
    if (!sessionId) {
      return {
        user: null,
        session: null,
      };
    }

    const result = await lucia.validateSession(sessionId);
    // next.js throws when you attempt to set cookie when rendering page
    try {
      if (result.session && result.session.fresh) {
        const sessionCookie = lucia.createSessionCookie(result.session.id);
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes
        );
      }
      if (!result.session) {
        const sessionCookie = lucia.createBlankSessionCookie();
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes
        );
      }
    } catch {}
    return result;
  }
);
Enter fullscreen mode Exit fullscreen mode

validateRequest() can be used on server components to check if a user is authenticated. Setting up validation in client component requires setting up API or context, which is not covered in this guide.
Add redirect to landing for unauthenticated users:

// monorepo/web/app/page.tsx

import { validateRequest } from "../utils/auth";
import type { ActionResult } from "next/dist/server/app-render/types";
import { redirect } from "next/navigation"


export default async function ProtectedPage() {
  const { user } = await validateRequest();
  if (!user) {
    return redirect(process.env.LANDING_URL);
  }
  return (
    <>
      <h1>Web-app</h1>
      <h2>Hi, {user.username}!</h2>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create logout button in Next.js

Since authenticated users don't have access to landing page (it redirects them to web), logout feature should be implemented in web package:

// monorepo/web/app/page.tsx

import { validateRequest } from "../utils/auth";
import { lucia } from "@monorepo-auth/auth-utils";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

import type { ActionResult } from "next/dist/server/app-render/types";


export default async function ProtectedPage() {
  const { user } = await validateRequest();
  if (!user) {
    return redirect(process.env.LANDING_URL as string);
  }
  return (
    <>
      <h1>Web-app</h1>
      <h2>Hi, {user.username}!</h2>
      <form action={logout}>
        <button>Sign out</button>
      </form>
    </>
  );
}

async function logout(): Promise<ActionResult> {
  "use server";
  const { session } = await validateRequest();
  if (!session) {
    return {
      error: "Unauthorized",
    };
  }

  await lucia.invalidateSession(session.id);

  const sessionCookie = lucia.createBlankSessionCookie();
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
  return redirect(process.env.LANDING_URL as string);
}
Enter fullscreen mode Exit fullscreen mode

Outcome

  • Both packages in a monorepo can access user session and validate if user is authenticated.
  • db-utils and auth-utils can be used by other packages that might be added to monorepo in the future.
  • project source code: GitHub - skorphil/monorepo-auth

Further reading:

Happy coding!
Feedback is appreciated.

💖 💪 🙅 🚩
skorphil
Philipp Rich

Posted on June 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related