Discord bot dashboard with OAuth2 (Nextjs)
Tags: nextjs, prisma, oauth, discord
Published: 2024-07-11 21:51:42

## Preface
> I wanted to build a Discord bot with TypeScript that had:
> - A database
> - A dashboard/website/domain
> - An API for interactions & authentication with Discord

I previously created this same "hbd" bot that ran on nodejs runtime, built with the [discord.js](https://discord.js.org/) library.

{% github https://github.com/clxrityy/hbd %}

While, the discord.js library offers a lot of essential utilities for interacting with discord, it doesn't quite fit for a bot that's going to be running through Nextjs/Vercel. 

I wanted the bot to respond to interactions through [edge runtime](https://vercel.com/docs/functions/runtimes/edge-runtime) rather than running in an environment 24/7 waiting for interactions.

Now, bare with me... I am merely learning everything as I go along. 🤖

---
- [Getting started](#getting-started)
  - [Interactions endpoint](#interactions-endpoint)
- [OAuth2](#oauth2)
- [Vercel postgres](#vercel-postgres)
- [Prisma](#prisma)
- [OAuth2 (continued)](#back-to-oauth2)
  - [Access token](#access-token)
  - [Refresh token](#refresh-token)
- [Encryption](#encryption)
- [Cookies / JWT](#cookies-jwt)
- [What's next?](#whats-next)
- [Final product](#final-product)

---

## Getting started

- Copy all the Discord bot values (token, application ID, oauth token, public key, etc.) place them your environment variables locally and on Vercel.
- Clone the template repository
  - Either the initial one: [jzxhuang/nextjs-discord-bot](https://github.com/jzxhuang/nextjs-discord-bot)
  - Or the one I built that already has oauth2 (but I will discuss both): [clxrityy/nextjs-discord-bot-with-oauth](https://github.com/clxrityy/nextjs-discord-bot-with-oauth)

---

Alright, as much as I'd like to take credit for the whole "discord bot with nextjs" implementation, my starting point was finding this extremely useful repository that had already put an interactions endpoint & command registration script into place. 

> [jzxhuang/nextjs-discord-bot](https://github.com/jzxhuang/nextjs-discord-bot)

### Interactions endpoint

- Set your discord bot's [interactions endpoint url](https://discord.com/developers/docs/interactions/slash-commands#receiving-an-interaction) to **`https://<VERCEL_URL>/api/interactions`**.

#### `/api/interactions`

- Set the the runtime to **edge**:
```ts
export const runtime = "edge";
```

- The interaction is [**verified**](https://discord.com/developers/docs/interactions/receiving-and-responding#receiving-an-interaction) to be [received & responded](https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization) to within the route using some [logic](https://github.com/clxrityy/nextjs-discord-bot-with-oauth/blob/main/src/discord/verify-incoming-request.ts) implemented by the template creator that I haven't bothered to understand.
- The interaction data is parsed into a custom type so that it can be interacted with regardless of it's sub-command(s)/option(s) structure:
```ts
export interface InteractionData {
    id: string;
    name: string;
    options?: InteractionSubcommand<InteractionOption>[] | InteractionOption[] | InteractionSubcommandGroup<InteractionSubcommand<InteractionOption>>[];
}
```

The last bit of the interactions endpoint structure (that I'm not entirely proud of) is that I'm using `switch` cases between every command name within the route to execute an external function/handler that generates the response for that specific command. But, this could be more efficient/easier-to-read in the future

```ts
import { commands } from "@/data/commands";

const { name } = interaction.data;
// ...
switch (name) {
    case commands.ping.name:

        embed = {
             title: "Pong!",
             color: Colors.BLURPLE
        }

        return NextResponse.json({
               type: InteractionResponseType.ChannelMessageWithSource,
               data: {
                    embeds: [JSON.parse(JSON.stringify(embed))]
               }
        });
// ...
}
```

![ping command example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9iszt4ei4qyd8l0odayo.gif)


---

## OAuth2
> Authentication endpoint

That template had everything necessary to lift this project off the ground, use interactions, and display UI elements based on the bot's data. 
However, I wanted to create another template I could use that implemented authentication with Discord so that there can be an interactive dashboard.

I will go over the whole process, but you can see in-depth everything I changed about the initial template in this pull request:
{% github https://github.com/jzxhuang/nextjs-discord-bot/pull/4 %}

#### `/api/auth/discord/redirect`

- Add your redirect URI to your Discord application: (should be found at **`https://discord.com/developers/applications/{APP_ID}/oauth2`**)
  - **Development** - `http://localhost:3000/api/auth/discord/redirect`
  - **Production** - `https://VERCEL_URL/api/auth/discord/redirect`

I know off the bat I'm gonna need to start implementing the database aspect of this application now; as I need a way to store user data (such as refresh tokens, user id, etc.)

---

### ... Let's take a brief intermission and talk about [Prisma](https://www.prisma.io/) & [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres)

Vercel has this amazing feature, you can create a [postgresql](https://www.postgresql.org/) database directly through Vercel and connect it to any project(s) you want. 
> *I'm not sponsored but I should be*

#### Vercel Postgres
- `pnpm add @vercel/postgres`
- Install [Vercel CLI](https://vercel.com/docs/cli#installing-vercel-cli)
  - `pnpm i -g vercel@latest`
- [Create a postgres database](https://vercel.com/docs/storage/vercel-postgres/quickstart#create-a-postgres-database)
- Get those environment variables loaded locally
  - `vercel env pull .env.development.local`

#### Prisma
- Install prisma
  - `pnpm add -D prisma`
  - `pnpm add @prisma/client`
- Since I'm going to be using prisma *on the edge* as well, I'm going to install [Prisma Accelerate](https://www.prisma.io/data-platform/accelerate)
  - `pnpm add @prisma/extension-accelerate`

- Initialize the prisma client
  - `npx prisma init`

You should now have `prisma/schema.prisma` in your root directory:

```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("POSTGRES_URL")
  directUrl = env("POSTGRES_URL_NON_POOLING")
}
```
> Make sure `url` & `directUrl` are set to your environment variable values

- Get your accelerate URL: [console.prisma.io](https://console.prisma.io)

##### `src/lib/db.ts`
```ts
import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from '@prisma/extension-accelerate';

function makePrisma() {
    return new PrismaClient({
        datasources: {
            db: {
                url: process.env.ACCELERATE_URL!,
            }
        }
    }).$extends(withAccelerate());
}

const globalForPrisma = global as unknown as {
    prisma: ReturnType<typeof makePrisma>;
}

export const db = globalForPrisma.prisma ?? makePrisma();

if (process.env.NODE_ENV !== "production") {
    globalForPrisma.prisma = makePrisma();
}
```
> Don't ask me why it's set up this way, or why this is the best way... just trust~

- Lastly, update your `package.json` to generate the prisma client upon build.
  - Adding `--no-engine` is recommended when using prisma accelerate.
```json
scripts: {
   "build": "npx prisma generate --no-engine && next build",
},
```

---

### Back to OAuth2 

- Create your route (mine is `api/auth/discord/redirect/route.ts`)

This route is automatically going to give a `code` url parameter upon successful authentication with Discord (make sure the route is set as the REDIRECT_URI in your bot settings).

```ts
export async function GET(req: Request) {
    const urlParams = new URL(req.url).searchParams;

    const code = urlParams.get("code");
}
``` 

You need this code to generate an **access** token and a **refresh** token. 

##### Access token
Consider the access token as an item (such as a token) that authorizes the **client** (authorized website user) to interact with the API server (being Discord in this instance) on behalf of that same user. 

##### Refresh token
Access tokens can only be available for so long (for security purposes), and the refresh token allows users to literally *refresh* their access token without doing the entire log in process again.

- Set up a query string that says "hey, here's the code, can I have the access and refresh tokens"

```ts
const scope = ["identify"].join(" ");

const OAUTH_QS = new URLSearchParams({
    client_id: process.env.CLIENT_ID!,
    redirect_uri: CONFIG.URLS.REDIRECT_URI,
    response_type: "code",
    scope
}).toString();

const OAUTH_URL = `https://discord.com/api/oauth2/authorize?${OAUTH_QS}`;
```

- Build the OAuth2 request payload (the body of the upcoming request)

```ts
export type OAuthTokenExchangeRequestParams = {
    client_id: string;
    client_secret: string;
    grant_type: string;
    code: string;
    redirect_uri: string;
    scope: string;
}
```
```ts
const buildOAuth2RequestPayload = (data: OAuthTokenExchangeRequestParams) => new URLSearchParams(data).toString();

const body = buildOAuth2RequestPayload({
    client_id: process.env.CLIENT_ID!,
    client_secret: process.env.CLIENT_SECRET!,
    grant_type: "authorization_code",
    code,
    redirect_uri: CONFIG.URLS.REDIRECT_URI,
    scope
}).toString();
```

- Now we should be able to access the `access_token` and `refresh_token` by deconstructing the data from the **`POST`** request to the OAUTH_URL.

```ts
const { data } = await axios.post<OAuth2CrendialsResponse>(CONFIG.URLS.OAUTH2_TOKEN, body, {
    headers: {
        "Content-Type": "application/x-www-form-urlencoded",
    }
});
const { access_token, refresh_token } = data;
```

I'm gonna wanna store these as encrypted values, along with some other user data, in a `User` model, and set up functions to update those values.

```prisma
model User {
  id           String     @id @default(uuid())
  userId       String     @unique
  accessToken  String     @unique
  refreshToken String     @unique
}
```

- Get the user details using the access token

```ts
export async function getUserDetails(accessToken: string) {
    return await axios.get<OAuth2UserResponse>(`https://discord.com/api/v10/users/@me`, {
        headers: {
            Authorization: `Bearer ${accessToken}`
        }
    })
};
```

### Encryption

In order to store the `access_token` & `refresh_token`, it's good practice to encrypt those values.

I'm using [`crypto-js`](https://www.npmjs.com/package/crypto-js).

- Add an `ENCRYPTION_KEY` environment variable locally and on Vercel.

```ts
import CryptoJS from 'crypto-js';

export const encryptToken = (token: string) => CryptoJS.AES.encrypt(token, process.env.ENCRYPTION_KEY!);
export const decryptToken = (encrypted: string) => CryptoJS.AES.decrypt(encrypted, process.env.ENCRYPTION_KEY!).toString(CryptoJS.enc.Utf8);
```

- Now you can store those values in the `User` model

```ts
import { db } from "@/lib/db";

await db.user.create({
    data: {
        userId,
        accessToken, // encrypted
        refreshToken, // encrypted
    }
});
```

### Cookies / JWT

- Add a `JWT_SECRET` environment variable locally & on Vercel.

Cookies are bits of data the website sends to the client to recount information about the user's visit. 

I'm going to be using [`jsonwebtoken`](https://www.npmjs.com/package/jsonwebtoken), [`cookie`](https://www.npmjs.com/package/cookie), & the [`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies) (from `next/headers`) to manage cookies.

Within this route (if the code exists, there's no error, and user data exists) I'm going to set a cookie, as users should only be directed to this route upon authentication.

- *Sign* the token
```ts
import { sign } from "jsonwebtoken";

const token = sign(user.data, process.env.JWT_SECRET!, { expiresIn: "72h" });
```

- Set the cookie
  - You can name this cookie whatever you want.
```ts
import { cookies } from "next/headers";
import { serialize } from "cookie";

cookies().set("cookie_name", serialize("cookie_name", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production", // secure when in production
    sameSite: "lax",
    path: "/"
}));
```

Then you can redirect the user to the application!

```ts
import { NextResponse } from 'next/server';
//...
return NextResponse.redirect(BASE_URL);
```

> [View the full route code](https://github.com/clxrityy/mjanglin.com/blob/hbd/src/app/(api)/api/(discord)/auth/discord/redirect/route.ts)

Check for a cookie to ensure a user is authenticated:

```ts
import { parse } from "cookie";
import { verify } from "jsonwebtoken";
import { cookies } from "next/headers";

export function parseUser(): OAuth2UserResponse | null {

    const cookie = cookies().get(CONFIG.VALUES.COOKIE_NAME);
    if (!cookie?.value) {
        return null;
    }

    const token = parse(cookie.value)[CONFIG.VALUES.COOKIE_NAME];
    if (!token) {
        return null;
    }
    try {
        const { iat, exp, ...user } = verify(token, process.env.JWT_SECRET) as OAuth2UserResponse & { iat: number, exp: number };

        return user;
    } catch (e) {
        console.log(`Error parsing user: ${e}`);
        return null;
    }
}
```

---

## What's next?

With this, you have a fully authenticated Discord application with Nextjs!

Utilizing discord & user data, you can add on by...
- Add pages for guilds / user profiles
- Give guild admins the ability to alter specific guild configurations for the bot through the dashboard
- Display data about commands
  - [Example commands page](https://hbd.mjanglin.com/commands)
- Add **premium** features
  - Integrate [stripe](https://stripe.com/) for paid features only available to *premium* users
- Leaderboards / statistics
  - Guild with the most members, user who's used the most commands, etc...

The possibilities are endless, and your starting point to making something amazing is right here.

---

## Final product

> You can clone the template repository [here](https://github.com/clxrityy/nextjs-discord-bot-with-oauth).

- My bot (hbd) is hosted here: [hbd.mjanglin.com](https://hbd.mjanglin.com)
- [🔗 Invite](https://discord.com/oauth2/authorize?client_id=1211045842362966077&permissions=2147745792&redirect_uri=https%3A%2F%2Fhbd.mjanglin.com%2Fapi%2Fauth%2Fdiscord%2Fredirect&integration_type=0&scope=bot+applications.commands)
- [Support server](https://discord.gg/n65AVpTFNf)
- [GitHub repo](https://github.com/clxrityy/mjanglin.com/tree/hbd)
- [Source structure overview](https://github.com/clxrityy/mjanglin.com/tree/hbd/src#readme)

---

Thanks for reading! Give this post a ❤️ if you found it helpful!
I'm open to comments/suggestions/ideas!