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 }
}
})

View File

@@ -1,80 +1,101 @@
/**
* Check if the request is authenticated via either:
* 1. x-tag header (for webhooks/external calls)
* 2. Directus token (for Directus authenticated users)
* 3. OIDC session (for Keycloak authenticated users)
* Check if the request is authenticated via Keycloak OIDC session
*/
export const isAuthenticated = async (event: any): Promise<boolean> => {
// Check x-tag header authentication (existing method)
const xTagHeader = getRequestHeader(event, "x-tag");
if (xTagHeader && (xTagHeader === "094ut234" || xTagHeader === "pjnvü1230")) {
console.log('[auth] Authenticated via x-tag header');
return true;
}
// Check Directus token authentication
try {
const directusToken = getCookie(event, 'directus_token');
console.log('[auth] Checking Directus token:', directusToken ? 'present' : 'not found');
if (directusToken) {
// Validate Directus token is not expired
const directusExpiry = getCookie(event, 'directus_token_expired_at');
console.log('[auth] Directus expiry cookie:', directusExpiry ? directusExpiry : 'not found');
if (directusExpiry) {
const expiryTime = parseInt(directusExpiry);
const currentTime = Date.now();
console.log('[auth] Directus expiry check:', { currentTime, expiryTime, isValid: currentTime < expiryTime });
if (currentTime < expiryTime) {
console.log('[auth] Authenticated via Directus token');
return true;
} else {
console.log('[auth] Directus token expired');
}
} else {
// If no expiry cookie, assume token is valid
console.log('[auth] Authenticated via Directus token (no expiry check)');
return true;
}
}
} catch (error) {
console.log('[auth] Directus token check failed:', error);
}
console.log('[auth] Checking authentication for:', event.node.req.url);
// Check OIDC session authentication
try {
const oidcSession = getCookie(event, 'nuxt-oidc-auth');
console.log('[auth] Checking OIDC session:', oidcSession ? 'present' : 'not found');
console.log('[auth] OIDC session cookie:', oidcSession ? 'present' : 'not found');
if (oidcSession) {
// Note: OIDC session might be encrypted, we'll validate it properly in session endpoint
console.log('[auth] OIDC session found, type:', oidcSession.startsWith('Fe26.2**') ? 'encrypted' : 'plain');
console.log('[auth] Authenticated via OIDC session');
return true;
if (!oidcSession) {
console.log('[auth] No OIDC session found');
return false;
}
} catch (error) {
console.log('[auth] OIDC session check failed:', error);
}
console.log('[auth] No valid authentication found');
return false;
// Parse and validate session data
let sessionData;
try {
sessionData = JSON.parse(oidcSession);
} catch (parseError) {
console.error('[auth] Failed to parse session cookie:', parseError);
return false;
}
// Validate session structure
if (!sessionData.user || !sessionData.accessToken) {
console.error('[auth] Invalid session structure:', {
hasUser: !!sessionData.user,
hasAccessToken: !!sessionData.accessToken
});
return false;
}
// Check if session is still valid
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
console.log('[auth] Session expired:', {
expiresAt: sessionData.expiresAt,
currentTime: Date.now(),
expiredSince: Date.now() - sessionData.expiresAt
});
return false;
}
console.log('[auth] Valid OIDC session found for user:', {
id: sessionData.user.id,
email: sessionData.user.email
});
return true;
} catch (error) {
console.error('[auth] OIDC session check failed:', error);
return false;
}
}
export const requireAuth = async (event: any) => {
const authenticated = await isAuthenticated(event);
if (!authenticated) {
console.log('[requireAuth] Authentication failed for:', event.node.req.url);
console.log('[requireAuth] Available headers:', Object.keys(event.node.req.headers));
console.log('[requireAuth] Available cookies:', Object.keys(event.node.req.headers.cookie ? parseCookies(event.node.req.headers.cookie) : {}));
throw createError({
statusCode: 401,
statusMessage: "Authentication required. Please provide x-tag header or valid session."
statusMessage: "Authentication required. Please login with Keycloak."
});
}
}
/**
* Get the authenticated user from the session
*/
export const getAuthenticatedUser = async (event: any): Promise<any | null> => {
try {
const oidcSession = getCookie(event, 'nuxt-oidc-auth');
if (!oidcSession) {
return null;
}
const sessionData = JSON.parse(oidcSession);
// Validate session
if (!sessionData.user || !sessionData.accessToken) {
return null;
}
// Check if session is still valid
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
return null;
}
return sessionData.user;
} catch (error) {
console.error('[getAuthenticatedUser] Error:', error);
return null;
}
}
function parseCookies(cookieString: string): Record<string, string> {
return cookieString.split(';').reduce((cookies: Record<string, string>, cookie) => {
const [name, value] = cookie.trim().split('=');