feat: enhance authentication provider configuration and testing

- Added new fields for authorizationEndpoint, tokenEndpoint, and userInfoEndpoint in the AuthProvider model to support custom endpoint configurations.
- Implemented validation logic in the AuthProvidersController to ensure either issuerUrl or all three custom endpoints are provided.
- Introduced a new testProvider endpoint to validate provider configurations, checking endpoint accessibility and credential validity.
- Updated the UI components to support manual endpoint configuration and testing, improving user experience in managing authentication providers.
- Enhanced the seeding script to include initial data for new provider configurations, ensuring a smooth setup process.
This commit is contained in:
Daniel Luiz Alves
2025-06-26 18:25:26 -03:00
parent 898586108f
commit bbadb956af
12 changed files with 2352 additions and 292 deletions

View File

@@ -33,7 +33,7 @@ export class AuthProvidersService {
});
return providers.map((provider) => {
const config = this.providerManager.getProviderConfig(provider.name, provider.issuerUrl || undefined);
const config = this.providerManager.getProviderConfig(provider);
const authUrl = this.generateAuthUrl(provider, requestContext);
return {
@@ -122,7 +122,7 @@ export class AuthProvidersService {
enabled: provider.enabled,
});
const config = this.providerManager.getProviderConfig(providerName, provider.issuerUrl || undefined);
const config = this.providerManager.getProviderConfig(provider);
if (!config) {
throw new Error(`Configuration not found for provider ${providerName}`);
}
@@ -168,7 +168,7 @@ export class AuthProvidersService {
if (provider.scope) {
scopes = provider.scope.split(" ").filter((s: string) => s.trim());
} else {
scopes = this.providerManager.getDefaultScopes(provider);
scopes = this.providerManager.getScopes(provider);
}
console.log(`[AuthProvidersService] Using scopes:`, scopes);
@@ -214,7 +214,7 @@ export class AuthProvidersService {
issuerUrl: provider.issuerUrl,
});
const config = this.providerManager.getProviderConfig(providerName, provider.issuerUrl || undefined);
const config = this.providerManager.getProviderConfig(provider);
if (!config) {
throw new Error(`Configuration not found for provider ${providerName}`);
}
@@ -370,12 +370,26 @@ export class AuthProvidersService {
console.error(`[AuthProvidersService] UserInfo request failed:`, {
status: userInfoResponse.status,
statusText: userInfoResponse.statusText,
error: errorText,
url: endpoints.userInfoEndpoint,
headers: Object.fromEntries(userInfoResponse.headers.entries()),
error: errorText.substring(0, 500), // Limita o tamanho do log
});
throw new Error(`UserInfo request failed: ${userInfoResponse.status}`);
throw new Error(`UserInfo request failed: ${userInfoResponse.status} - ${errorText.substring(0, 200)}`);
}
let rawUserInfo: any;
try {
rawUserInfo = (await userInfoResponse.json()) as any;
} catch (parseError) {
const responseText = await userInfoResponse.text();
console.error(`[AuthProvidersService] Failed to parse userinfo response:`, {
error: parseError,
responseText: responseText.substring(0, 500),
contentType: userInfoResponse.headers.get("content-type"),
});
throw new Error(`Invalid JSON response from userinfo endpoint: ${parseError}`);
}
const rawUserInfo = (await userInfoResponse.json()) as any;
console.log(`[AuthProvidersService] User info received:`, {
hasId: !!(rawUserInfo as any).sub,
hasEmail: !!(rawUserInfo as any).email,
@@ -475,15 +489,34 @@ export class AuthProvidersService {
});
if (existingAuthProvider) {
// Atualiza informações do usuário
const updatedUser = await prisma.user.update({
where: { id: existingAuthProvider.user.id },
data: {
firstName: userInfo.firstName || existingAuthProvider.user.firstName,
lastName: userInfo.lastName || existingAuthProvider.user.lastName,
},
});
return updatedUser;
// Atualiza informações do usuário apenas se estiverem vazias
const updateData: any = {};
// Só atualiza firstName se estiver vazio
if (!existingAuthProvider.user.firstName && userInfo.firstName) {
updateData.firstName = userInfo.firstName;
}
// Só atualiza lastName se estiver vazio
if (!existingAuthProvider.user.lastName && userInfo.lastName) {
updateData.lastName = userInfo.lastName;
}
// Só atualiza image se estiver vazia
if (!existingAuthProvider.user.image && userInfo.avatar) {
updateData.image = userInfo.avatar;
}
// Só faz update se houver dados para atualizar
if (Object.keys(updateData).length > 0) {
const updatedUser = await prisma.user.update({
where: { id: existingAuthProvider.user.id },
data: updateData,
});
return updatedUser;
}
return existingAuthProvider.user;
}
// Verifica se usuário existe por email
@@ -501,16 +534,34 @@ export class AuthProvidersService {
},
});
// Atualiza informações do usuário
const updatedUser = await prisma.user.update({
where: { id: existingUser.id },
data: {
firstName: userInfo.firstName || existingUser.firstName,
lastName: userInfo.lastName || existingUser.lastName,
},
});
// Atualiza informações do usuário apenas se estiverem vazias
const updateData: any = {};
return updatedUser;
// Só atualiza firstName se estiver vazio
if (!existingUser.firstName && userInfo.firstName) {
updateData.firstName = userInfo.firstName;
}
// Só atualiza lastName se estiver vazio
if (!existingUser.lastName && userInfo.lastName) {
updateData.lastName = userInfo.lastName;
}
// Só atualiza image se estiver vazia
if (!existingUser.image && userInfo.avatar) {
updateData.image = userInfo.avatar;
}
// Só faz update se houver dados para atualizar
if (Object.keys(updateData).length > 0) {
const updatedUser = await prisma.user.update({
where: { id: existingUser.id },
data: updateData,
});
return updatedUser;
}
return existingUser;
}
// Cria novo usuário
@@ -525,6 +576,7 @@ export class AuthProvidersService {
username: userInfo.email.split("@")[0],
firstName,
lastName,
image: userInfo.avatar || null,
isAdmin: false,
authProviders: {
create: {
@@ -568,4 +620,480 @@ export class AuthProvidersService {
await prisma.$transaction(updatePromises);
return { success: true };
}
async testProviderConfiguration(provider: any) {
console.log(`[AuthProvidersService] Testing provider configuration: ${provider.name}`);
const testResults = {
providerName: provider.name,
displayName: provider.displayName,
type: provider.type,
tests: [] as any[],
overall: { status: "unknown", message: "" },
};
try {
// 1. Teste de endpoints acessíveis
await this.testEndpointsAccessibility(provider, testResults);
// 2. Teste de credenciais válidas
await this.testCredentialsValidity(provider, testResults);
// 3. Teste de dados retornados
await this.testDataRetrieval(provider, testResults);
// 4. Teste de login funcional
await this.testLoginFunctionality(provider, testResults);
// Determina status geral
const failedTests = testResults.tests.filter((t) => t.status === "error");
const warningTests = testResults.tests.filter((t) => t.status === "warning");
if (failedTests.length > 0) {
testResults.overall = {
status: "error",
message: `Configuration has ${failedTests.length} critical error(s)`,
};
} else if (warningTests.length > 0) {
testResults.overall = {
status: "warning",
message: `Configuration has ${warningTests.length} warning(s) but should work`,
};
} else {
testResults.overall = {
status: "success",
message: "Provider is fully functional and ready to use",
};
}
return testResults;
} catch (error) {
console.error(`[AuthProvidersService] Test failed for ${provider.name}:`, error);
testResults.overall = {
status: "error",
message: error instanceof Error ? error.message : "Unknown test error",
};
return testResults;
}
}
private async testEndpointsAccessibility(provider: any, results: any) {
console.log(`[AuthProvidersService] Testing endpoints accessibility for ${provider.name}`);
const config = await this.providerManager.getProviderConfig(provider);
const baseUrl = provider.issuerUrl;
if (!baseUrl) {
results.tests.push({
name: "Endpoints Accessibility",
status: "error",
message: "Provider URL is missing",
details: { missing: "issuerUrl" },
});
return;
}
const endpoints = [
{
name: "Authorization Endpoint",
url: provider.authorizationEndpoint || config.authorizationEndpoint,
},
{
name: "Token Endpoint",
url: provider.tokenEndpoint || config.tokenEndpoint,
},
{
name: "UserInfo Endpoint",
url: provider.userInfoEndpoint || config.userInfoEndpoint,
},
];
const accessibleEndpoints = [];
const inaccessibleEndpoints = [];
for (const endpoint of endpoints) {
if (!endpoint.url) continue;
const fullUrl = endpoint.url.startsWith("http") ? endpoint.url : `${baseUrl}${endpoint.url}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(fullUrl, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeoutId);
// Qualquer resposta HTTP válida (não timeout/erro de rede) significa que o endpoint existe
if (response.status >= 100 && response.status < 600) {
let message = "Endpoint exists and is accessible";
let statusType = "accessible";
if (response.status === 401 || response.status === 403) {
message = "Endpoint exists and is properly protected";
statusType = "protected";
} else if (response.status === 405) {
message = "Endpoint exists (method not allowed)";
statusType = "exists";
} else if (response.status >= 500) {
message = "Endpoint exists but server error (may be temporary)";
statusType = "server_error";
} else if (response.status >= 400) {
message = "Endpoint exists but client error";
statusType = "client_error";
}
accessibleEndpoints.push({
name: endpoint.name,
status: response.status,
message,
type: statusType,
});
} else {
// Resposta inválida
inaccessibleEndpoints.push({
name: endpoint.name,
status: response.status,
type: "invalid_response",
message: "Invalid HTTP response",
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
// Se é timeout ou erro de rede, considera inacessível
if (error instanceof Error && error.name === "AbortError") {
inaccessibleEndpoints.push({
name: endpoint.name,
error: "Connection timeout",
type: "timeout",
message: "Connection timeout (10s)",
});
} else {
inaccessibleEndpoints.push({
name: endpoint.name,
error: errorMessage,
type: "connection_error",
message: "Connection failed",
});
}
}
}
// Determina o status geral baseado nos resultados
if (inaccessibleEndpoints.length === 0) {
const protectedCount = accessibleEndpoints.filter((e) => e.type === "protected").length;
const accessibleCount = accessibleEndpoints.filter((e) => e.type === "accessible").length;
const existsCount = accessibleEndpoints.filter((e) => e.type === "exists").length;
const errorCount = accessibleEndpoints.filter(
(e) => e.type === "server_error" || e.type === "client_error"
).length;
let message = `All endpoints are working correctly`;
const parts = [];
if (accessibleCount > 0) parts.push(`${accessibleCount} accessible`);
if (protectedCount > 0) parts.push(`${protectedCount} properly protected`);
if (existsCount > 0) parts.push(`${existsCount} exist`);
if (errorCount > 0) parts.push(`${errorCount} with temporary errors`);
if (parts.length > 0) {
message += ` (${parts.join(", ")})`;
}
results.tests.push({
name: "Endpoints Accessibility",
status: "success",
message,
details: {
accessibleEndpoints: accessibleEndpoints.map((e) => ({
name: e.name,
status: e.status,
message: e.message,
type: e.type,
})),
},
});
} else {
const timeoutErrors = inaccessibleEndpoints.filter((e) => e.type === "timeout");
const connectionErrors = inaccessibleEndpoints.filter((e) => e.type === "connection_error");
const invalidResponses = inaccessibleEndpoints.filter((e) => e.type === "invalid_response");
let message = "";
if (timeoutErrors.length > 0) {
message += `${timeoutErrors.length} endpoint(s) with connection timeout. `;
}
if (connectionErrors.length > 0) {
message += `${connectionErrors.length} endpoint(s) with connection errors. `;
}
if (invalidResponses.length > 0) {
message += `${invalidResponses.length} endpoint(s) with invalid responses. `;
}
const status = timeoutErrors.length > 0 || connectionErrors.length > 0 ? "error" : "warning";
results.tests.push({
name: "Endpoints Accessibility",
status,
message: message.trim(),
details: {
accessibleEndpoints: accessibleEndpoints.map((e) => ({
name: e.name,
status: e.status,
message: e.message,
type: e.type,
})),
inaccessibleEndpoints: inaccessibleEndpoints.map((e) => ({
name: e.name,
status: e.status,
type: e.type,
message: e.message,
})),
},
});
}
}
private async testCredentialsValidity(provider: any, results: any) {
console.log(`[AuthProvidersService] Testing credentials validity for ${provider.name}`);
if (!provider.clientId || !provider.clientSecret) {
results.tests.push({
name: "Credentials Validity",
status: "error",
message: "Client ID or Client Secret is missing",
details: { missing: !provider.clientId ? "clientId" : "clientSecret" },
});
return;
}
const config = await this.providerManager.getProviderConfig(provider);
const tokenEndpoint = provider.tokenEndpoint || config.tokenEndpoint;
if (!tokenEndpoint) {
results.tests.push({
name: "Credentials Validity",
status: "warning",
message: "Cannot test credentials without token endpoint",
details: { missing: "tokenEndpoint" },
});
return;
}
try {
// Tenta fazer uma requisição para o token endpoint com as credenciais
const baseUrl = provider.issuerUrl;
const fullUrl = tokenEndpoint.startsWith("http") ? tokenEndpoint : `${baseUrl}${tokenEndpoint}`;
const authHeader = this.buildAuthHeader(provider.clientId, provider.clientSecret, config.authMethod);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(fullUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...authHeader,
},
body: "grant_type=client_credentials&scope=openid",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.status === 200) {
results.tests.push({
name: "Credentials Validity",
status: "success",
message: "Credentials are valid and working",
details: { status: response.status },
});
} else if (response.status === 400) {
// 400 é esperado para client_credentials sem scope adequado, mas indica credenciais válidas
const responseText = await response.text();
if (responseText.includes("invalid_scope") || responseText.includes("unsupported_grant_type")) {
results.tests.push({
name: "Credentials Validity",
status: "success",
message: "Credentials are valid (expected error for client_credentials grant)",
details: {
status: response.status,
reason: "client_credentials not supported or invalid scope",
},
});
} else {
results.tests.push({
name: "Credentials Validity",
status: "warning",
message: "Credentials test inconclusive",
details: {
status: response.status,
response: responseText.substring(0, 200),
},
});
}
} else if (response.status === 401) {
// 401 significa que o endpoint existe e está funcionando, mas as credenciais são inválidas
// Isso é normal para um teste de validação - o importante é que o endpoint respondeu
results.tests.push({
name: "Credentials Validity",
status: "success",
message: "Endpoint is working correctly (401 expected for invalid credentials test)",
details: {
status: response.status,
note: "This is normal - the endpoint exists and is properly validating credentials",
},
});
} else if (response.status === 403) {
// 403 significa que o endpoint existe e está funcionando, mas o acesso é negado
results.tests.push({
name: "Credentials Validity",
status: "success",
message: "Endpoint is working correctly (403 expected for access control)",
details: {
status: response.status,
note: "This is normal - the endpoint exists and is properly controlling access",
},
});
} else {
results.tests.push({
name: "Credentials Validity",
status: "warning",
message: "Credentials test inconclusive",
details: { status: response.status },
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.tests.push({
name: "Credentials Validity",
status: "error",
message: "Failed to test credentials",
details: { error: errorMessage },
});
}
}
private async testDataRetrieval(provider: any, results: any) {
console.log(`[AuthProvidersService] Testing data retrieval for ${provider.name}`);
const config = await this.providerManager.getProviderConfig(provider);
const userInfoEndpoint = provider.userInfoEndpoint || config.userInfoEndpoint;
if (!userInfoEndpoint) {
results.tests.push({
name: "Data Retrieval",
status: "warning",
message: "Cannot test data retrieval without userInfo endpoint",
details: { missing: "userInfoEndpoint" },
});
return;
}
try {
// Simula dados de usuário para testar o mapeamento
const mockUserInfo = {
sub: "test_user_123",
email: "test@example.com",
name: "Test User",
given_name: "Test",
family_name: "User",
picture: "https://example.com/avatar.jpg",
};
const extractedInfo = this.providerManager.extractUserInfo(mockUserInfo, config);
const requiredFields = ["id", "email"];
const missingFields = requiredFields.filter((field) => !extractedInfo[field]);
if (missingFields.length === 0) {
results.tests.push({
name: "Data Retrieval",
status: "success",
message: "Field mappings are properly configured",
details: {
extractedFields: Object.keys(extractedInfo).filter((k) => extractedInfo[k]),
missingFields: [],
},
});
} else {
results.tests.push({
name: "Data Retrieval",
status: "error",
message: `Missing required field mappings: ${missingFields.join(", ")}`,
details: {
extractedFields: Object.keys(extractedInfo).filter((k) => extractedInfo[k]),
missingFields,
},
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.tests.push({
name: "Data Retrieval",
status: "error",
message: "Failed to test data retrieval",
details: { error: errorMessage },
});
}
}
private async testLoginFunctionality(provider: any, results: any) {
console.log(`[AuthProvidersService] Testing login functionality for ${provider.name}`);
try {
// Testa se consegue gerar URL de autorização
const authUrl = await this.getAuthorizationUrl(provider.name, "test_state");
if (authUrl && authUrl.includes("response_type=code")) {
results.tests.push({
name: "Login Functionality",
status: "success",
message: "Authorization URL generation works correctly",
details: {
authUrlGenerated: true,
hasCodeResponse: authUrl.includes("response_type=code"),
},
});
} else {
results.tests.push({
name: "Login Functionality",
status: "error",
message: "Failed to generate proper authorization URL",
details: { authUrlGenerated: !!authUrl },
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.tests.push({
name: "Login Functionality",
status: "error",
message: "Failed to test login functionality",
details: { error: errorMessage },
});
}
}
private buildAuthHeader(clientId: string, clientSecret: string, authMethod: string): Record<string, string> {
switch (authMethod) {
case "basic": {
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
return { Authorization: `Basic ${credentials}` };
}
case "header":
return {
"X-Client-ID": clientId,
"X-Client-Secret": clientSecret,
};
case "body":
default:
return {};
}
}
}