/** * middleware.ts * * 2024-12-04 * The purpose of this middleware is to keep our Keycloak session up-to-date, so we can call Keycloak endpoints and not get rejected. * We are doing this because Keycloak client tokens expire after 5 minutes. * Ultimately we are doing this so we can get a Keycloak Identity Provider (Patreon) token, * get a list of the user's currently entitled tiers, * and assign them appropriate roles for access control. * * * @see https://github.com/nextauthjs/next-auth/discussions/9715#discussioncomment-10640404 * @see https://github.com/nextauthjs/next-auth/discussions/3940 * @see https://nextjs.org/docs/app/building-your-application/routing/middleware * @see https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java#L517 * @see https://github.com/nextauthjs/next-auth/discussions/3940#discussioncomment-8292882 tl;dr: use next.js middleware to refresh session * */ import { NextMiddleware, NextRequest, NextResponse } from "next/server"; import { encode, JWT } from 'next-auth/jwt'; import { getToken } from "next-auth/jwt"; import { configs } from "./app/config/configs"; export const tokenRefreshBufferSeconds = 300; export const isSessionSecure = configs.nextAuthUrl.startsWith("https://"); export const SESSION_COOKIE = isSessionSecure ? "__Secure-next-auth.session-token" : "next-auth.session-token"; // export const sessionTimeout = 60 * 60 * 24 * 30; // 30 days export const sessionTimeout = 60 * 5 export const signinSubUrl = "/api/auth/signin"; let isRefreshing = false; export async function refreshAccessToken(token: JWT): Promise { if (isRefreshing) { return token; } const timeInSeconds = Math.floor(Date.now() / 1000); isRefreshing = true; try { const newAccessTokenRes = await fetch(`https://keycloak.fp.sbtp.xyz/realms/futureporn/protocol/openid-connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: configs.keycloakClientId, client_secret: configs.keycloakClientSecret, refresh_token: token.refresh_token, }).toString(), }) const newAccessToken = await newAccessTokenRes.json() // console.log('newAccessToken json as follows') // console.log(newAccessToken) if (!newAccessTokenRes.ok) { console.error(newAccessToken) throw new Error(JSON.stringify(newAccessToken)); } const newToken = { ...token, access_token: newAccessToken?.access_token ?? token?.access_token, expires_at: newAccessToken?.expires_in + timeInSeconds, refresh_token: newAccessToken?.refresh_token ?? token?.refresh_token }; console.log('newToken as follows') console.log(newToken) return newToken } catch (e) { console.error('failed to refreshAccessToken()') console.error(e); } finally { isRefreshing = false; } return token; } /** * @see https://github.com/nextauthjs/next-auth/discussions/9715#discussioncomment-8319836 */ export function shouldUpdateToken(token: JWT): boolean { const timeInSeconds = Math.floor(Date.now() / 1000); return timeInSeconds >= (token?.expires_at - tokenRefreshBufferSeconds); } export function updateCookie( sessionToken: string | null, request: NextRequest, response: NextResponse ): NextResponse { /* * BASIC IDEA: * * 1. Set request cookies for the incoming getServerSession to read new session * 2. Updated request cookie can only be passed to server if it's passed down here after setting its updates * 3. Set response cookies to send back to browser */ if (sessionToken) { // console.log('Set the session token in the request and response cookies for a valid session') // request.cookies.set(SESSION_COOKIE, sessionToken); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set(SESSION_COOKIE, sessionToken, { httpOnly: true, maxAge: sessionTimeout, secure: isSessionSecure, sameSite: "lax" }); } else { request.cookies.delete(SESSION_COOKIE); return NextResponse.redirect(new URL(signinSubUrl, request.url)); } return response; } // @todo this is broken. This sets multiple cookies with the same name. // @todo this is broken. The updated session token does not get used! export const middleware: NextMiddleware = async (request: NextRequest) => { const token = await getToken({ req: request }); // console.log(`middlew.are. token as follows`) // console.log(token) let response = NextResponse.next(); // return response if (!token) { return response } // we only want to consider updating the token if the request is not an auth request if (request.nextUrl.pathname.startsWith('/api/auth')) { return response } // we only want to consider updating the token if the request is accessing /api/patreon/* endpoint if (!request.nextUrl.pathname.startsWith('/api/patreon')) { return response } if (shouldUpdateToken(token)) { console.log(`Attempting to update the token.`) try { const newSessionToken = await encode({ secret: configs.nextAuthSecret, token: await refreshAccessToken(token), maxAge: sessionTimeout }); response = updateCookie(newSessionToken, request, response); } catch (error) { console.log("Error refreshing token: ", error); return updateCookie(null, request, response); } } console.log(`We succeeded in updating the token.`) // console.log(response) return response; };