diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index fd7ca927..34251d30 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,308 +1,58 @@ import NextAuth, { NextAuthOptions } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; -import { jwtDecode } from "jwt-decode"; - -interface KeycloakProfile { - sub: string; - email?: string; - name?: string; - roles?: string[]; - preferred_username?: string; - given_name?: string; - family_name?: string; - realm_access?: { - roles: string[]; - }; -} - -interface DecodedToken { - realm_access?: { - roles: string[]; - }; - [key: string]: any; -} - -declare module "next-auth" { - interface Session { - user: { - id: string; - name?: string | null; - email?: string | null; - image?: string | null; - username: string; - first_name: string; - last_name: string; - role: string[]; - nextcloudInitialized?: boolean; - }; - accessToken?: string; - } - - interface JWT { - sub?: string; - accessToken?: string; - refreshToken?: string; - accessTokenExpires?: number; - role?: string[]; - username?: string; - first_name?: string; - last_name?: string; - error?: string; - email?: string | null; - name?: string | null; - } -} - -function getRequiredEnvVar(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - return value; -} - -async function refreshAccessToken(token: JWT) { - try { - console.log('Attempting to refresh access token'); - const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { - 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, - }), - method: "POST", - }); - - const refreshedTokens = await response.json(); - - if (!response.ok) { - console.error('Token refresh failed with status:', response.status); - console.error('Error response:', refreshedTokens); - throw refreshedTokens; - } - - console.log('Token refresh successful'); - return { - ...token, - accessToken: refreshedTokens.access_token, - refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, - accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - error: undefined, // Clear any previous errors - }; - } catch (error) { - console.error("Error refreshing access token:", error); - return { - ...token, - error: "RefreshAccessTokenError", - }; - } -} +// Simple, minimal implementation - NO REFRESH TOKEN LOGIC export const authOptions: NextAuthOptions = { providers: [ KeycloakProvider({ - clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), - clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), - issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), - authorization: { - params: { - scope: "openid profile email roles" - } - }, - profile(profile) { - // Simplified profile logging to reduce console noise - console.log('Keycloak profile received'); - - // Get roles from realm_access - const roles = profile.realm_access?.roles || []; - - // Clean up roles by removing ROLE_ prefix and converting to lowercase - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); - - return { - id: profile.sub, - name: profile.name ?? profile.preferred_username, - email: profile.email, - first_name: profile.given_name ?? '', - last_name: profile.family_name ?? '', - username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', - role: cleanRoles, - } - }, + clientId: process.env.KEYCLOAK_CLIENT_ID || "", + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "", + issuer: process.env.KEYCLOAK_ISSUER || "", }), ], session: { strategy: "jwt", - maxAge: 12 * 60 * 60, // Reduce to 12 hours to help with token size + maxAge: 8 * 60 * 60, // 8 hours only }, + callbacks: { + // Simple JWT callback - no refresh logic + async jwt({ token, account }) { + if (account) { + // Initial sign-in, store tokens + token.accessToken = account.access_token; + token.sub = account.providerAccountId; + } + return token; + }, + // Simple session callback + async session({ session, token }) { + session.accessToken = token.accessToken; + if (session.user) { + session.user.id = token.sub || ""; + } + return session; + } + }, + // Redirect to signin page for any errors + pages: { + signIn: '/signin', + error: '/signin', + }, + // Set reasonable cookie options cookies: { sessionToken: { - name: `next-auth.session-token`, + name: 'next-auth.session-token', options: { httpOnly: true, sameSite: 'none', path: '/', secure: true, - domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined, - maxAge: 12 * 60 * 60, // Match session maxAge }, }, }, - jwt: { - // Maximum JWT size to prevent chunking - maxAge: 12 * 60 * 60, // Reduce to 12 hours - }, - callbacks: { - async jwt({ token, account, profile }) { - // Initial sign in - if (account && profile) { - const keycloakProfile = profile as KeycloakProfile; - const roles = keycloakProfile.realm_access?.roles || []; - - // Only include admin, owner, user roles (most critical) - const criticalRoles = roles - .filter(role => - role.includes('admin') || - role.includes('owner') || - role.includes('user') - ) - .map(role => role.replace(/^ROLE_/, '').toLowerCase()); - - // Store absolute minimal data in the token - token.accessToken = account.access_token; - token.refreshToken = account.refresh_token; - token.accessTokenExpires = account.expires_at ?? 0; - token.sub = keycloakProfile.sub; - token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles - token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? ''; - token.error = undefined; - - // Don't store first/last name in the token to save space - // Applications can get these from the userinfo endpoint if needed - - return token; - } - - // Check if token has expired - const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0; - const currentTime = Date.now(); - const hasExpired = currentTime >= tokenExpiresAt; - - // If the token is still valid, return it - if (!hasExpired) { - return token; - } - - // If refresh token is missing, force sign in - if (!token.refreshToken) { - console.warn('No refresh token available, session cannot be refreshed'); - return { - ...token, - error: "RefreshAccessTokenError" - }; - } - - // Try to refresh the token - const refreshedToken = await refreshAccessToken(token as JWT); - - // If there was an error refreshing, mark token for re-authentication - if (refreshedToken.error) { - console.warn('Token refresh failed, user will need to reauthenticate'); - return { - ...refreshedToken, - error: "RefreshAccessTokenError" - }; - } - - return refreshedToken; - }, - async session({ session, token }) { - try { - // Handle the error from jwt callback - if (token.error === "RefreshAccessTokenError") { - console.warn("Session encountered a refresh token error, redirecting to login"); - // Return minimal session with error flag that will trigger re-auth in client - return { - ...session, - error: "RefreshTokenError", - user: { - id: token.sub ?? '', - role: ['user'], // Default role - username: '', // Empty username - first_name: '', - last_name: '', - name: null, - email: null, - image: null, - nextcloudInitialized: false, - } - }; - } - - const userRoles = Array.isArray(token.role) ? token.role : []; - - // Create an extremely minimal user object - session.user = { - id: token.sub ?? '', - email: token.email ?? null, - name: token.name ?? null, - image: null, - username: token.username ?? '', - first_name: token.first_name ?? '', - last_name: token.last_name ?? '', - role: userRoles, - // Don't include nextcloudInitialized or other non-essential fields - }; - - // Only store access token, not the entire token - session.accessToken = token.accessToken; - - return session; - } catch (error) { - console.error("Error in session callback:", error); - // Return minimal session with error flag - return { - ...session, - error: "SessionError", - user: { - id: token.sub ?? '', - role: ['user'], - username: '', - first_name: '', - last_name: '', - name: null, - email: null, - image: null, - nextcloudInitialized: false, - } - }; - } - } - }, - pages: { - signIn: '/signin', - error: '/signin', - }, - debug: false, // Disable debug to reduce cookie size from logging + debug: false, }; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; -interface JWT { - accessToken: string; - refreshToken: string; - accessTokenExpires: number; -} - -interface Profile { - sub?: string; - email?: string; - name?: string; - roles?: string[]; -} - diff --git a/app/api/auth/[...nextauth]/route.ts.bak b/app/api/auth/[...nextauth]/route.ts.bak new file mode 100644 index 00000000..fd7ca927 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts.bak @@ -0,0 +1,308 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; +import { jwtDecode } from "jwt-decode"; + +interface KeycloakProfile { + sub: string; + email?: string; + name?: string; + roles?: string[]; + preferred_username?: string; + given_name?: string; + family_name?: string; + realm_access?: { + roles: string[]; + }; +} + +interface DecodedToken { + realm_access?: { + roles: string[]; + }; + [key: string]: any; +} + +declare module "next-auth" { + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + username: string; + first_name: string; + last_name: string; + role: string[]; + nextcloudInitialized?: boolean; + }; + accessToken?: string; + } + + interface JWT { + sub?: string; + accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + role?: string[]; + username?: string; + first_name?: string; + last_name?: string; + error?: string; + email?: string | null; + name?: string | null; + } +} + +function getRequiredEnvVar(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function refreshAccessToken(token: JWT) { + try { + console.log('Attempting to refresh access token'); + const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { + 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, + }), + method: "POST", + }); + + const refreshedTokens = await response.json(); + + if (!response.ok) { + console.error('Token refresh failed with status:', response.status); + console.error('Error response:', refreshedTokens); + throw refreshedTokens; + } + + console.log('Token refresh successful'); + return { + ...token, + accessToken: refreshedTokens.access_token, + refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + error: undefined, // Clear any previous errors + }; + } catch (error) { + console.error("Error refreshing access token:", error); + return { + ...token, + error: "RefreshAccessTokenError", + }; + } +} + +export const authOptions: NextAuthOptions = { + providers: [ + KeycloakProvider({ + clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + authorization: { + params: { + scope: "openid profile email roles" + } + }, + profile(profile) { + // Simplified profile logging to reduce console noise + console.log('Keycloak profile received'); + + // Get roles from realm_access + const roles = profile.realm_access?.roles || []; + + // Clean up roles by removing ROLE_ prefix and converting to lowercase + const cleanRoles = roles.map((role: string) => + role.replace(/^ROLE_/, '').toLowerCase() + ); + + return { + id: profile.sub, + name: profile.name ?? profile.preferred_username, + email: profile.email, + first_name: profile.given_name ?? '', + last_name: profile.family_name ?? '', + username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', + role: cleanRoles, + } + }, + }), + ], + session: { + strategy: "jwt", + maxAge: 12 * 60 * 60, // Reduce to 12 hours to help with token size + }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'none', + path: '/', + secure: true, + domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined, + maxAge: 12 * 60 * 60, // Match session maxAge + }, + }, + }, + jwt: { + // Maximum JWT size to prevent chunking + maxAge: 12 * 60 * 60, // Reduce to 12 hours + }, + callbacks: { + async jwt({ token, account, profile }) { + // Initial sign in + if (account && profile) { + const keycloakProfile = profile as KeycloakProfile; + const roles = keycloakProfile.realm_access?.roles || []; + + // Only include admin, owner, user roles (most critical) + const criticalRoles = roles + .filter(role => + role.includes('admin') || + role.includes('owner') || + role.includes('user') + ) + .map(role => role.replace(/^ROLE_/, '').toLowerCase()); + + // Store absolute minimal data in the token + token.accessToken = account.access_token; + token.refreshToken = account.refresh_token; + token.accessTokenExpires = account.expires_at ?? 0; + token.sub = keycloakProfile.sub; + token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles + token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? ''; + token.error = undefined; + + // Don't store first/last name in the token to save space + // Applications can get these from the userinfo endpoint if needed + + return token; + } + + // Check if token has expired + const tokenExpiresAt = token.accessTokenExpires ? token.accessTokenExpires as number : 0; + const currentTime = Date.now(); + const hasExpired = currentTime >= tokenExpiresAt; + + // If the token is still valid, return it + if (!hasExpired) { + return token; + } + + // If refresh token is missing, force sign in + if (!token.refreshToken) { + console.warn('No refresh token available, session cannot be refreshed'); + return { + ...token, + error: "RefreshAccessTokenError" + }; + } + + // Try to refresh the token + const refreshedToken = await refreshAccessToken(token as JWT); + + // If there was an error refreshing, mark token for re-authentication + if (refreshedToken.error) { + console.warn('Token refresh failed, user will need to reauthenticate'); + return { + ...refreshedToken, + error: "RefreshAccessTokenError" + }; + } + + return refreshedToken; + }, + async session({ session, token }) { + try { + // Handle the error from jwt callback + if (token.error === "RefreshAccessTokenError") { + console.warn("Session encountered a refresh token error, redirecting to login"); + // Return minimal session with error flag that will trigger re-auth in client + return { + ...session, + error: "RefreshTokenError", + user: { + id: token.sub ?? '', + role: ['user'], // Default role + username: '', // Empty username + first_name: '', + last_name: '', + name: null, + email: null, + image: null, + nextcloudInitialized: false, + } + }; + } + + const userRoles = Array.isArray(token.role) ? token.role : []; + + // Create an extremely minimal user object + session.user = { + id: token.sub ?? '', + email: token.email ?? null, + name: token.name ?? null, + image: null, + username: token.username ?? '', + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + role: userRoles, + // Don't include nextcloudInitialized or other non-essential fields + }; + + // Only store access token, not the entire token + session.accessToken = token.accessToken; + + return session; + } catch (error) { + console.error("Error in session callback:", error); + // Return minimal session with error flag + return { + ...session, + error: "SessionError", + user: { + id: token.sub ?? '', + role: ['user'], + username: '', + first_name: '', + last_name: '', + name: null, + email: null, + image: null, + nextcloudInitialized: false, + } + }; + } + } + }, + pages: { + signIn: '/signin', + error: '/signin', + }, + debug: false, // Disable debug to reduce cookie size from logging +}; + +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; + +interface JWT { + accessToken: string; + refreshToken: string; + accessTokenExpires: number; +} + +interface Profile { + sub?: string; + email?: string; + name?: string; + roles?: string[]; +} + diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 0eb1c9bc..95d37c78 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -1,199 +1,52 @@ "use client"; -import { signIn, useSession } from "next-auth/react"; -import { useEffect, useState } from "react"; +import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { clearAuthCookies } from "@/lib/session"; export default function SignIn() { - const { data: session } = useSession(); const searchParams = useSearchParams(); - const signedOut = searchParams.get('signedOut') === 'true'; - const forceFreshLogin = searchParams.get('fresh') === 'true'; - const error = searchParams.get('error'); - const [isRedirecting, setIsRedirecting] = useState(false); - const [isFromLogout, setIsFromLogout] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - // Set error message based on the error param + const error = searchParams.get("error"); + const [message, setMessage] = useState(""); + useEffect(() => { + // Clear cookies on errors or manual signout if (error) { - let message = "An authentication error occurred"; + console.log("Clearing auth cookies due to error:", error); + clearAuthCookies(); - if (error === "RefreshTokenError") { - message = "Your session has expired. Please sign in again."; - } else if (error === "SessionError") { - message = "There was a problem with your session. Please sign in again."; - } else if (error === "invalid_grant") { - message = "Your authentication has expired. Please sign in again."; + // Set error message + if (error === "RefreshTokenError" || error === "invalid_grant") { + setMessage("Your session has expired. Please sign in again."); + } else { + setMessage("There was a problem with authentication. Please sign in."); } - - setErrorMessage(message); - console.log(`Authentication error: ${error}`); } }, [error]); - // Clear all Keycloak cookies when the fresh parameter is present - useEffect(() => { - if (forceFreshLogin || error) { - console.log('Fresh login requested or error detected, clearing all cookies'); - - // Clear auth cookies to ensure a fresh login - clearAuthCookies(); - - // Extra cleanup for Keycloak-specific cookies with different paths - const keycloakCookies = [ - 'KEYCLOAK_SESSION', - 'KEYCLOAK_IDENTITY', - 'KEYCLOAK_REMEMBER_ME', - 'KC_RESTART', - 'AUTH_SESSION_ID', - 'AUTH_SESSION_ID_LEGACY', - 'JSESSIONID' - ]; - - const domains = [ - window.location.hostname, - `.${window.location.hostname}`, - window.location.hostname.split('.').slice(-2).join('.'), - `.${window.location.hostname.split('.').slice(-2).join('.')}` - ]; - - // Clear each cookie with various paths and domains - keycloakCookies.forEach(cookieName => { - domains.forEach(domain => { - const paths = ['/', '/auth', '/realms']; - paths.forEach(path => { - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure`; - }); - }); - }); - - // Also check for chunked cookies - const cookies = document.cookie.split(';'); - cookies.forEach(cookie => { - const cookieName = cookie.split('=')[0].trim(); - if (/\.\d+$/.test(cookieName)) { - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure`; - } - }); - } - }, [forceFreshLogin, error]); - - // If signedOut is true, make sure we clean up any residual sessions - useEffect(() => { - if (signedOut) { - console.log('User explicitly signed out, clearing any residual session data'); - clearAuthCookies(); - } - }, [signedOut]); - - // Check if we came from the loggedout page - useEffect(() => { - const referrer = document.referrer; - const isFromLoggedOutPage = referrer && - (referrer.includes('/loggedout') || referrer.includes('/signout')); - - if (isFromLoggedOutPage) { - console.log('Detected navigation from logout page, preventing auto-login'); - setIsFromLogout(true); - } - }, []); - - useEffect(() => { - // Only automatically sign in if: - // - Not explicitly signed out - // - Not coming from logout - // - Not forcing a fresh login - // - No error present - // - Not already redirecting - // - No session exists - if (!signedOut && !isFromLogout && !forceFreshLogin && !error && !isRedirecting && !session) { - setIsRedirecting(true); - console.log('Triggering automatic sign-in'); - // Add a small delay to avoid immediate redirect which can cause loops - const timer = setTimeout(() => { - // Trigger Keycloak sign-in - signIn("keycloak", { callbackUrl: "/" }); - }, 500); - - return () => clearTimeout(timer); - } - }, [signedOut, isRedirecting, isFromLogout, forceFreshLogin, error, session]); - - useEffect(() => { - if (session?.user && !session.user.nextcloudInitialized) { - // Initialize Nextcloud - fetch('/api/nextcloud/init', { - method: 'POST' - }).then(response => { - if (!response.ok) { - console.error('Failed to initialize Nextcloud'); - } - }).catch(error => { - console.error('Error initializing Nextcloud:', error); - }); - } - }, [session]); - - const showManualLoginButton = signedOut || isFromLogout || isRedirecting || forceFreshLogin || error; - - // Determine the button text based on the context - const buttonText = error - ? 'Sign In Again' - : forceFreshLogin - ? 'Sign In (Fresh Login)' - : signedOut - ? 'Sign In Again' - : 'Sign In'; + // Simple login function + const handleSignIn = () => { + signIn("keycloak", { callbackUrl: "/" }); + }; return ( -
-
-
-

- {error ? 'Session Expired' : signedOut ? 'You have signed out' : 'Welcome Back'} -

- - {errorMessage && ( -
- {errorMessage} -
- )} - - {isRedirecting && !showManualLoginButton && ( -

- Redirecting to login... -

- )} - - {showManualLoginButton ? ( - - ) : ( -
-
-
- )} - - {(signedOut || forceFreshLogin || error) && ( -

- You'll need to enter your credentials again for security reasons -

- )} -
+
+
+

Sign In

+ + {message && ( +
+ {message} +
+ )} + +
); diff --git a/components/auth/auth-check.tsx b/components/auth/auth-check.tsx index d33844b7..2bf34774 100644 --- a/components/auth/auth-check.tsx +++ b/components/auth/auth-check.tsx @@ -1,61 +1,35 @@ "use client"; -import { useSession, signOut } from "next-auth/react"; +import { useSession } from "next-auth/react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useCallback } from "react"; +import { useEffect } from "react"; export function AuthCheck({ children }: { children: React.ReactNode }) { - const { data: session, status } = useSession(); + const { status } = useSession(); const pathname = usePathname(); const router = useRouter(); - // Create a memoized function to handle sign out to prevent excessive rerenders - const handleSessionError = useCallback((error: string) => { - console.log(`Session error detected: ${error}, signing out`); - - // Force a clean sign out and redirect to login - signOut({ - callbackUrl: `/signin?error=${encodeURIComponent(error)}`, - redirect: true - }); - }, []); - useEffect(() => { - // Handle expired sessions immediately - if (session?.error) { - handleSessionError(session.error); - return; - } - - // Handle unauthenticated status (after checking for errors) + // Simple redirect to login page if not authenticated if (status === "unauthenticated" && !pathname.includes("/signin")) { - console.log("User is not authenticated, redirecting to signin page"); router.push("/signin"); } - }, [status, session, router, pathname, handleSessionError]); + }, [status, router, pathname]); - // Show loading state + // Simple loading state if (status === "loading") { return ( -
-
-
-

