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

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

Set up simple authentication in a monorepo environment


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:

Terminal window
# ? Where would you like to create your turborepo? ./monorepo-auth
# ? Which package manager do you want to use? npm workspaces

Create landing page (@monorepo-auth/landing)

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

Terminal window
# 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

Rename the package to maintain consistency:

apps/landing/package.json
"name": "monorepo-auth-apps-landing",
"name": "@monorepo-auth/landing",

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",

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

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

Terminal window
mkdir packages/db-utils && touch packages/db-utils/package.json && touch packages/db-utils/.env

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"

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:

Terminal window
npm install [email protected]

Add globalDotEnv to turbo.json config:

monorepo-auth/turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalDotEnv": [".env"],

Edit global package.json to run turbo with dotenv

monorepo-auth/package.json
"scripts": {
"build": "turbo build",
"dev": "dotenv -- turbo dev",

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"
}

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

Terminal window
npm install [email protected] @lucia-auth/[email protected] --workspace="@monorepo-auth/db-utils"

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;
}
}

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);
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);

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;
}
}
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;
}

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")
);

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 };

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

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:

Terminal window
mkdir packages/auth-utils && touch packages/auth-utils/package.json && touch packages/auth-utils/tsconfig.json

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"
}
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"
}
}

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

Terminal window
npm install [email protected] --workspace="@monorepo-auth/auth-utils"

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;
}

Create auth-utils interface

There is only a single export needed so far.

monorepo-auth/packages/auth-utils/index.ts
export { lucia } from "./auth";

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

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();
});

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;
}
}

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",
}),
});

Enabling server mode requires to install @astrojs/node adapter

npm install @astrojs/[email protected] --workspace="@monorepo-auth/landing"

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("/");
}

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>

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>

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("/");
}
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>

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>

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"

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);
}
---

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;
}
);

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>
</>
);
}

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);
}

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.


← Back to home