import { NextAuthOptions } from "next-auth"; import { JWT } from "next-auth/jwt"; import KeycloakProvider from "next-auth/providers/keycloak"; import Avatar from "@assets/avatar/avatar-small.jpeg"; const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000; type KeycloakTokenResponse = { access_token: string; expires_in: number; refresh_token?: string; }; const getKeycloakTokenEndpoint = () => { const issuer = process.env.KEYCLOAK_ISSUER; return issuer ? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token` : undefined; }; const refreshAccessToken = async (token: JWT): Promise => { if (!token.refreshToken) { return { ...token, error: "RefreshAccessTokenError" }; } const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID; const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET; const keycloakTokenEndpoint = getKeycloakTokenEndpoint(); if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) { return { ...token, error: "RefreshAccessTokenError" }; } const body = new URLSearchParams({ grant_type: "refresh_token", client_id: keycloakClientId, client_secret: keycloakClientSecret, refresh_token: token.refreshToken, }); try { const response = await fetch(keycloakTokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const refreshed = (await response.json()) as Partial | null; if ( !response.ok || !refreshed || typeof refreshed.access_token !== "string" || !refreshed.access_token.trim() || typeof refreshed.expires_in !== "number" || !Number.isFinite(refreshed.expires_in) || refreshed.expires_in <= 0 ) { return { ...token, error: "RefreshAccessTokenError" }; } const rotatedRefreshToken = typeof refreshed.refresh_token === "string" && refreshed.refresh_token.trim() ? refreshed.refresh_token.trim() : token.refreshToken; return { ...token, accessToken: refreshed.access_token.trim(), accessTokenIssuedAt: Date.now(), accessTokenExpires: Date.now() + refreshed.expires_in * 1000, refreshToken: rotatedRefreshToken, error: undefined, }; } catch { return { ...token, error: "RefreshAccessTokenError" }; } }; const authOptions: NextAuthOptions = { // Configure one or more authentication providers providers: [ KeycloakProvider({ clientId: process.env.KEYCLOAK_CLIENT_ID ?? "", clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "", issuer: process.env.KEYCLOAK_ISSUER ?? "", profile(profile) { return { id: profile.sub, username: profile.preferred_username, name: profile.name ?? profile.preferred_username, email: profile.email, image: Avatar.src, }; }, }), ], secret: process.env.NEXTAUTH_SECRET, callbacks: { jwt: async ({ token, profile, account, trigger, session }) => { if (profile?.sub) { token.sub = profile.sub; } const preferredUsername = ( profile as { preferred_username?: unknown } | undefined )?.preferred_username; if (typeof preferredUsername === "string") { token.username = preferredUsername; } if (account) { token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000; if (account.access_token) { token.accessToken = account.access_token; token.accessTokenIssuedAt = Date.now(); } if (account.refresh_token) { token.refreshToken = account.refresh_token; } if (account.id_token) { token.idToken = account.id_token; } if (typeof account.expires_at === "number") { token.accessTokenExpires = account.expires_at * 1000; } token.error = undefined; return token; } if ( typeof token.sessionExpiresAt === "number" && Date.now() >= token.sessionExpiresAt ) { return { ...token, error: "SessionExpired" }; } if ( trigger === "update" && (session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true ) { return refreshAccessToken(token); } const accessTokenIsFresh = typeof token.accessTokenExpires === "number" && typeof token.accessTokenIssuedAt === "number" && Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS; if (accessTokenIsFresh) { return token; } return refreshAccessToken(token); }, session: async ({ session, token }) => { if (session.user && token.sub) { session.user.id = token.sub; } if (session.user && token.username) { session.user.username = token.username; } if (token.accessToken) { session.accessToken = token.accessToken; } if (token.error) { session.error = token.error; } if (typeof token.sessionExpiresAt === "number") { session.sessionExpiresAt = token.sessionExpiresAt; } return session; }, }, session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS, }, jwt: { maxAge: SESSION_MAX_AGE_SECONDS, }, }; export default authOptions;