176 lines
5.6 KiB
TypeScript
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;
|
|
}; |