262 lines
9.6 KiB
TypeScript
262 lines
9.6 KiB
TypeScript
const config = useRuntimeConfig();
|
|
// Define session duration (default to 1 hour if not specified in config)
|
|
const SESSION_DURATION = (config.sessionDurationHours || 1) * 60 * 60 * 24;
|
|
|
|
// This is the MAIN SESSION duration. It controls how long a user stays logged in.
|
|
// Current configuration: 1 hour (3600 seconds).
|
|
|
|
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, `/LoginPage?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, `/LoginPage?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, `/LoginPage?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, `/LoginPage?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, `/LoginPage?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, `/LoginPage?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, `/LoginPage?error=${errorMsg}`);
|
|
}
|
|
|
|
const tokens = await tokenResponse.json();
|
|
|
|
// Parse token payloads for immediate availability
|
|
let accessTokenPayload;
|
|
let idTokenPayloadFull;
|
|
try {
|
|
accessTokenPayload = JSON.parse(
|
|
Buffer.from(tokens.access_token.split(".")[1], "base64").toString(),
|
|
);
|
|
idTokenPayloadFull = JSON.parse(
|
|
Buffer.from(tokens.id_token.split(".")[1], "base64").toString(),
|
|
);
|
|
} catch (parseError) {
|
|
console.error("❌ Failed to parse token payloads:", parseError);
|
|
const errorMsg = encodeURIComponent("Invalid token format");
|
|
return sendRedirect(event, `/LoginPage?error=${errorMsg}`);
|
|
}
|
|
|
|
// Create session data
|
|
const sessionData = {
|
|
user: {
|
|
id: idTokenPayloadFull.sub,
|
|
email: idTokenPayloadFull.email,
|
|
name: idTokenPayloadFull.name || idTokenPayloadFull.preferred_username,
|
|
preferred_username: idTokenPayloadFull.preferred_username,
|
|
},
|
|
accessToken: tokens.access_token,
|
|
idToken: tokens.id_token,
|
|
refreshToken: tokens.refresh_token,
|
|
expiresAt: Date.now() + SESSION_DURATION * 1000,
|
|
createdAt: Date.now(),
|
|
scope: accessTokenPayload.scope || "openid email profile",
|
|
status: "authenticated",
|
|
};
|
|
|
|
// Store session in server-side store and get session ID
|
|
const { createSession } = await import('~/server/utils/sessionStore');
|
|
const sessionId = createSession(sessionData);
|
|
|
|
// Determine if we should use secure cookies
|
|
const isSecure =
|
|
process.env.NODE_ENV === "production" &&
|
|
event.node.req.headers["x-forwarded-proto"] === "https";
|
|
|
|
console.log("🔗 Setting session ID cookie");
|
|
console.log("⏱️ Session duration:", SESSION_DURATION, "seconds");
|
|
|
|
const cookieOptions: any = {
|
|
httpOnly: true,
|
|
secure: isSecure,
|
|
sameSite: "lax" as const,
|
|
maxAge: SESSION_DURATION,
|
|
path: "/",
|
|
};
|
|
|
|
// Store only session ID in cookie (small size, ~64 bytes)
|
|
setCookie(event, "user_session", sessionId, cookieOptions);
|
|
|
|
console.log("✅ Session ID cookie created successfully");
|
|
console.log("🍪 Cookie details:");
|
|
console.log(" - Name: user_session");
|
|
console.log(" - Size:", sessionId.length, "bytes");
|
|
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 user data to database (first time login check)
|
|
// Pass session createdAt as loginTime to sync function
|
|
try {
|
|
const { syncUserFromTokens } = await import("~/server/utils/userSync");
|
|
const result = syncUserFromTokens(
|
|
tokens.id_token,
|
|
tokens.access_token,
|
|
sessionData.createdAt,
|
|
);
|
|
console.log(
|
|
`✅ User auto-sync on login: ${result.action} - ${result.message}`,
|
|
);
|
|
} catch (syncError: any) {
|
|
// Don't fail the login if sync fails, just log it
|
|
console.error("⚠️ Failed to auto-sync user 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, `/LoginPage?error=${errorMsg}`);
|
|
}
|
|
});
|