integrate login page wih api and keycloak
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
// server/api/auth/keycloak-callback.ts - EXTENDED SESSION FIX
|
||||
import { createUserSession } from '~/server/utils/sessionStore';
|
||||
|
||||
// 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);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const clientRoles = accessTokenPayload.resource_access?.[config.keycloakClientId]?.roles || [];
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const refreshTokenExpiresInSeconds = Math.max(
|
||||
refreshTokenPayload.exp - nowInSeconds,
|
||||
0
|
||||
);
|
||||
const sessionDurationSeconds = refreshTokenExpiresInSeconds || SESSION_DURATION;
|
||||
|
||||
const sessionData = {
|
||||
user: {
|
||||
auth_provider: 'keycloak',
|
||||
email: idTokenPayload.email,
|
||||
name: idTokenPayload.name || idTokenPayload.preferred_username,
|
||||
role: clientRoles,
|
||||
user_id: idTokenPayload.sub,
|
||||
username: idTokenPayload.preferred_username,
|
||||
},
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
// idToken: tokens.id_token,
|
||||
expiresAt: (nowInSeconds + sessionDurationSeconds) * 1000,
|
||||
};
|
||||
|
||||
// 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';
|
||||
|
||||
// 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: sessionDurationSeconds,
|
||||
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 sessionId = createUserSession(sessionData);
|
||||
|
||||
// Store only the session ID in the cookie (much smaller)
|
||||
setCookie(event, 'user_session', sessionId, cookieOptions);
|
||||
|
||||
return sendRedirect(event, '/apps/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}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// server/api/auth/keycloak-login.ts
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('🔐 Keycloak Login Handler Called')
|
||||
console.log('📍 Method:', getMethod(event))
|
||||
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Debug: Log runtime config (without secrets)
|
||||
console.log('🔧 Runtime Config Check:')
|
||||
console.log(' - Has keycloakIssuer:', !!config.keycloakIssuer)
|
||||
console.log(' - Has keycloakClientId:', !!config.keycloakClientId)
|
||||
console.log(' - Has keycloakSecret:', !!config.keycloakClientSecret)
|
||||
console.log(' - Issuer value:', config.keycloakIssuer)
|
||||
|
||||
// Validate required configuration
|
||||
if (!config.keycloakIssuer) {
|
||||
throw new Error('KEYCLOAK_ISSUER is not configured')
|
||||
}
|
||||
if (!config.keycloakClientId) {
|
||||
throw new Error('KEYCLOAK_CLIENT_ID is not configured')
|
||||
}
|
||||
|
||||
// Generate state parameter for security
|
||||
const state = randomBytes(32).toString('hex')
|
||||
console.log('🎲 Generated state:', state.substring(0, 8) + '...')
|
||||
|
||||
// Store state in session cookie
|
||||
setCookie(event, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 600 // 10 minutes
|
||||
})
|
||||
|
||||
// Build Keycloak authorization URL
|
||||
const redirectUri = `${config.public.authUrl}/api/auth/keycloak-callback`
|
||||
|
||||
// Debug: Log the redirect URI being used
|
||||
console.log('🔧 AUTH_ORIGIN from config:', config.public.authUrl)
|
||||
console.log('🔗 Redirect URI being sent to Keycloak:', redirectUri)
|
||||
|
||||
const authUrl = new URL(`${config.keycloakIssuer}/protocol/openid-connect/auth`)
|
||||
|
||||
authUrl.searchParams.set('client_id', config.keycloakClientId)
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri)
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('scope', 'openid profile email')
|
||||
authUrl.searchParams.set('state', state)
|
||||
|
||||
console.log('🏗️ Auth URL built:', authUrl.toString())
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
authUrl: authUrl.toString()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Login Error:', error.message)
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to generate authorization URL: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// server/api/auth/logout.post.ts
|
||||
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const asString = (value: unknown): string => (typeof value === 'string' ? value : '');
|
||||
|
||||
const buildLogoutUrl = (idToken?: string) => {
|
||||
const config = useRuntimeConfig();
|
||||
const keycloakIssuer = asString(config.keycloakIssuer);
|
||||
const keycloakClientId = asString(config.keycloakClientId);
|
||||
const authUrl = asString(config.public.authUrl);
|
||||
const baseUrl = asString(config.public.baseUrl);
|
||||
const keycloakLogoutUri = asString(config.keycloakLogoutUri);
|
||||
const postLogoutRedirectUriConfig = asString(config.postLogoutRedirectUri);
|
||||
|
||||
const logoutPath = keycloakLogoutUri || `${keycloakIssuer}/protocol/openid-connect/logout`;
|
||||
const postLogoutRedirectUri = postLogoutRedirectUriConfig || baseUrl || authUrl;
|
||||
|
||||
const logoutUrl = new URL(logoutPath);
|
||||
logoutUrl.searchParams.set('client_id', keycloakClientId);
|
||||
|
||||
if (postLogoutRedirectUri) {
|
||||
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
||||
}
|
||||
|
||||
if (idToken) {
|
||||
logoutUrl.searchParams.set('id_token_hint', idToken);
|
||||
}
|
||||
|
||||
return logoutUrl.toString();
|
||||
};
|
||||
|
||||
try {
|
||||
// Retrieve token from the active session when available.
|
||||
const sessionId = getCookie(event, 'user_session');
|
||||
let idToken: string | undefined;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
const { getUserSession, deleteUserSession } = await import('~/server/utils/sessionStore');
|
||||
const session = getUserSession(sessionId);
|
||||
|
||||
if (session) {
|
||||
idToken = session.idToken;
|
||||
deleteUserSession(sessionId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore session-store retrieval failures; cookies are still cleared below.
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear auth-related cookies.
|
||||
deleteCookie(event, 'user_session');
|
||||
deleteCookie(event, 'oauth_state');
|
||||
deleteCookie(event, 'user_session', { path: '/' });
|
||||
deleteCookie(event, 'oauth_state', { path: '/' });
|
||||
|
||||
const logoutUrl = buildLogoutUrl(idToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logoutUrl,
|
||||
message: 'Session cleared successfully',
|
||||
};
|
||||
|
||||
} catch {
|
||||
let fallbackLogoutUrl = '';
|
||||
|
||||
try {
|
||||
fallbackLogoutUrl = buildLogoutUrl();
|
||||
} catch {
|
||||
// Keep empty fallback URL if runtime config is not usable.
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
logoutUrl: fallbackLogoutUrl,
|
||||
error: 'Logout encountered an error, but providing fallback logout URL',
|
||||
message: 'Logout failed',
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parseJwtPayload = (token: string): { exp?: number } | null => {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return null;
|
||||
|
||||
const decoded = Buffer.from(payload, 'base64').toString();
|
||||
return JSON.parse(decoded) as { exp?: number };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionId = getCookie(event, "user_session");
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "No session cookie found",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get session from server-side store using session ID
|
||||
const { getUserSession, deleteUserSession } = await import('~/server/utils/sessionStore');
|
||||
const session = getUserSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Session expired or invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const accessPayload = parseJwtPayload(session.accessToken);
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!accessPayload?.exp || accessPayload.exp <= nowInSeconds) {
|
||||
deleteUserSession(sessionId);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Access token expired or invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("❌ Failed to validate session:", error);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid session data",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createError } from 'h3';
|
||||
import type { AuthInfoResponse } from '~/types/auth';
|
||||
import {
|
||||
createUserSession,
|
||||
deleteUserSession,
|
||||
getUserSession,
|
||||
} from '~/server/utils/sessionStore';
|
||||
|
||||
type SessionStoreRequest = {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SESSION_SECONDS = 60 * 60; // 1 hour
|
||||
|
||||
|
||||
const parseJwtPayload = (token: string): { exp?: number } | null => {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
if (!payload) return null;
|
||||
|
||||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const decoded = Buffer.from(padded, 'base64').toString();
|
||||
|
||||
return JSON.parse(decoded) as { exp?: number };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SessionStoreRequest>(event);
|
||||
|
||||
const accessToken = body?.accessToken;
|
||||
const refreshToken = body?.refreshToken;
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'accessToken and refreshToken are required',
|
||||
});
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const baseUrl = config.public.baseUrl;
|
||||
const authInfoUrl = `${baseUrl}/api/v1/auth/info`;
|
||||
|
||||
let authInfoResponse: AuthInfoResponse;
|
||||
try {
|
||||
authInfoResponse = await $fetch<AuthInfoResponse>(authInfoUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage:
|
||||
error?.data?.message || error?.message || 'Failed to fetch auth info',
|
||||
});
|
||||
}
|
||||
|
||||
if (!authInfoResponse?.data) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Invalid auth info response',
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const refreshPayload = parseJwtPayload(refreshToken);
|
||||
const refreshExpSeconds = refreshPayload?.exp;
|
||||
|
||||
const expiresAt =
|
||||
typeof refreshExpSeconds === 'number' && refreshExpSeconds > 0
|
||||
? refreshExpSeconds * 1000
|
||||
: now + DEFAULT_SESSION_SECONDS * 1000;
|
||||
|
||||
const maxAge = Math.max(Math.floor((expiresAt - now) / 1000), 60);
|
||||
|
||||
const previousSessionId = getCookie(event, 'user_session');
|
||||
if (previousSessionId && getUserSession(previousSessionId)) {
|
||||
deleteUserSession(previousSessionId);
|
||||
}
|
||||
|
||||
const sessionId = createUserSession({
|
||||
user: {
|
||||
auth_provider: authInfoResponse.data.auth_provider || 'jwt',
|
||||
email: authInfoResponse.data.email,
|
||||
name: authInfoResponse.data.name,
|
||||
role: authInfoResponse.data.role,
|
||||
user_id: authInfoResponse.data.user_id,
|
||||
username: authInfoResponse.data.username,
|
||||
},
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const isSecure =
|
||||
process.env.NODE_ENV === 'production' && event.node.req.headers['x-forwarded-proto'] === 'https';
|
||||
|
||||
setCookie(event, 'user_session', sessionId, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Session stored successfully',
|
||||
expiresAt,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user