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,