From bdbaf3645642b945b166017bb62280e7e9635919 Mon Sep 17 00:00:00 2001 From: Thatsaphorn Atchariyaphap Date: Fri, 11 Jul 2025 23:42:41 +0200 Subject: [PATCH] Centralize authentication logic and integrate token refresh mechanism - Introduce `AuthWrapper` component for streamlined session-based layouts and authentication handling. - Add new utilities (`tokenUtils.ts`) for JWT decoding, token expiration checks, and refresh operations via Keycloak. - Refactor `serverCall` and `authOptions` to use centralized token refresh logic, removing redundant implementations. - Implement `ClientSessionProvider` for consistent session management across the client application. - Simplify `RootLayout` by delegating authentication enforcement to `AuthWrapper`. --- internal_frontend/app/layout.tsx | 71 ++------- .../components/ClientSessionProvider.tsx | 12 ++ internal_frontend/components/auth-wrapper.tsx | 63 ++++++++ internal_frontend/lib/api/auth/authOptions.ts | 36 +++-- internal_frontend/lib/api/auth/tokenUtils.ts | 147 ++++++++++++++++++ internal_frontend/lib/api/serverCall.ts | 108 +------------ 6 files changed, 272 insertions(+), 165 deletions(-) create mode 100644 internal_frontend/components/ClientSessionProvider.tsx create mode 100644 internal_frontend/components/auth-wrapper.tsx create mode 100644 internal_frontend/lib/api/auth/tokenUtils.ts diff --git a/internal_frontend/app/layout.tsx b/internal_frontend/app/layout.tsx index e0cfce2..0599358 100644 --- a/internal_frontend/app/layout.tsx +++ b/internal_frontend/app/layout.tsx @@ -1,76 +1,39 @@ import type {Metadata} from "next"; import "./globals.css"; import {ThemeProvider} from "@/components/theme-provider"; -import {SidebarInset, SidebarProvider, SidebarTrigger} from "@/components/ui/sidebar"; -import {AppSidebar} from "@/components/app-sidebar"; import React from "react"; -import {Separator} from "@/components/ui/separator"; -import {DynamicBreadcrumb} from "@/components/dynamic-breadcrumb"; -import {getServerSession} from "next-auth"; -import LoginScreen from "@/components/login-screen"; -import {authOptions} from "@/lib/api/auth/authOptions"; import {ErrorBoundary} from "@/components/error-boundary"; import {Toaster} from "sonner"; +import {AuthWrapper} from "@/components/auth-wrapper"; +import {ClientSessionProvider} from "@/components/ClientSessionProvider"; export const metadata: Metadata = { title: "Internal | Rhein Software", description: "Internal Tools for Rhein Software Development", }; -export default async function RootLayout({ - children, - }: Readonly<{ +export default function RootLayout({ + children, + }: Readonly<{ children: React.ReactNode; }>) { - const session = await getServerSession(authOptions); - return ( - - {session?.accessToken ? ( - - - - -
-
- - - -
-
- - {children} - -
-
- ) : ( - - - - )} - -
+ + + {children} + + +
); -} +} \ No newline at end of file diff --git a/internal_frontend/components/ClientSessionProvider.tsx b/internal_frontend/components/ClientSessionProvider.tsx new file mode 100644 index 0000000..7eb7cb1 --- /dev/null +++ b/internal_frontend/components/ClientSessionProvider.tsx @@ -0,0 +1,12 @@ +"use client"; + +import {SessionProvider} from "next-auth/react"; +import React from "react"; + +interface Props { + children: React.ReactNode; +} + +export function ClientSessionProvider({children}: Props) { + return {children}; +} diff --git a/internal_frontend/components/auth-wrapper.tsx b/internal_frontend/components/auth-wrapper.tsx new file mode 100644 index 0000000..1efe2fd --- /dev/null +++ b/internal_frontend/components/auth-wrapper.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React from "react"; +import {useSession} from "next-auth/react"; +import {SidebarInset, SidebarProvider, SidebarTrigger} from "@/components/ui/sidebar"; +import {AppSidebar} from "@/components/app-sidebar"; +import {Separator} from "@/components/ui/separator"; +import {DynamicBreadcrumb} from "@/components/dynamic-breadcrumb"; +import LoginScreen from "@/components/login-screen"; +import {ErrorBoundary} from "@/components/error-boundary"; + +interface AuthWrapperProps { + children: React.ReactNode; +} + +export function AuthWrapper({children}: AuthWrapperProps) { + const {data: session, status} = useSession(); + + if (status === "loading") { + return ( +
+
+
+ ); + } + + if (session?.accessToken) { + return ( + + + +
+
+ + + +
+
+ + {children} + +
+
+ ); + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/internal_frontend/lib/api/auth/authOptions.ts b/internal_frontend/lib/api/auth/authOptions.ts index a8bb1eb..341a3b5 100644 --- a/internal_frontend/lib/api/auth/authOptions.ts +++ b/internal_frontend/lib/api/auth/authOptions.ts @@ -1,5 +1,6 @@ import KeycloakProvider from "next-auth/providers/keycloak"; import type {NextAuthOptions} from "next-auth"; +import {getAccessToken} from "@/lib/api/auth/tokenUtils"; interface TypedJWT { access_token?: string; @@ -20,9 +21,9 @@ if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET"); if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER"); if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET"); -console.log("[auth] Using Keycloak provider:"); -console.log(" - Client ID:", KEYCLOAK_CLIENT_ID); -console.log(" - Issuer:", KEYCLOAK_ISSUER); +// console.log("[auth] Using Keycloak provider:"); +// console.log(" - Client ID:", KEYCLOAK_CLIENT_ID); +// console.log(" - Issuer:", KEYCLOAK_ISSUER); async function isTokenValid(token: string): Promise { try { @@ -60,22 +61,39 @@ export const authOptions: NextAuthOptions = { return token; } - const {access_token} = token as TypedJWT; + const {access_token, refresh_token} = token as TypedJWT; if (access_token) { - const valid = await isTokenValid(access_token); - if (!valid) { - console.warn("[auth] Access token invalid — clearing session"); + // Use centralized getAccessToken function + const tokenResult = await getAccessToken(access_token, refresh_token); + + if (tokenResult.accessToken) { + token.access_token = tokenResult.accessToken; + if (tokenResult.refreshToken) { + token.refresh_token = tokenResult.refreshToken; + } + + if (tokenResult.refreshed) { + console.log("[auth] Token refreshed successfully in JWT callback"); + return token; + } + + // If token wasn't refreshed, fall back to network validation + const valid = await isTokenValid(tokenResult.accessToken); + if (!valid) { + console.warn("[auth] Access token invalid — clearing session"); + return {}; + } + } else { + console.warn("[auth] No valid access token available — clearing session"); return {}; } } - console.log("[auth] JWT callback: reusing existing token"); return token; }, async session({session, token}) { const {access_token, refresh_token} = token as TypedJWT; - console.log("[auth] Session callback: enriching session with tokens"); return { ...session, accessToken: access_token, diff --git a/internal_frontend/lib/api/auth/tokenUtils.ts b/internal_frontend/lib/api/auth/tokenUtils.ts new file mode 100644 index 0000000..de66a26 --- /dev/null +++ b/internal_frontend/lib/api/auth/tokenUtils.ts @@ -0,0 +1,147 @@ +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}; + } +} diff --git a/internal_frontend/lib/api/serverCall.ts b/internal_frontend/lib/api/serverCall.ts index d810b55..eb1d30b 100644 --- a/internal_frontend/lib/api/serverCall.ts +++ b/internal_frontend/lib/api/serverCall.ts @@ -1,89 +1,6 @@ import {getServerSession} from "next-auth"; import {authOptions} from "@/lib/api/auth/authOptions"; -interface JwtPayload { - exp: number; - iat?: number; - sub?: string; - - [key: string]: unknown; // for any extra claims -} - -// Function to decode JWT payload without verification (for local expiration check) -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) -function isTokenExpiredOrExpiring(token: string): 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 = 5 * 60; // 5 minutes buffer - - return (expirationTime - currentTime) <= bufferTime; -} - -// Function to refresh token using refresh token -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(); - console.log("[auth] Token refreshed successfully"); - return data.access_token; - } catch (error) { - console.error("[auth] Error refreshing token:", error); - return null; - } -} - export async function serverCall( path: string, method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", @@ -97,28 +14,15 @@ export async function serverCall( }; if (session?.accessToken) { - // Check if token is expired or about to expire - if (isTokenExpiredOrExpiring(session.accessToken)) { - console.log("[auth] Access token is expired or about to expire, attempting refresh"); - - if (session.refreshToken) { - const newAccessToken = await refreshAccessToken(session.refreshToken); - if (newAccessToken) { - // Update the session with new token (note: this won't persist across requests) - session.accessToken = newAccessToken; - console.log("[auth] Using refreshed access token"); - } else { - console.warn("[auth] Failed to refresh token, proceeding with expired token"); - } - } else { - console.warn("[auth] No refresh token available, proceeding with expired token"); - } - } - + // Use the access token from the session directly + // Token refresh is handled by the JWT callback in authOptions.ts headers["Authorization"] = `Bearer ${session.accessToken}`; + // console.log("[auth] Using access token from session for API call"); + } else { + console.warn("[auth] No access token available in session for API call"); } - console.log("[api] Calling backend API: ", method, url, body); + console.log("[api] Calling backend API - [" + method + "]", path, body ?? ""); return fetch(url, { method,