Implement Official Keycloak JS Adapter with Proxy-Aware Configuration

MAJOR ENHANCEMENT: Complete Keycloak integration with proper HTTPS/proxy handling

## Core Improvements:

### 1. Enhanced Configuration (nuxt.config.ts)
- Added proxy trust configuration for nginx environments
- Configured baseUrl for production HTTPS enforcement
- Added debug mode configuration for development

### 2. Proxy-Aware Keycloak Composable (composables/useKeycloak.ts)
- Intelligent base URL detection (production vs development)
- Force HTTPS redirect URIs in production environments
- Enhanced debugging and logging capabilities
- Proper PKCE implementation for security
- Automatic token refresh mechanism

### 3. Dual Authentication System
- Updated middleware to support both Directus and Keycloak
- Enhanced useUnifiedAuth for seamless auth source switching
- Maintains backward compatibility with existing Directus users

### 4. OAuth Flow Implementation
- Created proper callback handler (pages/auth/callback.vue)
- Comprehensive error handling and user feedback
- Automatic redirect to dashboard on success

### 5. Enhanced Login Experience (pages/login.vue)
- Restored SSO login button with proper error handling
- Maintained existing Directus login form
- Clear separation between auth methods with visual divider

### 6. Comprehensive Testing Suite (pages/dashboard/keycloak-test.vue)
- Real-time configuration display
- Authentication status monitoring
- Interactive testing tools
- Detailed debug logging system

## Technical Solutions:

 **Proxy Detection**: Automatically detects nginx proxy and uses correct HTTPS URLs
 **HTTPS Enforcement**: Forces secure redirect URIs in production
 **Error Handling**: Comprehensive error catching with user-friendly messages
 **Debug Capabilities**: Enhanced logging for troubleshooting
 **Security**: Implements PKCE and secure token handling

## Infrastructure Compatibility:
- Works with nginx reverse proxy setups
- Compatible with Docker container networking
- Handles SSL termination at proxy level
- Supports both development and production environments

This implementation specifically addresses the HTTP/HTTPS redirect URI mismatch
that was causing 'unauthorized_client' errors in the proxy environment.
This commit is contained in:
2025-06-14 15:26:26 +02:00
parent fa35fcd235
commit 0c9cd89667
8 changed files with 819 additions and 11 deletions

79
pages/auth/callback.vue Normal file
View File

@@ -0,0 +1,79 @@
<template>
<v-app>
<v-main>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center" class="fill-height">
<v-col cols="12" class="text-center">
<v-progress-circular
:size="70"
:width="7"
color="primary"
indeterminate
></v-progress-circular>
<h3 class="mt-4">Processing authentication...</h3>
<p class="text-body-2 mt-2">Please wait while we complete your login</p>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
// Define page meta for public access (no auth required)
definePageMeta({
auth: false,
layout: false
});
// Handle OAuth callback
onMounted(async () => {
try {
const route = useRoute()
const keycloak = useKeycloak()
// Check if we have an authorization code
const code = route.query.code as string
const state = route.query.state as string
const error = route.query.error as string
if (error) {
console.error('OAuth error:', error, route.query.error_description)
throw new Error(`Authentication failed: ${error}`)
}
if (!code) {
throw new Error('No authorization code received')
}
console.log('[CALLBACK] Processing OAuth callback with code:', code.substring(0, 10) + '...')
// Initialize Keycloak if not already done
if (!keycloak.isInitialized.value) {
await keycloak.initKeycloak()
}
// Check if user is now authenticated
if (keycloak.isAuthenticated.value) {
console.log('[CALLBACK] User authenticated successfully')
// Redirect to dashboard
await navigateTo('/dashboard')
} else {
throw new Error('Authentication process failed')
}
} catch (error) {
console.error('[CALLBACK] Authentication error:', error)
// Show error and redirect to login
const toast = useToast()
toast.error('Authentication failed. Unable to complete login. Please try again.')
await navigateTo('/login')
}
})
useHead({
title: 'Authenticating...'
})
</script>

View File

