// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX import { syncRole } from '~/services/access'; // Add this at the top of the file (after imports) const SESSION_DURATION = 1 * 60 * 60; // 1 hour in seconds (3600 seconds) // Or use one of these alternatives: // const SESSION_DURATION = 24 * 60 * 60; // 1 day // const SESSION_DURATION = 30 * 24 * 60 * 60; // 30 days // const SESSION_DURATION = 12 * 60 * 60; // 12 hours // const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days export default defineEventHandler(async (event) => { try { const config = useRuntimeConfig(); const query = getQuery(event); console.log('🔄 === KEYCLOAK CALLBACK STARTED ==='); console.log('📋 Query parameters:', query); const code = query.code as string; const state = query.state as string; const error = query.error as string; const storedState = getCookie(event, 'oauth_state'); if (error) { console.error('❌ OAuth error from Keycloak:', error); const errorDescription = query.error_description as string; console.error('❌ Error description:', errorDescription); const errorMsg = encodeURIComponent(`Keycloak error: ${error} - ${errorDescription || 'Please try again'}`); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } console.log('📝 Code received:', !!code); console.log('🎲 State from URL:', state); console.log('🎲 State from cookie:', storedState); console.log('🎲 State validation:', state === storedState); if (!state || state !== storedState) { console.error('❌ Invalid state parameter - possible CSRF attack'); console.error(' Expected:', storedState); console.error(' Received:', state); const errorMsg = encodeURIComponent('Security validation failed. Please try logging in again.'); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } deleteCookie(event, 'oauth_state'); if (!code) { console.error('❌ Authorization code not provided'); const errorMsg = encodeURIComponent('No authorization code received from Keycloak.'); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } // Validate Keycloak configuration if (!config.keycloakIssuer) { console.error('❌ KEYCLOAK_ISSUER is not configured'); const errorMsg = encodeURIComponent('Keycloak server is not configured. Please contact administrator.'); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } if (!config.keycloakClientId || !config.keycloakClientSecret) { console.error('❌ Keycloak client credentials are not configured'); const errorMsg = encodeURIComponent('Keycloak client credentials are missing. Please contact administrator.'); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } const tokenUrl = `${config.keycloakIssuer}/protocol/openid-connect/token`; const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`; console.log('🔗 Token URL:', tokenUrl); console.log('🔗 Redirect URI:', redirectUri); console.log('🔑 Client ID:', config.keycloakClientId ? '***configured***' : 'MISSING'); const tokenPayload = new URLSearchParams({ grant_type: 'authorization_code', client_id: config.keycloakClientId, client_secret: config.keycloakClientSecret, code, redirect_uri: redirectUri, }); let tokenResponse; try { // Create abort controller for timeout (compatible with all Node.js versions) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout tokenResponse = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: tokenPayload, signal: controller.signal, }); clearTimeout(timeoutId); } catch (fetchError: any) { console.error('❌ Fetch error details:'); console.error(' - Error type:', fetchError.name); console.error(' - Error message:', fetchError.message); console.error(' - Token URL attempted:', tokenUrl); // Provide more specific error messages let errorMsg = 'Failed to connect to authentication server.'; if (fetchError.name === 'AbortError' || fetchError.message.includes('timeout')) { errorMsg = 'Authentication server timeout. Please try again.'; } else if (fetchError.message.includes('ENOTFOUND') || fetchError.message.includes('getaddrinfo')) { errorMsg = 'Cannot reach authentication server. Please check network connection.'; } else if (fetchError.message.includes('ECONNREFUSED')) { errorMsg = 'Authentication server refused connection. Server may be down.'; } else if (fetchError.message.includes('certificate') || fetchError.message.includes('SSL')) { errorMsg = 'SSL certificate error. Please contact administrator.'; } const encodedError = encodeURIComponent(errorMsg); return sendRedirect(event, `/auth/login?error=${encodedError}`); } if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); console.error('❌ Token exchange failed:', errorText); const errorMsg = encodeURIComponent(`Token exchange failed: ${tokenResponse.status} - Please check Keycloak configuration`); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } const tokens = await tokenResponse.json(); let idTokenPayload; let accessTokenPayload; let refreshTokenPayload; try { idTokenPayload = JSON.parse( Buffer.from(tokens.id_token.split('.')[1], 'base64').toString() ); accessTokenPayload = JSON.parse( Buffer.from(tokens.access_token.split('.')[1], 'base64').toString() ); refreshTokenPayload = JSON.parse( Buffer.from(tokens.refresh_token.split('.')[1], 'base64').toString() ); } catch (decodeError) { console.error('❌ Failed to decode token:', decodeError); const errorMsg = encodeURIComponent('Invalid token format'); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } // Extract roles from Keycloak token // Keycloak stores roles in different places depending on configuration const clientRoles = accessTokenPayload.resource_access?.[config.keycloakClientId]?.roles || []; console.log("refreshTokenPayload.exp:", refreshTokenPayload.exp); console.log("Current time (seconds):", Math.floor(Date.now() / 1000)); console.log("Token expires in (seconds):", refreshTokenPayload.exp - Math.floor(Date.now() / 1000)); // Store minimal session data in cookie to reduce size // The ID token contains user info, so we can decode it when needed const sessionData = { // Store only essential user info (can be decoded from ID token if needed) user: { id: idTokenPayload.sub, email: idTokenPayload.email, name: idTokenPayload.name || idTokenPayload.preferred_username, preferred_username: idTokenPayload.preferred_username, client_roles: clientRoles, // Client-specific roles }, // Store tokens - these are necessary for API calls // Note: These JWT tokens are large, but necessary for authentication accessToken: tokens.access_token, idToken: tokens.id_token, refreshToken: tokens.refresh_token, // Session metadata expiresAt: refreshTokenPayload.exp * 1000, // Convert to milliseconds createdAt: refreshTokenPayload.iat ? refreshTokenPayload.iat * 1000 : Date.now(), }; // Determine if we should use secure cookies // For localhost, always use secure: false const isSecure = process.env.NODE_ENV === 'production' && event.node.req.headers['x-forwarded-proto'] === 'https'; console.log('🔗 Setting session cookie with secure flag:', isSecure); console.log('⏱️ Session duration:', SESSION_DURATION, 'seconds'); console.log('🌐 Request host:', event.node.req.headers.host); console.log('🔒 Protocol:', event.node.req.headers['x-forwarded-proto'] || 'http'); // Set cookie with proper settings for localhost // For localhost HTTP, we need secure: false and sameSite: 'lax' // IMPORTANT: Ensure domain is not set for localhost (allows cookie to work) const cookieOptions: any = { httpOnly: true, secure: isSecure, sameSite: 'lax' as const, maxAge: SESSION_DURATION, path: '/', // Explicitly don't set domain for localhost - this is important! // Setting domain to 'localhost' can cause cookies to not work }; // For localhost, don't set domain (allows cookie to work on localhost) // Only set domain in production if needed if (process.env.NODE_ENV === 'production' && !event.node.req.headers.host?.includes('localhost')) { // Optionally set domain in production // cookieOptions.domain = '.yourdomain.com'; } // Store session in server-side store and use session ID in cookie // This avoids cookie size limits (4KB) const { createSession } = await import('~/server/utils/sessionStore'); const sessionId = createSession(sessionData); console.log('💾 Session stored server-side with ID:', sessionId.substring(0, 8) + '...'); console.log('📦 Session ID cookie size: ~64 bytes (much smaller!)'); // Store only the session ID in the cookie (much smaller) setCookie(event, 'user_session', sessionId, cookieOptions); console.log('✅ Session ID cookie set in response headers (will be available in next request)'); console.log('✅ Session cookie created successfully'); console.log('🍪 Cookie details:'); console.log(' - Path: /'); console.log(' - Secure:', isSecure); console.log(' - SameSite: lax'); console.log(' - HttpOnly: true'); console.log(' - MaxAge:', SESSION_DURATION, 'seconds'); console.log(' - Host:', event.node.req.headers.host); // Auto-sync hakAkses and user data after successful login let responseUserAcess try { console.log('🔄 Starting auto-sync process...'); responseUserAcess = await syncRole({ client_role: clientRoles, email: sessionData.user.email || '', keycloak_id: sessionData.user.id, name: sessionData.user.name || sessionData.user.preferred_username || 'Unknown', }, tokens.id_token); console.log("responseUserAcess", responseUserAcess); console.log('✅ Auto-sync process completed successfully'); } catch (syncError: any) { // Don't fail the login if sync fails, just log it console.error('⚠️ Failed to auto-sync data on login:', syncError); } // IMPORTANT: Ensure cookie is set before redirect // The cookie should be in the Set-Cookie header of the redirect response console.log('↪️ Redirecting to dashboard with cookie in response headers...'); // Note: In H3/Nitro, setCookie automatically adds Set-Cookie header to response // The cookie will be available in the browser after the redirect // We can't verify it in the same request, but it should be set correctly // Use sendRedirect - it should include the Set-Cookie header // The browser will receive the cookie and include it in the next request // check user access before redirecting to dashboard if(responseUserAcess && responseUserAcess.data){ return sendRedirect(event, '/dashboard?authenticated=true', 302); }else{ return sendRedirect(event, '/auth/access-denied', 302); } } catch (error: any) { console.error('❌ === CALLBACK ERROR ==='); console.error('❌ Error message:', error.message); console.error('❌ Error stack:', error.stack); console.error('❌ =================='); const errorMsg = encodeURIComponent(`Authentication failed: ${error.message}`); return sendRedirect(event, `/auth/login?error=${errorMsg}`); } });