#Handling Access Token Expiration in Next.js Middleware

3 messages · Page 1 of 1 (latest)

twin idol
#

Problem

Hello, I'm currently utilizing middleware to verify whether the user's access token stored in cookies has expired. If it has, the middleware automatically initiates a request to an API I created. This API generates a new access token using the refresh token, also found in the cookies.

However, I'm encountering an issue. When I intentionally expire the token for testing purposes and refresh the page to activate the middleware, I notice that the tokens in the cookies are simply replaced with their previous values, instead of being updated.

The primary objective is to use the middleware to monitor the expiration status of the user's access token. This approach is ideal as the middleware can be activated across the entire application. If the token is found to be expired, the middleware should then acquire new tokens and update them in the cookies.

Files

middleware.ts

import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
    const { cookies } = request;
    const logged_in = cookies.get("logged_in");
    const path = request.nextUrl.pathname;

    const access_token = cookies.get("access_token");
    const refresh_token = cookies.get("refresh_token");
    const generated_at = cookies.get("generated_at");
    const expires_in = cookies.get("expires_in");

    // Check if the access token is expired
    if (access_token && refresh_token && generated_at && expires_in) {
        const now = Date.now();
        const expires_at =
            parseInt(generated_at.value) + parseInt(expires_in.value) * 1000;
        if (now > expires_at) {
            // EXPIRED
            console.log("EXPIRED");
            const result = await fetch("https://localhost:3000/api/refresh-token", {
                method: "POST",
                body: JSON.stringify({ refresh_token: refresh_token.value }),
            });

            if (result) {
                console.log("REFRESHED");
            }
        }
    }

    if (path === "/") {
        if (!logged_in) {
            return NextResponse.redirect(new URL("/login", request.url));
        }
    }

    if (path === "/login") {
        if (logged_in) {
            return NextResponse.redirect(new URL("/", request.url));
        }
    }
}

app\api\refresh-token\route.ts

import { BungieTokensResponseType } from "@/types";
import StoreTokensInCookies from "@/utils/StoreTokensInCookies";

export async function POST(
    request: Request,
): Promise<BungieTokensResponseType | Response> {
    const { refresh_token }: { refresh_token: string } = await request.json();

    if (!refresh_token) {
        return Response.json(
            { message: "No refresh token provided" },
            { status: 400 },
        );
    }

    if (refresh_token) {
        const options = {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded",
            },
            body: new URLSearchParams({
                grant_type: "refresh_token",
                refresh_token,
                client_id: process.env.BUNGIE_CLIENT_ID as string,
                client_secret: process.env.BUNGIE_CLIENT_SECRET as string,
            }),
        };

        const result = await fetch(
            "https://www.bungie.net/Platform/App/OAuth/token/",
            options,
        );
        const Tokens: BungieTokensResponseType = await result.json();
        StoreTokensInCookies(Tokens);
        return new Response(JSON.stringify(Tokens), { status: 200 });
    }

    return Response.json({ message: "Unknown error" }, { status: 500 });
}
boreal timberBOT
#

🔎 This post has been indexed in our web forum and will be seen by search engines so other users can find it outside Discord

🕵️ Your user profile is private by default and won't be visible to users outside Discord, if you want to be visible in the web forum you can add the "Public Profile" role in id:customize

✅ You can mark a message as the answer for your post with Right click -> Apps -> Mark Solution
(if you don't see the option, try refreshing Discord with Ctrl + R)

twin idol
#

Solution

The issue was resolved unexpectedly, and I'm not sure exactly how, but it seems to be related to the use of const response = NextResponse.next();.

Working Files

middleware.ts

import type { NextRequest } from "next/server";
import type { BungieTokensResponseType } from "./types";
import { NextResponse } from "next/server";
import isTokenExpired from "./utils/isTokenExpired";
import setTokensInCookies from "./utils/setTokensInCookies";
import refreshToken from "./utils/refreshToken";

export async function middleware(request: NextRequest) {
    const { cookies } = request;
    const loggedIn = cookies.has("logged_in");
    const path = request.nextUrl.pathname;

    const accessToken = cookies.get("access_token")?.value;
    const refreshTokenValue = cookies.get("refresh_token")?.value;
    const generatedAt = cookies.get("generated_at")?.value;
    const expiresIn = cookies.get("expires_in")?.value;
    const refreshExpiresIn = cookies.get("refresh_expires_in")?.value;

    if (accessToken && refreshTokenValue && generatedAt && expiresIn && refreshExpiresIn) {
        if (isTokenExpired(generatedAt, refreshExpiresIn)) {
            return NextResponse.redirect(new URL("/login", request.url));
        }

        if (isTokenExpired(generatedAt, expiresIn)) {
            const newTokens: BungieTokensResponseType = await refreshToken(refreshTokenValue);

            if (newTokens && "access_token" in newTokens) {
                const response = NextResponse.next();
                setTokensInCookies(response, newTokens);
                return response;
            }
        }
    }

    if (loggedIn) {
        if (path === "/login") {
            return NextResponse.redirect(new URL("/", request.url));
        }
    }

    if (!loggedIn) {
        if (path === "/") {
            return NextResponse.redirect(new URL("/login", request.url));
        }
    }
}

utils\refreshToken.ts

export default async function refreshToken(refreshToken: string) {
    try {
        const result = await fetch("https://localhost:3000/api/refresh-token", {
            method: "POST",
            body: JSON.stringify({ refresh_token: refreshToken }),
        });

        if (!result.ok) {
            throw new Error("Failed to refresh token");
        }

        return await result.json();
    } catch (error) {
        console.error("Error refreshing tokens", error);
        return null;
    }
}

utils\setTokensInCookies.ts

import type { BungieTokensSuccessType } from "@/types";
import { NextResponse } from "next/server";

export default function setTokensInCookies(response: NextResponse, newTokens: BungieTokensSuccessType) {
    response.cookies.set("access_token", newTokens.access_token);
    response.cookies.set("refresh_token", newTokens.refresh_token);
    response.cookies.set("generated_at", Date.now().toString());
    response.cookies.set("expires_in", newTokens.expires_in.toString());
    response.cookies.set("refresh_expires_in", newTokens.refresh_expires_in.toString());
    response.cookies.set("membership_id", newTokens.membership_id);
    response.cookies.set("token_type", newTokens.token_type);
    response.cookies.set("logged_in", "");
}