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 toapp.mysite.com
app.mysite.com
– web application built with NextJs (app Router)
Available only for authenticated users
Provides sign-out feature
Redirects unauthenticated users tomysite.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 byauth-utils
. -
auth-utils
- provides methods to create users and user sessions. Used byweb
andlanding
-
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 inusers
collection as well as a corresponding session insessions
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
andauth-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.
Top comments (0)