2024-12-12 07:23:46 +00:00
|
|
|
/**
|
|
|
|
* 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://");
|
2024-12-16 20:39:23 +00:00
|
|
|
export const sessionCookieName = isSessionSecure ? "__Secure-next-auth.session-token" : "next-auth.session-token";
|
2024-12-12 07:23:46 +00:00
|
|
|
// 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<JWT> {
|
|
|
|
|
|
|
|
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
|
|
|
|
};
|
2024-12-16 20:39:23 +00:00
|
|
|
console.log('refreshAccessToken() succeeded.')
|
2024-12-12 07:23:46 +00:00
|
|
|
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);
|
2024-12-16 20:39:23 +00:00
|
|
|
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
|
2024-12-12 07:23:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function updateCookie(
|
2024-12-16 20:39:23 +00:00
|
|
|
sessionToken: string | null,
|
|
|
|
request: NextRequest,
|
|
|
|
response: NextResponse
|
2024-12-12 07:23:46 +00:00
|
|
|
): NextResponse<unknown> {
|
2024-12-16 20:39:23 +00:00
|
|
|
/*
|
|
|
|
* 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})`;
|
2024-12-12 07:23:46 +00:00
|
|
|
} else {
|
2024-12-16 20:39:23 +00:00
|
|
|
return "Token is expiring now"; // Edge case when the token expires exactly at the current time
|
2024-12-12 07:23:46 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// @todo this is broken. The updated session token does not get used!
|
|
|
|
export const middleware: NextMiddleware = async (request: NextRequest) => {
|
|
|
|
|
2024-12-16 20:39:23 +00:00
|
|
|
const path = request.nextUrl.pathname;
|
2024-12-12 07:23:46 +00:00
|
|
|
const token = await getToken({ req: request });
|
|
|
|
|
|
|
|
let response = NextResponse.next();
|
2024-12-16 20:39:23 +00:00
|
|
|
return response
|
|
|
|
|
|
|
|
console.log(`middleware on path=${path}`)
|
|
|
|
response.cookies.set('cookie-crisp', 'yummy-cereal-yum-yum!');
|
|
|
|
|
2024-12-12 07:23:46 +00:00
|
|
|
|
|
|
|
if (!token) {
|
2024-12-16 20:39:23 +00:00
|
|
|
return response;
|
2024-12-12 07:23:46 +00:00
|
|
|
}
|
|
|
|
|
2024-12-16 20:39:23 +00:00
|
|
|
if (path.startsWith('/api/auth')) {
|
|
|
|
return response;
|
2024-12-12 07:23:46 +00:00
|
|
|
}
|
|
|
|
|
2024-12-16 20:39:23 +00:00
|
|
|
|
2024-12-12 07:23:46 +00:00
|
|
|
|
|
|
|
if (shouldUpdateToken(token)) {
|
|
|
|
console.log(`Attempting to update the token.`)
|
|
|
|
try {
|
2024-12-16 20:39:23 +00:00
|
|
|
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))
|
2024-12-12 07:23:46 +00:00
|
|
|
const newSessionToken = await encode({
|
|
|
|
secret: configs.nextAuthSecret,
|
2024-12-16 20:39:23 +00:00
|
|
|
token: newToken,
|
2024-12-12 07:23:46 +00:00
|
|
|
maxAge: sessionTimeout
|
|
|
|
});
|
2024-12-16 20:39:23 +00:00
|
|
|
console.log('newSessionToken as follows')
|
|
|
|
console.log(newSessionToken)
|
2024-12-12 07:23:46 +00:00
|
|
|
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.`)
|
2024-12-16 20:39:23 +00:00
|
|
|
|
|
|
|
|
2024-12-12 07:23:46 +00:00
|
|
|
return response;
|
2024-12-16 20:39:23 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const config = {
|
|
|
|
matcher: [
|
|
|
|
/*
|
|
|
|
* Match all request paths that start with:
|
|
|
|
* - /api/patreon/
|
|
|
|
*/
|
|
|
|
'/api/patreon/:path*',
|
|
|
|
],
|
|
|
|
}
|