Refactor password reset to use dedicated Keycloak admin client
All checks were successful
Build And Push Image / docker (push) Successful in 2m55s

- Add Keycloak admin credentials configuration to environment variables
- Extract Keycloak admin operations into reusable utility module
- Refactor forgot-password endpoint to use new admin client utility
- Add documentation for Keycloak custom login implementation
- Add password reset fix summary documentation

This improves code organization by separating admin operations from
business logic and provides proper admin credentials for Keycloak
API operations instead of using regular client credentials.
This commit is contained in:
2025-08-07 17:50:09 +02:00
parent c6a57c7922
commit c84442433f
7 changed files with 1746 additions and 102 deletions

View File

@@ -1,3 +1,5 @@
import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin';
export default defineEventHandler(async (event) => {
console.log('🔄 Forgot password endpoint called at:', new Date().toISOString());
@@ -23,62 +25,20 @@ export default defineEventHandler(async (event) => {
});
}
const config = useRuntimeConfig();
// Validate Keycloak configuration
if (!config.keycloak?.issuer || !config.keycloak?.clientId || !config.keycloak?.clientSecret) {
console.error('❌ Missing Keycloak configuration');
throw createError({
statusCode: 500,
statusMessage: 'Authentication service configuration error'
});
}
console.log('🔧 Using Keycloak config for password reset:', {
issuer: config.keycloak.issuer,
clientId: config.keycloak.clientId
});
const config = useRuntimeConfig() as any;
try {
// Get admin token for Keycloak admin API
const adminTokenResponse = await fetch(`${config.keycloak.issuer}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.keycloak.clientId,
client_secret: config.keycloak.clientSecret
})
});
// Create Keycloak admin client
const adminClient = createKeycloakAdminClient();
console.log('🔧 Using Keycloak admin client for password reset');
if (!adminTokenResponse.ok) {
console.error('❌ Failed to get admin token:', adminTokenResponse.status);
throw new Error('Failed to authenticate with admin service');
}
const adminToken = await adminTokenResponse.json();
// Get admin token
const adminToken = await adminClient.getAdminToken();
console.log('✅ Admin token obtained');
// Find user by email using Keycloak admin API
const realmName = config.keycloak.issuer.split('/realms/')[1];
const adminBaseUrl = config.keycloak.issuer.replace('/realms/', '/admin/realms/');
const usersResponse = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
headers: {
'Authorization': `Bearer ${adminToken.access_token}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!usersResponse.ok) {
console.error('❌ Failed to search users:', usersResponse.status);
throw new Error('Failed to search for user');
}
const users = await usersResponse.json();
// Find user by email
const users = await adminClient.findUserByEmail(email, adminToken);
console.log('🔍 User search result:', { found: users.length > 0 });
if (users.length === 0) {
@@ -93,56 +53,13 @@ export default defineEventHandler(async (event) => {
const userId = users[0].id;
console.log('👤 Found user:', { id: userId, email: users[0].email });
// Send reset password email using Keycloak's execute-actions-email
// Add query parameters for better email template rendering
const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
resetUrl.searchParams.set('clientId', config.keycloak.clientId);
resetUrl.searchParams.set('redirectUri', `${config.keycloak.callbackUrl.replace('/auth/callback', '/login')}`);
resetUrl.searchParams.set('lifespan', '43200'); // 12 hours
console.log('🔄 Sending password reset email with parameters:', {
clientId: config.keycloak.clientId,
redirectUri: resetUrl.searchParams.get('redirectUri'),
lifespan: resetUrl.searchParams.get('lifespan')
});
// Create AbortController for timeout handling
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const resetResponse = await fetch(resetUrl.toString(), {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken.access_token}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify(['UPDATE_PASSWORD']),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!resetResponse.ok) {
console.error('❌ Failed to send reset email:', resetResponse.status);
const errorText = await resetResponse.text().catch(() => 'Unknown error');
console.error('Reset email error details:', errorText);
// Enhanced error handling for different scenarios
if (resetResponse.status === 500) {
console.error('🚨 SMTP server error detected - this usually indicates email configuration issues in Keycloak');
console.error('💡 Suggestion: Check Keycloak Admin Console → Realm Settings → Email tab');
// For now, still return success to user for security, but log the issue
console.log('🔄 Returning success message to user despite email failure for security');
return {
success: true,
message: 'If the email exists in our system, a reset link has been sent. If you don\'t receive an email, please contact your administrator.'
};
}
throw new Error('Failed to send reset email');
}
// Send password reset email
await adminClient.sendPasswordResetEmail(
userId,
adminToken,
config.keycloak.clientId,
config.keycloak.callbackUrl
);
console.log('✅ Password reset email sent successfully');
@@ -164,7 +81,7 @@ export default defineEventHandler(async (event) => {
}
// Handle SMTP/email server errors
if (keycloakError.message?.includes('send reset email') || keycloakError.message?.includes('SMTP')) {
if (keycloakError.message?.includes('send reset email') || keycloakError.message?.includes('SMTP') || keycloakError.message?.includes('500')) {
console.error('📧 Email server error detected, but user search was successful');
return {
success: true,
@@ -172,6 +89,16 @@ export default defineEventHandler(async (event) => {
};
}
// Handle permission errors
if (keycloakError.message?.includes('403') || keycloakError.message?.includes('Forbidden')) {
console.error('🔒 Permission error detected - admin client may not have proper roles');
console.error('💡 Suggestion: Check that admin-cli client has view-users and manage-users roles');
return {
success: true,
message: 'Password reset service is temporarily unavailable. Please contact your administrator.'
};
}
// For security, don't reveal specific errors to the user
return {
success: true,

View File

@@ -0,0 +1,110 @@
import type { KeycloakAdminConfig } from '~/utils/types';
export class KeycloakAdminClient {
private config: KeycloakAdminConfig;
constructor(config: KeycloakAdminConfig) {
this.config = config;
}
/**
* Get an admin access token using client credentials grant
*/
async getAdminToken(): Promise<string> {
const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret
})
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to get admin token: ${response.status} - ${errorText}`);
}
const tokenData = await response.json();
return tokenData.access_token;
}
/**
* Find a user by email address
*/
async findUserByEmail(email: string, adminToken: string): Promise<any[]> {
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const response = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
'User-Agent': 'MonacoUSA-Portal/1.0'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to search users: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Send password reset email to a user
*/
async sendPasswordResetEmail(userId: string, adminToken: string, portalClientId: string, callbackUrl: string): Promise<void> {
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
// Add query parameters for better email template rendering
resetUrl.searchParams.set('clientId', portalClientId);
resetUrl.searchParams.set('redirectUri', callbackUrl.replace('/auth/callback', '/login'));
resetUrl.searchParams.set('lifespan', '43200'); // 12 hours
// Create AbortController for timeout handling
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await fetch(resetUrl.toString(), {
method: 'PUT',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
'User-Agent': 'MonacoUSA-Portal/1.0'
},
body: JSON.stringify(['UPDATE_PASSWORD']),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to send reset email: ${response.status} - ${errorText}`);
}
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
}
export function createKeycloakAdminClient(): KeycloakAdminClient {
const config = useRuntimeConfig() as any;
if (!config.keycloakAdmin?.clientId || !config.keycloakAdmin?.clientSecret || !config.keycloak?.issuer) {
throw new Error('Missing Keycloak admin configuration');
}
return new KeycloakAdminClient({
issuer: config.keycloak.issuer,
clientId: config.keycloakAdmin.clientId,
clientSecret: config.keycloakAdmin.clientSecret
});
}