OAuth 2.0 Integration and Session Handling with Node.js

Francisco MendesFrancisco Mendes
14 min read

If you have ever logged into a platform using a provider, you have likely experienced the OAuth flow, whether it was with Google, Facebook, GitHub, or others. For us as users, it's a convenient process because we don't need to remember a new password, check our email to confirm a new account, or worry about forgetting the password and having to go through the whole verification process again.

In my experience, this topic often confuses people and leads to misunderstandings. It's important to clarify that OAuth is an authorization protocol, not an authentication protocol. When discussing user identity, there's another layer called OpenID Connect (OIDC) that provides single sign-on across multiple sites.

When you need to log in to a site using OIDC, you're redirected to your OpenID site to sign in, and then you're taken back to the original site. It's crucial to make this distinction because they serve different purposes.

Before We Dive In

First, we need to prepare the basics that outline how the API we want to integrate will work. We typically need two endpoints: one to handle the authorization process and another for the callback that receives the authorization code after the user grants access. This code is then exchanged for an access token to complete the OAuth flow.

Regarding authorization, this is often referred to as consent. It's important to note that not all providers offer OIDC. Therefore, we need to pay attention to the details in the technical specifications.

Providers that only offer OAuth 2.0 let users grant access to their resources. During this process, we get an Access Token, which we use to access APIs on behalf of the user. However, we don't know the user's identity. To get this information, we need to include it in the scopes and make a separate HTTP request to retrieve the data. With OIDC, we include OpenID in the authorization scope, and we receive an ID Token. This token is a JWT that, after decoding, contains claims like name, gender, sub, and email, which identify the user.

To set it up, we need to register the application on the provider's developer portal. You must obtain application-specific credentials, such as the client id and client secret (or others), and register a callback/redirect URI to maintain the flow's integrity.

When it comes to the API, there are different ways to generate tokens and manage the application session. Generally, I handle these on the server side, storing them in a database or an in-memory data store. This is where we take control, deciding what data to keep, what session invalidation methods to use, and everything else related to session management.

In this article, we won't build everything from scratch, but we also won't use all-in-one solutions. We'll use primitives provided by packages like Oslo and Arctic to simplify some of the complex parts.

Behind the Curtain

Lately, I've been learning more about databases and have become particularly interested in SQLite. However, there's a catch: SQLite lacks some features that can be added by installing extensions like sqlean, setting up WAL mode, replication, and a server mode accessible via HTTP. Because of this, I decided to use libSQL. If you’re interested, here is the link to view the Docker Compose file.

For this article, I simplified the database schema as much as possible to provide a clear example. I decided to create three tables: users, accounts, and sessions. The users table stores basic information about each user. The accounts table stores each provider associated with the users. The sessions table represents each user's active sessions. These relationships are one-to-many.

