/** * 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 sessionCookieName = 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('refreshAccessToken() succeeded.') 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); const timeRemaining = token?.expires_at - timeInSeconds; // Calculate time remaining console.log(`shouldUpdateToken() -- access_token -- ${getTokenExpiryStatus(token.expires_at)}`); return timeRemaining <= tokenRefreshBufferSeconds; // Should refresh if within buffer } 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) { // Set the session token in the request and response cookies for a valid session request.cookies.set(sessionCookieName, sessionToken); response = NextResponse.next({ request: { headers: request.headers } }); response.cookies.set(sessionCookieName, sessionToken, { httpOnly: true, maxAge: sessionTimeout, secure: isSessionSecure, sameSite: "lax" }); } else { request.cookies.delete(sessionCookieName); return NextResponse.redirect(new URL(signinSubUrl, request.url)); } return response; } function getTokenExpiryStatus(expiryDate: number): string { const now = Date.now(); // Current time in milliseconds // Convert expiryDate (which is in seconds) to milliseconds const expiryTime = expiryDate * 1000; const differenceInSeconds = Math.floor((expiryTime - now) / 1000); // Difference in seconds if (differenceInSeconds > 0) { return `Token will expire in ${differenceInSeconds} seconds (${expiryDate})`; } else if (differenceInSeconds < 0) { return `Token expired ${Math.abs(differenceInSeconds)} seconds ago (${expiryDate})`; } else { return "Token is expiring now"; // Edge case when the token expires exactly at the current time } } // @todo this is broken. The updated session token does not get used! export const middleware: NextMiddleware = async (request: NextRequest) => { const path = request.nextUrl.pathname; const token = await getToken({ req: request }); let response = NextResponse.next(); return response console.log(`middleware on path=${path}`) response.cookies.set('cookie-crisp', 'yummy-cereal-yum-yum!'); if (!token) { return response; } if (path.startsWith('/api/auth')) { return response; } if (shouldUpdateToken(token)) { console.log(`Attempting to update the token.`) try { console.log(`middleware.ts before refreshAccessToken() we are looking at token -- ${getTokenExpiryStatus(token.expires_at)}`) const newToken = await refreshAccessToken(token) console.log(`middleware.ts after refreshAccessToken() -- ${getTokenExpiryStatus(newToken.expires_at)}`) // console.log(JSON.stringify(newToken)) const newSessionToken = await encode({ secret: configs.nextAuthSecret, token: newToken, maxAge: sessionTimeout }); console.log('newSessionToken as follows') console.log(newSessionToken) 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.`) return response; }; export const config = { matcher: [ /* * Match all request paths that start with: * - /api/patreon/ */ '/api/patreon/:path*', ], }