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

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.

Create a monorepo mockup (with turborepo)
Create a shared package to work with MongoDB database (with mongoose)
Create a shared package to manage auth across monorepo (with lucia-auth)
Set up user validation in Astro.js
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

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

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

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

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:

npm install dotenv-cli@7.4.2

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

npm install mongoose@8.4.0 @lucia-auth/adapter-mongodb@1.0.3 –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:

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

npm install lucia@3.2.0 –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/node@8.2.5 –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:

Launch project npm run dev

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.


In browser there should be auth_session cookie

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:

Lucia documentation
Building Your Application: Authentication | Next.js
Authentication | Astro Docs
The Copenhagen Book
Mongoose v8.4.1: Getting Started

Happy coding!
Feedback is appreciated.