diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 0a375f6..5720406 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -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) diff --git a/apps/server/prisma/seed.js b/apps/server/prisma/seed.js index 37a5e8f..837ad46 100644 --- a/apps/server/prisma/seed.js +++ b/apps/server/prisma/seed.js @@ -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 }) }, ]; diff --git a/apps/server/src/modules/auth-providers/controller.ts b/apps/server/src/modules/auth-providers/controller.ts index 2d9b1a2..827ea96 100644 --- a/apps/server/src/modules/auth-providers/controller.ts +++ b/apps/server/src/modules/auth-providers/controller.ts @@ -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, + }); + } + } } diff --git a/apps/server/src/modules/auth-providers/dto.ts b/apps/server/src/modules/auth-providers/dto.ts new file mode 100644 index 0000000..11c115a --- /dev/null +++ b/apps/server/src/modules/auth-providers/dto.ts @@ -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(), + }) + ), +}); diff --git a/apps/server/src/modules/auth-providers/provider-manager.ts b/apps/server/src/modules/auth-providers/provider-manager.ts index dd2cb00..5101e9b 100644 --- a/apps/server/src/modules/auth-providers/provider-manager.ts +++ b/apps/server/src/modules/auth-providers/provider-manager.ts @@ -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 { 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 { 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 = { + // 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 = { + 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 = { + // 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 = { + // 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 = { + // 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", + } + ); } } diff --git a/apps/server/src/modules/auth-providers/providers.config.ts b/apps/server/src/modules/auth-providers/providers.config.ts index 7eb6dff..154352f 100644 --- a/apps/server/src/modules/auth-providers/providers.config.ts +++ b/apps/server/src/modules/auth-providers/providers.config.ts @@ -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, +}; diff --git a/apps/server/src/modules/auth-providers/routes.ts b/apps/server/src/modules/auth-providers/routes.ts index d7ff636..d933eb8 100644 --- a/apps/server/src/modules/auth-providers/routes.ts +++ b/apps/server/src/modules/auth-providers/routes.ts @@ -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(), diff --git a/apps/server/src/modules/auth-providers/service.ts b/apps/server/src/modules/auth-providers/service.ts index f9830e9..d91cd21 100644 --- a/apps/server/src/modules/auth-providers/service.ts +++ b/apps/server/src/modules/auth-providers/service.ts @@ -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 { + 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 {}; + } + } } diff --git a/apps/server/src/modules/auth-providers/types.ts b/apps/server/src/modules/auth-providers/types.ts index 3bea64f..fb043f4 100644 --- a/apps/server/src/modules/auth-providers/types.ts +++ b/apps/server/src/modules/auth-providers/types.ts @@ -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[]; diff --git a/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/test/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/test/route.ts new file mode 100644 index 0000000..3af8c3a --- /dev/null +++ b/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/test/route.ts @@ -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 }); + } +} diff --git a/apps/web/src/app/settings/components/auth-providers-settings.tsx b/apps/web/src/app/settings/components/auth-providers-settings.tsx index 08ce43b..73a58c4 100644 --- a/apps/web/src/app/settings/components/auth-providers-settings.tsx +++ b/apps/web/src/app/settings/components/auth-providers-settings.tsx @@ -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(null); const [editingFormData, setEditingFormData] = useState>({}); + const [testResults, setTestResults] = useState>({}); + const [testingProvider, setTestingProvider] = useState(null); const [newProvider, setNewProvider] = useState({ 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() { /> + + {/* Configuration Method Toggle */} +
+
+

Configuration Method

+
+
+ + setNewProvider((prev) => ({ + ...prev, + authorizationEndpoint: "", + tokenEndpoint: "", + userInfoEndpoint: "", + })) + } + className="w-4 h-4" + /> + +
+
+ { + 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" + /> + +
+
+
+ + {/* Automatic Discovery Mode */} + {!newProvider.authorizationEndpoint && + !newProvider.tokenEndpoint && + !newProvider.userInfoEndpoint && ( +
+ + setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))} + onBlur={(e) => updateProviderUrl(e.target.value)} + /> +

+ The system will automatically discover authorization, token, and userinfo endpoints +

+
+ )} + + {/* Manual Endpoints Mode */} + {(newProvider.authorizationEndpoint || newProvider.tokenEndpoint || newProvider.userInfoEndpoint) && ( +
+
+ + setNewProvider((prev) => ({ ...prev, issuerUrl: e.target.value }))} + onBlur={(e) => updateProviderUrl(e.target.value)} + /> +

+ Base URL of your provider (endpoints will be relative to this) +

+
+
+ + + setNewProvider((prev) => ({ ...prev, authorizationEndpoint: e.target.value })) + } + /> +
+
+ + setNewProvider((prev) => ({ ...prev, tokenEndpoint: e.target.value }))} + /> +
+
+ + setNewProvider((prev) => ({ ...prev, userInfoEndpoint: e.target.value }))} + /> +
+
+
+ +
+

Manual Configuration