Chargement...

-
+
+
); } - // Do not render with session errors - if (session?.error) { - return null; - } - - // Do not render if not authenticated and not on signin page + // Don't render on unauthenticated if (status === "unauthenticated" && !pathname.includes("/signin")) { return null; } - // Authentication is valid, render children + // Render children if authenticated return <>{children}; } \ No newline at end of file diff --git a/lib/session.ts b/lib/session.ts index 7ff2cbbe..756b145b 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -90,179 +90,41 @@ export async function invalidateServiceTokens(session: ExtendedSession) { await Promise.all(invalidatePromises); } +/** + * Clears all authentication-related cookies from the browser + */ export function clearAuthCookies() { - const cookies = document.cookie.split(';'); - console.log('Clearing all auth cookies'); - - // List of known auth-related cookie prefixes and specific cookies - const authCookiePrefixes = [ - 'next-auth.', - '__Secure-next-auth.', - '__Host-next-auth.', - 'KEYCLOAK_', - 'KC_', - 'JSESSIONID', - 'OAuth_Token_Request_State', - 'OAUTH2_CLIENT_ID', - 'OAUTH2_STATE', - 'XSRF-TOKEN', - 'AUTH_SESSION_', - 'identity', - 'session', - 'connect.sid' - ]; - - // Specific Keycloak cookies that need to be cleared - const specificCookies = [ + // List of common auth-related cookies + const authCookies = [ + 'next-auth.session-token', + 'next-auth.callback-url', + 'next-auth.csrf-token', + '__Secure-next-auth.session-token', + '__Host-next-auth.csrf-token', + 'next-auth.pkce.code_verifier', 'KEYCLOAK_SESSION', 'KEYCLOAK_IDENTITY', 'KEYCLOAK_REMEMBER_ME', 'KC_RESTART', - 'KEYCLOAK_SESSION_LEGACY', - 'KEYCLOAK_IDENTITY_LEGACY', 'AUTH_SESSION_ID', 'AUTH_SESSION_ID_LEGACY', 'JSESSIONID' ]; - console.log(`Processing ${cookies.length} cookies`); + // Get all cookies to check for chunked auth cookies + const cookies = document.cookie.split(';'); - // Get all cookie names to detect chunks (like next-auth.session-token.0) - const allCookieNames = cookies.map(cookie => cookie.split('=')[0].trim()); + // Clear main auth cookies + authCookies.forEach(cookieName => { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure`; + }); - // First attempt: Forcefully delete all auth and session cookies by name - for (const cookie of cookies) { - const [name] = cookie.split('='); - const trimmedName = name.trim(); - - // Check if this is an auth-related cookie - const isAuthCookie = authCookiePrefixes.some(prefix => - trimmedName.startsWith(prefix) - ) || specificCookies.includes(trimmedName); - - // Also clear cookies with auth-related terms - const containsAuthTerm = - trimmedName.toLowerCase().includes('auth') || - trimmedName.toLowerCase().includes('token') || - trimmedName.toLowerCase().includes('session') || - trimmedName.toLowerCase().includes('login') || - trimmedName.toLowerCase().includes('id'); - - if (isAuthCookie || containsAuthTerm || /\.\d+$/.test(trimmedName)) { - console.log(`Clearing cookie: ${trimmedName}`); - - // Try different combinations to ensure the cookie is cleared - const paths = ['/', '/auth', '/realms', '/admin', '/api', '/signin', '/login', '/account']; - const domains = [ - window.location.hostname, // Exact domain - `.${window.location.hostname}`, // Domain with leading dot - window.location.hostname.split('.').slice(-2).join('.'), // Root domain - `.${window.location.hostname.split('.').slice(-2).join('.')}`, // Root domain with leading dot - "" // No domain - ]; - - // Try each combination of path and domain - for (const path of paths) { - // Try without domain - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path};`; - - // Try with SameSite and Secure attributes - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Lax;`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Strict;`; - - // Try with different domains - for (const domain of domains) { - if (domain) { - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain};`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure;`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=Lax;`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=Strict;`; - } - } - } + // Check for and clear any chunked cookies + cookies.forEach(cookie => { + const cookieName = cookie.split('=')[0].trim(); + // Check for chunked cookies (they end with a number) + if (cookieName.startsWith('next-auth.') && /\.\d+$/.test(cookieName)) { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure`; } - } - - // Second attempt: Clear by prefix pattern (helps with dynamically named cookies) - for (const prefix of authCookiePrefixes) { - // Set of paths and domains to try - const paths = ['/', '/auth', '/realms', '/admin', '/api', '/signin', '/login', '/account']; - const domains = [ - window.location.hostname, - `.${window.location.hostname}`, - window.location.hostname.split('.').slice(-2).join('.'), - `.${window.location.hostname.split('.').slice(-2).join('.')}` - ]; - - for (const path of paths) { - for (const domain of domains) { - document.cookie = `${prefix}*=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure;`; - } - } - } - - // Clear localStorage items that might be related to authentication - try { - const authLocalStoragePrefixes = ['token', 'auth', 'session', 'keycloak', 'kc', 'user', 'oidc', 'login', 'next-auth']; - - console.log(`Checking localStorage (${localStorage.length} items)`); - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - const keyLower = key.toLowerCase(); - if (authLocalStoragePrefixes.some(prefix => keyLower.includes(prefix))) { - console.log(`Clearing localStorage: ${key}`); - localStorage.removeItem(key); - } - } - } - } catch (e) { - console.error('Error clearing localStorage:', e); - } - - // Also try to clear sessionStorage - try { - console.log('Clearing sessionStorage'); - sessionStorage.clear(); - } catch (e) { - console.error('Error clearing sessionStorage:', e); - } - - // Check for any IndexedDB databases related to auth - try { - if (window.indexedDB) { - window.indexedDB.databases().then(databases => { - databases.forEach(db => { - if (db.name && - (db.name.includes('auth') || - db.name.includes('keycloak') || - db.name.includes('token'))) { - console.log(`Deleting IndexedDB database: ${db.name}`); - window.indexedDB.deleteDatabase(db.name); - } - }); - }).catch(err => { - console.error('Error accessing IndexedDB databases:', err); - }); - } - } catch (e) { - console.error('Error clearing IndexedDB:', e); - } - - // Third attempt: check again for any remaining auth cookies - const remainingCookies = document.cookie.split(';'); - for (const cookie of remainingCookies) { - const [name] = cookie.split('='); - const trimmedName = name.trim(); - - if (trimmedName.includes('auth') || - trimmedName.includes('session') || - trimmedName.includes('token') || - trimmedName.includes('.')) { - console.log(`Still trying to clear cookie: ${trimmedName}`); - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; - document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`; - } - } + }); } \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index 9b444660..e26ceaac 100644 --- a/middleware.ts +++ b/middleware.ts @@ -17,125 +17,31 @@ export default async function middleware(req: NextRequest) { const url = req.nextUrl; const response = NextResponse.next(); - // Maximum size to prevent cookie chunking - make it even more conservative - const MAX_COOKIE_SIZE = 3000; // even more conservative limit in bytes + // Simple cookie cleanup on logout or signin error + const isLogout = url.pathname === '/loggedout' || url.pathname === '/signout'; + const isSigninError = url.pathname === '/signin' && url.searchParams.has('error'); - // Function to set all required nextAuth environment variables - const setNextAuthEnvVars = () => { - // Set strict cookie size limits - process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = String(MAX_COOKIE_SIZE); + if (isLogout || isSigninError) { + // Clear all auth-related cookies when logging out or on error + const authCookies = [ + 'next-auth.session-token', + 'next-auth.csrf-token', + 'next-auth.callback-url', + '__Secure-next-auth.session-token', + '__Host-next-auth.csrf-token', + 'KEYCLOAK_SESSION', + 'KEYCLOAK_IDENTITY', + 'KC_RESTART', + 'JSESSIONID', + 'AUTH_SESSION_ID', + 'AUTH_SESSION_ID_LEGACY' + ]; - // Force cookie compression to reduce size - process.env.NEXTAUTH_COOKIES_CHUNKING = 'false'; // Disable chunking and force smaller cookies - process.env.NEXTAUTH_COOKIES_CHUNKING_SIZE = String(MAX_COOKIE_SIZE); - - // Set secure cookie settings - process.env.NEXTAUTH_COOKIES_SECURE = 'true'; - process.env.NEXTAUTH_COOKIES_SAMESITE = 'none'; - - // Disable unnecessary callbacks that might increase cookie size - process.env.NEXTAUTH_DISABLE_CALLBACK = 'true'; - process.env.NEXTAUTH_DISABLE_JWT_CALLBACK = 'true'; - process.env.NEXTAUTH_JWT_STORE_RAW_TOKEN = 'false'; - - // Strongly enforce JWT max age - process.env.NEXTAUTH_JWT_MAX_AGE = String(12 * 60 * 60); // 12 hours in seconds - }; - - // Set environment variables for all routes - setNextAuthEnvVars(); - - // Detect refresh token errors in cookies and clean them up - const checkForErrorsAndCleanup = () => { - const cookies = req.cookies; - const cookieNames = Object.keys(cookies.getAll()); - - // Check for error param in URL that would indicate token refresh errors - if (url.pathname.includes('/signin') && url.searchParams.has('error')) { - // Clean up all auth cookies to ensure a fresh start - const allAuthCookies = cookieNames.filter(name => - name.includes('auth') || - name.includes('keycloak') || - name.includes('session') || - name.includes('KEYCLOAK') || - name.includes('KC_') - ); - - allAuthCookies.forEach(name => { - response.cookies.delete(name); - }); - - // Special header to indicate a serious error that requires full cleanup - response.headers.set('X-Auth-Error-Recovery', 'true'); - } - }; - - // Check for and clean up error cookies - checkForErrorsAndCleanup(); - - // Special handling for loggedout page to clean up cookies - if (url.pathname === '/loggedout') { - // Check if we're preserving SSO or doing a full logout - const preserveSso = url.searchParams.get('preserveSso') === 'true'; - - console.log(`Middleware detected logout (preserveSso: ${preserveSso})`); - - if (preserveSso) { - // Only clean up NextAuth cookies but preserve Keycloak SSO cookies - const nextAuthCookies = [ - 'next-auth.session-token', - 'next-auth.csrf-token', - 'next-auth.callback-url', - '__Secure-next-auth.session-token', - '__Host-next-auth.csrf-token' - ]; - - nextAuthCookies.forEach(name => { - response.cookies.delete(name); - }); - - // Also delete any chunked cookies - const cookieNames = Object.keys(req.cookies.getAll()); - const chunkedCookies = cookieNames.filter(name => /next-auth.*\.\d+$/.test(name)); - chunkedCookies.forEach(name => { - response.cookies.delete(name); - }); - } else { - // Full logout - clear all auth-related cookies - const authCookies = [ - 'next-auth.session-token', - 'next-auth.csrf-token', - 'next-auth.callback-url', - '__Secure-next-auth.session-token', - '__Host-next-auth.csrf-token', - 'KEYCLOAK_SESSION', - 'KEYCLOAK_IDENTITY', - 'KC_RESTART', - 'JSESSIONID' - ]; - - authCookies.forEach(name => { - response.cookies.delete(name); - }); - - // Also delete any chunked cookies - const cookieNames = Object.keys(req.cookies.getAll()); - const chunkedCookies = cookieNames.filter(name => - /next-auth.*\.\d+$/.test(name) || - /KEYCLOAK.*/.test(name) || - /KC_.*/.test(name) - ); - chunkedCookies.forEach(name => { - response.cookies.delete(name); - }); - } + authCookies.forEach(name => { + response.cookies.delete(name); + }); } - // For sign-in page, add header if fresh login is requested - if (url.pathname === '/api/auth/signin' && url.searchParams.get('fresh') === 'true') { - response.headers.set('X-Auth-Fresh-Login', 'true'); - } - return response; }