FEAT: Migrate authentication system from Directus to Keycloak, implementing token refresh and enhancing session management

This commit is contained in:
2025-06-15 17:37:14 +02:00
parent d53f4f03f5
commit a7df6834d7
10 changed files with 529 additions and 336 deletions

View File

@@ -21,6 +21,16 @@ export default defineEventHandler(async (event) => {
}
try {
// Validate environment variables
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
if (!clientSecret) {
console.error('[KEYCLOAK] KEYCLOAK_CLIENT_SECRET not configured')
throw createError({
statusCode: 500,
statusMessage: 'Authentication service misconfigured'
})
}
// Exchange authorization code for tokens
const tokenResponse = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token', {
method: 'POST',
@@ -30,13 +40,17 @@ export default defineEventHandler(async (event) => {
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'client-portal',
client_secret: process.env.KEYCLOAK_CLIENT_SECRET || '',
client_secret: clientSecret,
code: code as string,
redirect_uri: 'https://client.portnimara.dev/api/auth/keycloak/callback'
}).toString()
}) as any
console.log('[KEYCLOAK] Token exchange successful')
console.log('[KEYCLOAK] Token exchange successful:', {
hasAccessToken: !!tokenResponse.access_token,
hasRefreshToken: !!tokenResponse.refresh_token,
expiresIn: tokenResponse.expires_in
})
// Get user info
const userInfo = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/userinfo', {
@@ -48,35 +62,50 @@ export default defineEventHandler(async (event) => {
console.log('[KEYCLOAK] User info retrieved:', {
sub: userInfo.sub,
email: userInfo.email,
username: userInfo.preferred_username
username: userInfo.preferred_username,
name: userInfo.name
})
// Set session cookie
// Set session cookie with proper configuration
const sessionData = {
user: userInfo,
user: {
id: userInfo.sub,
email: userInfo.email,
username: userInfo.preferred_username || userInfo.email,
name: userInfo.name || userInfo.preferred_username || userInfo.email,
authMethod: 'keycloak'
},
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: Date.now() + (tokenResponse.expires_in * 1000)
expiresAt: Date.now() + (tokenResponse.expires_in * 1000),
createdAt: Date.now()
}
// Create a simple session using a secure cookie
// Create session cookie with better security settings
setCookie(event, 'nuxt-oidc-auth', JSON.stringify(sessionData), {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: tokenResponse.expires_in
maxAge: tokenResponse.expires_in,
domain: '.portnimara.dev',
path: '/'
})
console.log('[OIDC] Session cookie set, redirecting to dashboard')
console.log('[KEYCLOAK] Session cookie set successfully')
console.log('[KEYCLOAK] Redirecting to dashboard...')
// Redirect to dashboard
await sendRedirect(event, '/dashboard')
} catch (error) {
} catch (error: any) {
console.error('[KEYCLOAK] Token exchange failed:', error)
throw createError({
statusCode: 500,
statusMessage: 'Authentication failed during token exchange'
console.error('[KEYCLOAK] Error details:', {
message: error.message,
status: error.status,
data: error.data
})
// Redirect to login with error
await sendRedirect(event, '/login?error=auth_failed')
}
})

View File

@@ -1,40 +1,41 @@
export default defineEventHandler(async (event) => {
console.log('[LOGOUT] Processing logout request')
try {
// Check which authentication method is being used
const directusToken = getCookie(event, 'directus_token')
// Check for OIDC session
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
// Clear Directus cookies if they exist
if (directusToken) {
deleteCookie(event, 'directus_token')
deleteCookie(event, 'directus_refresh_token')
deleteCookie(event, 'directus_token_expired_at')
console.log('[LOGOUT] Directus session cleared')
}
// Clear OIDC session cookie if it exists
// Clear OIDC session cookie
if (oidcSession) {
deleteCookie(event, 'nuxt-oidc-auth')
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
console.log('[LOGOUT] OIDC session cleared')
}
// If user was authenticated via OIDC/Keycloak, redirect to Keycloak logout
if (oidcSession) {
const logoutUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/logout?' +
new URLSearchParams({
redirect_uri: 'https://client.portnimara.dev/login'
}).toString()
await sendRedirect(event, logoutUrl)
} else {
// For Directus users or others, just redirect to login
await sendRedirect(event, '/login')
}
// Always redirect to Keycloak logout to ensure complete logout
const logoutUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/logout?' +
new URLSearchParams({
redirect_uri: 'https://client.portnimara.dev/login'
}).toString()
console.log('[LOGOUT] Redirecting to Keycloak logout:', logoutUrl)
await sendRedirect(event, logoutUrl)
} catch (error) {
console.error('[LOGOUT] Logout error:', error)
throw createError({
statusCode: 500,
statusMessage: 'Logout failed'
})
// Fallback: clear cookies and redirect to login
try {
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
} catch (cookieError) {
console.error('[LOGOUT] Cookie cleanup error:', cookieError)
}
await sendRedirect(event, '/login')
}
})

106
server/api/auth/refresh.ts Normal file
View File

@@ -0,0 +1,106 @@
export default defineEventHandler(async (event) => {
console.log('[REFRESH] Processing token refresh request')
try {
// Get current session
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
if (!oidcSession) {
console.error('[REFRESH] No session found')
throw createError({
statusCode: 401,
statusMessage: 'No session found'
})
}
let sessionData
try {
sessionData = JSON.parse(oidcSession)
} catch (parseError) {
console.error('[REFRESH] Failed to parse session:', parseError)
throw createError({
statusCode: 401,
statusMessage: 'Invalid session format'
})
}
// Check if we have a refresh token
if (!sessionData.refreshToken) {
console.error('[REFRESH] No refresh token available')
throw createError({
statusCode: 401,
statusMessage: 'No refresh token available'
})
}
// Validate environment variables
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
if (!clientSecret) {
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
throw createError({
statusCode: 500,
statusMessage: 'Authentication service misconfigured'
})
}
// Use refresh token to get new access token
const tokenResponse = await $fetch('https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: 'client-portal',
client_secret: clientSecret,
refresh_token: sessionData.refreshToken
}).toString()
}) as any
console.log('[REFRESH] Token refresh successful:', {
hasAccessToken: !!tokenResponse.access_token,
hasRefreshToken: !!tokenResponse.refresh_token,
expiresIn: tokenResponse.expires_in
})
// Update session with new tokens
const updatedSessionData = {
...sessionData,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token || sessionData.refreshToken, // Keep old refresh token if new one not provided
expiresAt: Date.now() + (tokenResponse.expires_in * 1000),
refreshedAt: Date.now()
}
// Set updated session cookie
setCookie(event, 'nuxt-oidc-auth', JSON.stringify(updatedSessionData), {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: tokenResponse.expires_in,
domain: '.portnimara.dev',
path: '/'
})
console.log('[REFRESH] Session updated successfully')
return {
success: true,
expiresAt: updatedSessionData.expiresAt
}
} catch (error: any) {
console.error('[REFRESH] Token refresh failed:', error)
// Clear invalid session
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
throw createError({
statusCode: 401,
statusMessage: 'Token refresh failed - please login again'
})
}
})

