diff --git a/composables/useKeycloak.ts b/composables/useKeycloak.ts index 75a74c6..38bd323 100644 --- a/composables/useKeycloak.ts +++ b/composables/useKeycloak.ts @@ -8,10 +8,44 @@ export const useKeycloak = () => { const token = ref(null) const isInitialized = ref(false) + // Get the correct base URL considering proxy setup + const getBaseUrl = () => { + if (process.server) { + // In production, always use the configured HTTPS base URL + return config.public.baseUrl + } + + // Client-side: detect if we're behind a proxy + const currentOrigin = window.location.origin + const isProduction = !currentOrigin.includes('localhost') && !currentOrigin.includes('127.0.0.1') + + if (isProduction) { + // Force HTTPS in production environments + return config.public.baseUrl + } + + // Development: use current origin + return currentOrigin + } + + const logDebug = (message: string, data?: any) => { + if (config.public.keycloakDebug) { + console.log(`[KEYCLOAK] ${message}`, data) + } + } + const initKeycloak = async () => { if (process.server) return false try { + const baseUrl = getBaseUrl() + logDebug('Initializing Keycloak', { + baseUrl, + keycloakUrl: config.public.keycloak.url, + realm: config.public.keycloak.realm, + clientId: config.public.keycloak.clientId + }) + // Dynamically import keycloak-js const KeycloakModule = await import('keycloak-js') const KeycloakConstructor = KeycloakModule.default @@ -24,8 +58,16 @@ export const useKeycloak = () => { const authenticated = await keycloak.value.init({ onLoad: 'check-sso', - silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + // Use proper HTTPS redirect URI + redirectUri: `${baseUrl}/auth/callback`, + silentCheckSsoRedirectUri: `${baseUrl}/silent-check-sso.html`, checkLoginIframe: false, // Disable iframe checks for better compatibility + pkceMethod: 'S256', // Use PKCE for better security + }) + + logDebug('Keycloak initialization result', { + authenticated, + redirectUri: `${baseUrl}/auth/callback` }) isAuthenticated.value = authenticated diff --git a/composables/useUnifiedAuth.ts b/composables/useUnifiedAuth.ts index eba28cd..6309401 100644 --- a/composables/useUnifiedAuth.ts +++ b/composables/useUnifiedAuth.ts @@ -8,19 +8,42 @@ export interface UnifiedUser { } export const useUnifiedAuth = () => { - // Get auth system (Directus only for now) + // Get both auth systems const directusAuth = useDirectusAuth(); const directusUser = useDirectusUser(); + const keycloak = useKeycloak(); - // Create unified user object (Directus only) + // Create unified user object const user = computed(() => { - // Only use Directus user for now + // Check Keycloak user first + if (keycloak.user?.value) { + const keycloakUser = keycloak.user.value; + // Construct name from available fields + let name = keycloakUser.fullName; + if (!name && (keycloakUser.firstName || keycloakUser.lastName)) { + name = `${keycloakUser.firstName || ''} ${keycloakUser.lastName || ''}`.trim(); + } + if (!name) { + name = keycloakUser.username || keycloakUser.email; + } + + return { + id: keycloakUser.id, + email: keycloakUser.email, + name: name, + tier: 'basic', // Could be enhanced with Keycloak attributes + authSource: 'keycloak', + raw: keycloakUser + }; + } + + // Fall back to Directus user if (directusUser.value && directusUser.value.email) { return { id: directusUser.value.id, email: directusUser.value.email, name: `${directusUser.value.first_name || ''} ${directusUser.value.last_name || ''}`.trim() || directusUser.value.email, - tier: directusUser.value.tier || 'basic', // If Directus has tier field + tier: directusUser.value.tier || 'basic', authSource: 'directus', raw: directusUser.value }; @@ -29,11 +52,16 @@ export const useUnifiedAuth = () => { return null; }); - // Unified logout function (Directus only) + // Unified logout function const logout = async () => { - // Directus logout - await directusAuth.logout(); - await navigateTo('/login'); + if (user.value?.authSource === 'keycloak') { + // Keycloak logout + await keycloak.logout(); + } else if (user.value?.authSource === 'directus') { + // Directus logout + await directusAuth.logout(); + await navigateTo('/login'); + } }; // Check if user is authenticated diff --git a/middleware/authentication.ts b/middleware/authentication.ts index c1e4168..97b2db0 100644 --- a/middleware/authentication.ts +++ b/middleware/authentication.ts @@ -6,7 +6,7 @@ export default defineNuxtRouteMiddleware(async (to) => { const isAuthRequired = to.meta.auth !== false; try { - // Only use Directus auth for now (disable Keycloak temporarily) + // Check Directus auth first (most reliable) const { fetchUser, setUser } = useDirectusAuth(); const directusUser = useDirectusUser(); @@ -27,6 +27,28 @@ export default defineNuxtRouteMiddleware(async (to) => { return; } + // Check Keycloak auth if authentication is required + if (isAuthRequired) { + const keycloak = useKeycloak(); + + // Initialize Keycloak if not already done + if (!keycloak.isInitialized.value) { + try { + const authenticated = await keycloak.initKeycloak(); + if (authenticated) { + // User authenticated with Keycloak + return; + } + } catch (error) { + console.error('Keycloak initialization failed:', error); + // Continue to login redirect + } + } else if (keycloak.isAuthenticated.value) { + // User already authenticated with Keycloak + return; + } + } + // No authentication found if (isAuthRequired) { // Redirect to login page diff --git a/nuxt.config.ts b/nuxt.config.ts index ee62c26..f0f3054 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -105,6 +105,12 @@ export default defineNuxtConfig({ type: 'module' } }, + nitro: { + // Trust proxy headers for proper HTTPS detection + experimental: { + wasm: true + } + }, runtimeConfig: { nocodb: { url: "", @@ -127,6 +133,10 @@ export default defineNuxtConfig({ realm: "client-portal", clientId: "client-portal", }, + // Force HTTPS base URL for production + baseUrl: "https://client.portnimara.dev", + // Enable debug mode for troubleshooting + keycloakDebug: process.env.NODE_ENV === 'development', }, }, vuetify: { diff --git a/pages/auth/callback.vue b/pages/auth/callback.vue new file mode 100644 index 0000000..321d7c5 --- /dev/null +++ b/pages/auth/callback.vue @@ -0,0 +1,79 @@ + + + diff --git a/pages/dashboard/keycloak-test.vue b/pages/dashboard/keycloak-test.vue new file mode 100644 index 0000000..5d076dd --- /dev/null +++ b/pages/dashboard/keycloak-test.vue @@ -0,0 +1,249 @@ + + + diff --git a/pages/login.vue b/pages/login.vue index 938591d..9453a07 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -10,6 +10,27 @@ + + + + Login with Single Sign-On + + + + + + + OR + + + @@ -91,10 +112,14 @@ definePageMeta({ auth: false }); -// Directus auth only +// Directus auth const { login } = useDirectusAuth(); +// Keycloak auth +const keycloak = useKeycloak(); + const loading = ref(false); +const keycloakLoading = ref(false); const errorThrown = ref(false); const emailAddress = ref(); @@ -103,6 +128,33 @@ const passwordVisible = ref(false); const valid = ref(false); +// Keycloak login function with proper error handling +const loginWithKeycloak = async () => { + try { + keycloakLoading.value = true; + + console.log('[LOGIN] Starting Keycloak authentication...'); + + // Initialize Keycloak first if needed + if (!keycloak.isInitialized.value) { + console.log('[LOGIN] Initializing Keycloak...'); + await keycloak.initKeycloak(); + } + + // Perform login + await keycloak.login(); + + } catch (error) { + console.error('[LOGIN] Keycloak login error:', error); + + const toast = useToast(); + toast.error('SSO login failed. Please try again or use email/password login.'); + + } finally { + keycloakLoading.value = false; + } +}; + // Directus login function const submit = async () => { try { diff --git a/server/utils/keycloak-oauth.ts b/server/utils/keycloak-oauth.ts new file mode 100644 index 0000000..9184731 --- /dev/null +++ b/server/utils/keycloak-oauth.ts @@ -0,0 +1,326 @@ +import { H3Event, getHeader, getCookie, setCookie } from 'h3' +import crypto from 'crypto' + +interface OAuthConfig { + issuer: string + clientId: string + clientSecret: string + scope: string[] +} + +interface PKCEChallenge { + codeVerifier: string + codeChallenge: string + state: string +} + +interface TokenResponse { + access_token: string + refresh_token: string + id_token: string + token_type: string + expires_in: number +} + +interface KeycloakUser { + sub: string + email: string + preferred_username: string + given_name?: string + family_name?: string + name?: string + realm_access?: { + roles: string[] + } +} + +/** + * Smart base URL detection that handles proxy environments + */ +export function getBaseUrl(event: H3Event): string { + // Check for proxy headers first (nginx/reverse proxy) + const forwardedProto = getHeader(event, 'x-forwarded-proto') + const forwardedHost = getHeader(event, 'x-forwarded-host') + + if (forwardedProto && forwardedHost) { + return `${forwardedProto}://${forwardedHost}` + } + + // Fallback to host header + const host = getHeader(event, 'host') + + // Force HTTPS in production + const proto = process.env.NODE_ENV === 'production' ? 'https' : 'http' + + return `${proto}://${host}` +} + +/** + * Get OAuth configuration from environment + */ +export function getOAuthConfig(): OAuthConfig { + const config = useRuntimeConfig() + + return { + issuer: config.public.keycloak.url + '/realms/' + config.public.keycloak.realm, + clientId: config.public.keycloak.clientId, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, + scope: ['openid', 'email', 'profile'] + } +} + +/** + * Generate PKCE challenge for enhanced security + */ +export async function generatePKCEChallenge(): Promise { + // Generate code verifier (random string) + const codeVerifier = generateRandomString(128) + + // Create code challenge (SHA256 hash of verifier, base64url encoded) + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + const digest = await crypto.subtle.digest('SHA-256', data) + const codeChallenge = base64urlEncode(new Uint8Array(digest)) + + // Generate state parameter for CSRF protection + const state = generateRandomString(32) + + return { + codeVerifier, + codeChallenge, + state + } +} + +/** + * Generate Keycloak authorization URL + */ +export async function generateAuthUrl(event: H3Event): Promise { + const config = getOAuthConfig() + const baseUrl = getBaseUrl(event) + const pkce = await generatePKCEChallenge() + + // Store PKCE challenge and state in encrypted session + const sessionData = encrypt(JSON.stringify(pkce)) + setCookie(event, 'oauth_session', sessionData, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600 // 10 minutes + }) + + // Build authorization URL + const authUrl = new URL(`${config.issuer}/protocol/openid-connect/auth`) + authUrl.searchParams.set('client_id', config.clientId) + authUrl.searchParams.set('redirect_uri', `${baseUrl}/api/auth/keycloak/callback`) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', config.scope.join(' ')) + authUrl.searchParams.set('state', pkce.state) + authUrl.searchParams.set('code_challenge', pkce.codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + + return authUrl.toString() +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForToken( + event: H3Event, + code: string, + state: string +): Promise<{ tokens: TokenResponse; user: KeycloakUser }> { + const config = getOAuthConfig() + const baseUrl = getBaseUrl(event) + + // Retrieve and validate session data + const sessionCookie = getCookie(event, 'oauth_session') + if (!sessionCookie) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing OAuth session' + }) + } + + let sessionData: PKCEChallenge + try { + sessionData = JSON.parse(decrypt(sessionCookie)) + } catch (error) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid OAuth session' + }) + } + + // Validate state parameter (CSRF protection) + if (state !== sessionData.state) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid state parameter' + }) + } + + // Prepare token request + const tokenUrl = `${config.issuer}/protocol/openid-connect/token` + const tokenData = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: `${baseUrl}/api/auth/keycloak/callback`, + code_verifier: sessionData.codeVerifier + }) + + // Exchange code for tokens + const tokenResponse = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: tokenData.toString() + }) + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text() + console.error('Token exchange failed:', errorText) + throw createError({ + statusCode: 400, + statusMessage: 'Token exchange failed' + }) + } + + const tokens: TokenResponse = await tokenResponse.json() + + // Decode user info from ID token + const user = decodeJWTPayload(tokens.id_token) + + // Clean up session cookie + setCookie(event, 'oauth_session', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0 + }) + + return { tokens, user } +} + +/** + * Refresh access token using refresh token + */ +export async function refreshAccessToken(refreshToken: string): Promise { + const config = getOAuthConfig() + + const tokenUrl = `${config.issuer}/protocol/openid-connect/token` + const tokenData = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: refreshToken + }) + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: tokenData.toString() + }) + + if (!response.ok) { + throw createError({ + statusCode: 401, + statusMessage: 'Token refresh failed' + }) + } + + return await response.json() +} + +/** + * Validate JWT token and extract payload + */ +export function decodeJWTPayload(token: string): T { + try { + const parts = token.split('.') + if (parts.length !== 3) { + throw new Error('Invalid JWT format') + } + + const payload = parts[1] + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) + + // Check if token is expired + if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Token expired') + } + + return decoded + } catch (error) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid token' + }) + } +} + +/** + * Simple encryption for OAuth session data + */ +function encrypt(data: string): string { + const key = getEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipher('aes-256-cbc', key) + cipher.setAutoPadding(true) + + let encrypted = cipher.update(data, 'utf8', 'base64') + encrypted += cipher.final('base64') + + return iv.toString('base64') + ':' + encrypted +} + +/** + * Simple decryption for OAuth session data + */ +function decrypt(encryptedData: string): string { + const key = getEncryptionKey() + const parts = encryptedData.split(':') + const iv = Buffer.from(parts[0], 'base64') + const encrypted = parts[1] + + const decipher = crypto.createDecipher('aes-256-cbc', key) + decipher.setAutoPadding(true) + + let decrypted = decipher.update(encrypted, 'base64', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted +} + +/** + * Get encryption key from environment + */ +function getEncryptionKey(): string { + const key = process.env.OIDC_ENCRYPT_KEY || 'default-encrypt-key-change-in-prod' + return key.substring(0, 32).padEnd(32, '0') +} + +/** + * Generate cryptographically secure random string + */ +function generateRandomString(length: number): string { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + const array = new Uint8Array(length) + crypto.getRandomValues(array) + return Array.from(array, byte => charset[byte % charset.length]).join('') +} + +/** + * Base64URL encode (without padding) + */ +function base64urlEncode(buffer: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...buffer)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +}