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

@@ -160,6 +160,10 @@ model AuthProvider {
redirectUri String?
scope String? @default("openid profile email")
authorizationEndpoint String?
tokenEndpoint String?
userInfoEndpoint String?
metadata String?
autoRegister Boolean @default(true)

View File

@@ -157,12 +157,16 @@ const defaultAuthProviders = [
type: "oauth2",
icon: "SiGithub",
enabled: false,
issuerUrl: "https://github.com/login/oauth",
issuerUrl: "https://github.com/login/oauth", // URL fixa do GitHub
authorizationEndpoint: "/authorize",
tokenEndpoint: "/access_token",
userInfoEndpoint: "https://api.github.com/user", // GitHub usa URL absoluta para userInfo
scope: "user:email",
sortOrder: 1,
metadata: JSON.stringify({
description: "Sign in with your GitHub account",
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps"
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps",
specialHandling: "email_fetch_required"
})
},
{
@@ -171,11 +175,16 @@ const defaultAuthProviders = [
type: "oidc",
icon: "SiAuth0",
enabled: false,
issuerUrl: "https://your-tenant.auth0.com", // Placeholder - usuário deve configurar
authorizationEndpoint: "/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/userinfo",
scope: "openid profile email",
sortOrder: 2,
metadata: JSON.stringify({
description: "Sign in with Auth0",
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow"
description: "Sign in with Auth0 - Replace 'your-tenant' with your Auth0 domain",
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow",
supportsDiscovery: true
})
},
{
@@ -184,11 +193,16 @@ const defaultAuthProviders = [
type: "oidc",
icon: "FaKey",
enabled: false,
issuerUrl: "https://your-tenant.kinde.com", // Placeholder - usuário deve configurar
authorizationEndpoint: "/oauth2/auth",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/user_profile",
scope: "openid profile email",
sortOrder: 3,
metadata: JSON.stringify({
description: "Sign in with Kinde",
docs: "https://kinde.com/docs/developer-tools/about/"
description: "Sign in with Kinde - Replace 'your-tenant' with your Kinde domain",
docs: "https://kinde.com/docs/developer-tools/about/",
supportsDiscovery: true
})
},
{
@@ -197,11 +211,17 @@ const defaultAuthProviders = [
type: "oidc",
icon: "FaShield",
enabled: false,
issuerUrl: "https://your-instance.zitadel.cloud", // Placeholder - usuário deve configurar
authorizationEndpoint: "/oauth/v2/authorize",
tokenEndpoint: "/oauth/v2/token",
userInfoEndpoint: "/oidc/v1/userinfo",
scope: "openid profile email",
sortOrder: 4,
metadata: JSON.stringify({
description: "Sign in with Zitadel",
docs: "https://zitadel.com/docs/guides/integrate/login/oidc"
description: "Sign in with Zitadel - Replace with your Zitadel instance URL",
docs: "https://zitadel.com/docs/guides/integrate/login/oidc",
supportsDiscovery: true,
authMethod: "basic"
})
},
{
@@ -210,11 +230,34 @@ const defaultAuthProviders = [
type: "oidc",
icon: "FaShieldAlt",
enabled: false,
issuerUrl: "https://your-authentik.domain.com", // Placeholder - usuário deve configurar
authorizationEndpoint: "/application/o/authorize/",
tokenEndpoint: "/application/o/token/",
userInfoEndpoint: "/application/o/userinfo/",
scope: "openid profile email",
sortOrder: 5,
metadata: JSON.stringify({
description: "Sign in with Authentik",
docs: "https://goauthentik.io/docs/providers/oauth2"
description: "Sign in with Authentik - Replace with your Authentik instance URL",
docs: "https://goauthentik.io/docs/providers/oauth2",
supportsDiscovery: true
})
},
{
name: "frontegg",
displayName: "Frontegg",
type: "oidc",
icon: "FaEgg",
enabled: false,
issuerUrl: "https://your-tenant.frontegg.com", // Placeholder - usuário deve configurar
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/identity/resources/users/v2/me",
scope: "openid profile email",
sortOrder: 6,
metadata: JSON.stringify({
description: "Sign in with Frontegg - Replace 'your-tenant' with your Frontegg tenant",
docs: "https://docs.frontegg.com",
supportsDiscovery: true
})
},
];

View File

@@ -1,3 +1,4 @@
import { UpdateAuthProviderSchema } from "./dto";
import { AuthProvidersService } from "./service";
import { FastifyRequest, FastifyReply } from "fastify";
@@ -54,7 +55,27 @@ export class AuthProvidersController {
if (reply.sent) return;
try {
const data = request.body;
const data = request.body as any;
// Validação adicional: se modo manual, todos os 3 endpoints são obrigatórios
const hasAnyCustomEndpoint = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint);
const hasAllCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
if (hasAnyCustomEndpoint && !hasAllCustomEndpoints) {
return reply.status(400).send({
success: false,
error: "When using manual endpoints, all three endpoints (authorization, token, userInfo) are required",
});
}
// Validação: deve ter ou issuerUrl ou endpoints customizados
if (!data.issuerUrl && !hasAllCustomEndpoints) {
return reply.status(400).send({
success: false,
error: "Either provide issuerUrl for automatic discovery OR all three custom endpoints",
});
}
const provider = await this.authProvidersService.createProvider(data);
return reply.send({
@@ -63,6 +84,15 @@ export class AuthProvidersController {
});
} catch (error) {
console.error("Error creating provider:", error);
// Se é erro de validação do Zod
if (error instanceof Error && error.message.includes("Either provide issuerUrl")) {
return reply.status(400).send({
success: false,
error: error.message,
});
}
return reply.status(500).send({
success: false,
error: "Failed to create provider",
@@ -75,16 +105,92 @@ export class AuthProvidersController {
try {
const { id } = request.params;
const data = request.body;
const data = request.body as any;
const provider = await this.authProvidersService.updateProvider(id, data);
// Buscar provider para verificar se é oficial
const existingProvider = await this.authProvidersService.getProviderById(id);
if (!existingProvider) {
return reply.status(404).send({
success: false,
error: "Provider not found",
});
}
return reply.send({
success: true,
data: provider,
});
const isOfficial = this.authProvidersService.isOfficialProvider(existingProvider.name);
// Para providers oficiais, só permite alterar issuerUrl, clientId, clientSecret, enabled, autoRegister, icon
if (isOfficial) {
const allowedFields = [
"issuerUrl",
"clientId",
"clientSecret",
"enabled",
"autoRegister",
"adminEmailDomains",
"icon",
];
const sanitizedData: any = {};
for (const field of allowedFields) {
if (data[field] !== undefined) {
sanitizedData[field] = data[field];
}
}
console.log(
`[Controller] Official provider ${existingProvider.name} - only allowing fields:`,
Object.keys(sanitizedData)
);
console.log(`[Controller] Sanitized data:`, sanitizedData);
// Validação adicional para issuerUrl se fornecida
if (sanitizedData.issuerUrl && typeof sanitizedData.issuerUrl === "string") {
try {
new URL(sanitizedData.issuerUrl);
} catch (e) {
return reply.status(400).send({
success: false,
error: "Invalid Provider URL format",
});
}
}
const provider = await this.authProvidersService.updateProvider(id, sanitizedData);
console.log(`[Controller] Provider updated successfully:`, provider?.id);
return reply.send({
success: true,
data: provider,
});
}
// Para providers customizados, aplica validação normal
try {
// Valida usando o schema do Zod
const validatedData = UpdateAuthProviderSchema.parse(data);
const provider = await this.authProvidersService.updateProvider(id, validatedData);
return reply.send({
success: true,
data: provider,
});
} catch (validationError) {
console.error("Validation error for custom provider:", validationError);
return reply.status(400).send({
success: false,
error: "Invalid data provided",
});
}
} catch (error) {
console.error("Error updating provider:", error);
// Se é erro de validação do Zod
if (error instanceof Error && error.message.includes("Either provide issuerUrl")) {
return reply.status(400).send({
success: false,
error: error.message,
});
}
return reply.status(500).send({
success: false,
error: "Failed to update provider",
@@ -267,4 +373,37 @@ export class AuthProvidersController {
);
}
}
async testProvider(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
if (reply.sent) return;
try {
const { id } = request.params;
const provider = await this.authProvidersService.getProviderById(id);
if (!provider) {
return reply.status(404).send({
success: false,
error: "Provider not found",
});
}
console.log(`[Controller] Testing provider: ${provider.name}`);
const testResult = await this.authProvidersService.testProviderConfiguration(provider);
return reply.send({
success: true,
data: testResult,
});
} catch (error) {
console.error("Error testing provider:", error);
return reply.status(400).send({
success: false,
error: error instanceof Error ? error.message : "Provider test failed",
details: error instanceof Error ? error.stack : undefined,
});
}
}
}

View File

@@ -0,0 +1,111 @@
import { z } from "zod";
// Schema base para provider
export const BaseAuthProviderSchema = z.object({
name: z.string().min(1, "Name is required").describe("Provider name"),
displayName: z.string().min(1, "Display name is required").describe("Provider display name"),
type: z.enum(["oidc", "oauth2"]).describe("Provider type"),
icon: z.string().optional().describe("Provider icon"),
enabled: z.boolean().default(false).describe("Whether provider is enabled"),
autoRegister: z.boolean().default(true).describe("Auto-register new users"),
scope: z.string().optional().describe("OAuth scopes"),
adminEmailDomains: z.string().optional().describe("Admin email domains (comma-separated)"),
clientId: z.string().min(1, "Client ID is required").describe("OAuth client ID"),
clientSecret: z.string().min(1, "Client secret is required").describe("OAuth client secret"),
});
// Schema para modo discovery automático (apenas issuerUrl)
export const DiscoveryModeSchema = BaseAuthProviderSchema.extend({
issuerUrl: z.string().url("Invalid issuer URL").describe("Provider issuer URL for discovery"),
authorizationEndpoint: z.literal("").optional(),
tokenEndpoint: z.literal("").optional(),
userInfoEndpoint: z.literal("").optional(),
});
// Schema para modo manual (todos os endpoints)
export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({
issuerUrl: z.string().optional(),
authorizationEndpoint: z
.string()
.min(1, "Authorization endpoint is required")
.describe("Authorization endpoint URL or path"),
tokenEndpoint: z.string().min(1, "Token endpoint is required").describe("Token endpoint URL or path"),
userInfoEndpoint: z.string().min(1, "User info endpoint is required").describe("User info endpoint URL or path"),
});
// Schema principal que aceita ambos os modos
export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({
issuerUrl: z.string().url("Invalid issuer URL").optional(),
authorizationEndpoint: z.string().min(1).optional(),
tokenEndpoint: z.string().min(1).optional(),
userInfoEndpoint: z.string().min(1).optional(),
}).refine(
(data) => {
// Modo discovery: deve ter issuerUrl e não ter endpoints customizados
const hasIssuerUrl = !!data.issuerUrl;
const hasCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
// Deve ter ou issuerUrl OU todos os endpoints customizados
return hasIssuerUrl || hasCustomEndpoints;
},
{
message:
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
}
);
// Schema para atualização (todos os campos opcionais exceto validação de modo)
export const UpdateAuthProviderSchema = z
.object({
name: z.string().min(1).optional(),
displayName: z.string().min(1).optional(),
type: z.enum(["oidc", "oauth2"]).optional(),
icon: z.string().optional(),
enabled: z.boolean().optional(),
autoRegister: z.boolean().optional(),
scope: z.string().optional(),
adminEmailDomains: z.string().optional(),
clientId: z.string().min(1).optional(),
clientSecret: z.string().min(1).optional(),
issuerUrl: z.string().url().optional(),
authorizationEndpoint: z.string().min(1).optional(),
tokenEndpoint: z.string().min(1).optional(),
userInfoEndpoint: z.string().min(1).optional(),
})
.refine(
(data) => {
// Se está atualizando endpoints, deve seguir a mesma regra
const hasIssuerUrl = !!data.issuerUrl;
const hasCustomEndpoints = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint);
// Se nenhum dos dois foi fornecido, está OK (não está alterando o modo)
if (!hasIssuerUrl && !hasCustomEndpoints) return true;
// Se forneceu um, não pode fornecer o outro
return hasIssuerUrl !== hasCustomEndpoints;
},
{
message:
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
}
);
// Schema específico para providers oficiais (apenas campos permitidos)
export const UpdateOfficialProviderSchema = z.object({
issuerUrl: z.string().url().optional(),
clientId: z.string().min(1).optional(),
clientSecret: z.string().min(1).optional(),
enabled: z.boolean().optional(),
autoRegister: z.boolean().optional(),
adminEmailDomains: z.string().optional(),
});
// Schema para reordenação
export const UpdateProvidersOrderSchema = z.object({
providers: z.array(
z.object({
id: z.string(),
sortOrder: z.number(),
})
),
});

View File

@@ -26,27 +26,58 @@ export class ProviderManager {
}
/**
* Obtém a configuração de um provider (oficial ou genérico)
* Usa apenas o nome do provider para determinar a configuração
* Obtém configuração para um provider de forma INTELIGENTE
*/
getProviderConfig(providerName: string, issuerUrl?: string): ProviderConfig | null {
console.log(`[ProviderManager] getProviderConfig called for: ${providerName}`);
getProviderConfig(provider: any): ProviderConfig {
console.log(`[ProviderManager] Getting config for provider:`, provider);
console.log(`[ProviderManager] Provider name: ${provider?.name}`);
console.log(`[ProviderManager] Provider type from DB: ${provider?.type}`);
console.log(`[ProviderManager] Provider issuerUrl: ${provider?.issuerUrl}`);
// Detecção baseada apenas no nome do provider
const normalizedName = this.normalizeProviderName(providerName);
console.log(`[ProviderManager] Normalized name: ${normalizedName}`);
if (this.isOfficialProvider(normalizedName)) {
console.log(`[ProviderManager] Using official provider config: ${normalizedName}`);
return this.config.officialProviders[normalizedName];
if (!provider || !provider.name) {
console.error(`[ProviderManager] Invalid provider object:`, provider);
throw new Error("Invalid provider object: missing name");
}
// Retorna template genérico para providers customizados
console.log(`[ProviderManager] Using generic template for: ${providerName}`);
return {
// PRIMEIRO: Se é um provider oficial, usa a configuração específica
const officialConfig = this.config.officialProviders[provider.name];
if (officialConfig) {
console.log(`[ProviderManager] Using official config for: ${provider.name}`);
return officialConfig;
}
// SEGUNDO: Detecta automaticamente o tipo baseado na URL
const detectedType = this.detectProviderType(provider.issuerUrl || "");
console.log(`[ProviderManager] Auto-detected type: ${detectedType}`);
// TERCEIRO: Se o provider tem um tipo definido no banco, usa ele
const providerType = provider.type || detectedType;
console.log(`[ProviderManager] Final provider type: ${providerType}`);
// QUARTO: Cria configuração inteligente baseada no tipo detectado
const intelligentConfig: ProviderConfig = {
...this.config.genericProviderTemplate,
name: providerName,
name: provider.name,
// Ajusta configuração baseada no tipo detectado
supportsDiscovery: this.shouldSupportDiscovery(providerType),
discoveryEndpoint: this.getDiscoveryEndpoint(providerType),
// Ajusta fallbacks baseado no tipo
fallbackEndpoints: this.getFallbackEndpoints(providerType),
// Ajusta método de autenticação baseado no tipo
authMethod: this.getAuthMethod(providerType),
// Ajusta field mappings baseado no tipo
fieldMappings: this.getFieldMappings(providerType),
// Ajusta special handling baseado no tipo
specialHandling: this.getSpecialHandling(providerType),
};
console.log(`[ProviderManager] Generated intelligent config:`, intelligentConfig);
return intelligentConfig;
}
/**
@@ -58,7 +89,7 @@ export class ProviderManager {
/**
* Resolve endpoints para um provider com base na configuração
* PRIORIZA dados do usuário sobre configuração interna
* ULTRA-INTELIGENTE com múltiplos fallbacks e detecção automática
*/
async resolveEndpoints(provider: any, config: ProviderConfig): Promise<ProviderEndpoints> {
console.log(`[ProviderManager] Resolving endpoints for provider: ${provider.name}, config: ${config.name}`);
@@ -72,11 +103,16 @@ export class ProviderManager {
// PRIMEIRO: Verifica se o usuário especificou endpoints customizados
if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.userInfoEndpoint) {
console.log(`[ProviderManager] Using custom endpoints from user config`);
return {
authorizationEndpoint: provider.authorizationEndpoint,
tokenEndpoint: provider.tokenEndpoint,
userInfoEndpoint: provider.userInfoEndpoint,
// Se endpoints são relativos, combina com issuerUrl
const resolvedEndpoints = {
authorizationEndpoint: this.resolveEndpointUrl(provider.authorizationEndpoint, provider.issuerUrl),
tokenEndpoint: this.resolveEndpointUrl(provider.tokenEndpoint, provider.issuerUrl),
userInfoEndpoint: this.resolveEndpointUrl(provider.userInfoEndpoint, provider.issuerUrl),
};
console.log(`[ProviderManager] Resolved custom endpoints:`, resolvedEndpoints);
return resolvedEndpoints;
}
// SEGUNDO: Tenta discovery automático se suportado
@@ -88,29 +124,33 @@ export class ProviderManager {
if (config.supportsDiscovery && provider.issuerUrl) {
console.log(`[ProviderManager] Attempting discovery for ${provider.issuerUrl}`);
const discoveredEndpoints = await this.attemptDiscovery(
provider.issuerUrl,
config.discoveryEndpoint || "/.well-known/openid_configuration"
);
if (discoveredEndpoints) {
console.log(`[ProviderManager] Discovery successful:`, discoveredEndpoints);
endpoints = discoveredEndpoints;
} else {
console.log(`[ProviderManager] Discovery failed, using fallbacks`);
// Tenta múltiplos endpoints de discovery
const discoveryEndpoints = [
"/.well-known/openid_configuration",
"/.well-known/openid-configuration",
"/.well-known/oauth-authorization-server",
"/.well-known/oauth2-authorization-server",
];
for (const discoveryPath of discoveryEndpoints) {
const discoveredEndpoints = await this.attemptDiscovery(provider.issuerUrl, discoveryPath);
if (discoveredEndpoints) {
console.log(`[ProviderManager] Discovery successful with ${discoveryPath}:`, discoveredEndpoints);
endpoints = discoveredEndpoints;
break;
}
}
if (!endpoints.tokenEndpoint) {
console.log(`[ProviderManager] All discovery attempts failed`);
}
}
// TERCEIRO: Se não conseguiu descobrir, usa fallbacks da configuração
// TERCEIRO: Se não conseguiu descobrir, usa fallbacks simples
if (!endpoints.tokenEndpoint) {
console.log(`[ProviderManager] Building fallback endpoints`);
endpoints = this.buildFallbackEndpoints(provider, config);
}
// QUARTO: Aplica limpeza de URL se necessário
if (config.specialHandling?.urlCleaning) {
console.log(`[ProviderManager] Applying URL cleaning for ${config.name}`);
endpoints = this.cleanUrls(endpoints, provider.issuerUrl, config);
endpoints = this.buildIntelligentFallbackEndpoints(provider, config);
}
console.log(`[ProviderManager] Final endpoints:`, endpoints);
@@ -118,129 +158,137 @@ export class ProviderManager {
}
/**
* Tenta descoberta automática de endpoints
* Resolve URL de endpoint - combina URLs relativas com issuerUrl
*/
private resolveEndpointUrl(endpoint: string, issuerUrl?: string): string {
// Se endpoint já é absoluto (contém http), retorna como está
if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
return endpoint;
}
// Se não tem issuerUrl, retorna endpoint como está
if (!issuerUrl) {
return endpoint;
}
// Remove trailing slash do issuerUrl
const baseUrl = issuerUrl.replace(/\/$/, "");
// Garante que endpoint comece com /
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
return `${baseUrl}${path}`;
}
/**
* Tenta descobrir endpoints via discovery de forma ROBUSTA
*/
private async attemptDiscovery(issuerUrl: string, discoveryPath: string): Promise<ProviderEndpoints | null> {
try {
// Remove trailing slash do issuerUrl se existir
const cleanIssuerUrl = issuerUrl.replace(/\/$/, "");
const wellKnownUrl = `${cleanIssuerUrl}${discoveryPath}`;
console.log(`[ProviderManager] Attempting discovery at: ${wellKnownUrl}`);
const discoveryUrl = `${issuerUrl}${discoveryPath}`;
console.log(`[ProviderManager] Attempting discovery at: ${discoveryUrl}`);
const response = await fetch(wellKnownUrl);
const response = await fetch(discoveryUrl, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": "Palmr/1.0",
},
// Timeout mais generoso para discovery
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
const discoveryData: any = await response.json();
console.log(`[ProviderManager] Discovery successful for ${issuerUrl}:`, {
authorization_endpoint: discoveryData.authorization_endpoint,
token_endpoint: discoveryData.token_endpoint,
userinfo_endpoint: discoveryData.userinfo_endpoint,
});
console.log(`[ProviderManager] Full discovery data:`, discoveryData);
return {
authorizationEndpoint: discoveryData.authorization_endpoint,
tokenEndpoint: discoveryData.token_endpoint,
userInfoEndpoint: discoveryData.userinfo_endpoint,
};
} else {
console.log(`[ProviderManager] Discovery failed for ${issuerUrl}: ${response.status} ${response.statusText}`);
if (!response.ok) {
console.log(`[ProviderManager] Discovery failed with status: ${response.status}`);
return null;
}
const discoveryData = (await response.json()) as any;
console.log(`[ProviderManager] Discovery response:`, discoveryData);
// Extrai endpoints com múltiplos fallbacks
const endpoints: ProviderEndpoints = {
authorizationEndpoint: "",
tokenEndpoint: "",
userInfoEndpoint: "",
};
// Authorization endpoint - tenta múltiplos campos
const authEndpoints = [
"authorization_endpoint",
"authorizationEndpoint",
"authorize_endpoint",
"authorizeEndpoint",
"auth_endpoint",
"authEndpoint",
];
for (const field of authEndpoints) {
if (discoveryData[field]) {
endpoints.authorizationEndpoint = discoveryData[field];
break;
}
}
// Token endpoint - tenta múltiplos campos
const tokenEndpoints = ["token_endpoint", "tokenEndpoint", "access_token_endpoint", "accessTokenEndpoint"];
for (const field of tokenEndpoints) {
if (discoveryData[field]) {
endpoints.tokenEndpoint = discoveryData[field];
break;
}
}
// UserInfo endpoint - tenta múltiplos campos
const userInfoEndpoints = [
"userinfo_endpoint",
"userInfoEndpoint",
"user_info_endpoint",
"userInfo_endpoint",
"profile_endpoint",
"profileEndpoint",
];
for (const field of userInfoEndpoints) {
if (discoveryData[field]) {
endpoints.userInfoEndpoint = discoveryData[field];
break;
}
}
// Valida se pelo menos os endpoints essenciais foram encontrados
if (!endpoints.authorizationEndpoint || !endpoints.tokenEndpoint) {
console.log(`[ProviderManager] Discovery incomplete - missing essential endpoints`);
return null;
}
console.log(`[ProviderManager] Discovery successful:`, endpoints);
return endpoints;
} catch (error) {
console.warn(`[ProviderManager] Discovery failed for ${issuerUrl}:`, error);
console.log(`[ProviderManager] Discovery error:`, error);
return null;
}
return null;
}
/**
* Constrói endpoints usando fallbacks da configuração
* Constrói endpoints usando fallbacks simples baseados no tipo
*/
private buildFallbackEndpoints(provider: any, config: ProviderConfig): ProviderEndpoints {
const baseUrl = provider.issuerUrl || "";
private buildIntelligentFallbackEndpoints(provider: any, config: ProviderConfig): ProviderEndpoints {
const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || "";
console.log(`[ProviderManager] Building fallback endpoints for baseUrl: ${baseUrl}`);
// Se tem endpoints absolutos na config, usa eles
if (config.authorizationEndpoint?.startsWith("http")) {
return {
authorizationEndpoint: config.authorizationEndpoint,
tokenEndpoint: config.tokenEndpoint || "",
userInfoEndpoint: config.userInfoEndpoint || "",
};
}
// Detecta tipo do provider para usar padrão apropriado
const detectedType = this.detectProviderType(provider.issuerUrl || "");
const fallbackPattern = this.getFallbackEndpoints(detectedType);
// Senão, constrói com base no issuerUrl
const fallbacks = config.fallbackEndpoints;
return {
authorizationEndpoint: `${baseUrl}${fallbacks?.authorizationEndpoint || "/auth"}`,
tokenEndpoint: `${baseUrl}${fallbacks?.tokenEndpoint || "/token"}`,
userInfoEndpoint: `${baseUrl}${fallbacks?.userInfoEndpoint || "/userinfo"}`,
authorizationEndpoint: `${baseUrl}${fallbackPattern.authorizationEndpoint}`,
tokenEndpoint: `${baseUrl}${fallbackPattern.tokenEndpoint}`,
userInfoEndpoint: `${baseUrl}${fallbackPattern.userInfoEndpoint}`,
};
}
/**
* Aplica limpeza de URLs conforme configuração
*/
private cleanUrls(endpoints: ProviderEndpoints, issuerUrl: string, config: ProviderConfig): ProviderEndpoints {
const cleaning = config.specialHandling?.urlCleaning;
if (!cleaning || !issuerUrl) return endpoints;
console.log(`[ProviderManager] Cleaning URLs for ${config.name}`);
console.log(`[ProviderManager] Original issuerUrl: ${issuerUrl}`);
console.log(`[ProviderManager] Cleaning rules:`, cleaning);
let cleanBaseUrl = issuerUrl;
// Remove sufixos especificados (mais robusto)
for (const suffix of cleaning.removeFromEnd) {
if (cleanBaseUrl.endsWith(suffix)) {
console.log(`[ProviderManager] Removing suffix: ${suffix}`);
cleanBaseUrl = cleanBaseUrl.replace(suffix, "");
break;
}
}
// Remove trailing slash se existir
cleanBaseUrl = cleanBaseUrl.replace(/\/$/, "");
console.log(`[ProviderManager] Cleaned base URL: ${cleanBaseUrl}`);
// Reconstrói endpoints com URL limpa
const fallbacks = config.fallbackEndpoints;
// Caso especial para Authentik - extrai o nome da aplicação da URL
if (config.name === "Authentik") {
// Remove o discovery endpoint se estiver presente
let cleanUrl = cleanBaseUrl;
if (cleanUrl.includes("/.well-known/openid-configuration")) {
cleanUrl = cleanUrl.replace("/.well-known/openid-configuration", "");
}
if (cleanUrl.includes("/.well-known/openid_configuration")) {
cleanUrl = cleanUrl.replace("/.well-known/openid_configuration", "");
}
// Para Authentik, os endpoints são globais, não específicos por aplicação
const baseUrl = cleanUrl.replace(/\/application\/o\/[^/]+.*$/, "");
const cleanedEndpoints = {
authorizationEndpoint: `${baseUrl}/application/o/authorize/`,
tokenEndpoint: `${baseUrl}/application/o/token/`,
userInfoEndpoint: `${baseUrl}/application/o/userinfo/`,
};
console.log(`[ProviderManager] Authentik cleaned endpoints:`, cleanedEndpoints);
return cleanedEndpoints;
}
const cleanedEndpoints = {
authorizationEndpoint: `${cleanBaseUrl}${fallbacks?.authorizationEndpoint || "/auth"}`,
tokenEndpoint: `${cleanBaseUrl}${fallbacks?.tokenEndpoint || "/token"}`,
userInfoEndpoint: `${cleanBaseUrl}${fallbacks?.userInfoEndpoint || "/userinfo"}`,
};
console.log(`[ProviderManager] Cleaned endpoints:`, cleanedEndpoints);
return cleanedEndpoints;
}
/**
* Extrai informações do usuário com base no mapeamento de campos
*/
@@ -269,11 +317,14 @@ export class ProviderManager {
}
/**
* Extrai um campo específico usando lista de possíveis nomes
* Extrai um campo específico usando lista de possíveis nomes de forma INTELIGENTE
*/
private extractField(obj: any, fieldNames: string[]): string | undefined {
if (!obj || !fieldNames.length) return undefined;
console.log(`[ProviderManager] Extracting field from:`, fieldNames);
console.log(`[ProviderManager] Available fields:`, Object.keys(obj));
// Para campos especiais como nome completo que pode ser composto
if (fieldNames.length > 1 && fieldNames.includes("first_name") && fieldNames.includes("last_name")) {
const firstName = obj["first_name"];
@@ -287,18 +338,61 @@ export class ProviderManager {
}
}
// Para campos normais
// Para campos especiais como nome completo com given_name/family_name
if (fieldNames.length > 1 && fieldNames.includes("given_name") && fieldNames.includes("family_name")) {
const firstName = obj["given_name"];
const lastName = obj["family_name"];
if (firstName && lastName) {
return `${firstName} ${lastName}`;
} else if (firstName) {
return firstName;
} else if (lastName) {
return lastName;
}
}
// Para campos normais - tenta cada nome na lista
for (const fieldName of fieldNames) {
if (obj[fieldName] !== undefined && obj[fieldName] !== null) {
console.log(`[ProviderManager] Found field: ${fieldName} = ${obj[fieldName]}`);
return String(obj[fieldName]);
}
}
// Tenta variações case-insensitive
for (const fieldName of fieldNames) {
const lowerFieldName = fieldName.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === lowerFieldName && obj[key] !== undefined && obj[key] !== null) {
console.log(`[ProviderManager] Found field (case-insensitive): ${key} = ${obj[key]}`);
return String(obj[key]);
}
}
}
// Tenta variações com underscores vs camelCase
for (const fieldName of fieldNames) {
const variations = [
fieldName,
fieldName.replace(/_/g, ""),
fieldName.replace(/([A-Z])/g, "_$1").toLowerCase(),
fieldName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
];
for (const variation of variations) {
if (obj[variation] !== undefined && obj[variation] !== null) {
console.log(`[ProviderManager] Found field (variation): ${variation} = ${obj[variation]}`);
return String(obj[variation]);
}
}
}
console.log(`[ProviderManager] Field not found for:`, fieldNames);
return undefined;
}
/**
* Determina o método de autenticação para token exchange
* Obtém método de autenticação da configuração
*/
getAuthMethod(config: ProviderConfig): "body" | "basic" | "header" {
return config.authMethod || "body";
@@ -326,20 +420,381 @@ export class ProviderManager {
}
/**
* Obtém scopes padrão para um provider baseado no tipo do banco
* Obtém scopes para um provider de forma INTELIGENTE
*/
getDefaultScopes(provider: any): string[] {
// GitHub OAuth2 usa scopes específicos
if (provider.type === "oauth2" && provider.name === "github") {
return ["user:email"];
getScopes(provider: any): string[] {
console.log(`[ProviderManager] Getting scopes for provider: ${provider.name}`);
console.log(`[ProviderManager] Provider type: ${provider.type}`);
console.log(`[ProviderManager] Provider scope from DB: ${provider.scope}`);
// PRIMEIRO: Se o provider tem scope definido no banco, usa ele
if (provider.scope && provider.scope.trim()) {
const scopes = provider.scope.split(" ").filter((s: string) => s.trim());
console.log(`[ProviderManager] Using scope from database: ${scopes}`);
return scopes;
}
// OIDC padrão
if (provider.type === "oidc") {
return ["openid", "profile", "email"];
// SEGUNDO: Detecta automaticamente baseado no tipo
const detectedType = this.detectProviderType(provider.issuerUrl || "");
console.log(`[ProviderManager] Auto-detected type: ${detectedType}`);
// Scopes específicos por tipo de provider
const typeScopes: Record<string, string[]> = {
// Frontegg
frontegg: ["openid", "profile", "email"],
// Social providers
discord: ["identify", "email"],
github: ["read:user", "user:email"],
gitlab: ["read_user", "read_api"],
google: ["openid", "profile", "email"],
microsoft: ["openid", "profile", "email", "User.Read"],
facebook: ["public_profile", "email"],
twitter: ["tweet.read", "users.read"],
linkedin: ["r_liteprofile", "r_emailaddress"],
// Enterprise providers
authentik: ["openid", "profile", "email"],
keycloak: ["openid", "profile", "email"],
auth0: ["openid", "profile", "email"],
okta: ["openid", "profile", "email"],
onelogin: ["openid", "profile", "email"],
ping: ["openid", "profile", "email"],
azure: ["openid", "profile", "email", "User.Read"],
aws: ["openid", "profile", "email"],
// Communication
slack: ["identity.basic", "identity.email", "identity.avatar"],
teams: ["openid", "profile", "email", "User.Read"],
// Development tools
bitbucket: ["account", "repository"],
atlassian: ["read:jira-user", "read:jira-work"],
jira: ["read:jira-user", "read:jira-work"],
confluence: ["read:confluence-content.summary"],
// Business tools
salesforce: ["api", "refresh_token"],
zendesk: ["read"],
shopify: ["read_products", "read_customers"],
stripe: ["read"],
twilio: ["read"],
sendgrid: ["mail.send"],
mailchimp: ["read"],
hubspot: ["contacts", "crm.objects.contacts.read"],
// Productivity
zoom: ["user:read:admin"],
notion: ["read"],
figma: ["files:read"],
dropbox: ["files.content.read"],
box: ["root_readwrite"],
trello: ["read"],
asana: ["default"],
monday: ["read"],
clickup: ["read"],
linear: ["read"],
};
// TERCEIRO: Se encontrou tipo específico, usa os scopes dele
if (typeScopes[detectedType]) {
console.log(`[ProviderManager] Using type-specific scopes for ${detectedType}: ${typeScopes[detectedType]}`);
return typeScopes[detectedType];
}
// OAuth2 genérico
return ["profile", "email"];
// QUARTO: Se o provider tem um tipo definido no banco, tenta usar ele
if (provider.type && typeScopes[provider.type]) {
console.log(`[ProviderManager] Using database type scopes for ${provider.type}: ${typeScopes[provider.type]}`);
return typeScopes[provider.type];
}
// QUINTO: Fallback para scopes OIDC padrão
console.log(`[ProviderManager] Using default OIDC scopes: openid, profile, email`);
return ["openid", "profile", "email"];
}
/**
* Detecta automaticamente o tipo de provider baseado na URL
*/
private detectProviderType(issuerUrl: string): string {
const url = issuerUrl.toLowerCase();
// Padrões conhecidos para detecção automática
const patterns = [
{ pattern: "frontegg.com", type: "frontegg" },
{ pattern: "discord.com", type: "discord" },
{ pattern: "github.com", type: "github" },
{ pattern: "gitlab.com", type: "gitlab" },
{ pattern: "google.com", type: "google" },
{ pattern: "microsoft.com", type: "microsoft" },
{ pattern: "facebook.com", type: "facebook" },
{ pattern: "twitter.com", type: "twitter" },
{ pattern: "linkedin.com", type: "linkedin" },
{ pattern: "authentik", type: "authentik" },
{ pattern: "keycloak", type: "keycloak" },
{ pattern: "auth0.com", type: "auth0" },
{ pattern: "okta.com", type: "okta" },
{ pattern: "onelogin.com", type: "onelogin" },
{ pattern: "pingidentity.com", type: "ping" },
{ pattern: "azure.com", type: "azure" },
{ pattern: "aws.amazon.com", type: "aws" },
{ pattern: "slack.com", type: "slack" },
{ pattern: "bitbucket.org", type: "bitbucket" },
{ pattern: "atlassian.com", type: "atlassian" },
{ pattern: "salesforce.com", type: "salesforce" },
{ pattern: "zendesk.com", type: "zendesk" },
{ pattern: "shopify.com", type: "shopify" },
{ pattern: "stripe.com", type: "stripe" },
{ pattern: "twilio.com", type: "twilio" },
{ pattern: "sendgrid.com", type: "sendgrid" },
{ pattern: "mailchimp.com", type: "mailchimp" },
{ pattern: "hubspot.com", type: "hubspot" },
{ pattern: "zoom.us", type: "zoom" },
{ pattern: "teams.microsoft.com", type: "teams" },
{ pattern: "notion.so", type: "notion" },
{ pattern: "figma.com", type: "figma" },
{ pattern: "dropbox.com", type: "dropbox" },
{ pattern: "box.com", type: "box" },
{ pattern: "trello.com", type: "trello" },
{ pattern: "asana.com", type: "asana" },
{ pattern: "monday.com", type: "monday" },
{ pattern: "clickup.com", type: "clickup" },
{ pattern: "linear.app", type: "linear" },
{ pattern: "jira", type: "jira" },
{ pattern: "confluence", type: "confluence" },
{ pattern: "bamboo", type: "bamboo" },
{ pattern: "bitbucket", type: "bitbucket" },
{ pattern: "crowd", type: "crowd" },
{ pattern: "fisheye", type: "fisheye" },
{ pattern: "crucible", type: "crucible" },
{ pattern: "statuspage", type: "statuspage" },
{ pattern: "opsgenie", type: "opsgenie" },
{ pattern: "jira", type: "jira" },
{ pattern: "confluence", type: "confluence" },
{ pattern: "bamboo", type: "bamboo" },
{ pattern: "bitbucket", type: "bitbucket" },
{ pattern: "crowd", type: "crowd" },
{ pattern: "fisheye", type: "fisheye" },
{ pattern: "crucible", type: "crucible" },
{ pattern: "statuspage", type: "statuspage" },
{ pattern: "opsgenie", type: "opsgenie" },
];
for (const { pattern, type } of patterns) {
if (url.includes(pattern)) {
console.log(`[ProviderManager] Auto-detected provider type: ${type} from URL: ${issuerUrl}`);
return type;
}
}
// Se não encontrou padrão conhecido, tenta extrair domínio
try {
const domain = new URL(issuerUrl).hostname.replace("www.", "");
console.log(`[ProviderManager] No known pattern found, using domain: ${domain}`);
return domain;
} catch {
console.log(`[ProviderManager] Could not parse URL, using 'custom'`);
return "custom";
}
}
/**
* Determina se o provider deve suportar discovery baseado no tipo
*/
private shouldSupportDiscovery(providerType: string): boolean {
// Providers que geralmente suportam discovery
const discoveryProviders = [
"frontegg",
"oidc",
"authentik",
"keycloak",
"auth0",
"okta",
"onelogin",
"ping",
"azure",
"aws",
"google",
"microsoft",
];
return discoveryProviders.includes(providerType);
}
/**
* Obtém o endpoint de discovery apropriado para o tipo
*/
private getDiscoveryEndpoint(providerType: string): string {
const discoveryEndpoints: Record<string, string> = {
frontegg: "/.well-known/openid_configuration",
oidc: "/.well-known/openid_configuration",
authentik: "/.well-known/openid_configuration",
keycloak: "/.well-known/openid_configuration",
auth0: "/.well-known/openid_configuration",
okta: "/.well-known/openid_configuration",
onelogin: "/.well-known/openid_configuration",
ping: "/.well-known/openid_configuration",
azure: "/.well-known/openid_configuration",
aws: "/.well-known/openid_configuration",
google: "/.well-known/openid_configuration",
microsoft: "/.well-known/openid_configuration",
};
return discoveryEndpoints[providerType] || "/.well-known/openid_configuration";
}
/**
* Obtém fallback endpoints apropriados para o tipo
*/
private getFallbackEndpoints(providerType: string): any {
const fallbackPatterns: Record<string, any> = {
// Frontegg
frontegg: {
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/api/oauth/userinfo",
},
// OIDC padrão
oidc: {
authorizationEndpoint: "/oauth2/authorize",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/userinfo",
},
// OAuth2 padrão
oauth2: {
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/oauth/userinfo",
},
// GitHub
github: {
authorizationEndpoint: "/login/oauth/authorize",
tokenEndpoint: "/login/oauth/access_token",
userInfoEndpoint: "/user",
},
// GitLab
gitlab: {
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/api/v4/user",
},
// Discord
discord: {
authorizationEndpoint: "/oauth2/authorize",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/users/@me",
},
// Slack
slack: {
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/api/oauth.access",
userInfoEndpoint: "/api/users.identity",
},
};
return (
fallbackPatterns[providerType] || {
authorizationEndpoint: "/oauth2/authorize",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/userinfo",
}
);
}
/**
* Obtém field mappings apropriados para o tipo
*/
private getFieldMappings(providerType: string): any {
const fieldMappings: Record<string, any> = {
// Frontegg
frontegg: {
id: ["sub", "id", "user_id"],
email: ["email", "preferred_username"],
name: ["name", "preferred_username"],
firstName: ["given_name", "name"],
lastName: ["family_name"],
avatar: ["picture"],
},
// GitHub
github: {
id: ["id"],
email: ["email"],
name: ["name", "login"],
firstName: ["name"],
lastName: [""],
avatar: ["avatar_url"],
},
// GitLab
gitlab: {
id: ["id"],
email: ["email"],
name: ["name", "username"],
firstName: ["name"],
lastName: [""],
avatar: ["avatar_url"],
},
// Discord
discord: {
id: ["id"],
email: ["email"],
name: ["username", "global_name"],
firstName: ["username"],
lastName: [""],
avatar: ["avatar"],
},
// Slack
slack: {
id: ["id"],
email: ["email"],
name: ["name", "real_name"],
firstName: ["name"],
lastName: [""],
avatar: ["image_192"],
},
};
return fieldMappings[providerType] || this.config.genericProviderTemplate.fieldMappings;
}
/**
* Obtém special handling apropriado para o tipo
*/
private getSpecialHandling(providerType: string): any {
const specialHandling: Record<string, any> = {
// Frontegg - usa OAuth2 padrão
frontegg: {
emailEndpoint: "",
emailFetchRequired: false,
responseFormat: "json",
urlCleaning: {
removeFromEnd: [],
},
},
// GitHub - precisa de endpoint adicional para email
github: {
emailEndpoint: "/user/emails",
emailFetchRequired: true,
responseFormat: "json",
},
// GitLab - OAuth2 padrão
gitlab: {
emailEndpoint: "",
emailFetchRequired: false,
responseFormat: "json",
},
// Discord - OAuth2 padrão
discord: {
emailEndpoint: "",
emailFetchRequired: false,
responseFormat: "json",
},
};
return (
specialHandling[providerType] || {
emailEndpoint: "",
emailFetchRequired: false,
responseFormat: "json",
}
);
}
}

View File

@@ -3,12 +3,9 @@ import { ProviderConfig, ProvidersConfigFile } from "./types";
/**
* Configuração técnica oficial do GitHub
* OAuth2 com busca separada de email
* Endpoints vêm do banco de dados
*/
const githubConfig: ProviderConfig = {
name: "GitHub",
authorizationEndpoint: "https://github.com/login/oauth/authorize",
tokenEndpoint: "https://github.com/login/oauth/access_token",
userInfoEndpoint: "https://api.github.com/user",
supportsDiscovery: false,
authMethod: "body",
specialHandling: {
@@ -29,16 +26,11 @@ const githubConfig: ProviderConfig = {
/**
* Configuração técnica oficial do Auth0
* OIDC com discovery automático
* Endpoints vêm do banco de dados
*/
const auth0Config: ProviderConfig = {
name: "Auth0",
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid_configuration",
fallbackEndpoints: {
authorizationEndpoint: "/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/userinfo",
},
authMethod: "body",
fieldMappings: {
id: ["sub"],
@@ -53,16 +45,11 @@ const auth0Config: ProviderConfig = {
/**
* Configuração técnica oficial do Kinde
* OIDC com mapeamentos de campo customizados
* Endpoints vêm do banco de dados
*/
const kindeConfig: ProviderConfig = {
name: "Kinde",
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid_configuration",
fallbackEndpoints: {
authorizationEndpoint: "/oauth2/auth",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/user_profile",
},
authMethod: "body",
fieldMappings: {
id: ["id"],
@@ -76,23 +63,13 @@ const kindeConfig: ProviderConfig = {
/**
* Configuração técnica oficial do Zitadel
* OIDC com Basic Auth e limpeza de URL
* OIDC com Basic Auth
* Endpoints vêm do banco de dados
*/
const zitadelConfig: ProviderConfig = {
name: "Zitadel",
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid_configuration",
fallbackEndpoints: {
authorizationEndpoint: "/oauth/v2/authorize",
tokenEndpoint: "/oauth/v2/token",
userInfoEndpoint: "/oidc/v1/userinfo",
},
authMethod: "basic",
specialHandling: {
urlCleaning: {
removeFromEnd: ["/oauth/v2/authorize", "/oauth/v2", "/authorize"],
},
},
fieldMappings: {
id: ["sub"],
email: ["email"],
@@ -105,23 +82,13 @@ const zitadelConfig: ProviderConfig = {
/**
* Configuração técnica oficial do Authentik
* OIDC self-hosted com discovery e limpeza de URL
* OIDC self-hosted com discovery
* Endpoints vêm do banco de dados
*/
const authentikConfig: ProviderConfig = {
name: "Authentik",
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid_configuration",
fallbackEndpoints: {
authorizationEndpoint: "/application/o/authorize",
tokenEndpoint: "/application/o/token",
userInfoEndpoint: "/application/o/userinfo",
},
authMethod: "body",
specialHandling: {
urlCleaning: {
removeFromEnd: ["/.well-known/openid-configuration", "/.well-known/openid_configuration"],
},
},
fieldMappings: {
id: ["sub"],
email: ["email"],
@@ -133,26 +100,50 @@ const authentikConfig: ProviderConfig = {
};
/**
* Template genérico para providers customizados
* Configuração técnica padrão OIDC que funciona com a maioria dos providers
* Configuração técnica oficial do Frontegg
* OIDC multi-tenant com discovery automático
* Endpoints vêm do banco de dados
*/
const fronteggConfig: ProviderConfig = {
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid-configuration",
authMethod: "body",
fieldMappings: {
id: ["sub", "id", "user_id"],
email: ["email", "preferred_username"],
name: ["name", "preferred_username"],
firstName: ["given_name", "name"],
lastName: ["family_name"],
avatar: ["picture"],
},
};
/**
* Template genérico ULTRA-INTELIGENTE para providers customizados
* Detecta automaticamente padrões comuns e se adapta
*/
const genericProviderTemplate: ProviderConfig = {
name: "",
supportsDiscovery: true,
discoveryEndpoint: "/.well-known/openid_configuration",
fallbackEndpoints: {
authorizationEndpoint: "/auth",
tokenEndpoint: "/token",
userInfoEndpoint: "/userinfo",
authorizationEndpoint: "/oauth2/authorize",
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/userinfo",
},
authMethod: "body",
specialHandling: {
emailEndpoint: "",
emailFetchRequired: false,
responseFormat: "json",
},
fieldMappings: {
id: ["sub", "id"],
email: ["email"],
name: ["name"],
firstName: ["given_name", "first_name"],
lastName: ["family_name", "last_name"],
avatar: ["picture", "avatar_url"],
id: ["sub", "id", "user_id", "uid", "userid", "account_id"],
email: ["email", "mail", "email_address", "preferred_email", "primary_email"],
name: ["name", "display_name", "full_name", "username", "login", "first_name last_name", "given_name family_name"],
firstName: ["given_name", "first_name", "firstname", "first", "name"],
lastName: ["family_name", "last_name", "lastname", "last", "surname"],
avatar: ["picture", "avatar", "avatar_url", "profile_picture", "photo", "image", "thumbnail"],
},
};
@@ -167,6 +158,7 @@ export const providersConfig: ProvidersConfigFile = {
kinde: kindeConfig,
zitadel: zitadelConfig,
authentik: authentikConfig,
frontegg: fronteggConfig,
},
genericProviderTemplate,
};
@@ -174,4 +166,12 @@ export const providersConfig: ProvidersConfigFile = {
/**
* Exportações individuais para facilitar importação
*/
export { githubConfig, auth0Config, kindeConfig, zitadelConfig, authentikConfig, genericProviderTemplate };
export {
githubConfig,
auth0Config,
kindeConfig,
zitadelConfig,
authentikConfig,
fronteggConfig,
genericProviderTemplate,
};

View File

@@ -1,5 +1,6 @@
import { prisma } from "../../shared/prisma";
import { AuthProvidersController } from "./controller";
import { CreateAuthProviderSchema, UpdateAuthProviderSchema, UpdateProvidersOrderSchema } from "./dto";
import { FastifyInstance } from "fastify";
import { z } from "zod";
@@ -110,13 +111,18 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
tags: ["Auth Providers"],
operationId: "createAuthProvider",
summary: "Create authentication provider",
description: "Create a new authentication provider",
body: z.any(),
description:
"Create a new authentication provider. Use either issuerUrl for automatic discovery OR provide all three custom endpoints.",
body: CreateAuthProviderSchema,
response: {
200: z.object({
success: z.boolean(),
data: z.any(),
}),
400: z.object({
success: z.boolean(),
error: z.string(),
}),
401: z.object({
success: z.boolean(),
error: z.string(),
@@ -145,14 +151,7 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
operationId: "updateProvidersOrder",
summary: "Update providers order",
description: "Update the display order of authentication providers",
body: z.object({
providers: z.array(
z.object({
id: z.string(),
sortOrder: z.number(),
})
),
}),
body: UpdateProvidersOrderSchema,
response: {
200: z.object({
success: z.boolean(),
@@ -180,6 +179,51 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
authProvidersController.updateProvidersOrder.bind(authProvidersController)
);
// Test provider configuration (admin only)
fastify.post(
"/providers/:id/test",
{
preValidation: adminPreValidation,
schema: {
tags: ["Auth Providers"],
operationId: "testAuthProvider",
summary: "Test authentication provider configuration",
description: "Test if the provider configuration is valid by checking endpoints and connectivity",
params: z.object({
id: z.string(),
}),
response: {
200: z.object({
success: z.boolean(),
data: z.any(),
}),
400: z.object({
success: z.boolean(),
error: z.string(),
details: z.string().optional(),
}),
401: z.object({
success: z.boolean(),
error: z.string(),
}),
403: z.object({
success: z.boolean(),
error: z.string(),
}),
404: z.object({
success: z.boolean(),
error: z.string(),
}),
500: z.object({
success: z.boolean(),
error: z.string(),
}),
},
},
},
authProvidersController.testProvider.bind(authProvidersController)
);
// Update provider configuration (admin only)
fastify.put(
"/providers/:id",
@@ -189,16 +233,21 @@ export async function authProvidersRoutes(fastify: FastifyInstance) {
tags: ["Auth Providers"],
operationId: "updateAuthProvider",
summary: "Update authentication provider",
description: "Update configuration for a specific authentication provider",
description:
"Update configuration for a specific authentication provider. Use either issuerUrl for automatic discovery OR provide all three custom endpoints.",
params: z.object({
id: z.string(),
}),
body: z.any(),
body: z.any(), // Validação manual no controller para providers oficiais
response: {
200: z.object({
success: z.boolean(),
data: z.any(),
}),
400: z.object({
success: z.boolean(),
error: z.string(),
}),
401: z.object({
success: z.boolean(),
error: z.string(),

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 {};
}
}
}

View File

@@ -1,5 +1,5 @@
export interface ProviderConfig {
name: string;
name?: string;
issuerUrl?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
@@ -16,9 +16,6 @@ export interface ProviderConfig {
emailEndpoint?: string;
emailFetchRequired?: boolean;
responseFormat?: string;
urlCleaning?: {
removeFromEnd: string[];
};
};
fieldMappings: {
id: string[];

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const apiRes = await fetch(`${API_BASE_URL}/auth/providers/${id}/test`, {
method: "POST",
headers: {
// Forward any authorization headers
...Object.fromEntries(
Array.from(request.headers.entries()).filter(
([key]) => key.startsWith("authorization") || key.startsWith("cookie")
)
),
},
});
const data = await apiRes.json();
return NextResponse.json(data, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error proxying auth provider test:", error);
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from "react";
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import {
IconAlertTriangle,
IconCheck,
IconChevronDown,
IconChevronUp,
@@ -15,6 +16,7 @@ import {
IconPlus,
IconSettings,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { Globe } from "lucide-react";
import { toast } from "sonner";
@@ -44,6 +46,9 @@ interface AuthProvider {
adminEmailDomains?: string;
sortOrder: number;
isOfficial?: boolean;
authorizationEndpoint?: string;
tokenEndpoint?: string;
userInfoEndpoint?: string;
}
interface NewProvider {
@@ -51,6 +56,14 @@ interface NewProvider {
displayName: string;
type: "oidc" | "oauth2";
icon: string;
clientId: string;
clientSecret: string;
issuerUrl: string;
scope: string;
// Endpoints customizados opcionais
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
}
export function AuthProvidersSettings() {
@@ -61,13 +74,102 @@ export function AuthProvidersSettings() {
const [showAddForm, setShowAddForm] = useState(false);
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
const [editingFormData, setEditingFormData] = useState<Record<string, any>>({});
const [testResults, setTestResults] = useState<Record<string, any>>({});
const [testingProvider, setTestingProvider] = useState<string | null>(null);
const [newProvider, setNewProvider] = useState<NewProvider>({
name: "",
displayName: "",
type: "oidc",
icon: "",
clientId: "",
clientSecret: "",
issuerUrl: "",
scope: "openid profile email",
authorizationEndpoint: "",
tokenEndpoint: "",
userInfoEndpoint: "",
});
// Auto-sugestão de scopes baseada na Provider URL
const detectProviderTypeAndSuggestScopes = (url: string): string[] => {
if (!url) return [];
const urlLower = url.toLowerCase();
// Padrões conhecidos para detecção automática
const providerPatterns = [
{ pattern: "frontegg.com", scopes: ["openid", "profile", "email"] },
{ pattern: "discord.com", scopes: ["identify", "email"] },
{ pattern: "github.com", scopes: ["read:user", "user:email"] },
{ pattern: "gitlab.com", scopes: ["read_user", "read_api"] },
{ pattern: "google.com", scopes: ["openid", "profile", "email"] },
{ pattern: "microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
{ pattern: "zendesk.com", scopes: ["read"] },
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
{ pattern: "stripe.com", scopes: ["read"] },
{ pattern: "twilio.com", scopes: ["read"] },
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
{ pattern: "mailchimp.com", scopes: ["read"] },
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "notion.so", scopes: ["read"] },
{ pattern: "figma.com", scopes: ["files:read"] },
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
{ pattern: "box.com", scopes: ["root_readwrite"] },
{ pattern: "trello.com", scopes: ["read"] },
{ pattern: "asana.com", scopes: ["default"] },
{ pattern: "monday.com", scopes: ["read"] },
{ pattern: "clickup.com", scopes: ["read"] },
{ pattern: "linear.app", scopes: ["read"] },
];
// Procura por padrões conhecidos
for (const { pattern, scopes } of providerPatterns) {
if (urlLower.includes(pattern)) {
return scopes;
}
}
// Fallback baseado no tipo do provider
if (newProvider.type === "oidc") {
return ["openid", "profile", "email"];
} else {
return ["profile", "email"];
}
};
// Função para auto-sugerir scopes baseado na Provider URL (onBlur)
const updateProviderUrl = (url: string) => {
if (!url.trim()) return;
const suggestedScopes = detectProviderTypeAndSuggestScopes(url);
setNewProvider((prev) => {
const shouldUpdateScopes = !prev.scope || prev.scope === "openid profile email" || prev.scope === "profile email";
return {
...prev,
scope: shouldUpdateScopes ? suggestedScopes.join(" ") : prev.scope,
};
});
};
// Load providers
useEffect(() => {
loadProviders();
@@ -146,8 +248,26 @@ export function AuthProvidersSettings() {
// Add new provider
const addProvider = async () => {
if (!newProvider.name || !newProvider.displayName) {
toast.error("Please fill in all required fields");
if (!newProvider.name || !newProvider.displayName || !newProvider.clientId || !newProvider.clientSecret) {
toast.error("Please fill in all required fields (name, display name, client ID, client secret)");
return;
}
// Validação de configuração
const hasIssuerUrl = !!newProvider.issuerUrl;
const hasAllCustomEndpoints = !!(
newProvider.authorizationEndpoint &&
newProvider.tokenEndpoint &&
newProvider.userInfoEndpoint
);
if (!hasIssuerUrl && !hasAllCustomEndpoints) {
toast.error("Either provide a Provider URL for automatic discovery OR all three custom endpoints");
return;
}
if (hasIssuerUrl && hasAllCustomEndpoints) {
toast.error("Choose either automatic discovery (Provider URL) OR manual endpoints, not both");
return;
}
@@ -157,12 +277,21 @@ export function AuthProvidersSettings() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...newProvider,
name: newProvider.name.toLowerCase().replace(/\s+/g, "-"),
displayName: newProvider.displayName,
type: newProvider.type,
icon: newProvider.icon,
clientId: newProvider.clientId,
clientSecret: newProvider.clientSecret,
enabled: false,
autoRegister: true,
scope: newProvider.type === "oidc" ? "openid profile email" : "user:email",
scope: newProvider.scope || (newProvider.type === "oidc" ? "openid profile email" : "user:email"),
sortOrder: providers.length + 1,
// Incluir apenas campos relevantes baseado no modo
...(newProvider.issuerUrl ? { issuerUrl: newProvider.issuerUrl } : {}),
...(newProvider.authorizationEndpoint ? { authorizationEndpoint: newProvider.authorizationEndpoint } : {}),
...(newProvider.tokenEndpoint ? { tokenEndpoint: newProvider.tokenEndpoint } : {}),
...(newProvider.userInfoEndpoint ? { userInfoEndpoint: newProvider.userInfoEndpoint } : {}),
}),
});
@@ -170,7 +299,19 @@ export function AuthProvidersSettings() {
if (data.success) {
await loadProviders();
setNewProvider({ name: "", displayName: "", type: "oidc", icon: "" });
setNewProvider({
name: "",
displayName: "",
type: "oidc",
icon: "",
clientId: "",
clientSecret: "",
issuerUrl: "",
scope: "openid profile email",
authorizationEndpoint: "",
tokenEndpoint: "",
userInfoEndpoint: "",
});
setShowAddForm(false);
toast.success("Provider added");
} else {
@@ -219,6 +360,57 @@ export function AuthProvidersSettings() {
}
};
// Test provider
const testProvider = async (id: string) => {
try {
setTestingProvider(id);
setTestResults((prev) => ({ ...prev, [id]: null }));
const response = await fetch(`/api/auth/providers/manage/${id}/test`, {
method: "POST",
});
const data = await response.json();
if (data.success) {
const result = data.data;
setTestResults((prev) => ({ ...prev, [id]: result }));
if (result.overall.status === "success") {
toast.success(`${result.overall.message}`);
} else if (result.overall.status === "warning") {
toast.warning(`⚠️ ${result.overall.message}`);
} else {
toast.error(`${result.overall.message}`);
}
// Log detailed results for debugging
console.log("Provider Test Results:", result);
} else {
setTestResults((prev) => ({
...prev,
[id]: {
overall: { status: "error", message: data.error },
tests: [],
},
}));
toast.error(`Test failed: ${data.error}`);
}
} catch (error) {
console.error("Error testing provider:", error);
setTestResults((prev) => ({
...prev,
[id]: {
overall: { status: "error", message: "Failed to test provider" },
tests: [],
},
}));
toast.error("Failed to test provider");
} finally {
setTestingProvider(null);
}
};
// Handle drag and drop
const handleDragEnd = async (result: DropResult) => {
if (!result.destination) return;
@@ -397,6 +589,186 @@ export function AuthProvidersSettings() {
/>
</div>
</div>
{/* Configuration Method Toggle */}
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-4">
<h4 className="text-sm font-medium mb-3">Configuration Method</h4>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<input
type="radio"
id="add-auto-discovery"
name="addConfigMethod"
checked={
!newProvider.authorizationEndpoint &&
!newProvider.tokenEndpoint &&
!newProvider.userInfoEndpoint
}
onChange={() =>
setNewProvider((prev) => ({
...prev,
authorizationEndpoint: "",
tokenEndpoint: "",
userInfoEndpoint: "",
}))
}
className="w-4 h-4"
/>
<label htmlFor="add-auto-discovery" className="text-sm">
<span className="font-medium">Automatic Discovery</span>
<span className="text-muted-foreground ml-2">(Just provide Provider URL)</span>
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="add-manual-endpoints"
name="addConfigMethod"
checked={
!!(
newProvider.authorizationEndpoint ||
newProvider.tokenEndpoint ||
newProvider.userInfoEndpoint
)
}
onChange={() => {
if (
!newProvider.authorizationEndpoint &&
!newProvider.tokenEndpoint &&
!newProvider.userInfoEndpoint
) {
setNewProvider((prev) => ({
...prev,
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/oauth/userinfo",
issuerUrl: "",
}));
}
}}
className="w-4 h-4"
/>
<label htmlFor="add-manual-endpoints" className="text-sm">
<span className="font-medium">Manual Endpoints</span>
<span className="text-muted-foreground ml-2">
(Recommended - For providers that don't support discovery)
</span>
</label>
</div>
</div>
</div>
{/* Automatic Discovery Mode */}
{!newProvider.authorizationEndpoint &&
!newProvider.tokenEndpoint &&
!newProvider.userInfoEndpoint && (
<div>
<Label className="mb-2 block">Provider URL *</Label>
<Input
placeholder="https://your-provider.com (endpoints will be discovered automatically)"
value={newProvider.issuerUrl}
onChange={(e) => setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))}
onBlur={(e) => updateProviderUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
The system will automatically discover authorization, token, and userinfo endpoints
</p>
</div>
)}
{/* Manual Endpoints Mode */}
{(newProvider.authorizationEndpoint || newProvider.tokenEndpoint || newProvider.userInfoEndpoint) && (
<div className="space-y-4">
<div>
<Label className="mb-2 block">Provider URL *</Label>
<Input
placeholder="https://your-provider.com"
value={newProvider.issuerUrl}
onChange={(e) => setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))}
onBlur={(e) => updateProviderUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Base URL of your provider (endpoints will be relative to this)
</p>
</div>
<div>
<Label className="mb-2 block">Authorization Endpoint *</Label>
<Input
placeholder="/oauth/authorize"
value={newProvider.authorizationEndpoint}
onChange={(e) =>
setNewProvider((prev) => ({ ...prev, authorizationEndpoint: e.target.value }))
}
/>
</div>
<div>
<Label className="mb-2 block">Token Endpoint *</Label>
<Input
placeholder="/oauth/token"
value={newProvider.tokenEndpoint}
onChange={(e) => setNewProvider((prev) => ({ ...prev, tokenEndpoint: e.target.value }))}
/>
</div>
<div>
<Label className="mb-2 block">User Info Endpoint *</Label>
<Input
placeholder="/oauth/userinfo"
value={newProvider.userInfoEndpoint}
onChange={(e) => setNewProvider((prev) => ({ ...prev, userInfoEndpoint: e.target.value }))}
/>
</div>
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-start gap-2 text-blue-700 dark:text-blue-300">
<IconInfoCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="text-xs">
<p className="font-medium">Manual Configuration</p>
<p className="mt-1">
You're providing all endpoints manually. Make sure they're correct for your provider.
</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Client Credentials */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="mb-2 block">Client ID *</Label>
<Input
placeholder="Your OAuth client ID"
value={newProvider.clientId}
onChange={(e) => setNewProvider((prev) => ({ ...prev, clientId: e.target.value }))}
/>
</div>
<div>
<Label className="mb-2 block">Client Secret *</Label>
<Input
type="password"
placeholder="Your OAuth client secret"
value={newProvider.clientSecret}
onChange={(e) => setNewProvider((prev) => ({ ...prev, clientSecret: e.target.value }))}
/>
</div>
</div>
{/* OAuth Scopes */}
<div>
<Label className="mb-2 block">OAuth Scopes</Label>
<TagsInput
value={newProvider.scope ? newProvider.scope.split(/[,\s]+/).filter(Boolean) : []}
onChange={(tags) => setNewProvider((prev) => ({ ...prev, scope: tags.join(" ") }))}
placeholder="Enter scopes (e.g., openid, profile, email)"
/>
<p className="text-xs text-muted-foreground mt-1">
{newProvider.type === "oidc"
? "Scopes auto-suggested based on Provider URL. Common OIDC scopes: openid, profile, email, groups"
: "Scopes auto-suggested based on Provider URL. Common OAuth2 scopes depend on the provider"}
</p>
</div>
{/* Show callback URL if provider name is filled */}
{newProvider.name && (
<div className="pt-2">
@@ -448,7 +820,10 @@ export function AuthProvidersSettings() {
}
}}
onDelete={() => deleteProvider(provider.id, provider.displayName)}
onTest={() => testProvider(provider.id)}
saving={saving === provider.id}
testing={testingProvider === provider.id}
testResults={testResults[provider.id]}
getIcon={getProviderIcon}
editingProvider={editingProvider}
editProvider={editProvider}
@@ -491,7 +866,10 @@ interface ProviderRowProps {
onUpdate: (updates: Partial<AuthProvider>) => void;
onEdit: () => void;
onDelete: () => void;
onTest: () => void;
saving: boolean;
testing: boolean;
testResults: any;
getIcon: (provider: AuthProvider) => React.ReactNode;
editingProvider: AuthProvider | null;
editProvider: (data: Partial<AuthProvider>) => void;
@@ -507,7 +885,10 @@ function ProviderRow({
onUpdate,
onEdit,
onDelete,
onTest,
saving,
testing,
testResults,
getIcon,
editingProvider,
editProvider,
@@ -545,7 +926,7 @@ function ProviderRow({
</div>
<div className="text-xs text-muted-foreground">
{provider.type.toUpperCase()} • {provider.name}
{provider.isOfficial && <span className="text-blue-600 dark:text-blue-400"> Optimized by Palmr.</span>}
{provider.isOfficial && <span className="text-blue-600 dark:text-blue-400"> • Official Provider</span>}
</div>
</div>
</div>
@@ -582,7 +963,10 @@ function ProviderRow({
provider={provider}
onSave={editProvider}
onCancel={onCancelEdit}
onTest={onTest}
saving={saving}
testing={testing}
testResults={testResults}
editingFormData={editingFormData}
setEditingFormData={setEditingFormData}
/>
@@ -597,7 +981,10 @@ interface EditProviderFormProps {
provider: AuthProvider;
onSave: (data: Partial<AuthProvider>) => void;
onCancel: () => void;
onTest: () => void;
saving: boolean;
testing: boolean;
testResults: any;
editingFormData: Record<string, any>;
setEditingFormData: (data: Record<string, any>) => void;
}
@@ -606,7 +993,10 @@ function EditProviderForm({
provider,
onSave,
onCancel,
onTest,
saving,
testing,
testResults,
editingFormData,
setEditingFormData,
}: EditProviderFormProps) {
@@ -623,11 +1013,99 @@ function EditProviderForm({
scope: savedData.scope || provider.scope || "",
autoRegister: savedData.autoRegister !== undefined ? savedData.autoRegister : provider.autoRegister,
adminEmailDomains: savedData.adminEmailDomains || provider.adminEmailDomains || "",
authorizationEndpoint: savedData.authorizationEndpoint || provider.authorizationEndpoint || "",
tokenEndpoint: savedData.tokenEndpoint || provider.tokenEndpoint || "",
userInfoEndpoint: savedData.userInfoEndpoint || provider.userInfoEndpoint || "",
});
const [showClientSecret, setShowClientSecret] = useState(false);
const isOfficial = provider.isOfficial;
// Auto-sugestão de scopes para formulário de edição
const detectProviderTypeAndSuggestScopesEdit = (url: string, currentType: string): string[] => {
if (!url) return [];
const urlLower = url.toLowerCase();
// Mesmos padrões do formulário de adição
const providerPatterns = [
{ pattern: "frontegg.com", scopes: ["openid", "profile", "email"] },
{ pattern: "discord.com", scopes: ["identify", "email"] },
{ pattern: "github.com", scopes: ["read:user", "user:email"] },
{ pattern: "gitlab.com", scopes: ["read_user", "read_api"] },
{ pattern: "google.com", scopes: ["openid", "profile", "email"] },
{ pattern: "microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
{ pattern: "zendesk.com", scopes: ["read"] },
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
{ pattern: "stripe.com", scopes: ["read"] },
{ pattern: "twilio.com", scopes: ["read"] },
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
{ pattern: "mailchimp.com", scopes: ["read"] },
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "notion.so", scopes: ["read"] },
{ pattern: "figma.com", scopes: ["files:read"] },
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
{ pattern: "box.com", scopes: ["root_readwrite"] },
{ pattern: "trello.com", scopes: ["read"] },
{ pattern: "asana.com", scopes: ["default"] },
{ pattern: "monday.com", scopes: ["read"] },
{ pattern: "clickup.com", scopes: ["read"] },
{ pattern: "linear.app", scopes: ["read"] },
];
// Procura por padrões conhecidos
for (const { pattern, scopes } of providerPatterns) {
if (urlLower.includes(pattern)) {
return scopes;
}
}
// Fallback baseado no tipo do provider
if (currentType === "oidc") {
return ["openid", "profile", "email"];
} else {
return ["profile", "email"];
}
};
// Função para auto-sugerir scopes baseado na Provider URL no formulário de edição (onBlur)
const updateProviderUrlEdit = (url: string) => {
if (!url.trim()) return;
if (isOfficial) {
// Para providers oficiais, não faz auto-sugestão de scopes
return;
}
const suggestedScopes = detectProviderTypeAndSuggestScopesEdit(url, formData.type);
const shouldUpdateScopes =
!formData.scope || formData.scope === "openid profile email" || formData.scope === "profile email";
// Só atualiza scopes, não a URL (já foi atualizada pelo onChange)
if (shouldUpdateScopes) {
updateFormData({
scope: suggestedScopes.join(" "),
});
}
};
const updateFormData = (updates: Partial<typeof formData>) => {
const newFormData = { ...formData, ...updates };
setFormData(newFormData);
@@ -706,14 +1184,154 @@ function EditProviderForm({
</div>
)}
{formData.type === "oidc" && (
<div>
<Label className="mb-2 block">Issuer URL *</Label>
<Input
placeholder="https://your-provider.com/app/authorize"
value={formData.issuerUrl}
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
/>
{/* Configuration - Only for custom providers */}
{!isOfficial && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-4">
<h4 className="text-sm font-medium mb-3">Configuration Method</h4>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<input
type="radio"
id="auto-discovery"
name="configMethod"
checked={!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint}
onChange={() =>
updateFormData({
authorizationEndpoint: "",
tokenEndpoint: "",
userInfoEndpoint: "",
})
}
className="w-4 h-4"
/>
<label htmlFor="auto-discovery" className="text-sm">
<span className="font-medium">Automatic Discovery</span>
<span className="text-muted-foreground ml-2">(Just provide Provider URL)</span>
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="manual-endpoints"
name="configMethod"
checked={!!(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint)}
onChange={() => {
if (!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint) {
updateFormData({
authorizationEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/oauth/userinfo",
});
}
}}
className="w-4 h-4"
/>
<label htmlFor="manual-endpoints" className="text-sm">
<span className="font-medium">Manual Endpoints</span>
<span className="text-muted-foreground ml-2">
(Recommended - For providers that don't support discovery)
</span>
</label>
</div>
</div>
</div>
{/* Automatic Discovery Mode */}
{!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint && (
<div>
<Label className="mb-2 block">Provider URL *</Label>
<Input
placeholder="https://your-provider.com (endpoints will be discovered automatically)"
value={formData.issuerUrl}
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
The system will automatically discover authorization, token, and userinfo endpoints
</p>
</div>
)}
{/* Manual Endpoints Mode */}
{(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint) && (
<div className="space-y-4">
<div>
<Label className="mb-2 block">Provider URL *</Label>
<Input
placeholder="https://your-provider.com"
value={formData.issuerUrl}
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Base URL of your provider (endpoints will be relative to this)
</p>
</div>
<div>
<Label className="mb-2 block">Authorization Endpoint *</Label>
<Input
placeholder="/oauth/authorize"
value={formData.authorizationEndpoint}
onChange={(e) => updateFormData({ authorizationEndpoint: e.target.value })}
/>
</div>
<div>
<Label className="mb-2 block">Token Endpoint *</Label>
<Input
placeholder="/oauth/token"
value={formData.tokenEndpoint}
onChange={(e) => updateFormData({ tokenEndpoint: e.target.value })}
/>
</div>
<div>
<Label className="mb-2 block">User Info Endpoint *</Label>
<Input
placeholder="/oauth/userinfo"
value={formData.userInfoEndpoint}
onChange={(e) => updateFormData({ userInfoEndpoint: e.target.value })}
/>
</div>
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-start gap-2 text-blue-700 dark:text-blue-300">
<IconInfoCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="text-xs">
<p className="font-medium">Manual Configuration</p>
<p className="mt-1">
You're providing all endpoints manually. Make sure they're correct for your provider.
</p>
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* Official Provider - Only Provider URL and Icon */}
{isOfficial && (
<div className="space-y-4">
<div>
<Label className="mb-2 block">Provider URL *</Label>
<Input
placeholder={`Replace placeholder with your ${provider.displayName} URL`}
value={formData.issuerUrl}
onChange={(e) => updateFormData({ issuerUrl: e.target.value })}
onBlur={(e) => updateProviderUrlEdit(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
This is an official provider. Endpoints are pre-configured. You can edit just this URL.
</p>
</div>
<div>
<Label className="mb-2 block">Icon</Label>
<IconPicker
value={formData.icon}
onChange={(icon) => updateFormData({ icon })}
placeholder="Select an icon"
/>
<p className="text-xs text-muted-foreground mt-1">You can customize the icon for this official provider.</p>
</div>
</div>
)}
@@ -762,8 +1380,8 @@ function EditProviderForm({
/>
<p className="text-xs text-muted-foreground mt-1">
{formData.type === "oidc"
? "Common OIDC scopes: openid, profile, email, groups"
: "Common OAuth2 scopes depend on the provider"}
? "Scopes auto-suggested based on Provider URL. Common OIDC scopes: openid, profile, email, groups"
: "Scopes auto-suggested based on Provider URL. Common OAuth2 scopes depend on the provider"}
</p>
</div>
@@ -787,14 +1405,90 @@ function EditProviderForm({
<Label className="cursor-pointer">Auto-register new users</Label>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={onCancel} size="sm">
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving} size="sm">
{saving ? "Salvando..." : "Salvar Provider"}
<div className="flex gap-2 justify-between pt-4">
<Button variant="outline" onClick={onTest} disabled={saving || testing} size="sm">
{testing ? (
<>
<IconSettings className="h-3 w-3 animate-spin" />
Testing...
</>
) : (
"Test Provider"
)}
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} size="sm">
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving || testing} size="sm">
{saving ? "Salvando..." : "Salvar Provider"}
</Button>
</div>
</div>
{/* Test Results Display */}
{testResults && (
<div className="mt-4">
<div
className={`rounded-lg p-3 border ${
testResults.overall?.status === "success"
? "bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800"
: testResults.overall?.status === "warning"
? "bg-yellow-50 dark:bg-yellow-950/50 border-yellow-200 dark:border-yellow-800"
: "bg-red-50 dark:bg-red-950/50 border-red-200 dark:border-red-800"
}`}
>
<div className="flex items-start gap-2">
{testResults.overall?.status === "success" ? (
<IconCheck className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400 flex-shrink-0" />
) : testResults.overall?.status === "warning" ? (
<IconAlertTriangle className="h-4 w-4 mt-0.5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
) : (
<IconX className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400 flex-shrink-0" />
)}
<div className="flex-1">
<p
className={`text-sm font-medium ${
testResults.overall?.status === "success"
? "text-green-700 dark:text-green-300"
: testResults.overall?.status === "warning"
? "text-yellow-700 dark:text-yellow-300"
: "text-red-700 dark:text-red-300"
}`}
>
{testResults.overall?.message}
</p>
{testResults.tests && testResults.tests.length > 0 && (
<div className="mt-2 space-y-1">
{testResults.tests.map((test: any, index: number) => (
<div key={index} className="flex items-center gap-2 text-xs">
{test.status === "success" ? (
<IconCheck className="h-3 w-3 text-green-600 dark:text-green-400" />
) : test.status === "warning" ? (
<IconAlertTriangle className="h-3 w-3 text-yellow-600 dark:text-yellow-400" />
) : (
<IconX className="h-3 w-3 text-red-600 dark:text-red-400" />
)}
<span
className={
test.status === "success"
? "text-green-700 dark:text-green-300"
: test.status === "warning"
? "text-yellow-700 dark:text-yellow-300"
: "text-red-700 dark:text-red-300"
}
>
{test.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -36,7 +36,7 @@ import * as WiIcons from "react-icons/wi";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -158,7 +158,7 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
<div className="grid grid-cols-8 sm:grid-cols-12 lg:grid-cols-16 xl:grid-cols-20 gap-2 sm:gap-3">
{categoryIcons.map((icon) => (
<button
key={icon.name}
key={`${icon.category}-${icon.name}`}
className="h-12 w-12 sm:h-14 sm:w-14 p-0 hover:bg-muted transition-colors flex-shrink-0 rounded-md flex items-center justify-center cursor-pointer"
onClick={() => onIconSelect(icon.name)}
title={icon.name}
@@ -190,7 +190,7 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
<div className="grid grid-cols-8 sm:grid-cols-12 lg:grid-cols-16 xl:grid-cols-20 gap-2 sm:gap-3">
{visibleIcons.map((icon) => (
<button
key={icon.name}
key={`${icon.category}-${icon.name}`}
className="h-12 w-12 sm:h-14 sm:w-14 p-0 hover:bg-muted transition-colors flex-shrink-0 rounded-md flex items-center justify-center cursor-pointer"
onClick={() => onIconSelect(icon.name)}
title={`${icon.name} (${icon.category})`}
@@ -312,10 +312,17 @@ export function IconPicker({ value, onChange, placeholder = "Select an icon" }:
];
const icons: IconData[] = [];
const seenNames = new Set<string>();
iconSets.forEach(({ icons: iconSet, prefix, category }) => {
Object.entries(iconSet).forEach(([name, component]) => {
if (typeof component === "function" && name.startsWith(prefix)) {
// Skip duplicates - keep only the first occurrence
if (seenNames.has(name)) {
return;
}
seenNames.add(name);
icons.push({
name,
component: component as React.ComponentType<{ className?: string }>,
@@ -390,7 +397,7 @@ export function IconPicker({ value, onChange, placeholder = "Select an icon" }:
<DialogContent className="max-w-5xl xl:max-w-6xl max-h-[90vh] overflow-hidden">
<div className="space-y-4 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Selecionar Ícone</h3>
<DialogTitle>Selecionar Ícone</DialogTitle>
<div className="text-sm text-muted-foreground">
{allIcons.length.toLocaleString()} ícones de {categories.length} bibliotecas
</div>