Authentication in monorepo(NextJs, Astro) with Lucia and MongoDB
Tags: webdev, tutorial, security, react
Published: 2024-06-06 17:00:00

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](https://github.com/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4cysfylayw0fi35elsre.png)

- `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:
```shell
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`
```shell
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:
```diff
// 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:

```diff
// 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`
```shell
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](https://www.mongodb.com/docs/manual/reference/connection-string/)

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*:
```shell
npm install dotenv-cli@7.4.2
```

Add `globalDotEnv` to `turbo.json` config:
```diff
// monorepo-auth/turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
+ "globalDotEnv": [".env"],
```

Edit global `package.json` to run `turbo` with `dotenv`
```diff
// monorepo-auth/package.json

"scripts": {
    "build": "turbo build",
+   "dev": "dotenv -- turbo dev",
```

Continue creating db-utils. Edit db-utils `package.json`:
```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`
```shell
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.
```js
// 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`:
```js
// 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);


```

```js
// 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.
```js
// 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;
  }
}
```

```js
// 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`
```js
// 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:
```js
// 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`:
```shell
mkdir packages/auth-utils && touch packages/auth-utils/package.json && touch packages/auth-utils/tsconfig.json
```

Edit created `package.json` and `tsconfig.json`
```json
// monorepo-auth/packages/auth-utils/package.json

{
    "name": "@monorepo-auth/auth-utils",
    "type": "module",
    "exports": "./index.js",
    "version": "0.0.1"
}
```

```json
// 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`
```shell
npm install lucia@3.2.0 --workspace="@monorepo-auth/auth-utils"
```

### Create `lucia` module
I've followed Lucia docs here, performing some decomposition.
```ts
// 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.
```js
// 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.
```ts
// 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
```ts
// 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`:
```js
// 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`:

```ts
// 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:
```html
<!--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:

```diff
// 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ji8b40dcxczl67uiit71.png)
In browser there should be `auth_session` cookie
![Browser devtools screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x95g2kpz7abyjrs1tvi0.png)


### Create login form and API
```ts
// 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("/");
}
```

```html
<!--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`:
```diff
// 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:
```diff
# 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:
```js
---
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.
```ts
// 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:

```tsx
// 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:

```tsx
// 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](https://github.com/skorphil/monorepo-auth)


## Further reading:
- [Lucia documentation](https://lucia-auth.com)
- [Building Your Application: Authentication | Next.js](https://nextjs.org/docs/pages/building-your-application/authentication#authentication)
- [Authentication | Astro Docs](https://docs.astro.build/en/guides/authentication/#lucia)
- [The Copenhagen Book](https://thecopenhagenbook.com)
- [Mongoose v8.4.1: Getting Started](https://mongoosejs.com/docs/)


Happy coding! 
Feedback is appreciated.