Files
antrean-operasi/server/api/auth/keycloak-callback.get.ts
2026-02-24 14:33:16 +07:00

275 lines
13 KiB
TypeScript

// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX
// 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
try {
console.log('🔄 Starting auto-sync process...');
// 1. Sync hakAkses (roles) to hakAkses.json
const { syncHakAksesFromRoles } = await import('~/server/utils/hakAksesSync');
const hakAksesResult = syncHakAksesFromRoles(clientRoles);
console.log(`📋 HakAkses sync result:`, hakAksesResult);
// 2. Sync user data to users.json
const { syncUserData } = await import('~/server/utils/userDataSync');
const userResult = syncUserData({
id: sessionData.user.id,
name: sessionData.user.name,
email: sessionData.user.email,
roles: clientRoles
});
console.log(`👤 User sync result: ${userResult.action} - ${userResult.message}`);
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
return sendRedirect(event, '/dashboard?authenticated=true', 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}`);
}
});