MAJOR: Replace keycloak-js with nuxt-oidc-auth for seamless SSO integration
## **SOLUTION: Migrate to Server-Side OIDC Authentication**
This completely replaces the problematic keycloak-js client-side implementation
with nuxt-oidc-auth, eliminating all CORS and iframe issues.
### **Benefits:**
- **No more CORS errors** - Server-side OAuth flow
- **No iframe dependencies** - Eliminates cross-domain issues
- **Works with nginx proxy** - No proxy configuration conflicts
- **Better security** - Tokens handled server-side
- **Cleaner integration** - Native Nuxt patterns
- **Maintains Directus compatibility** - Dual auth support
### **Installation & Configuration:**
- Added
uxt-oidc-auth module to nuxt.config.ts
- Configured Keycloak provider with proper OIDC settings
- Updated environment variables for security keys
### **Code Changes:**
#### **Authentication Flow:**
- **middleware/authentication.ts** - Updated to check both Directus + OIDC auth
- **composables/useUnifiedAuth.ts** - Migrated to use useOidcAuth()
- **pages/login.vue** - Updated SSO button to use oidcLogin('keycloak')
#### **Configuration:**
- **nuxt.config.ts** - Added OIDC provider configuration
- **.env.example** - Updated with nuxt-oidc-auth environment variables
- Removed old Keycloak runtime config
#### **Cleanup:**
- Removed keycloak-js dependency from package.json
- Deleted obsolete files:
- composables/useKeycloak.ts
- pages/auth/callback.vue
- server/utils/keycloak-oauth.ts
- server/api/debug/ directory
### **Authentication Routes (Auto-Generated):**
- /auth/keycloak/login - SSO login endpoint
- /auth/keycloak/logout - SSO logout endpoint
- /auth/keycloak/callback - OAuth callback (handled automatically)
### **Security Setup Required:**
Environment variables needed for production:
- NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET
- NUXT_OIDC_TOKEN_KEY (base64 encoded 32-byte key)
- NUXT_OIDC_SESSION_SECRET (48-character random string)
- NUXT_OIDC_AUTH_SESSION_SECRET (48-character random string)
### **Expected Results:**
SSO login should work without CORS errors
Compatible with nginx proxy setup
Maintains existing Directus authentication
Server-side session management
Automatic token refresh
Ready for container rebuild and production testing!
This commit is contained in:
@@ -1,326 +0,0 @@
|
||||
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, '')
|
||||
}
|
||||
Reference in New Issue
Block a user