From a9294b02316840e465553ee8452ddffd9314d740 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 2 May 2025 12:56:20 +0200 Subject: [PATCH] auth flow --- app/api/auth/[...nextauth]/route.ts | 81 ++++++++++++++++------------- app/layout.tsx | 20 +++++-- components/auth/auth-check.tsx | 41 +++++++++------ lib/session.ts | 71 ++++++++++++++++++------- middleware.ts | 50 +++++++++++++++--- types/next-auth.d.ts | 17 ++++-- 6 files changed, 196 insertions(+), 84 deletions(-) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index bf076e42..fd7ca927 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -112,24 +112,17 @@ export const authOptions: NextAuthOptions = { } }, profile(profile) { - console.log('Keycloak profile callback:', { - rawProfile: profile, - rawRoles: profile.roles, - realmAccess: profile.realm_access, - groups: profile.groups - }); + // Simplified profile logging to reduce console noise + console.log('Keycloak profile received'); // Get roles from realm_access const roles = profile.realm_access?.roles || []; - console.log('Profile callback raw roles:', roles); - + // Clean up roles by removing ROLE_ prefix and converting to lowercase const cleanRoles = roles.map((role: string) => role.replace(/^ROLE_/, '').toLowerCase() ); - console.log('Profile callback cleaned roles:', cleanRoles); - return { id: profile.sub, name: profile.name ?? profile.preferred_username, @@ -144,7 +137,7 @@ export const authOptions: NextAuthOptions = { ], session: { strategy: "jwt", - maxAge: 30 * 24 * 60 * 60, // 30 days + maxAge: 12 * 60 * 60, // Reduce to 12 hours to help with token size }, cookies: { sessionToken: { @@ -155,12 +148,13 @@ export const authOptions: NextAuthOptions = { path: '/', secure: true, domain: process.env.NEXTAUTH_COOKIE_DOMAIN || undefined, + maxAge: 12 * 60 * 60, // Match session maxAge }, }, }, jwt: { - // Add explicit max size to prevent chunking - maxAge: 30 * 24 * 60 * 60, // 30 days + // Maximum JWT size to prevent chunking + maxAge: 12 * 60 * 60, // Reduce to 12 hours }, callbacks: { async jwt({ token, account, profile }) { @@ -168,26 +162,27 @@ export const authOptions: NextAuthOptions = { if (account && profile) { const keycloakProfile = profile as KeycloakProfile; const roles = keycloakProfile.realm_access?.roles || []; - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); + + // 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 minimal data in the token - token.accessToken = account.access_token ?? ''; - token.refreshToken = account.refresh_token ?? ''; + // 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 = cleanRoles; - token.username = keycloakProfile.preferred_username ?? ''; - token.error = undefined; // Clear any errors + token.role = criticalRoles.length > 0 ? criticalRoles : ['user']; // Only critical roles + token.username = keycloakProfile.preferred_username?.substring(0, 30) ?? ''; + token.error = undefined; - // Only store these if they're short - if (keycloakProfile.given_name && keycloakProfile.given_name.length < 30) { - token.first_name = keycloakProfile.given_name; - } - if (keycloakProfile.family_name && keycloakProfile.family_name.length < 30) { - token.last_name = keycloakProfile.family_name; - } + // Don't store first/last name in the token to save space + // Applications can get these from the userinfo endpoint if needed return token; } @@ -235,15 +230,22 @@ export const authOptions: NextAuthOptions = { ...session, error: "RefreshTokenError", user: { - ...session.user, - id: token.sub ?? '' + 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 a minimal user object + // Create an extremely minimal user object session.user = { id: token.sub ?? '', email: token.email ?? null, @@ -253,7 +255,7 @@ export const authOptions: NextAuthOptions = { first_name: token.first_name ?? '', last_name: token.last_name ?? '', role: userRoles, - nextcloudInitialized: false, + // Don't include nextcloudInitialized or other non-essential fields }; // Only store access token, not the entire token @@ -267,8 +269,15 @@ export const authOptions: NextAuthOptions = { ...session, error: "SessionError", user: { - ...session.user, - id: token.sub ?? '' + id: token.sub ?? '', + role: ['user'], + username: '', + first_name: '', + last_name: '', + name: null, + email: null, + image: null, + nextcloudInitialized: false, } }; } @@ -278,7 +287,7 @@ export const authOptions: NextAuthOptions = { signIn: '/signin', error: '/signin', }, - debug: process.env.NODE_ENV === 'development', + debug: false, // Disable debug to reduce cookie size from logging }; const handler = NextAuth(authOptions); diff --git a/app/layout.tsx b/app/layout.tsx index c726c206..dee70fe9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -18,18 +18,32 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); + // Try to get the session, but handle potential errors gracefully + let session = null; + let sessionError = false; + + try { + session = await getServerSession(authOptions); + } catch (error) { + console.error("Error getting server session:", error); + sessionError = true; + } + const headersList = await headers(); const pathname = headersList.get("x-pathname") || ""; const isSignInPage = pathname === "/signin"; + + // If we're on the signin page and there was a session error, + // don't pass the session to avoid refresh attempts + const safeSession = isSignInPage && sessionError ? null : session; return ( - + {children} diff --git a/components/auth/auth-check.tsx b/components/auth/auth-check.tsx index eaaa04bb..d33844b7 100644 --- a/components/auth/auth-check.tsx +++ b/components/auth/auth-check.tsx @@ -2,30 +2,37 @@ import { useSession, signOut } from "next-auth/react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; export function AuthCheck({ children }: { children: React.ReactNode }) { const { data: session, 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 authentication status changes + // Handle expired sessions immediately + if (session?.error) { + handleSessionError(session.error); + return; + } + + // Handle unauthenticated status (after checking for errors) if (status === "unauthenticated" && !pathname.includes("/signin")) { console.log("User is not authenticated, redirecting to signin page"); router.push("/signin"); } - - // Handle session errors (like refresh token failures) - if (session?.error) { - console.log(`Session error detected: ${session.error}, signing out`); - // Force a clean sign out - signOut({ - callbackUrl: `/signin?error=${encodeURIComponent(session.error)}`, - redirect: true - }); - } - }, [status, session, router, pathname]); + }, [status, session, router, pathname, handleSessionError]); // Show loading state if (status === "loading") { @@ -39,13 +46,13 @@ export function AuthCheck({ children }: { children: React.ReactNode }) { ); } - // If not authenticated and not on signin page, don't render children - if (status === "unauthenticated" && !pathname.includes("/signin")) { + // Do not render with session errors + if (session?.error) { return null; } - // Session has error, don't render children - if (session?.error) { + // Do not render if not authenticated and not on signin page + if (status === "unauthenticated" && !pathname.includes("/signin")) { return null; } diff --git a/lib/session.ts b/lib/session.ts index e31785aa..7ff2cbbe 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -119,7 +119,10 @@ export function clearAuthCookies() { 'KEYCLOAK_REMEMBER_ME', 'KC_RESTART', 'KEYCLOAK_SESSION_LEGACY', - 'KEYCLOAK_IDENTITY_LEGACY' + 'KEYCLOAK_IDENTITY_LEGACY', + 'AUTH_SESSION_ID', + 'AUTH_SESSION_ID_LEGACY', + 'JSESSIONID' ]; console.log(`Processing ${cookies.length} cookies`); @@ -127,18 +130,7 @@ export function clearAuthCookies() { // Get all cookie names to detect chunks (like next-auth.session-token.0) const allCookieNames = cookies.map(cookie => cookie.split('=')[0].trim()); - // Find any chunked cookies - const chunkedCookies = allCookieNames.filter(name => { - return /\.\d+$/.test(name) && authCookiePrefixes.some(prefix => name.startsWith(prefix)); - }); - - if (chunkedCookies.length > 0) { - console.log(`Found ${chunkedCookies.length} chunked cookies:`, chunkedCookies); - } - - // Add detected chunked cookies to our specific cookies list - specificCookies.push(...chunkedCookies); - + // First attempt: Forcefully delete all auth and session cookies by name for (const cookie of cookies) { const [name] = cookie.split('='); const trimmedName = name.trim(); @@ -156,16 +148,17 @@ export function clearAuthCookies() { trimmedName.toLowerCase().includes('login') || trimmedName.toLowerCase().includes('id'); - if (isAuthCookie || containsAuthTerm) { + 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']; + 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 + `.${window.location.hostname.split('.').slice(-2).join('.')}`, // Root domain with leading dot + "" // No domain ]; // Try each combination of path and domain @@ -175,19 +168,43 @@ export function clearAuthCookies() { // 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) { - 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;`; + 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;`; + } } } } } + // 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']; + 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++) { @@ -232,4 +249,20 @@ export function clearAuthCookies() { } 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 ff5d8cd5..9b444660 100644 --- a/middleware.ts +++ b/middleware.ts @@ -17,24 +17,62 @@ export default async function middleware(req: NextRequest) { const url = req.nextUrl; const response = NextResponse.next(); - // Maximum size to prevent cookie chunking - const MAX_COOKIE_SIZE = 3500; // conservative limit in bytes + // Maximum size to prevent cookie chunking - make it even more conservative + const MAX_COOKIE_SIZE = 3000; // even more conservative limit in bytes // Function to set all required nextAuth environment variables const setNextAuthEnvVars = () => { - // Disable callbacks that could increase cookie size - process.env.NEXTAUTH_DISABLE_CALLBACK = 'true'; - process.env.NEXTAUTH_DISABLE_JWT_CALLBACK = 'true'; + // Set strict cookie size limits process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = String(MAX_COOKIE_SIZE); - process.env.NEXTAUTH_COOKIES_CHUNKING = 'true'; + + // 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 diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index b660b33d..df632783 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -1,5 +1,13 @@ import NextAuth, { DefaultSession, DefaultUser } from "next-auth"; +// Define possible error types for better type checking +type AuthErrorType = + | "RefreshTokenError" + | "SessionError" + | "TokenError" + | "ConfigError" + | string; + declare module "next-auth" { interface Session { user: { @@ -14,7 +22,8 @@ declare module "next-auth" { refreshToken?: string; rocketChatToken?: string | null; rocketChatUserId?: string | null; - error?: string; + error?: AuthErrorType; + errorDescription?: string; } interface JWT { @@ -27,7 +36,8 @@ declare module "next-auth" { role?: string[]; rocketChatToken?: string | null; rocketChatUserId?: string | null; - error?: string; + error?: AuthErrorType; + errorDescription?: string; } interface User extends DefaultUser { @@ -62,6 +72,7 @@ declare module "next-auth/jwt" { role?: string[]; rocketChatToken?: string; rocketChatUserId?: string; - error?: string; + error?: AuthErrorType; + errorDescription?: string; } }