Philipp Rich
Posted on June 6, 2024
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.
Posted on June 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.