+

+ You're providing all endpoints manually. Make sure they're correct for your provider. +

+
+
+
+
+ )} +
+ + {/* Client Credentials */} +
+
+ + setNewProvider((prev) => ({ ...prev, clientId: e.target.value }))} + /> +
+
+ + setNewProvider((prev) => ({ ...prev, clientSecret: e.target.value }))} + /> +
+
+ + {/* OAuth Scopes */} +
+ + setNewProvider((prev) => ({ ...prev, scope: tags.join(" ") }))} + placeholder="Enter scopes (e.g., openid, profile, email)" + /> +

+ {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"} +

+
+ {/* Show callback URL if provider name is filled */} {newProvider.name && (
@@ -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) => void; onEdit: () => void; onDelete: () => void; + onTest: () => void; saving: boolean; + testing: boolean; + testResults: any; getIcon: (provider: AuthProvider) => React.ReactNode; editingProvider: AuthProvider | null; editProvider: (data: Partial) => void; @@ -507,7 +885,10 @@ function ProviderRow({ onUpdate, onEdit, onDelete, + onTest, saving, + testing, + testResults, getIcon, editingProvider, editProvider, @@ -545,7 +926,7 @@ function ProviderRow({
{provider.type.toUpperCase()} • {provider.name} - {provider.isOfficial && • Optimized by Palmr.} + {provider.isOfficial && • Official Provider}
@@ -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) => void; onCancel: () => void; + onTest: () => void; saving: boolean; + testing: boolean; + testResults: any; editingFormData: Record; setEditingFormData: (data: Record) => 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) => { const newFormData = { ...formData, ...updates }; setFormData(newFormData); @@ -706,14 +1184,154 @@ function EditProviderForm({ )} - {formData.type === "oidc" && ( -
- - updateFormData({ issuerUrl: e.target.value })} - /> + {/* Configuration - Only for custom providers */} + {!isOfficial && ( +
+
+

Configuration Method

+
+
+ + updateFormData({ + authorizationEndpoint: "", + tokenEndpoint: "", + userInfoEndpoint: "", + }) + } + className="w-4 h-4" + /> + +
+
+ { + if (!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint) { + updateFormData({ + authorizationEndpoint: "/oauth/authorize", + tokenEndpoint: "/oauth/token", + userInfoEndpoint: "/oauth/userinfo", + }); + } + }} + className="w-4 h-4" + /> + +
+
+
+ + {/* Automatic Discovery Mode */} + {!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint && ( +
+ + updateFormData({ issuerUrl: e.target.value })} + onBlur={(e) => updateProviderUrlEdit(e.target.value)} + /> +

+ The system will automatically discover authorization, token, and userinfo endpoints +

+
+ )} + + {/* Manual Endpoints Mode */} + {(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint) && ( +
+
+ + updateFormData({ issuerUrl: e.target.value })} + onBlur={(e) => updateProviderUrlEdit(e.target.value)} + /> +

+ Base URL of your provider (endpoints will be relative to this) +

+
+
+ + updateFormData({ authorizationEndpoint: e.target.value })} + /> +
+
+ + updateFormData({ tokenEndpoint: e.target.value })} + /> +
+
+ + updateFormData({ userInfoEndpoint: e.target.value })} + /> +
+
+
+ +
+

Manual Configuration

+

+ You're providing all endpoints manually. Make sure they're correct for your provider. +

+
+
+
+
+ )} +
+ )} + + {/* Official Provider - Only Provider URL and Icon */} + {isOfficial && ( +
+
+ + updateFormData({ issuerUrl: e.target.value })} + onBlur={(e) => updateProviderUrlEdit(e.target.value)} + /> +

+ This is an official provider. Endpoints are pre-configured. You can edit just this URL. +

+
+
+ + updateFormData({ icon })} + placeholder="Select an icon" + /> +

You can customize the icon for this official provider.

+
)} @@ -762,8 +1380,8 @@ function EditProviderForm({ />

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

@@ -787,14 +1405,90 @@ function EditProviderForm({ -
- - +
+ + +
+ + {/* Test Results Display */} + {testResults && ( +
+
+
+ {testResults.overall?.status === "success" ? ( + + ) : testResults.overall?.status === "warning" ? ( + + ) : ( + + )} +
+

+ {testResults.overall?.message} +

+ {testResults.tests && testResults.tests.length > 0 && ( +
+ {testResults.tests.map((test: any, index: number) => ( +
+ {test.status === "success" ? ( + + ) : test.status === "warning" ? ( + + ) : ( + + )} + + {test.message} + +
+ ))} +
+ )} +
+
+
+
+ )} ); } diff --git a/apps/web/src/components/ui/icon-picker.tsx b/apps/web/src/components/ui/icon-picker.tsx index a0c1a20..4aacf3f 100644 --- a/apps/web/src/components/ui/icon-picker.tsx +++ b/apps/web/src/components/ui/icon-picker.tsx @@ -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 =
{categoryIcons.map((icon) => (