port-nimara-client-portal/server/utils/keycloak-oauth.ts

327 lines
8.5 KiB
TypeScript
Raw Normal View History

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.
2025-06-14 15:26:26 +02:00
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<PKCEChallenge> {
// 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<string> {
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<KeycloakUser>(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<TokenResponse> {
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<T = any>(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, '')
}