From 8f85874cbe2fc311d06c0ab97520a97ec9db1c47 Mon Sep 17 00:00:00 2001 From: Daniel Luiz Alves Date: Fri, 27 Jun 2025 13:14:22 -0300 Subject: [PATCH] feat: enhance authentication provider management and error handling - Refactored AuthProvidersController to improve request handling and error responses, introducing utility methods for success and error replies. - Added new request context and validation logic for custom endpoints in provider configurations, ensuring robust setup and error messaging. - Implemented detailed error handling for callback and authorization processes, enhancing user feedback during authentication flows. - Removed the ProviderManager class and integrated its functionality directly into AuthProvidersService for streamlined provider management. - Updated DTOs and types to support new request structures and validation requirements, improving overall code clarity and maintainability. --- .../src/modules/auth-providers/controller.ts | 525 +++++------ apps/server/src/modules/auth-providers/dto.ts | 18 +- .../auth-providers/provider-manager.ts | 795 ---------------- .../auth-providers/providers.config.ts | 112 +++ .../src/modules/auth-providers/routes.ts | 14 +- .../src/modules/auth-providers/service.ts | 886 ++++++++++-------- .../src/modules/auth-providers/types.ts | 58 ++ .../providers/[provider]/authorize/route.ts | 5 +- .../providers/[provider]/callback/route.ts | 10 +- .../api/(proxy)/auth/providers/all/route.ts | 1 - .../auth/providers/manage/[id]/route.ts | 2 - .../api/(proxy)/auth/providers/order/route.ts | 1 - .../app/api/(proxy)/auth/providers/route.ts | 3 - .../components/auth-providers-settings.tsx | 57 -- .../app/settings/components/settings-form.tsx | 2 - 15 files changed, 918 insertions(+), 1571 deletions(-) delete mode 100644 apps/server/src/modules/auth-providers/provider-manager.ts diff --git a/apps/server/src/modules/auth-providers/controller.ts b/apps/server/src/modules/auth-providers/controller.ts index 49c70d4..f737578 100644 --- a/apps/server/src/modules/auth-providers/controller.ts +++ b/apps/server/src/modules/auth-providers/controller.ts @@ -1,7 +1,41 @@ import { UpdateAuthProviderSchema } from "./dto"; import { AuthProvidersService } from "./service"; +import { + AuthorizeRequest, + CallbackRequest, + CreateProviderRequest, + DeleteProviderRequest, + RequestContext, + UpdateProviderRequest, + UpdateProvidersOrderRequest, +} from "./types"; import { FastifyRequest, FastifyReply } from "fastify"; +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; + +const OFFICIAL_PROVIDER_ALLOWED_FIELDS = [ + "issuerUrl", + "clientId", + "clientSecret", + "enabled", + "autoRegister", + "adminEmailDomains", + "icon", +]; + +const ERROR_MESSAGES = { + ENDPOINTS_INCOMPLETE: + "When using manual endpoints, all three endpoints (authorization, token, userInfo) are required", + MISSING_CONFIG: "Either provide issuerUrl for automatic discovery OR all three custom endpoints", + PROVIDER_NOT_FOUND: "Provider not found", + INVALID_URL: "Invalid Provider URL format", + INVALID_DATA: "Invalid data provided", + OFFICIAL_CANNOT_DELETE: "Official providers cannot be deleted", + INVALID_PROVIDERS_ARRAY: "Invalid providers array", + AUTHORIZATION_FAILED: "Authorization failed", + AUTHENTICATION_FAILED: "Authentication failed", +} as const; + export class AuthProvidersController { private authProvidersService: AuthProvidersService; @@ -9,26 +43,148 @@ export class AuthProvidersController { this.authProvidersService = new AuthProvidersService(); } + private buildRequestContext(request: FastifyRequest): RequestContext { + return { + protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol, + host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string), + headers: request.headers, + }; + } + + private buildBaseUrl(requestContext: RequestContext): string { + return `${requestContext.protocol}://${requestContext.host}`; + } + + private sendSuccessResponse(reply: FastifyReply, data?: any, message?: string) { + return reply.send({ + success: true, + ...(data && { data }), + ...(message && { message }), + }); + } + + private sendErrorResponse(reply: FastifyReply, status: number, error: string) { + return reply.status(status).send({ + success: false, + error, + }); + } + + private async handleControllerError(reply: FastifyReply, error: unknown, defaultMessage: string) { + console.error(`Controller error: ${defaultMessage}`, error); + + if (error instanceof Error && error.message.includes("Either provide issuerUrl")) { + return this.sendErrorResponse(reply, 400, error.message); + } + + return this.sendErrorResponse(reply, 500, defaultMessage); + } + + private validateCustomEndpoints(data: any): string | null { + const hasAnyCustomEndpoint = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint); + const hasAllCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint); + + if (hasAnyCustomEndpoint && !hasAllCustomEndpoints) { + return ERROR_MESSAGES.ENDPOINTS_INCOMPLETE; + } + + if (!data.issuerUrl && !hasAllCustomEndpoints) { + return ERROR_MESSAGES.MISSING_CONFIG; + } + + return null; + } + + private validateIssuerUrl(issuerUrl: string): boolean { + try { + new URL(issuerUrl); + return true; + } catch { + return false; + } + } + + private sanitizeOfficialProviderData(data: any): any { + const sanitizedData: any = {}; + + for (const field of OFFICIAL_PROVIDER_ALLOWED_FIELDS) { + if (data[field] !== undefined) { + sanitizedData[field] = data[field]; + } + } + + return sanitizedData; + } + + private setAuthCookie(reply: FastifyReply, token: string, isSecure: boolean) { + reply.setCookie("token", token, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: COOKIE_MAX_AGE, + path: "/", + }); + } + + private determineCallbackError(error: Error, provider: string): { type: string; message: string } { + const errorMessage = error.message; + + if (errorMessage.includes("registration via") && errorMessage.includes("disabled")) { + return { + type: "registration_disabled", + message: `Registration via ${provider} is disabled. Contact your administrator.`, + }; + } + + if (errorMessage.includes("not enabled")) { + return { + type: "provider_disabled", + message: `${provider} authentication is currently disabled.`, + }; + } + + if (errorMessage.includes("expired")) { + return { + type: "state_expired", + message: "Authentication session expired. Please try again.", + }; + } + + if (errorMessage.includes("No email found")) { + return { + type: "no_email", + message: `No email address found in your ${provider} account.`, + }; + } + + if (errorMessage.includes("Token exchange failed")) { + return { + type: "token_exchange_failed", + message: `Failed to authenticate with ${provider}. Please try again.`, + }; + } + + if (errorMessage.includes("Missing required user information")) { + return { + type: "missing_user_info", + message: `Incomplete user information from ${provider}.`, + }; + } + + return { + type: "unknown_error", + message: ERROR_MESSAGES.AUTHENTICATION_FAILED, + }; + } + async getProviders(request: FastifyRequest, reply: FastifyReply) { try { - const requestContext = { - protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol, - host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string), - headers: request.headers, - }; - + const requestContext = this.buildRequestContext(request); const providers = await this.authProvidersService.getEnabledProviders(requestContext); - return reply.send({ - success: true, - data: providers, - }); + return this.sendSuccessResponse(reply, providers); } catch (error) { - console.error("Error getting auth providers:", error); - return reply.status(500).send({ - success: false, - error: "Failed to get auth providers", - }); + return this.handleControllerError(reply, error, "Failed to get auth providers"); } } @@ -37,205 +193,97 @@ export class AuthProvidersController { try { const providers = await this.authProvidersService.getAllProviders(); - - return reply.send({ - success: true, - data: providers, - }); + return this.sendSuccessResponse(reply, providers); } catch (error) { - console.error("Error getting all providers:", error); - return reply.status(500).send({ - success: false, - error: "Failed to get providers", - }); + return this.handleControllerError(reply, error, "Failed to get providers"); } } - async createProvider(request: FastifyRequest<{ Body: any }>, reply: FastifyReply) { + async createProvider(request: FastifyRequest, reply: FastifyReply) { if (reply.sent) return; try { - const data = request.body as any; + const data = request.body; - // 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 validationError = this.validateCustomEndpoints(data); + if (validationError) { + return this.sendErrorResponse(reply, 400, validationError); } const provider = await this.authProvidersService.createProvider(data); - - return reply.send({ - success: true, - data: provider, - }); + return this.sendSuccessResponse(reply, provider); } 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", - }); + return this.handleControllerError(reply, error, "Failed to create provider"); } } - async updateProvider(request: FastifyRequest<{ Params: { id: string }; Body: any }>, reply: FastifyReply) { + async updateProvider(request: FastifyRequest, reply: FastifyReply) { if (reply.sent) return; try { const { id } = request.params; - const data = request.body as any; + const data = request.body; - // 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 this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND); } 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); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } 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, - }); + return this.updateOfficialProvider(reply, id, data); } - // Para providers customizados, aplica validação normal - try { - console.log(`[Controller] Updating custom provider with data:`, data); - - // Valida usando o schema do Zod - const validatedData = UpdateAuthProviderSchema.parse(data); - console.log(`[Controller] Validation passed, validated data:`, validatedData); - - 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); - console.error("Raw data that failed validation:", data); - return reply.status(400).send({ - success: false, - error: "Invalid data provided", - }); - } + return this.updateCustomProvider(reply, id, data); } 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", - }); + return this.handleControllerError(reply, error, "Failed to update provider"); } } - async updateProvidersOrder( - request: FastifyRequest<{ Body: { providers: { id: string; sortOrder: number }[] } }>, - reply: FastifyReply - ) { + private async updateOfficialProvider(reply: FastifyReply, id: string, data: any) { + const sanitizedData = this.sanitizeOfficialProviderData(data); + + if (sanitizedData.issuerUrl && typeof sanitizedData.issuerUrl === "string") { + if (!this.validateIssuerUrl(sanitizedData.issuerUrl)) { + return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.INVALID_URL); + } + } + + const provider = await this.authProvidersService.updateProvider(id, sanitizedData); + return this.sendSuccessResponse(reply, provider); + } + + private async updateCustomProvider(reply: FastifyReply, id: string, data: any) { + try { + const validatedData = UpdateAuthProviderSchema.parse(data); + const provider = await this.authProvidersService.updateProvider(id, validatedData); + return this.sendSuccessResponse(reply, provider); + } catch (validationError) { + console.error("Validation error for custom provider:", validationError); + console.error("Raw data that failed validation:", data); + return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.INVALID_DATA); + } + } + + async updateProvidersOrder(request: FastifyRequest, reply: FastifyReply) { if (reply.sent) return; try { const { providers } = request.body; if (!Array.isArray(providers)) { - return reply.status(400).send({ - success: false, - error: "Invalid providers array", - }); + return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.INVALID_PROVIDERS_ARRAY); } await this.authProvidersService.updateProvidersOrder(providers); - - return reply.send({ - success: true, - message: "Providers order updated successfully", - }); + return this.sendSuccessResponse(reply, undefined, "Providers order updated successfully"); } catch (error) { - console.error("Error updating providers order:", error); - return reply.status(500).send({ - success: false, - error: "Failed to update providers order", - }); + return this.handleControllerError(reply, error, "Failed to update providers order"); } } - async deleteProvider(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { + async deleteProvider(request: FastifyRequest, reply: FastifyReply) { if (reply.sent) return; try { @@ -243,47 +291,27 @@ export class AuthProvidersController { const provider = await this.authProvidersService.getProviderById(id); if (!provider) { - return reply.status(404).send({ - success: false, - error: "Provider not found", - }); + return this.sendErrorResponse(reply, 404, ERROR_MESSAGES.PROVIDER_NOT_FOUND); } const isOfficial = this.authProvidersService.isOfficialProvider(provider.name); if (isOfficial) { - return reply.status(400).send({ - success: false, - error: "Official providers cannot be deleted", - }); + return this.sendErrorResponse(reply, 400, ERROR_MESSAGES.OFFICIAL_CANNOT_DELETE); } await this.authProvidersService.deleteProvider(id); - - return reply.send({ - success: true, - message: "Provider deleted successfully", - }); + return this.sendSuccessResponse(reply, undefined, "Provider deleted successfully"); } catch (error) { - console.error("Error deleting provider:", error); - return reply.status(500).send({ - success: false, - error: "Failed to delete provider", - }); + return this.handleControllerError(reply, error, "Failed to delete provider"); } } - async authorize(request: FastifyRequest<{ Params: { provider: string }; Querystring: any }>, reply: FastifyReply) { + async authorize(request: FastifyRequest, reply: FastifyReply) { try { const { provider: providerName } = request.params; - const query = request.query as any; - const { state, redirect_uri } = query; - - const requestContext = { - protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol, - host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string), - headers: request.headers, - }; + const { state, redirect_uri } = request.query; + const requestContext = this.buildRequestContext(request); const authUrl = await this.authProvidersService.getAuthorizationUrl( providerName, state, @@ -293,62 +321,31 @@ export class AuthProvidersController { return reply.redirect(authUrl); } catch (error) { - console.error("Error in authorize:", error); - return reply.status(400).send({ - success: false, - error: error instanceof Error ? error.message : "Authorization failed", - }); + const errorMessage = error instanceof Error ? error.message : ERROR_MESSAGES.AUTHORIZATION_FAILED; + return this.sendErrorResponse(reply, 400, errorMessage); } } - async callback(request: FastifyRequest<{ Params: { provider: string }; Querystring: any }>, reply: FastifyReply) { - console.log(`[Controller] Callback called for provider: ${request.params.provider}`); - console.log(`[Controller] Query params:`, request.query); - console.log(`[Controller] Headers:`, { - host: request.headers.host, - "x-forwarded-proto": request.headers["x-forwarded-proto"], - "x-forwarded-host": request.headers["x-forwarded-host"], - }); - + async callback(request: FastifyRequest, reply: FastifyReply) { try { const { provider: providerName } = request.params; - const query = request.query as any; - const { code, state, error } = query; + const { code, state, error } = request.query; - console.log(`[Controller] Extracted params:`, { providerName, code, state, error }); - console.log(`[Controller] All query params:`, query); - - const requestContext = { - protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol, - host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string), - }; - const baseUrl = `${requestContext.protocol}://${requestContext.host}`; - - console.log(`[Controller] Request context:`, requestContext); - console.log(`[Controller] Base URL:`, baseUrl); + const requestContext = this.buildRequestContext(request); + const baseUrl = this.buildBaseUrl(requestContext); if (error) { - console.error(`OAuth error from ${providerName}:`, error); return reply.redirect(`${baseUrl}/login?error=oauth_error&provider=${providerName}`); } if (!code) { - console.error(`Missing code parameter for ${providerName}`); return reply.redirect(`${baseUrl}/login?error=missing_code&provider=${providerName}`); } - // Validação de parâmetros obrigatórios - const requiredParams = { code: !!code, state: !!state }; - const missingParams = Object.entries(requiredParams) - .filter(([, hasValue]) => !hasValue) - .map(([param]) => param); - - if (missingParams.length > 0) { - console.error(`Missing parameters for ${providerName}:`, missingParams); + if (!state) { return reply.redirect(`${baseUrl}/login?error=missing_parameters&provider=${providerName}`); } - console.log(`[Controller] Calling handleCallback for ${providerName}`); const result = await this.authProvidersService.handleCallback(providerName, code, state, requestContext); const jwt = await request.jwtSign({ @@ -356,55 +353,29 @@ export class AuthProvidersController { isAdmin: result.user.isAdmin, }); - reply.setCookie("token", jwt, { - httpOnly: true, - secure: request.protocol === "https", - sameSite: "lax", - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days - path: "/", - }); + this.setAuthCookie(reply, jwt, request.protocol === "https"); const redirectUrl = result.redirectUrl || "/dashboard"; const fullRedirectUrl = redirectUrl.startsWith("http") ? redirectUrl : `${baseUrl}${redirectUrl}`; - console.log(`[Controller] Redirecting to:`, fullRedirectUrl); + return reply.redirect(fullRedirectUrl); } catch (error) { - console.error(`Error in ${request.params.provider} callback:`, error); - - let errorType = "unknown_error"; - let errorMessage = "Authentication failed"; - - if (error instanceof Error) { - if (error.message.includes("registration via") && error.message.includes("disabled")) { - errorType = "registration_disabled"; - errorMessage = `Registration via ${request.params.provider} is disabled. Contact your administrator.`; - } else if (error.message.includes("not enabled")) { - errorType = "provider_disabled"; - errorMessage = `${request.params.provider} authentication is currently disabled.`; - } else if (error.message.includes("expired")) { - errorType = "state_expired"; - errorMessage = "Authentication session expired. Please try again."; - } else if (error.message.includes("No email found")) { - errorType = "no_email"; - errorMessage = `No email address found in your ${request.params.provider} account.`; - } else if (error.message.includes("Token exchange failed")) { - errorType = "token_exchange_failed"; - errorMessage = `Failed to authenticate with ${request.params.provider}. Please try again.`; - } else if (error.message.includes("Missing required user information")) { - errorType = "missing_user_info"; - errorMessage = `Incomplete user information from ${request.params.provider}.`; - } - } - - const requestContext = { - protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol, - host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string), - }; - const baseUrl = `${requestContext.protocol}://${requestContext.host}`; - const encodedMessage = encodeURIComponent(errorMessage); - return reply.redirect( - `${baseUrl}/login?error=${errorType}&provider=${request.params.provider}&message=${encodedMessage}` - ); + return this.handleCallbackError(request, reply, error); } } + + private handleCallbackError(request: FastifyRequest, reply: FastifyReply, error: unknown) { + const { type: errorType, message: errorMessage } = + error instanceof Error + ? this.determineCallbackError(error, request.params.provider) + : { type: "unknown_error", message: ERROR_MESSAGES.AUTHENTICATION_FAILED }; + + const requestContext = this.buildRequestContext(request); + const baseUrl = this.buildBaseUrl(requestContext); + const encodedMessage = encodeURIComponent(errorMessage); + + return reply.redirect( + `${baseUrl}/login?error=${errorType}&provider=${request.params.provider}&message=${encodedMessage}` + ); + } } diff --git a/apps/server/src/modules/auth-providers/dto.ts b/apps/server/src/modules/auth-providers/dto.ts index 1994206..25b669b 100644 --- a/apps/server/src/modules/auth-providers/dto.ts +++ b/apps/server/src/modules/auth-providers/dto.ts @@ -1,6 +1,5 @@ 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"), @@ -14,7 +13,6 @@ export const BaseAuthProviderSchema = z.object({ 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(), @@ -22,7 +20,6 @@ export const DiscoveryModeSchema = BaseAuthProviderSchema.extend({ userInfoEndpoint: z.literal("").optional(), }); -// Schema para modo manual (todos os endpoints) export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({ issuerUrl: z.string().optional(), authorizationEndpoint: z @@ -33,7 +30,6 @@ export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({ 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().optional(), @@ -48,8 +44,7 @@ export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({ data.userInfoEndpoint?.trim() ); - // Deve ter pelo menos issuerUrl OU todos os endpoints customizados - if (hasIssuerUrl && !hasAnyCustomEndpoint) return true; // Modo discovery + if (hasIssuerUrl && !hasAnyCustomEndpoint) return true; if (hasAnyCustomEndpoint) { const hasAllCustomEndpoints = !!( @@ -57,10 +52,10 @@ export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({ data.tokenEndpoint?.trim() && data.userInfoEndpoint?.trim() ); - return hasAllCustomEndpoints; // Precisa ter todos os 3 endpoints + return hasAllCustomEndpoints; } - return false; // Precisa ter pelo menos um dos dois modos + return false; }, { message: @@ -68,7 +63,6 @@ export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({ } ); -// Schema para atualização (todos os campos opcionais exceto validação de modo) export const UpdateAuthProviderSchema = z .object({ name: z.string().min(1).optional(), @@ -88,7 +82,6 @@ export const UpdateAuthProviderSchema = z }) .refine( (data) => { - // Se não está alterando nenhum campo de configuração, permite const hasIssuerUrl = !!data.issuerUrl; const hasAnyCustomEndpoint = !!( data.authorizationEndpoint?.trim() || @@ -96,13 +89,10 @@ export const UpdateAuthProviderSchema = z data.userInfoEndpoint?.trim() ); - // Se não está alterando nenhum campo de configuração, permite if (!hasIssuerUrl && !hasAnyCustomEndpoint) return true; - // Se está fornecendo apenas issuerUrl, permite (modo discovery) if (hasIssuerUrl && !hasAnyCustomEndpoint) return true; - // Se está fornecendo endpoints customizados, deve fornecer todos os 3 if (hasAnyCustomEndpoint) { const hasAllCustomEndpoints = !!( data.authorizationEndpoint?.trim() && @@ -119,7 +109,6 @@ export const UpdateAuthProviderSchema = z } ); -// 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(), @@ -129,7 +118,6 @@ export const UpdateOfficialProviderSchema = z.object({ adminEmailDomains: z.string().optional(), }); -// Schema para reordenação export const UpdateProvidersOrderSchema = z.object({ providers: z.array( z.object({ diff --git a/apps/server/src/modules/auth-providers/provider-manager.ts b/apps/server/src/modules/auth-providers/provider-manager.ts deleted file mode 100644 index 332b8a0..0000000 --- a/apps/server/src/modules/auth-providers/provider-manager.ts +++ /dev/null @@ -1,795 +0,0 @@ -import { providersConfig } from "./providers.config"; -import { ProviderConfig, ProvidersConfigFile, ProviderEndpoints, ProviderUserInfo } from "./types"; - -export class ProviderManager { - private config!: ProvidersConfigFile; - - constructor() { - this.loadConfiguration(); - } - - private loadConfiguration() { - try { - this.config = providersConfig; - } catch (error) { - console.error("Failed to load providers configuration:", error); - throw new Error("Failed to load providers configuration"); - } - } - - /** - * Determina se um provider é oficialmente suportado - */ - isOfficialProvider(providerName: string): boolean { - const normalizedName = this.normalizeProviderName(providerName); - return normalizedName in this.config.officialProviders; - } - - /** - * Obtém configuração para um provider de forma INTELIGENTE - */ - 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}`); - - if (!provider || !provider.name) { - console.error(`[ProviderManager] Invalid provider object:`, provider); - throw new Error("Invalid provider object: missing name"); - } - - // 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: 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; - } - - /** - * Obtém todos os providers oficiais disponíveis - */ - getOfficialProviders(): Record { - return this.config.officialProviders; - } - - /** - * Resolve endpoints para um provider com base na configuração - * 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}`); - console.log(`[ProviderManager] Provider issuerUrl: ${provider.issuerUrl}`); - console.log(`[ProviderManager] Provider custom endpoints:`, { - auth: provider.authorizationEndpoint, - token: provider.tokenEndpoint, - userInfo: provider.userInfoEndpoint, - }); - - // 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`); - - // 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 - let endpoints: ProviderEndpoints = { - authorizationEndpoint: "", - tokenEndpoint: "", - userInfoEndpoint: "", - }; - - if (config.supportsDiscovery && provider.issuerUrl) { - console.log(`[ProviderManager] Attempting discovery for ${provider.issuerUrl}`); - - // 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 simples - if (!endpoints.tokenEndpoint) { - console.log(`[ProviderManager] Building fallback endpoints`); - endpoints = this.buildIntelligentFallbackEndpoints(provider); - } - - console.log(`[ProviderManager] Final endpoints:`, endpoints); - return 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 { - const discoveryUrl = `${issuerUrl}${discoveryPath}`; - console.log(`[ProviderManager] Attempting discovery at: ${discoveryUrl}`); - - 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) { - 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.log(`[ProviderManager] Discovery error:`, error); - return null; - } - } - - /** - * Constrói endpoints usando fallbacks simples baseados no tipo - */ - private buildIntelligentFallbackEndpoints(provider: any): ProviderEndpoints { - const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || ""; - console.log(`[ProviderManager] Building fallback endpoints for baseUrl: ${baseUrl}`); - - // Detecta tipo do provider para usar padrão apropriado - const detectedType = this.detectProviderType(provider.issuerUrl || ""); - const fallbackPattern = this.getFallbackEndpoints(detectedType); - - return { - authorizationEndpoint: `${baseUrl}${fallbackPattern.authorizationEndpoint}`, - tokenEndpoint: `${baseUrl}${fallbackPattern.tokenEndpoint}`, - userInfoEndpoint: `${baseUrl}${fallbackPattern.userInfoEndpoint}`, - }; - } - - /** - * Extrai informações do usuário com base no mapeamento de campos - */ - extractUserInfo(rawUserInfo: any, config: ProviderConfig): ProviderUserInfo { - console.log(`[ProviderManager] Extracting user info for ${config.name}`); - console.log(`[ProviderManager] Raw user info:`, rawUserInfo); - - const userInfo: ProviderUserInfo = { - id: "", - email: "", - }; - - // Mapeia campos usando as configurações - const mappings = config.fieldMappings; - console.log(`[ProviderManager] Field mappings:`, mappings); - - userInfo.id = this.extractField(rawUserInfo, mappings.id) || ""; - userInfo.email = this.extractField(rawUserInfo, mappings.email) || ""; - userInfo.name = this.extractField(rawUserInfo, mappings.name); - userInfo.firstName = this.extractField(rawUserInfo, mappings.firstName); - userInfo.lastName = this.extractField(rawUserInfo, mappings.lastName); - userInfo.avatar = this.extractField(rawUserInfo, mappings.avatar); - - console.log(`[ProviderManager] Extracted user info:`, userInfo); - return userInfo; - } - - /** - * 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"]; - const lastName = obj["last_name"]; - if (firstName && lastName) { - return `${firstName} ${lastName}`; - } else if (firstName) { - return firstName; - } else if (lastName) { - return lastName; - } - } - - // 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; - } - - /** - * Obtém método de autenticação da configuração - */ - getAuthMethod(config: ProviderConfig): "body" | "basic" | "header" { - return config.authMethod || "body"; - } - - /** - * Determina se precisa buscar email em endpoint separado - */ - requiresEmailFetch(config: ProviderConfig): boolean { - return config.specialHandling?.emailFetchRequired || false; - } - - /** - * Obtém endpoint específico para buscar email - */ - getEmailEndpoint(config: ProviderConfig): string | null { - return config.specialHandling?.emailEndpoint || null; - } - - /** - * Normaliza nome do provider para busca - */ - private normalizeProviderName(name: string): string { - return name.toLowerCase().trim(); - } - - /** - * Obtém scopes para um provider de forma INTELIGENTE - */ - 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; - } - - // 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"], - kinde: ["openid", "profile", "email"], - zitadel: ["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]; - } - - // 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: "kinde.com", type: "kinde" }, - { pattern: "zitadel.com", type: "zitadel" }, - { 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 a38534a..bd7749a 100644 --- a/apps/server/src/modules/auth-providers/providers.config.ts +++ b/apps/server/src/modules/auth-providers/providers.config.ts @@ -1,5 +1,82 @@ import { ProviderConfig, ProvidersConfigFile } from "./types"; +export const PROVIDER_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: "authentik", type: "authentik" }, + { pattern: "keycloak", type: "keycloak" }, + { pattern: "auth0.com", type: "auth0" }, + { pattern: "okta.com", type: "okta" }, + { pattern: "kinde.com", type: "kinde" }, + { pattern: "zitadel.com", type: "zitadel" }, +] as const; + +export const DEFAULT_SCOPES_BY_TYPE: Record = { + frontegg: ["openid", "profile", "email"], + discord: ["identify", "email"], + github: ["read:user", "user:email"], + gitlab: ["read_user", "read_api"], + google: ["openid", "profile", "email"], + microsoft: ["openid", "profile", "email", "User.Read"], + authentik: ["openid", "profile", "email"], + keycloak: ["openid", "profile", "email"], + auth0: ["openid", "profile", "email"], + okta: ["openid", "profile", "email"], + kinde: ["openid", "profile", "email"], + zitadel: ["openid", "profile", "email"], +} as const; + +export const DISCOVERY_SUPPORTED_PROVIDERS = [ + "frontegg", + "oidc", + "authentik", + "keycloak", + "auth0", + "okta", + "google", + "microsoft", + "kinde", + "zitadel", +] as const; + +export const DISCOVERY_PATHS = [ + "/.well-known/openid_configuration", + "/.well-known/openid-configuration", + "/.well-known/oauth-authorization-server", +] as const; + +export const FALLBACK_ENDPOINTS: Record = { + frontegg: { + authorizationEndpoint: "/oauth/authorize", + tokenEndpoint: "/oauth/token", + userInfoEndpoint: "/api/oauth/userinfo", + }, + github: { + authorizationEndpoint: "/login/oauth/authorize", + tokenEndpoint: "/login/oauth/access_token", + userInfoEndpoint: "/user", + }, + gitlab: { + authorizationEndpoint: "/oauth/authorize", + tokenEndpoint: "/oauth/token", + userInfoEndpoint: "/api/v4/user", + }, + discord: { + authorizationEndpoint: "/oauth2/authorize", + tokenEndpoint: "/oauth2/token", + userInfoEndpoint: "/users/@me", + }, + oidc: { + authorizationEndpoint: "/oauth2/authorize", + tokenEndpoint: "/oauth2/token", + userInfoEndpoint: "/oauth2/userinfo", + }, +} as const; + /** * Configuração técnica oficial do Discord * OAuth2 com mapeamentos específicos do Discord @@ -216,3 +293,38 @@ export { fronteggConfig, genericProviderTemplate, }; + +export function detectProviderType(issuerUrl: string): string { + const url = issuerUrl.toLowerCase(); + + for (const { pattern, type } of PROVIDER_PATTERNS) { + if (url.includes(pattern)) { + return type; + } + } + + try { + return new URL(issuerUrl).hostname.replace("www.", ""); + } catch { + return "custom"; + } +} + +export function getProviderScopes(provider: any): string[] { + if (provider.scope) { + return provider.scope.split(" ").filter((s: string) => s.trim()); + } + + const detectedType = detectProviderType(provider.issuerUrl || ""); + return ( + DEFAULT_SCOPES_BY_TYPE[detectedType] || DEFAULT_SCOPES_BY_TYPE[provider.type] || ["openid", "profile", "email"] + ); +} + +export function shouldSupportDiscovery(providerType: string): boolean { + return DISCOVERY_SUPPORTED_PROVIDERS.includes(providerType as any); +} + +export function getFallbackEndpoints(providerType: string): any { + return FALLBACK_ENDPOINTS[providerType] || FALLBACK_ENDPOINTS.oidc; +} diff --git a/apps/server/src/modules/auth-providers/routes.ts b/apps/server/src/modules/auth-providers/routes.ts index bfc07e9..def7c98 100644 --- a/apps/server/src/modules/auth-providers/routes.ts +++ b/apps/server/src/modules/auth-providers/routes.ts @@ -7,20 +7,16 @@ import { z } from "zod"; export async function authProvidersRoutes(fastify: FastifyInstance) { const authProvidersController = new AuthProvidersController(); - // Admin-only middleware const adminPreValidation = async (request: any, reply: any) => { try { const usersCount = await prisma.user.count(); - // Se há apenas 1 usuário ou menos, permite acesso (setup inicial) if (usersCount <= 1) { return; } - // Verifica JWT await request.jwtVerify(); - // Verifica se é admin if (!request.user.isAdmin) { return reply.status(403).send({ success: false, @@ -36,7 +32,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { } }; - // Get enabled providers for login page fastify.get( "/providers", { @@ -69,7 +64,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.getProviders.bind(authProvidersController) ); - // Get all providers (admin only) fastify.get( "/providers/all", { @@ -102,7 +96,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.getAllProviders.bind(authProvidersController) ); - // Create new provider (admin only) fastify.post( "/providers", { @@ -141,7 +134,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.createProvider.bind(authProvidersController) ); - // Update providers order (admin only) - MUST be before /providers/:id route fastify.put( "/providers/order", { @@ -179,7 +171,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.updateProvidersOrder.bind(authProvidersController) ); - // Update provider configuration (admin only) fastify.put( "/providers/:id", { @@ -193,7 +184,7 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { params: z.object({ id: z.string(), }), - body: z.any(), // Validação manual no controller para providers oficiais + body: z.any(), response: { 200: z.object({ success: z.boolean(), @@ -221,7 +212,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.updateProvider.bind(authProvidersController) ); - // Delete provider (admin only) fastify.delete( "/providers/:id", { @@ -257,7 +247,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.deleteProvider.bind(authProvidersController) ); - // Initiate authentication with specific provider fastify.get( "/providers/:provider/authorize", { @@ -289,7 +278,6 @@ export async function authProvidersRoutes(fastify: FastifyInstance) { authProvidersController.authorize.bind(authProvidersController) ); - // Handle callback from provider fastify.get( "/providers/:provider/callback", { diff --git a/apps/server/src/modules/auth-providers/service.ts b/apps/server/src/modules/auth-providers/service.ts index 68b5b74..3046255 100644 --- a/apps/server/src/modules/auth-providers/service.ts +++ b/apps/server/src/modules/auth-providers/service.ts @@ -1,20 +1,305 @@ import { prisma } from "../../shared/prisma"; -import { ProviderManager } from "./provider-manager"; -import { ProviderConfig, ProviderUserInfo, TokenResponse } from "./types"; +import { + providersConfig, + detectProviderType, + getProviderScopes, + shouldSupportDiscovery, + getFallbackEndpoints, + DISCOVERY_PATHS, +} from "./providers.config"; +import { + ProviderConfig, + ProviderUserInfo, + TokenResponse, + ProviderEndpoints, + RequestContextService, + PendingState, +} from "./types"; import crypto from "crypto"; -interface PendingState { - codeVerifier: string; - redirectUrl: string; - expiresAt: number; - providerId: string; -} +// Constants +const DEFAULT_BASE_URL = "http://localhost:3000"; +const STATE_EXPIRY_TIME = 600000; // 10 minutes +const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes +const DEFAULT_PROVIDER_TYPE = "oidc"; + +const ERROR_MESSAGES = { + PROVIDER_NOT_FOUND: "Provider not found or disabled", + CONFIG_NOT_FOUND: "Configuration not found for provider", + INVALID_STATE: "Invalid or expired state", + CLIENT_ID_MISSING: "Client ID not configured for provider", + TOKEN_EXCHANGE_FAILED: "Token exchange failed", + NO_ACCESS_TOKEN: "No access token received", + USERINFO_FAILED: "UserInfo request failed", + NO_EMAIL_FOUND: "No email address found in account", + MISSING_USER_INFO: "Missing required user information (email or external ID)", +} as const; export class AuthProvidersService { - private providerManager = new ProviderManager(); private pendingStates = new Map(); - async getEnabledProviders(requestContext?: { protocol: string; host: string }) { + constructor() { + setInterval(() => this.cleanupExpiredStates(), CLEANUP_INTERVAL); + } + + // Utility methods + private buildBaseUrl(requestContext?: RequestContextService): string { + return requestContext ? `${requestContext.protocol}://${requestContext.host}` : DEFAULT_BASE_URL; + } + + private generateState(): string { + return crypto.randomBytes(32).toString("hex"); + } + + private generateCodeVerifier(): string { + return crypto.randomBytes(32).toString("base64url"); + } + + private generateCodeChallenge(codeVerifier: string): string { + return crypto.createHash("sha256").update(codeVerifier).digest("base64url"); + } + + private createPendingState(providerId: string, codeVerifier: string, redirectUrl: string): PendingState { + return { + codeVerifier, + redirectUrl, + expiresAt: Date.now() + STATE_EXPIRY_TIME, + providerId, + }; + } + + private validateProvider(provider: any, providerName: string): void { + if (!provider || !provider.enabled) { + throw new Error(`${ERROR_MESSAGES.PROVIDER_NOT_FOUND}: ${providerName}`); + } + } + + private validateConfig(config: ProviderConfig | null, providerName: string): void { + if (!config) { + throw new Error(`${ERROR_MESSAGES.CONFIG_NOT_FOUND}: ${providerName}`); + } + } + + private validateClientId(provider: any, providerName: string): void { + if (!provider.clientId) { + throw new Error(`${ERROR_MESSAGES.CLIENT_ID_MISSING}: ${providerName}`); + } + } + + // Provider configuration methods + private isOfficial(providerName: string): boolean { + return providerName in providersConfig.officialProviders; + } + + private getProviderConfig(provider: any): ProviderConfig { + const officialConfig = providersConfig.officialProviders[provider.name]; + if (officialConfig) { + return officialConfig; + } + + const detectedType = detectProviderType(provider.issuerUrl || ""); + const providerType = provider.type || detectedType; + + return { + ...providersConfig.genericProviderTemplate, + name: provider.name, + supportsDiscovery: shouldSupportDiscovery(providerType), + discoveryEndpoint: "/.well-known/openid_configuration", + fallbackEndpoints: getFallbackEndpoints(providerType), + authMethod: "body", + fieldMappings: providersConfig.genericProviderTemplate.fieldMappings, + specialHandling: providersConfig.genericProviderTemplate.specialHandling, + }; + } + + private async resolveEndpoints(provider: any, config: ProviderConfig): Promise { + // Use custom endpoints if all are provided + if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.userInfoEndpoint) { + return { + authorizationEndpoint: this.resolveEndpointUrl(provider.authorizationEndpoint, provider.issuerUrl), + tokenEndpoint: this.resolveEndpointUrl(provider.tokenEndpoint, provider.issuerUrl), + userInfoEndpoint: this.resolveEndpointUrl(provider.userInfoEndpoint, provider.issuerUrl), + }; + } + + // Try discovery if supported + if (config.supportsDiscovery && provider.issuerUrl) { + const discoveredEndpoints = await this.attemptDiscovery(provider.issuerUrl); + if (discoveredEndpoints) { + return discoveredEndpoints; + } + } + + // Fallback to intelligent endpoints + const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || ""; + const detectedType = detectProviderType(provider.issuerUrl || ""); + const fallbackPattern = getFallbackEndpoints(detectedType); + + return { + authorizationEndpoint: `${baseUrl}${fallbackPattern.authorizationEndpoint}`, + tokenEndpoint: `${baseUrl}${fallbackPattern.tokenEndpoint}`, + userInfoEndpoint: `${baseUrl}${fallbackPattern.userInfoEndpoint}`, + }; + } + + private resolveEndpointUrl(endpoint: string, issuerUrl?: string): string { + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + + if (!issuerUrl) { + return endpoint; + } + + const baseUrl = issuerUrl.replace(/\/$/, ""); + const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + return `${baseUrl}${path}`; + } + + private async attemptDiscovery(issuerUrl: string): Promise { + for (const discoveryPath of DISCOVERY_PATHS) { + try { + const discoveryUrl = `${issuerUrl}${discoveryPath}`; + const response = await fetch(discoveryUrl, { + method: "GET", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) continue; + + const discoveryData = (await response.json()) as any; + const endpoints = { + authorizationEndpoint: discoveryData.authorization_endpoint || "", + tokenEndpoint: discoveryData.token_endpoint || "", + userInfoEndpoint: discoveryData.userinfo_endpoint || "", + }; + + if (endpoints.authorizationEndpoint && endpoints.tokenEndpoint) { + return endpoints; + } + } catch { + continue; + } + } + return null; + } + + private getAuthMethod(config: ProviderConfig): "body" | "basic" | "header" { + return config.authMethod || "body"; + } + + private extractUserInfo(rawUserInfo: any, config: ProviderConfig): ProviderUserInfo { + const userInfo: ProviderUserInfo = { id: "", email: "" }; + const mappings = config.fieldMappings; + + userInfo.id = this.extractField(rawUserInfo, mappings.id) || ""; + userInfo.email = this.extractField(rawUserInfo, mappings.email) || ""; + userInfo.name = this.extractField(rawUserInfo, mappings.name); + userInfo.firstName = this.extractField(rawUserInfo, mappings.firstName); + userInfo.lastName = this.extractField(rawUserInfo, mappings.lastName); + userInfo.avatar = this.extractField(rawUserInfo, mappings.avatar); + + return userInfo; + } + + private extractField(obj: any, fieldNames: string[]): string | undefined { + if (!obj || !fieldNames.length) return undefined; + + for (const fieldName of fieldNames) { + if (obj[fieldName] !== undefined && obj[fieldName] !== null) { + return String(obj[fieldName]); + } + } + + return undefined; + } + + private requiresEmailFetch(config: ProviderConfig): boolean { + return config.specialHandling?.emailFetchRequired || false; + } + + private getEmailEndpoint(config: ProviderConfig): string | null { + return config.specialHandling?.emailEndpoint || null; + } + + // PKCE and OAuth setup methods + private setupPkceIfNeeded(provider: any): { codeVerifier?: string; codeChallenge?: string } { + const needsPkce = provider.type === DEFAULT_PROVIDER_TYPE; + + if (needsPkce) { + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = this.generateCodeChallenge(codeVerifier); + return { codeVerifier, codeChallenge }; + } + + return {}; + } + + private async buildAuthorizationUrl( + provider: any, + endpoints: any, + callbackUrl: string, + state: string, + codeChallenge?: string, + providerName?: string + ): Promise { + this.validateClientId(provider, providerName || provider.name); + + const authUrl = new URL(endpoints.authorizationEndpoint); + const scopes = getProviderScopes(provider); + authUrl.searchParams.set("client_id", provider.clientId); + authUrl.searchParams.set("redirect_uri", callbackUrl); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", scopes.join(" ")); + authUrl.searchParams.set("state", state); + + if (codeChallenge) { + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + } + + return authUrl.toString(); + } + + // Callback handling methods + private validateAndGetPendingState(state: string): PendingState { + const pendingState = this.pendingStates.get(state); + + if (!pendingState) { + throw new Error(ERROR_MESSAGES.INVALID_STATE); + } + + return pendingState; + } + + private async executeAuthenticationFlow( + provider: any, + config: ProviderConfig, + code: string, + pendingState: PendingState, + requestContext?: RequestContextService + ) { + const authResult = await this.performTokenExchange( + provider, + config, + code, + pendingState.codeVerifier, + requestContext + ); + + const userInfo = await this.processUserInfo(authResult.userInfo, authResult.tokens, config); + const user = await this.findOrCreateUser(userInfo, provider); + + return { + user, + isNewUser: false, + redirectUrl: pendingState.redirectUrl, + }; + } + + // Public methods + async getEnabledProviders(requestContext?: RequestContextService) { const providers = await prisma.authProvider.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, @@ -40,7 +325,7 @@ export class AuthProvidersService { type: provider.type, icon: provider.icon || "generic", authUrl, - isOfficial: this.providerManager.isOfficialProvider(provider.name), + isOfficial: this.isOfficial(provider.name), sortOrder: provider.sortOrder, }; }); @@ -53,7 +338,7 @@ export class AuthProvidersService { return providers.map((provider) => ({ ...provider, - isOfficial: this.providerManager.isOfficialProvider(provider.name), + isOfficial: this.isOfficial(provider.name), })); } @@ -70,17 +355,14 @@ export class AuthProvidersService { } isOfficialProvider(providerName: string): boolean { - return this.providerManager.isOfficialProvider(providerName); + return this.isOfficial(providerName); } async createProvider(data: any) { - // A configuração é usada apenas internamente para autenticação - // Não sobrescreve dados do usuário como nome, ícone, etc. return await prisma.authProvider.create({ data: { ...data, - // Se o usuário não especificar tipo, usa OIDC como padrão - type: data.type || "oidc", + type: data.type || DEFAULT_PROVIDER_TYPE, displayName: data.displayName || data.name, }, }); @@ -99,170 +381,64 @@ export class AuthProvidersService { }); } - private generateAuthUrl(provider: any, requestContext?: { protocol: string; host: string }) { - const baseUrl = requestContext ? `${requestContext.protocol}://${requestContext.host}` : "http://localhost:3000"; - + private generateAuthUrl(provider: any, requestContext?: RequestContextService) { + const baseUrl = this.buildBaseUrl(requestContext); return `${baseUrl}/api/auth/providers/${provider.name}/authorize`; } - async getAuthorizationUrl(providerName: string, state?: string, redirectUri?: string, requestContext?: any) { - console.log(`[AuthProvidersService] Getting authorization URL for provider: ${providerName}`); - + async getAuthorizationUrl( + providerName: string, + state?: string, + redirectUri?: string, + requestContext?: RequestContextService + ) { const provider = await this.getProviderByName(providerName); - if (!provider || !provider.enabled) { - throw new Error(`Provider ${providerName} not found or disabled`); - } + this.validateProvider(provider, providerName); + const validatedProvider = provider!; - console.log(`[AuthProvidersService] Provider found:`, { - name: provider.name, - issuerUrl: provider.issuerUrl, - enabled: provider.enabled, - }); - - const config = this.providerManager.getProviderConfig(provider); - if (!config) { - throw new Error(`Configuration not found for provider ${providerName}`); - } - - console.log(`[AuthProvidersService] Config found:`, { - name: config.name, - supportsDiscovery: config.supportsDiscovery, - }); - - const finalState = state || crypto.randomBytes(32).toString("hex"); - const baseUrl = requestContext ? `${requestContext.protocol}://${requestContext.host}` : "http://localhost:3000"; + const config = this.getProviderConfig(validatedProvider); + this.validateConfig(config, providerName); + const validatedConfig = config!; + const finalState = state || this.generateState(); + const baseUrl = this.buildBaseUrl(requestContext); const callbackUrl = redirectUri || `${baseUrl}/api/auth/providers/${providerName}/callback`; - // Determina se precisa de PKCE - const needsPkce = provider.type === "oidc"; - let codeVerifier: string | undefined; - let codeChallenge: string | undefined; + const { codeVerifier, codeChallenge } = this.setupPkceIfNeeded(validatedProvider); - if (needsPkce) { - codeVerifier = crypto.randomBytes(32).toString("base64url"); - codeChallenge = this.generateCodeChallenge(codeVerifier); - } + const pendingState = this.createPendingState( + validatedProvider.id, + codeVerifier || "", + redirectUri || `${baseUrl}/dashboard` + ); + this.pendingStates.set(finalState, pendingState); - // Salva estado - this.pendingStates.set(finalState, { - codeVerifier: codeVerifier || "", - redirectUrl: redirectUri || `${baseUrl}/dashboard`, - expiresAt: Date.now() + 600000, // 10 minutes - providerId: provider.id, - }); + const endpoints = await this.resolveEndpoints(validatedProvider, validatedConfig); - // Resolve endpoints - const endpoints = await this.providerManager.resolveEndpoints(provider, config); - - console.log(`[AuthProvidersService] Resolved endpoints:`, endpoints); - - // Constrói URL de autorização - const authUrl = new URL(endpoints.authorizationEndpoint); - - // Usa scopes do usuário, ou fallback baseado no tipo do provider - let scopes: string[]; - if (provider.scope) { - scopes = provider.scope.split(" ").filter((s: string) => s.trim()); - } else { - scopes = this.providerManager.getScopes(provider); - } - - console.log(`[AuthProvidersService] Using scopes:`, scopes); - - if (!provider.clientId) { - throw new Error(`Client ID not configured for provider ${providerName}`); - } - - authUrl.searchParams.set("client_id", provider.clientId); - authUrl.searchParams.set("redirect_uri", callbackUrl); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", scopes.join(" ")); - authUrl.searchParams.set("state", finalState); - - if (codeChallenge) { - authUrl.searchParams.set("code_challenge", codeChallenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - } - - const finalAuthUrl = authUrl.toString(); - console.log(`[AuthProvidersService] Final authorization URL: ${finalAuthUrl}`); + const finalAuthUrl = await this.buildAuthorizationUrl( + validatedProvider, + endpoints, + callbackUrl, + finalState, + codeChallenge, + providerName + ); return finalAuthUrl; } - async handleCallback(providerName: string, code: string, state: string, requestContext?: any) { - console.log(`[AuthProvidersService] Handling callback for provider: ${providerName}`); - console.log(`[AuthProvidersService] State received: ${state}`); - - const pendingState = this.pendingStates.get(state); - - if (!pendingState) { - console.error(`[AuthProvidersService] No valid pending state found for ${providerName}`); - throw new Error("Invalid or expired state"); - } - - console.log(`[AuthProvidersService] Using pending state for ${providerName}`); + async handleCallback(providerName: string, code: string, state: string, requestContext?: RequestContextService) { + const pendingState = this.validateAndGetPendingState(state); const provider = await this.getProviderByName(providerName); - if (!provider) { - throw new Error(`Provider ${providerName} not found`); - } + this.validateProvider(provider, providerName); + const validatedProvider = provider!; - console.log(`[AuthProvidersService] Provider found in callback:`, { - name: provider.name, - issuerUrl: provider.issuerUrl, - }); + const config = this.getProviderConfig(validatedProvider); + this.validateConfig(config, providerName); + const validatedConfig = config!; - const config = this.providerManager.getProviderConfig(provider); - if (!config) { - throw new Error(`Configuration not found for provider ${providerName}`); - } - - console.log(`[AuthProvidersService] Config found in callback:`, { - name: config.name, - }); - - try { - // Executa token exchange - console.log(`[AuthProvidersService] Starting token exchange for ${providerName}`); - const authResult = await this.performTokenExchange( - provider, - config, - code, - pendingState.codeVerifier, - requestContext - ); - - console.log(`[AuthProvidersService] Token exchange successful for ${providerName}`); - - // Processa user info - console.log(`[AuthProvidersService] Processing user info for ${providerName}`); - const userInfo = await this.processUserInfo(authResult.userInfo, authResult.tokens, config); - - console.log(`[AuthProvidersService] User info processed:`, { - id: userInfo.id, - email: userInfo.email, - name: userInfo.name, - }); - - // Encontra ou cria usuário - const user = await this.findOrCreateUser(userInfo, provider); - - console.log(`[AuthProvidersService] User found/created:`, { - id: user.id, - email: user.email, - }); - - return { - user, - isNewUser: false, - redirectUrl: pendingState.redirectUrl, - }; - } catch (error) { - console.error(`[AuthProvidersService] Error in ${providerName} callback:`, error); - throw error; - } + return await this.executeAuthenticationFlow(validatedProvider, validatedConfig, code, pendingState, requestContext); } private async performTokenExchange( @@ -270,134 +446,16 @@ export class AuthProvidersService { config: ProviderConfig, code: string, codeVerifier: string, - requestContext?: any + requestContext?: RequestContextService ) { - console.log(`[AuthProvidersService] Starting token exchange for ${provider.name}`); - console.log(`[AuthProvidersService] Config:`, { - name: config.name, - authMethod: config.authMethod, - }); - - const endpoints = await this.providerManager.resolveEndpoints(provider, config); - const authMethod = this.providerManager.getAuthMethod(config); - - console.log(`[AuthProvidersService] Resolved endpoints:`, endpoints); - console.log(`[AuthProvidersService] Auth method:`, authMethod); - - const baseUrl = requestContext ? `${requestContext.protocol}://${requestContext.host}` : "http://localhost:3000"; + const endpoints = await this.resolveEndpoints(provider, config); + const authMethod = this.getAuthMethod(config); + const baseUrl = this.buildBaseUrl(requestContext); const callbackUrl = provider.redirectUri || `${baseUrl}/api/auth/providers/${provider.name}/callback`; - // Constrói body para token exchange - const body = new URLSearchParams(); - body.append("client_id", provider.clientId); - body.append("code", code); - body.append("redirect_uri", callbackUrl); - body.append("grant_type", "authorization_code"); - - // Adiciona client_secret se necessário - if (authMethod === "body" && provider.clientSecret) { - body.append("client_secret", provider.clientSecret); - } - - // Adiciona code_verifier se disponível (PKCE) - if (codeVerifier) { - body.append("code_verifier", codeVerifier); - } - - // Prepara headers - const headers: Record = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }; - - // Configura autenticação basic se necessário - if (authMethod === "basic" && provider.clientSecret) { - const auth = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString("base64"); - headers["Authorization"] = `Basic ${auth}`; - } - - console.log(`[AuthProvidersService] Token exchange request:`, { - url: endpoints.tokenEndpoint, - method: "POST", - headers: Object.keys(headers), - bodyParams: Array.from(body.entries()).map(([key]) => key), - }); - - // Executa token exchange - const tokenResponse = await fetch(endpoints.tokenEndpoint, { - method: "POST", - headers, - body, - }); - - console.log(`[AuthProvidersService] Token response status:`, tokenResponse.status); - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - console.error(`[AuthProvidersService] Token exchange failed:`, { - status: tokenResponse.status, - statusText: tokenResponse.statusText, - error: errorText, - }); - - throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`); - } - - const tokens = (await tokenResponse.json()) as TokenResponse; - - console.log(`[AuthProvidersService] Token exchange successful:`, { - hasAccessToken: !!tokens.access_token, - hasIdToken: !!tokens.id_token, - tokenType: tokens.token_type, - }); - - if (!tokens.access_token) { - throw new Error("No access token received"); - } - - // Busca user info - console.log(`[AuthProvidersService] Fetching user info from:`, endpoints.userInfoEndpoint); - - const userInfoResponse = await fetch(endpoints.userInfoEndpoint, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - }, - }); - - console.log(`[AuthProvidersService] User info response status:`, userInfoResponse.status); - - if (!userInfoResponse.ok) { - const errorText = await userInfoResponse.text(); - console.error(`[AuthProvidersService] UserInfo request failed:`, { - status: userInfoResponse.status, - statusText: userInfoResponse.statusText, - url: endpoints.userInfoEndpoint, - error: errorText.substring(0, 500), // Limita o tamanho do log - }); - 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}`); - } - - console.log(`[AuthProvidersService] User info received:`, { - hasId: !!(rawUserInfo as any).sub, - hasEmail: !!(rawUserInfo as any).email, - hasName: !!(rawUserInfo as any).name, - }); - console.log(`[AuthProvidersService] Raw user info from Kinde:`, rawUserInfo); + const tokens = await this.executeTokenRequest(provider, code, callbackUrl, codeVerifier, authMethod, endpoints); + const rawUserInfo = await this.fetchUserInfo(tokens, endpoints); return { userInfo: rawUserInfo, @@ -405,23 +463,83 @@ export class AuthProvidersService { }; } + private async executeTokenRequest( + provider: any, + code: string, + callbackUrl: string, + codeVerifier: string, + authMethod: string, + endpoints: any + ): Promise { + const body = new URLSearchParams(); + body.append("client_id", provider.clientId); + body.append("code", code); + body.append("redirect_uri", callbackUrl); + body.append("grant_type", "authorization_code"); + + if (authMethod === "body" && provider.clientSecret) { + body.append("client_secret", provider.clientSecret); + } + + if (codeVerifier) { + body.append("code_verifier", codeVerifier); + } + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + + if (authMethod === "basic" && provider.clientSecret) { + const auth = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString("base64"); + headers["Authorization"] = `Basic ${auth}`; + } + + const tokenResponse = await fetch(endpoints.tokenEndpoint, { + method: "POST", + headers, + body, + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + throw new Error(`${ERROR_MESSAGES.TOKEN_EXCHANGE_FAILED}: ${tokenResponse.status} - ${errorText}`); + } + + const tokens = (await tokenResponse.json()) as TokenResponse; + + if (!tokens.access_token) { + throw new Error(ERROR_MESSAGES.NO_ACCESS_TOKEN); + } + + return tokens; + } + + private async fetchUserInfo(tokens: TokenResponse, endpoints: any): Promise { + const userInfoResponse = await fetch(endpoints.userInfoEndpoint, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }, + }); + + if (!userInfoResponse.ok) { + const errorText = await userInfoResponse.text(); + throw new Error(`${ERROR_MESSAGES.USERINFO_FAILED}: ${userInfoResponse.status} - ${errorText}`); + } + + return await userInfoResponse.json(); + } + private async processUserInfo( rawUserInfo: any, tokens: TokenResponse, config: ProviderConfig ): Promise { - console.log(`[AuthProvidersService] Processing user info for ${config.name}`); - console.log(`[AuthProvidersService] Raw user info:`, rawUserInfo); - console.log(`[AuthProvidersService] Config field mappings:`, config.fieldMappings); + const userInfo = this.extractUserInfo(rawUserInfo, config); - // Extrai informações usando mapeamento da configuração - const userInfo = this.providerManager.extractUserInfo(rawUserInfo, config); - - console.log(`[AuthProvidersService] Extracted user info:`, userInfo); - - // Verifica se precisa buscar email separadamente - if (!userInfo.email && this.providerManager.requiresEmailFetch(config)) { - const emailEndpoint = this.providerManager.getEmailEndpoint(config); + if (!userInfo.email && this.requiresEmailFetch(config)) { + const emailEndpoint = this.getEmailEndpoint(config); if (emailEndpoint) { const email = await this.fetchEmailFromEndpoint(emailEndpoint, tokens.access_token); if (email) { @@ -431,7 +549,7 @@ export class AuthProvidersService { } if (!userInfo.email) { - throw new Error(`No email address found in ${config.name} account`); + throw new Error(`${ERROR_MESSAGES.NO_EMAIL_FOUND} in ${config.name} account`); } return userInfo; @@ -449,7 +567,6 @@ export class AuthProvidersService { if (response.ok) { const data: any = await response.json(); - // Para GitHub (array de emails) if (Array.isArray(data)) { const primaryEmail = data.find((e: any) => e.primary && e.verified); if (primaryEmail) return primaryEmail.email; @@ -457,14 +574,13 @@ export class AuthProvidersService { const verifiedEmail = data.find((e: any) => e.verified); if (verifiedEmail) return verifiedEmail.email; - if (data.length > 0) return data[0].email; + if (data.length > 0 && data[0].email) return data[0].email; } - // Para outros providers if (data.email) return data.email; } } catch (error) { - console.warn("Failed to fetch email:", error); + console.error("Error fetching email from endpoint:", error); } return null; @@ -474,105 +590,103 @@ export class AuthProvidersService { const externalId = userInfo.id; if (!userInfo.email || !externalId) { - throw new Error("Missing required user information (email or external ID)"); + throw new Error(ERROR_MESSAGES.MISSING_USER_INFO); } - // Verifica se usuário já existe com este provider - const existingAuthProvider = await prisma.userAuthProvider.findUnique({ + const existingAuthProvider = await this.findExistingAuthProvider(provider.id, String(externalId)); + if (existingAuthProvider) { + return await this.updateExistingUserFromProvider(existingAuthProvider.user, userInfo); + } + + const existingUser = await this.findExistingUserByEmail(userInfo.email); + if (existingUser) { + return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo); + } + + return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId)); + } + + private async findExistingAuthProvider(providerId: string, externalId: string) { + return await prisma.userAuthProvider.findUnique({ where: { providerId_externalId: { - providerId: provider.id, - externalId: String(externalId), + providerId, + externalId, }, }, include: { user: true, }, }); + } - if (existingAuthProvider) { - // Atualiza informações do usuário apenas se estiverem vazias - const updateData: any = {}; + private async findExistingUserByEmail(email: string) { + return await prisma.user.findUnique({ + where: { email }, + }); + } - // Só atualiza firstName se estiver vazio - if (!existingAuthProvider.user.firstName && userInfo.firstName) { - updateData.firstName = userInfo.firstName; - } + private buildUserUpdateData(existingUser: any, userInfo: ProviderUserInfo): any { + const updateData: any = {}; - // 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; + if (!existingUser.firstName && userInfo.firstName) { + updateData.firstName = userInfo.firstName; } - // Verifica se usuário existe por email - const existingUser = await prisma.user.findUnique({ - where: { email: userInfo.email }, + if (!existingUser.lastName && userInfo.lastName) { + updateData.lastName = userInfo.lastName; + } + + if (!existingUser.image && userInfo.avatar) { + updateData.image = userInfo.avatar; + } + + return updateData; + } + + private async updateExistingUserFromProvider(existingUser: any, userInfo: ProviderUserInfo) { + const updateData = this.buildUserUpdateData(existingUser, userInfo); + + if (Object.keys(updateData).length > 0) { + return await prisma.user.update({ + where: { id: existingUser.id }, + data: updateData, + }); + } + + return existingUser; + } + + private async linkProviderToExistingUser( + existingUser: any, + providerId: string, + externalId: string, + userInfo: ProviderUserInfo + ) { + await prisma.userAuthProvider.create({ + data: { + userId: existingUser.id, + providerId, + externalId, + }, }); - if (existingUser) { - // Associa provider ao usuário existente - await prisma.userAuthProvider.create({ - data: { - userId: existingUser.id, - providerId: provider.id, - externalId: String(externalId), - }, - }); + return await this.updateExistingUserFromProvider(existingUser, userInfo); + } - // Atualiza informações do usuário apenas se estiverem vazias - const updateData: any = {}; - - // 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 + private generateUserNames(userInfo: ProviderUserInfo) { const displayName = userInfo.name || userInfo.email.split("@")[0]; const firstName = userInfo.firstName || displayName.split(" ")[0] || userInfo.email.split("@")[0]; const lastName = userInfo.lastName || (displayName.split(" ").length > 1 ? displayName.split(" ").slice(1).join(" ") : ""); - const newUser = await prisma.user.create({ + return { firstName, lastName }; + } + + private async createNewUserWithProvider(userInfo: ProviderUserInfo, providerId: string, externalId: string) { + const { firstName, lastName } = this.generateUserNames(userInfo); + + return await prisma.user.create({ data: { email: userInfo.email, username: userInfo.email.split("@")[0], @@ -582,18 +696,12 @@ export class AuthProvidersService { isAdmin: false, authProviders: { create: { - providerId: provider.id, - externalId: String(externalId), + providerId, + externalId, }, }, }, }); - - return newUser; - } - - private generateCodeChallenge(codeVerifier: string): string { - return crypto.createHash("sha256").update(codeVerifier).digest("base64url"); } private cleanupExpiredStates() { @@ -605,13 +713,7 @@ export class AuthProvidersService { } } - constructor() { - // Limpa estados expirados a cada 5 minutos - setInterval(() => this.cleanupExpiredStates(), 5 * 60 * 1000); - } - async updateProvidersOrder(providersOrder: { id: string; sortOrder: number }[]) { - // Update all providers in a transaction const updatePromises = providersOrder.map((provider) => prisma.authProvider.update({ where: { id: provider.id }, diff --git a/apps/server/src/modules/auth-providers/types.ts b/apps/server/src/modules/auth-providers/types.ts index fb043f4..ff005fc 100644 --- a/apps/server/src/modules/auth-providers/types.ts +++ b/apps/server/src/modules/auth-providers/types.ts @@ -61,3 +61,61 @@ export interface AuthResult { userInfo: ProviderUserInfo; tokens: TokenResponse; } + +export interface RequestContext { + protocol: string; + host: string; + headers: any; +} + +export interface CreateProviderRequest { + Body: { + authorizationEndpoint?: string; + tokenEndpoint?: string; + userInfoEndpoint?: string; + issuerUrl?: string; + [key: string]: any; + }; +} + +export interface UpdateProviderRequest { + Params: { id: string }; + Body: any; +} + +export interface UpdateProvidersOrderRequest { + Body: { providers: { id: string; sortOrder: number }[] }; +} + +export interface DeleteProviderRequest { + Params: { id: string }; +} + +export interface AuthorizeRequest { + Params: { provider: string }; + Querystring: { + state?: string; + redirect_uri?: string; + }; +} + +export interface CallbackRequest { + Params: { provider: string }; + Querystring: { + code?: string; + state?: string; + error?: string; + }; +} + +export interface PendingState { + codeVerifier: string; + redirectUrl: string; + expiresAt: number; + providerId: string; +} + +export interface RequestContextService { + protocol: string; + host: string; +} diff --git a/apps/web/src/app/api/(proxy)/auth/providers/[provider]/authorize/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/[provider]/authorize/route.ts index 8b5b524..e3cddc8 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/[provider]/authorize/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/[provider]/authorize/route.ts @@ -8,7 +8,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const url = new URL(request.url); const queryString = url.search; - // Forward the original host and protocol to backend const originalHost = request.headers.get("host") || url.host; const originalProtocol = request.headers.get("x-forwarded-proto") || url.protocol.replace(":", ""); @@ -18,17 +17,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ "Content-Type": "application/json", "x-forwarded-host": originalHost, "x-forwarded-proto": originalProtocol, - // Forward any authorization headers if needed ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => key.startsWith("authorization") || key.startsWith("cookie") ) ), }, - redirect: "manual", // Don't follow redirects automatically + redirect: "manual", }); - // If it's a redirect (OAuth flow), forward the redirect if (apiRes.status >= 300 && apiRes.status < 400) { const location = apiRes.headers.get("location"); if (location) { diff --git a/apps/web/src/app/api/(proxy)/auth/providers/[provider]/callback/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/[provider]/callback/route.ts index 3e1871f..e62be50 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/[provider]/callback/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/[provider]/callback/route.ts @@ -8,7 +8,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const url = new URL(request.url); const queryString = url.search; - // Forward the original host and protocol to backend const originalHost = request.headers.get("host") || url.host; const originalProtocol = request.headers.get("x-forwarded-proto") || url.protocol.replace(":", ""); @@ -18,31 +17,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ "Content-Type": "application/json", "x-forwarded-host": originalHost, "x-forwarded-proto": originalProtocol, - // Forward any authorization headers if needed ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => key.startsWith("authorization") || key.startsWith("cookie") ) ), }, - redirect: "manual", // Don't follow redirects automatically + redirect: "manual", }); - // Handle redirects from the backend if (apiRes.status >= 300 && apiRes.status < 400) { const location = apiRes.headers.get("location"); if (location) { - // Create redirect response and forward any set-cookie headers const response = NextResponse.redirect(location); - // Copy all set-cookie headers from backend response const setCookieHeaders = apiRes.headers.getSetCookie?.() || []; if (setCookieHeaders.length > 0) { setCookieHeaders.forEach((cookie) => { response.headers.append("set-cookie", cookie); }); } else { - // Fallback for older implementations const singleCookie = apiRes.headers.get("set-cookie"); if (singleCookie) { response.headers.set("set-cookie", singleCookie); @@ -53,12 +47,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } - // Try to parse JSON response let data; try { data = await apiRes.json(); } catch { - // If not JSON, it might be a redirect or other response return new NextResponse(null, { status: apiRes.status, headers: Object.fromEntries(apiRes.headers.entries()), diff --git a/apps/web/src/app/api/(proxy)/auth/providers/all/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/all/route.ts index f2aa784..35739e8 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/all/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/all/route.ts @@ -8,7 +8,6 @@ export async function GET(request: NextRequest) { method: "GET", headers: { "Content-Type": "application/json", - // Forward any authorization headers ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => key.startsWith("authorization") || key.startsWith("cookie") diff --git a/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/route.ts index 8816257..e19c54e 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/manage/[id]/route.ts @@ -11,7 +11,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ method: "PUT", headers: { "Content-Type": "application/json", - // Forward any authorization headers ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => key.startsWith("authorization") || key.startsWith("cookie") @@ -41,7 +40,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise const apiRes = await fetch(`${API_BASE_URL}/auth/providers/${id}`, { method: "DELETE", headers: { - // Forward any authorization headers (but don't include Content-Type for DELETE) ...Object.fromEntries( Array.from(request.headers.entries()).filter( ([key]) => key.startsWith("authorization") || key.startsWith("cookie") diff --git a/apps/web/src/app/api/(proxy)/auth/providers/order/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/order/route.ts index 2b41530..efa3790 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/order/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/order/route.ts @@ -11,7 +11,6 @@ export async function PUT(request: NextRequest) { headers: { "Content-Type": "application/json", cookie: request.headers.get("cookie") || "", - // Forward any authorization headers if needed ...Object.fromEntries(Array.from(request.headers.entries()).filter(([key]) => key.startsWith("authorization"))), }, body: JSON.stringify(body), diff --git a/apps/web/src/app/api/(proxy)/auth/providers/route.ts b/apps/web/src/app/api/(proxy)/auth/providers/route.ts index 59a3153..bcbe38f 100644 --- a/apps/web/src/app/api/(proxy)/auth/providers/route.ts +++ b/apps/web/src/app/api/(proxy)/auth/providers/route.ts @@ -7,7 +7,6 @@ export async function GET(request: NextRequest) { const url = new URL(request.url); const queryString = url.search; - // Forward the original host and protocol to backend const originalHost = request.headers.get("host") || url.host; const originalProtocol = request.headers.get("x-forwarded-proto") || url.protocol.replace(":", ""); @@ -18,7 +17,6 @@ export async function GET(request: NextRequest) { "x-forwarded-host": originalHost, "x-forwarded-proto": originalProtocol, cookie: request.headers.get("cookie") || "", - // Forward any authorization headers if needed ...Object.fromEntries(Array.from(request.headers.entries()).filter(([key]) => key.startsWith("authorization"))), }, }); @@ -46,7 +44,6 @@ export async function POST(request: NextRequest) { headers: { "Content-Type": "application/json", cookie: request.headers.get("cookie") || "", - // Forward any authorization headers if needed ...Object.fromEntries(Array.from(request.headers.entries()).filter(([key]) => key.startsWith("authorization"))), }, body: JSON.stringify(body), 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 a19d899..055a1eb 100644 --- a/apps/web/src/app/settings/components/auth-providers-settings.tsx +++ b/apps/web/src/app/settings/components/auth-providers-settings.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useState } from "react"; import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; import { - IconAlertTriangle, IconCheck, IconChevronDown, IconChevronUp, @@ -16,7 +15,6 @@ import { IconPlus, IconSettings, IconTrash, - IconX, } from "@tabler/icons-react"; import { Globe } from "lucide-react"; import { toast } from "sonner"; @@ -62,7 +60,6 @@ interface NewProvider { clientSecret: string; issuerUrl: string; scope: string; - // Endpoints customizados opcionais authorizationEndpoint: string; tokenEndpoint: string; userInfoEndpoint: string; @@ -96,13 +93,11 @@ export function AuthProvidersSettings() { 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"] }, @@ -147,14 +142,12 @@ export function AuthProvidersSettings() { { pattern: "zitadel.com", scopes: ["openid", "profile", "email"] }, ]; - // 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 { @@ -162,7 +155,6 @@ export function AuthProvidersSettings() { } }; - // Função para auto-sugerir scopes baseado na Provider URL (onBlur) const updateProviderUrl = (url: string) => { if (!url.trim()) return; @@ -178,7 +170,6 @@ export function AuthProvidersSettings() { }); }; - // Load hide disabled providers state from localStorage useEffect(() => { const savedState = localStorage.getItem("hideDisabledProviders"); if (savedState !== null) { @@ -186,7 +177,6 @@ export function AuthProvidersSettings() { } }, []); - // Load providers useEffect(() => { loadProviders(); }, []); @@ -210,7 +200,6 @@ export function AuthProvidersSettings() { } }; - // Update provider const updateProvider = async (id: string, updates: Partial) => { try { setSaving(id); @@ -236,7 +225,6 @@ export function AuthProvidersSettings() { } }; - // Delete provider const deleteProvider = async (id: string, name: string) => { try { setIsDeleting(true); @@ -261,7 +249,6 @@ export function AuthProvidersSettings() { } }; - // Open delete confirmation modal const openDeleteModal = (provider: AuthProvider) => { setProviderToDelete({ id: provider.id, @@ -270,14 +257,12 @@ export function AuthProvidersSettings() { }); }; - // Add new provider const addProvider = async () => { 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 && @@ -311,7 +296,6 @@ export function AuthProvidersSettings() { autoRegister: true, 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 } : {}), @@ -349,7 +333,6 @@ export function AuthProvidersSettings() { } }; - // Edit provider const editProvider = async (providerData: Partial) => { if (!editingProvider || !providerData.name || !providerData.displayName) { toast.error("Please fill in all required fields"); @@ -384,7 +367,6 @@ export function AuthProvidersSettings() { } }; - // Handle drag and drop const handleDragEnd = async (result: DropResult) => { if (!result.destination) return; @@ -392,14 +374,12 @@ export function AuthProvidersSettings() { const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); - // Update local state immediately for better UX const updatedItems = items.map((provider, index) => ({ ...provider, sortOrder: index + 1, })); setProviders(updatedItems); - // Update sortOrder values for API const updatedProviders = updatedItems.map((provider) => ({ id: provider.id, sortOrder: provider.sortOrder, @@ -416,22 +396,18 @@ export function AuthProvidersSettings() { if (data.success) { toast.success("Provider order updated"); - // No need to reload - state is already updated locally } else { toast.error("Failed to update provider order"); - // Revert local state on error await loadProviders(); } } catch (error) { console.error("Error updating provider order:", error); toast.error("Failed to update provider order"); - // Revert local state on error await loadProviders(); } }; const getProviderIcon = (provider: AuthProvider) => { - // Use the icon saved in the database, fallback to FaCog if not set const iconName = provider.icon || "FaCog"; return renderIconByName(iconName, "w-5 h-5"); }; @@ -481,7 +457,6 @@ export function AuthProvidersSettings() { ) : (
- {/* Add Provider Button */}
{hideDisabledProviders @@ -503,7 +478,6 @@ export function AuthProvidersSettings() {
- {/* Add Provider Form */} {showAddForm && (
@@ -571,7 +545,6 @@ export function AuthProvidersSettings() {
- {/* Configuration Method Toggle */}

Configuration Method

@@ -640,7 +613,6 @@ export function AuthProvidersSettings() {
- {/* Automatic Discovery Mode */} {!newProvider.authorizationEndpoint && !newProvider.tokenEndpoint && !newProvider.userInfoEndpoint && ( @@ -658,7 +630,6 @@ export function AuthProvidersSettings() {
)} - {/* Manual Endpoints Mode */} {(newProvider.authorizationEndpoint || newProvider.tokenEndpoint || newProvider.userInfoEndpoint) && (
@@ -714,7 +685,6 @@ export function AuthProvidersSettings() { )}
- {/* Client Credentials */}
@@ -735,7 +705,6 @@ export function AuthProvidersSettings() {
- {/* OAuth Scopes */}
- {/* Show callback URL if provider name is filled */} {newProvider.name && (
@@ -767,7 +735,6 @@ export function AuthProvidersSettings() {
)} - {/* Hide Disabled Providers Checkbox */} {providers.length > 0 && (
)} - {/* Providers List - Compact with Drag and Drop */}

@@ -892,7 +858,6 @@ export function AuthProvidersSettings() { )} - {/* Delete Confirmation Modal */} setProviderToDelete(null)} @@ -908,7 +873,6 @@ export function AuthProvidersSettings() { ); } -// Compact provider row component interface ProviderRowProps { provider: AuthProvider; onUpdate: (updates: Partial) => void; @@ -946,10 +910,8 @@ function ProviderRow({ return (

- {/* Compact Header */}
- {/* Drag Handle */} {!isDragDisabled ? (
{getIcon(provider)}
- {/* Status dot */}
- {/* Edit Form - Shows right below this provider when editing */} {isEditing && (
@@ -1019,7 +979,6 @@ function ProviderRow({ ); } -// Edit Provider Form Component interface EditProviderFormProps { provider: AuthProvider; onSave: (data: Partial) => void; @@ -1037,7 +996,6 @@ function EditProviderForm({ editingFormData, setEditingFormData, }: EditProviderFormProps) { - // Usar dados preservados se existirem, senão usar dados do provider const savedData = editingFormData[provider.id] || {}; const [formData, setFormData] = useState({ name: savedData.name || provider.name || "", @@ -1058,13 +1016,11 @@ function EditProviderForm({ 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"] }, @@ -1081,14 +1037,12 @@ function EditProviderForm({ { pattern: "zitadel.com", scopes: ["openid", "profile", "email"] }, ]; - // 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 { @@ -1096,12 +1050,10 @@ function EditProviderForm({ } }; - // 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; } @@ -1109,7 +1061,6 @@ function EditProviderForm({ 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(" "), @@ -1146,10 +1097,8 @@ function EditProviderForm({

)} - {/* Callback URL Display */} - {/* Only show basic fields for non-official providers */} {!isOfficial && (
@@ -1195,7 +1144,6 @@ function EditProviderForm({
)} - {/* Configuration - Only for custom providers */} {!isOfficial && (
@@ -1248,7 +1196,6 @@ function EditProviderForm({
- {/* Automatic Discovery Mode */} {!formData.authorizationEndpoint && !formData.tokenEndpoint && !formData.userInfoEndpoint && (
@@ -1264,7 +1211,6 @@ function EditProviderForm({
)} - {/* Manual Endpoints Mode */} {(formData.authorizationEndpoint || formData.tokenEndpoint || formData.userInfoEndpoint) && (
@@ -1319,7 +1265,6 @@ function EditProviderForm({
)} - {/* Official Provider - Only Provider URL and Icon */} {isOfficial && (
@@ -1428,7 +1373,6 @@ function EditProviderForm({ ); } -// Callback URL Display Component interface CallbackUrlDisplayProps { providerName: string; } @@ -1436,7 +1380,6 @@ interface CallbackUrlDisplayProps { function CallbackUrlDisplay({ providerName }: CallbackUrlDisplayProps) { const [copied, setCopied] = useState(false); - // Usar a URL atual da página const callbackUrl = typeof window !== "undefined" ? `${window.location.origin}/api/auth/providers/${providerName}/callback` diff --git a/apps/web/src/app/settings/components/settings-form.tsx b/apps/web/src/app/settings/components/settings-form.tsx index b5d814d..53abec4 100644 --- a/apps/web/src/app/settings/components/settings-form.tsx +++ b/apps/web/src/app/settings/components/settings-form.tsx @@ -24,12 +24,10 @@ export function SettingsForm({ return (
{sortedGroups.map(([group, configs]) => { - // Render custom auth providers component if (group === "auth-providers") { return ; } - // Only render SettingsGroup if we have a form for this group const form = groupForms[group as ValidGroup]; if (!form) { return null;