fp/services/next/middleware.ts

176 lines
5.6 KiB
TypeScript

/**
* 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<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
};
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<unknown> {
/*
* 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;
};