erDiagram
    users {
        TEXT id
        TEXT email
        TEXT username
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
        TIMESTAMP deletedAt
    }

    accounts {
        TEXT id
        TEXT userId
        TEXT provider
        TEXT providerId
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    sessions {
        TEXT id
        TEXT userId
        TIMESTAMP expiresAt
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
        TIMESTAMP deletedAt
    }

    users ||--o{ accounts : has
    users ||--o{ sessions : has
    accounts }o--|| users : "belongs to"
    sessions }o--|| users : "belongs to"

The id column in the sessions table serves as the session token and should be generated using a cryptographically secure random generator with at least 120-bit entropy. While UUIDs are commonly used, they are not cryptographically secure and take up more space. To clarify this pattern, you can create the following utility functions using some Oslo primitives:

// '@oslojs/crypto' and '@oslojs/encoding' pkgs
import { sha256 } from "@oslojs/crypto/sha2";
import {
  encodeHexLowerCase,
  encodeBase32LowerCaseNoPadding,
} from "@oslojs/encoding";

function encodeSha256Hex(input: string): string {
  const data = new TextEncoder().encode(input);
  return encodeHexLowerCase(sha256(data));
}

function generateSessionToken(): string {
  const bytes = new Uint8Array(20);
  crypto.getRandomValues(bytes);
  return encodeBase32LowerCaseNoPadding(bytes);
}

The output of these two functions serves different purposes. For example, the generateSessionToken function returns the session token with 160-bit entropy, which can be stored in a cookie. Meanwhile, the encodeSha256Hex function takes the token as input and creates a hash that we store in the database. When the client sends the token back, we hash it and check the database to see if there is a session with an id that matches the hash.

Wiring Up the Back-end

In this section, I will focus on the main points and skip some details about the tools I used, such as database migrations and other specifics. Considering this, we can decide which providers to integrate into the API. I chose to try Discord, and based on the Arctic documentation, we need to configure the OAuth 2.0 client in the following way:

import { Discord } from "arctic";

export enum OauthProviders {
  Discord = "discord",
}

export const discord = new Discord(
  process.env.DISCORD_CLIENT_ID ?? "",
  process.env.DISCORD_CLIENT_SECRET ?? null,
  process.env.DISCORD_REDIRECT_URI ?? "",
);

One important aspect to consider is how we manage session data, including setting default values for each property. To address this, we can design an object that represents an empty session. Additionally, we can create a utility function that generates an object with this structure for future use.

const SESSION_EXPIRATION_MS = 1000 * 60 * 60 * 24 * 30; // 30 days

export const EMPTY_SESSION = {
  id: null,
  userId: null,
  expiresAt: null,
};

export function createSession(token: string, userId: string) {
  return {
    id: encodeSha256Hex(token),
    userId,
    expiresAt: new Date(Date.now() + SESSION_EXPIRATION_MS).toUTCString(),
  };
}

As illustrated in the code block above, the session is set to last for 30 days. This duration was chosen thoughtfully, considering that this application does not require short-lived sessions to manage sensitive data and operations.

With the structure ready, we can choose a storage solution for saving the session on the client side. I decided to use cookies because this fits well with our upcoming tasks. By using the output of the createSession function, we can set the cookie's value and its expiration date.

import { parse as parseURL } from "tldts";

export function generateCookieOptions() {
  const url = new URL(process.env.FRONTEND_AUTH_CALLBACK_URL || "");
  const domain = parseURL(url.toString()).domain || undefined;
  return {
    domain,
    secure: url.protocol === "https:",
    sameSite: "lax" as const,
    httpOnly: true,
    path: "/",
  };
}

export function generateSessionCookie(token: string, session: SessionDatum) {
  if (!token || !session.expiresAt) throw new Error("Invalid session datums");
  const options = generateCookieOptions();
  return [
    "session",
    token,
    { ...options, expires: new Date(session.expiresAt) },
  ] as const;
}

We need to ensure the session is validated and the output remains consistent. If an issue arises, we can return an empty session. Using the token value, we should create a hash to find the session in the database and verify if it has expired or can still be renewed. If the session has expired, we can invalidate it by removing it from the database and safely return an empty session.

import { db } from "./db";

const RENEW_THRESHOLD_MS = SESSION_EXPIRATION_MS / 2; // 15 days

function isExpired(expiresAt: Date) {
  return Date.now() >= expiresAt.getTime();
}

function needsRenewal(expiresAt: Date) {
  return Date.now() >= expiresAt.getTime() - RENEW_THRESHOLD_MS;
}

export async function invalidateSession(sessionId: string) {
  await db
    .deleteFrom("sessions")
    .where("sessions.id", "=", sessionId)
    .executeTakeFirstOrThrow();
}

async function resolveSession(token: string | null) {
  if (!token) return EMPTY_SESSION;

  const sessionId = encodeSha256Hex(token);

  const session = await db
    .selectFrom("sessions")
    .select(["sessions.id", "sessions.expiresAt", "sessions.userId"])
    .where("sessions.id", "=", sessionId)
    .executeTakeFirst();
  if (!session) return EMPTY_SESSION;

  if (isExpired(new Date(session.expiresAt))) {
    try {
      await invalidateSession(sessionId);
    } catch {
      // silent error
    } finally {
      return EMPTY_SESSION;
    }
  }

  if (needsRenewal(new Date(session.expiresAt))) {
    const newExpiry = new Date(Date.now() + SESSION_EXPIRATION_MS);
    const updatedSession = await db
      .updateTable("sessions")
      .set({ expiresAt: newExpiry.toUTCString() })
      .where("sessions.id", "=", sessionId)
      .returning("sessions.expiresAt")
      .executeTakeFirst();
    if (!updatedSession) return EMPTY_SESSION;
    session.expiresAt = updatedSession.expiresAt;
  }

  return session;
}

We can enhance the session validation function by developing two middlewares. The first middleware will be global, responsible for resolving the session by verifying its status and assigning it to the request state. The second middleware will be more specific, designed to protect routes and ensure that only users with a valid session can access some resources in the API.

import { createMiddleware } from "hono/factory";
import { getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";

export const sessionMiddleware = createMiddleware(
  async (c, next) => {
    const sessionToken = getCookie(c, "session") || null;
    const userSession = await resolveSession(sessionToken);
    c.set("userSession", userSession);
    await next();
  },
);

export const secureWithUserAuth = createMiddleware(
  async (c, next) => {
    const datum = c.get("userSession");
    if (!datum.userId || !datum.id || !datum.expiresAt) {
      throw new HTTPException(401, { message: "Invalid or missing session" });
    }
    await next();
  },
);

Now that we've set up the basic session components, the next step is to create the routes for our API. I want to point out that I'll be adding schema validation to these routes. I'm using the Hono.js framework and have used the validator primitive to include the validation schema in the routes. I chose TypeBox because it can compile schemas ahead-of-time, which helps keep performance steady at runtime, unlike just-in-time compilation. If you're interested, here's the link to the utility function I created to compile the validation schemas.

Starting with the authorization route, my goal is to allow us to choose which provider to use. In this article, we'll only use Discord, but we can add more options in the future. We need to specify the provider in the request's query parameters. After selecting the provider, we must create a state, which is a random string that should be cryptographically secure with at least 128 bits.

Some providers, like Discord, support Proof Key for Code Exchange (PKCE), which improves OAuth security by removing the need for a static client secret. In this process, the client creates a random code_verifier per session. A hashed version of this verifier, called the code_challenge, is sent with the initial authorization request.

Later, when exchanging the authorization code for tokens, the client sends the original code_verifier. The server hashes this verifier and compares it to the original code_challenge to ensure the request is from the same client. Both the state and the code_verifier are stored in cookies so they can be validated during the OAuth callback.

We also need to define the scopes for the user resources we want to access. In this case, I want to know the user's identity and email. We should check the provider's documentation to see which scopes are available. Once everything is set up, we build the authorization URL and redirect the request to the provider’s authorization server.

import { Hono } from "hono";
import { Type as T } from "@sinclair/typebox";
import { cors } from "hono/cors";
import { validator } from "hono/validator";
import {
  generateState as generateArcticState,
  generateCodeVerifier as generateArcticCodeVerifier,
} from "arctic";
import { setCookie } from "hono/cookie";

import { OauthProviders, discord } from "./utils/oauth";
import { validatorFactory } from "./utils/validator";
import {
  generateCookieOptions,
  sessionMiddleware,
} from "./utils/session";

const signInQuerySchema = validatorFactory(
  T.Object({ provider: T.Enum(OauthProviders) }),
);

const COOKIE_OPTS = Object.freeze({
  ...generateCookieOptions(),
  maxAge: 60 * 10,
});

export default new Hono()
  .basePath("/api")
  .use(
    cors({
      origin: process.env.FRONTEND_AUTH_CALLBACK_URL || "*",
      credentials: true,
    }),
  )
  .use(sessionMiddleware)
  .get(
    "/auth/authorize",
    validator("query", signInQuerySchema.parse),
    async (c) => {
      const { provider } = c.req.valid("query");

      switch (provider) {
        case OauthProviders.Discord: {
          const state = generateArcticState();
          const codeVerifier = generateArcticCodeVerifier();

          const authorizationURL = discord.createAuthorizationURL(
            state,
            codeVerifier,
            ["identify", "email"],
          );

          setCookie(c, "discord_oauth_state", state, COOKIE_OPTS);
          setCookie(c, "discord_code_verifier", codeVerifier, COOKIE_OPTS);

          return c.redirect(authorizationURL.toString());
        }

        default:
          return c.redirect(process.env.FRONTEND_AUTH_CALLBACK_URL || "");
      }
    },
  )

Once the user agrees to let our application access their resources, they are redirected to the callback endpoint. At this point, it's important to first check for two properties in the request's query parameters: state and code. It's crucial to ensure both are present and that the state from the parameter matches the one stored in the cookie.

Using the code parameter, we exchange it for access tokens to access the user's resources. We only need the access token to make an HTTP request to get the user's profile and email. It's important to verify that the user's email or account is verified; if not, we won't allow registration in the app. Since we only need the tokens temporarily, we don't store them in the database.

If the provider you chose offers OpenID and you included it in the scope, you don't need to request the user profile as I did. The ID Token returned with the tokens already contains enough information about the user and you just need to decode it.

Here's a tip: create a validation schema to check the JSON returned by the API/ID Token. The data structure is usually detailed in the documentation, and from there, we define the interface at the application level. This helps ensure a safer implementation because it's an external data source that we don't have control of.

Finally, it's important to make sure the flow can handle different purposes. This session setup should work for both creating a new account and logging into an existing one. In both cases, we need to create the session token using the generateSessionToken function. This allows us to build the session datum and cookie. If everything goes well, we then redirect the user to the web application.

import { getCookie, deleteCookie } from "hono/cookie";

import {
  // ...
  createSession,
  generateSessionCookie,
  generateSessionToken,
} from "./utils/session";

const oauthCallbackQuerySchema = validatorFactory(
  T.Object({ code: T.String(), state: T.String() }),
);

const discordUserProfileSchema = validatorFactory(
  T.Object({
    id: T.String(),
    username: T.String(),
    // ...
  }),
);

export default new Hono()
  // ...
  .get(
    "/auth/discord/callback",
    validator("query", oauthCallbackQuerySchema.parse),
    async (c) => {
      const queryParams = c.req.valid("query");

      const oauthState = getCookie(c, "discord_oauth_state");
      const codeVerifier = getCookie(c, "discord_code_verifier");

      if (!oauthState || !codeVerifier) {
        throw new HTTPException(401, {
          message: "Invalid or missing OAuth state or code verifier",
          cause: "Missing required cookies for Discord OAuth flow",
        });
      }

      if (queryParams.state !== oauthState) {
        throw new HTTPException(401, {
          message: "OAuth state mismatch",
          cause:
            "The state parameter in the query does not match the stored OAuth state",
        });
      }

      const tokens = await discord.validateAuthorizationCode(
        queryParams.code,
        codeVerifier,
      );

      const userResponse = await fetch("https://discord.com/api/users/@me", {
        headers: { Authorization: `Bearer ${tokens.accessToken()}` },
      });

      const userProfile = discordUserProfileSchema.parse(
        await userResponse.json(),
      );

      if (!userProfile.verified) {
        throw new HTTPException(401, {
          message: "User verification failed",
          cause: "The provided claims could not be verified for authentication",
        });
      }

      deleteCookie(c, "discord_oauth_state");
      deleteCookie(c, "discord_code_verifier");

      const existingAccount = await db
        .selectFrom("accounts")
        .innerJoin("users", "users.id", "accounts.userId")
        .select([
          "accounts.id",
          "accounts.userId",
          "accounts.provider",
          "accounts.providerId",
          "users.email",
        ])
        .where(({ and, eb }) =>
          and([
            eb("accounts.provider", "=", OauthProviders.Discord),
            eb("accounts.providerId", "=", userProfile.id),
            ...(userProfile.email
              ? [eb("users.email", "=", userProfile.email)]
              : []),
          ]),
        )
        .executeTakeFirst();

      if (existingAccount) {
        const sessionToken = generateSessionToken();
        const sessionDatum = createSession(
          sessionToken,
          existingAccount.userId,
        );
        await db.insertInto("sessions").values(sessionDatum).execute();

        setCookie(c, ...generateSessionCookie(sessionToken, sessionDatum));
        return c.redirect(process.env.FRONTEND_AUTH_CALLBACK_URL || "");
      }

      const datums = await db.transaction().execute(async (trx) => {
        const user = await trx
          .insertInto("users")
          .values({
            email: userProfile.email!,
            username: userProfile.username,
          })
          .returning("id")
          .executeTakeFirstOrThrow();

        await trx
          .insertInto("accounts")
          .values({
            provider: OauthProviders.Discord,
            providerId: userProfile.id,
            userId: user.id,
          })
          .executeTakeFirstOrThrow();

        const sessionToken = generateSessionToken();
        const sessionDatum = createSession(sessionToken, user.id);
        await trx
          .insertInto("sessions")
          .values(sessionDatum)
          .executeTakeFirstOrThrow();

        return { sessionToken, sessionDatum };
      });

      setCookie(
        c,
        ...generateSessionCookie(datums.sessionToken, datums.sessionDatum),
      );
      return c.redirect(process.env.FRONTEND_AUTH_CALLBACK_URL || "");
    },
  )

Now I can say the hardest part is done. We just need to add two more things to our API. We need an endpoint to get information about the logged-in user and another to allow the user to end their session manually. Unlike before, there aren't many secrets here. For both endpoints, we need to check the user's session status linked to the request and ensure these resources are only available to logged-in users.

import {
  // ...
  secureWithUserAuth,
  invalidateSession,
} from "./utils/session";

export default new Hono()
  // ...
  .get("/auth/@me", secureWithUserAuth, async (c) => {
    const userSession = c.get("userSession");

    const result = await db
      .selectFrom("users")
      .select(["id", "email", "username"])
      .where("id", "==", userSession.userId)
      .executeTakeFirstOrThrow();

    return c.json(result);
  })
  .get("/auth/logout", secureWithUserAuth, async (c) => {
    const userSession = c.get("userSession");

    deleteCookie(c, "session");
    await invalidateSession(userSession.id);

    return c.redirect(process.env.FRONTEND_AUTH_CALLBACK_URL || "");
  });

You might’ve noticed that my auth setup is fully managed on the API side, even the redirects. That’s intentional. When it comes to session handling, I try to avoid stateless methods like JWTs or signed cookies unless there's a reason to use them. Instead, I prefer to let the back-end take care of sensitive data. The way I see it, sessions often carry some form of sensitive data, and if there’s a risk of that data leaking, I’d rather just not send it in the first place. If I do need to store session-related info, I prefer to keep it on the back-end. It’s simpler, and it gives me more confidence in how that data is handled.

I’m also cautious about how redirects are handled. Using query parameters like redirect_to is a common approach, but it can be risky if those values aren’t properly validated. It opens the door for potential redirect attacks or forged requests.

That said, there’s no one right way to do this. Every app has different needs, and what works well in one case might not be the best fit in another. These are just the trade-offs I’ve landed on after working with auth in different projects.

For the front-end, I won't go into specifics because the approach may vary depending on the framework used. However, I can provide a pseudo-code that can serve as a source of inspiration:

async function getUser() {
  try {
    const result = await fetch(
      "http://localhost:3333/api/auth/@me",
      { credentials: "include" }
    );
    if (!result.ok) return null;
    return await result.json();
  } catch (exception) {
    console.error(exception);
    return null;
  }
};

export function Component() {
  const user = use(getUser);
  return (
    <div>
      <h3>Hello, World</h3>
      {user ? (
        <div>
          <div>Welcome, {user.username}!</div>
          <a href="http://localhost:3333/api/auth/logout">Logout</a>
        </div>
      ) : (
        <a href="http://localhost:3333/api/auth/authorize?provider=discord">
          Login
        </a>
      )}
    </div>
  );
}

End Note

I hope you found this article useful, whether for an existing project or just for fun, and please let me know if you find any errors by leaving a comment; the source code is available in the GitHub repository linked here.

0
Subscribe to my newsletter

Read articles from Francisco Mendes directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Francisco Mendes
Francisco Mendes

Hey! My name is Francisco and my goal is to dive into the adventure of learning and personal growth. As a Software Developer, my focus is to understand what our customers need and turn those insights into real-world solutions. Whether it's building sophisticated frontend features with React or tackling backend challenges using Node.js/TypeScript, I love creating experiences that truly resonate with users, making their journey better. I am very proud of my work, always seeking high-level code quality and gradually improving it.