Initial commit

This commit is contained in:
Iceberg
2026-04-20 17:59:24 +00:00
commit a43f8de3e4
51 changed files with 9695 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import { NextAuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
import { JWT } from 'next-auth/jwt';
declare module 'next-auth' {
interface Session {
accessToken?: string;
roles?: string[];
error?: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string;
refreshToken?: string;
accessTokenExpires?: number;
error?: string;
roles?: string[];
}
}
function decode(token: string) {
try {
const base64 = token.split('.')[1];
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
} catch {
return null;
}
}
async function refreshAccessToken(token: JWT): Promise<JWT> {
try {
if (!token.refreshToken) {
throw new Error('No refresh token');
}
const response = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
},
);
if (!response.ok) {
throw new Error('Refresh failed');
}
const refreshed = await response.json();
const decoded = decode(refreshed.access_token);
return {
...token,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
accessTokenExpires: decoded?.exp
? decoded.exp * 1000
: Date.now() + 5 * 60 * 1000,
error: undefined,
};
} catch {
return {
...token,
error: 'RefreshAccessTokenError',
};
}
}
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async jwt({ token, account }) {
if (token.error === 'RefreshAccessTokenError') return token;
// initial login
if (account) {
const decoded = decode(account.access_token!);
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
const realmRoles = decoded?.realm_access?.roles ?? [];
const clientRoles = decoded?.resource_access?.[clientId]?.roles ?? [];
token.roles = [...new Set([...realmRoles, ...clientRoles])];
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpires = decoded?.exp
? decoded.exp * 1000
: Date.now() + 5 * 60 * 1000;
return token;
}
// stop loop if refresh already failed
if (
token.accessTokenExpires &&
Date.now() > token.accessTokenExpires - 60_000
) {
return { ...token, error: 'RefreshAccessTokenError' };
}
// still valid
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token;
}
// refresh expired token
return await refreshAccessToken(token);
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.error = token.error;
if (!token.accessToken) {
session.roles = [];
return session;
}
const payload = decode(token.accessToken);
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
const realmRoles = payload?.realm_access?.roles ?? [];
const clientRoles = payload?.resource_access?.[clientId]?.roles ?? [];
session.roles = [...new Set([...realmRoles, ...clientRoles])];
return session;
},
},
};