View File

@@ -1,85 +1,89 @@
export default defineEventHandler(async (event) => {
// Check Directus authentication first
try {
const directusToken = getCookie(event, 'directus_token')
if (directusToken) {
// Check if token is expired
const directusExpiry = getCookie(event, 'directus_token_expired_at')
if (directusExpiry) {
const expiryTime = parseInt(directusExpiry)
if (Date.now() >= expiryTime) {
console.log('[SESSION] Directus token expired')
return { user: null, authenticated: false }
}
}
// For Directus, we'll use generic user info since we don't decode the token
// You can expand this to fetch actual user data from Directus API if needed
return {
user: {
id: 'directus-user',
email: 'user@portnimara.com', // Could fetch from Directus API
username: 'directus-user',
name: 'Directus User',
authMethod: 'directus'
},
authenticated: true
}
}
} catch (error) {
console.error('[SESSION] Directus session check error:', error)
}
console.log('[SESSION] Checking authentication session...')
// Check OIDC authentication
// Check OIDC/Keycloak authentication only
try {
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
if (!oidcSessionCookie) {
console.log('[SESSION] No OIDC session cookie found')
return { user: null, authenticated: false }
}
// Handle encrypted OIDC cookies (Fe26.2** format)
let sessionData
if (oidcSessionCookie.startsWith('Fe26.2**')) {
// This is an encrypted cookie - for now we'll assume it's valid
// In a full implementation, you'd decrypt it properly
console.log('[SESSION] OIDC session found (encrypted)')
return {
user: {
id: 'oidc-user',
email: 'oidc-user@portnimara.com',
username: 'oidc-user',
name: 'OIDC User',
authMethod: 'oidc'
},
authenticated: true
}
} else {
// Try to parse as JSON (unencrypted)
sessionData = JSON.parse(oidcSessionCookie)
// Check if session is still valid
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
// Session expired, clear cookie
deleteCookie(event, 'nuxt-oidc-auth')
return { user: null, authenticated: false }
}
console.log('[SESSION] OIDC session cookie found, parsing...')
return {
user: {
id: sessionData.user.sub,
email: sessionData.user.email,
username: sessionData.user.preferred_username,
name: sessionData.user.name || sessionData.user.preferred_username,
authMethod: 'oidc'
},
authenticated: true
}
let sessionData
try {
// Parse the session data
sessionData = JSON.parse(oidcSessionCookie)
console.log('[SESSION] Session data parsed successfully:', {
hasUser: !!sessionData.user,
hasAccessToken: !!sessionData.accessToken,
expiresAt: sessionData.expiresAt,
createdAt: sessionData.createdAt,
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
})
} catch (parseError) {
console.error('[SESSION] Failed to parse session cookie:', parseError)
// Clear invalid session
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
return { user: null, authenticated: false }
}
// Validate session structure
if (!sessionData.user || !sessionData.accessToken) {
console.error('[SESSION] Invalid session structure:', {
hasUser: !!sessionData.user,
hasAccessToken: !!sessionData.accessToken
})
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
return { user: null, authenticated: false }
}
// Check if session is still valid
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
console.log('[SESSION] Session expired:', {
expiresAt: sessionData.expiresAt,
currentTime: Date.now(),
expiredSince: Date.now() - sessionData.expiresAt
})
// Session expired, clear cookie
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
return { user: null, authenticated: false }
}
console.log('[SESSION] Valid session found for user:', {
id: sessionData.user.id,
email: sessionData.user.email,
username: sessionData.user.username
})
return {
user: {
id: sessionData.user.id,
email: sessionData.user.email,
username: sessionData.user.username,
name: sessionData.user.name,
authMethod: sessionData.user.authMethod || 'keycloak'
},
authenticated: true
}
} catch (error) {
console.error('[SESSION] OIDC session check error:', error)
// Clear invalid session
deleteCookie(event, 'nuxt-oidc-auth')
deleteCookie(event, 'nuxt-oidc-auth', {
domain: '.portnimara.dev',
path: '/'
})
return { user: null, authenticated: false }
}
})