@@ -0,0 +1,249 @@
<template>
<v-container>
<v-row>
<v-col cols="12">
<h1>Keycloak Integration Test</h1>
<p class="text-body-1 mb-6">Test the Keycloak SSO integration and debug any issues</p>
</v-col>
</v-row>
<v-row>
<!-- Configuration Display -->
<v-col cols="12" md="6">
<v-card>
<v-card-title>Configuration</v-card-title>
<v-card-text>
<pre class="text-caption">{{ configInfo }}</pre>
</v-card-text>
</v-card>
</v-col>
<!-- Status Display -->
<v-col cols="12" md="6">
<v-card>
<v-card-title>Current Status</v-card-title>
<v-card-text>
<v-chip
:color="keycloak.isInitialized.value ? 'green' : 'red'"
variant="tonal"
class="mb-2"
>
Initialized: {{ keycloak.isInitialized.value }}
</v-chip>
<br>
<v-chip
:color="keycloak.isAuthenticated.value ? 'green' : 'red'"
variant="tonal"
class="mb-2"
>
Authenticated: {{ keycloak.isAuthenticated.value }}
</v-chip>
<br>
<v-chip
:color="unifiedAuth.user.value ? 'green' : 'gray'"
variant="tonal"
class="mb-2"
>
Auth Source: {{ unifiedAuth.authSource.value || 'None' }}
</v-chip>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<!-- User Information -->
<v-col cols="12" md="6" v-if="unifiedAuth.user.value">
<v-card>
<v-card-title>User Information</v-card-title>
<v-card-text>
<pre class="text-caption">{{ JSON.stringify(unifiedAuth.user.value, null, 2) }}</pre>
</v-card-text>
</v-card>
</v-col>
<!-- Actions -->
<v-col cols="12" md="6">
<v-card>
<v-card-title>Actions</v-card-title>
<v-card-text class="d-flex flex-column ga-3">
<v-btn
@click="initializeKeycloak"
:loading="initializing"
variant="outlined"
color="primary"
>
Initialize Keycloak
</v-btn>
<v-btn
@click="testLogin"
:loading="loginTesting"
variant="outlined"
color="success"
:disabled="!keycloak.isInitialized.value"
>
Test Login
</v-btn>
<v-btn
@click="testLogout"
variant="outlined"
color="warning"
:disabled="!keycloak.isAuthenticated.value"
>
Test Logout
</v-btn>
<v-btn
@click="refreshStatus"
variant="outlined"
>
Refresh Status
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<!-- Debug Logs -->
<v-col cols="12">
<v-card>
<v-card-title>
Debug Logs
<v-spacer></v-spacer>
<v-btn @click="clearLogs" size="small" variant="text">Clear</v-btn>
</v-card-title>
<v-card-text>
<v-sheet class="pa-3" style="background-color: #1e1e1e; color: #fff; height: 300px; overflow-y: auto;">
<div v-for="(log, index) in logs" :key="index" class="text-caption">
<span :style="{ color: getLogColor(log.level) }">
[{{ log.timestamp }}] {{ log.level.toUpperCase() }}: {{ log.message }}
</span>
<pre v-if="log.data" class="text-caption mt-1" style="color: #ccc;">{{ JSON.stringify(log.data, null, 2) }}</pre>
</div>
</v-sheet>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
// Auth systems
const keycloak = useKeycloak()
const unifiedAuth = useUnifiedAuth()
const config = useRuntimeConfig()
// Reactive state
const initializing = ref(false)
const loginTesting = ref(false)
const logs = ref<Array<{level: string, message: string, data?: any, timestamp: string}>>([])
// Computed properties
const configInfo = computed(() => ({
keycloakUrl: config.public.keycloak.url,
realm: config.public.keycloak.realm,
clientId: config.public.keycloak.clientId,
baseUrl: config.public.baseUrl,
debug: config.public.keycloakDebug,
currentOrigin: process.client ? window.location.origin : 'N/A (SSR)',
userAgent: process.client ? navigator.userAgent : 'N/A (SSR)'
}))
// Logging functions
const addLog = (level: string, message: string, data?: any) => {
logs.value.push({
level,
message,
data,
timestamp: new Date().toLocaleTimeString()
})
// Keep only last 50 logs
if (logs.value.length > 50) {
logs.value = logs.value.slice(-50)
}
}
const getLogColor = (level: string) => {
switch (level) {
case 'error': return '#ff5252'
case 'warn': return '#ff9800'
case 'info': return '#2196f3'
case 'success': return '#4caf50'
default: return '#fff'
}
}
const clearLogs = () => {
logs.value = []
}
// Test functions
const initializeKeycloak = async () => {
try {
initializing.value = true
addLog('info', 'Initializing Keycloak...')
const result = await keycloak.initKeycloak()
addLog('success', `Keycloak initialized. Authenticated: ${result}`, {
initialized: keycloak.isInitialized.value,
authenticated: keycloak.isAuthenticated.value
})
} catch (error) {
addLog('error', 'Keycloak initialization failed', error)
} finally {
initializing.value = false
}
}
const testLogin = async () => {
try {
loginTesting.value = true
addLog('info', 'Starting login test...')
await keycloak.login()
} catch (error) {
addLog('error', 'Login test failed', error)
loginTesting.value = false
}
}
const testLogout = async () => {
try {
addLog('info', 'Starting logout test...')
await keycloak.logout()
addLog('success', 'Logout completed')
} catch (error) {
addLog('error', 'Logout failed', error)
}
}
const refreshStatus = () => {
addLog('info', 'Refreshing status...', {
keycloakInitialized: keycloak.isInitialized.value,
keycloakAuthenticated: keycloak.isAuthenticated.value,
unifiedUser: unifiedAuth.user.value,
authSource: unifiedAuth.authSource.value
})
}
// Initialize on mount
onMounted(() => {
addLog('info', 'Keycloak test page loaded')
refreshStatus()
})
useHead({
title: 'Keycloak Integration Test'
})
</script>

View File

@@ -10,6 +10,27 @@
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" width="200" class="mb-3 mx-auto" />
</v-col>
<!-- Keycloak SSO Login -->
<v-col cols="12" class="mb-4">
<v-btn
color="primary"
size="large"
block
@click="loginWithKeycloak"
prepend-icon="mdi-shield-account"
:loading="keycloakLoading"
>
Login with Single Sign-On
</v-btn>
</v-col>
<!-- Divider -->
<v-col cols="12">
<v-divider class="my-4">
<span class="text-caption">OR</span>
</v-divider>
</v-col>
<!-- Directus Login Form -->
<v-col cols="12">
<v-form @submit.prevent="submit" v-model="valid">
@@ -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 {