interface JwtPayload { exp: number; iat?: number; sub?: string; [key: string]: unknown; // for any extra claims } interface TokenRefreshResult { access_token: string; refresh_token?: string; expires_in?: number; } // Function to decode JWT payload without verification (for local expiration check) export function decodeJwtPayload(token: string): JwtPayload | null { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent( atob(base64) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ); return JSON.parse(jsonPayload); } catch (error) { console.error("[auth] Failed to decode JWT payload:", error); return null; } } // Function to check if token is expired or about to expire (within 5 minutes) export function isTokenExpiring(token: string, bufferTimeMinutes: number = 5): boolean { const payload = decodeJwtPayload(token); if (!payload || !payload.exp) { return true; // Consider invalid tokens as expired } const currentTime = Math.floor(Date.now() / 1000); const expirationTime = payload.exp; const bufferTime = bufferTimeMinutes * 60; // Convert minutes to seconds const timeUntilExpiration = expirationTime - currentTime; // Log token expiration warning message if (timeUntilExpiration <= bufferTime) { const expirationDate = new Date(expirationTime * 1000); if (timeUntilExpiration <= 0) { console.warn(`[auth] Token has already expired at ${expirationDate.toISOString()}`); } else { const minutesUntilExpiration = Math.floor(timeUntilExpiration / 60); const secondsUntilExpiration = timeUntilExpiration % 60; console.warn(`[auth] Token will expire in ${minutesUntilExpiration}m ${secondsUntilExpiration}s at ${expirationDate.toISOString()}`); } } return timeUntilExpiration <= bufferTime; } // Function to refresh token using refresh token export async function refreshAccessToken(refreshToken: string): Promise { try { const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER, } = process.env; if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) { console.error("[auth] Missing Keycloak configuration for token refresh"); return null; } const tokenEndpoint = `${KEYCLOAK_ISSUER}/protocol/openid-connect/token`; const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: KEYCLOAK_CLIENT_ID, client_secret: KEYCLOAK_CLIENT_SECRET, refresh_token: refreshToken, }), }); if (!response.ok) { console.error("[auth] Failed to refresh token:", response.status, response.statusText); return null; } const data = await response.json(); return data as TokenRefreshResult; } catch (error) { console.error("[auth] Error refreshing token:", error); return null; } } // Helper function to get just the access token (for backward compatibility) export async function getRefreshedAccessToken(refreshToken: string): Promise { const result = await refreshAccessToken(refreshToken); return result?.access_token || null; } // Centralized function to get a valid access token (refreshing if needed) export async function getAccessToken(accessToken?: string, refreshToken?: string): Promise<{ accessToken: string | null; refreshToken?: string | null; refreshed: boolean; }> { // If no access token provided, return null if (!accessToken) { return {accessToken: null, refreshed: false}; } // Check if token is expiring within 1 minute (as requested in issue) if (!isTokenExpiring(accessToken, 1)) { return {accessToken, refreshed: false}; } console.log("[auth] Access token is expiring within 1 minute, attempting refresh"); // If no refresh token, return the current token (might be expired) if (!refreshToken) { console.warn("[auth] No refresh token available for refresh"); return {accessToken, refreshed: false}; } try { const refreshResult = await refreshAccessToken(refreshToken); if (refreshResult) { return { accessToken: refreshResult.access_token, refreshToken: refreshResult.refresh_token || refreshToken, refreshed: true }; } else { console.warn("[auth] Failed to refresh token, returning current token"); return {accessToken, refreshed: false}; } } catch (error) { console.error("[auth] Error refreshing token:", error); return {accessToken, refreshed: false}; } }