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)