mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-24 16:43:47 +00:00
feat: add Google and Discord authentication providers with configuration updates
- Introduced Google and Discord as new authentication providers in the seed script, including their OAuth2 configurations and metadata. - Updated existing provider configurations to adjust sort orders and ensure proper integration with the new providers. - Enhanced validation logic in the DTOs to accommodate optional endpoint fields and ensure correct provider setup. - Implemented a delete confirmation modal in the web settings for managing authentication providers, improving user experience. - Added logging for better debugging and tracking of provider-related operations in the controller and service layers.
This commit is contained in:
@@ -151,6 +151,44 @@ const defaultConfigs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const defaultAuthProviders = [
|
const defaultAuthProviders = [
|
||||||
|
{
|
||||||
|
name: "google",
|
||||||
|
displayName: "Google",
|
||||||
|
type: "oauth2",
|
||||||
|
icon: "FcGoogle",
|
||||||
|
enabled: false,
|
||||||
|
issuerUrl: "https://accounts.google.com",
|
||||||
|
authorizationEndpoint: "/o/oauth2/v2/auth",
|
||||||
|
tokenEndpoint: "/o/oauth2/token",
|
||||||
|
userInfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
|
scope: "openid profile email",
|
||||||
|
sortOrder: 1,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
description: "Sign in with your Google account",
|
||||||
|
docs: "https://developers.google.com/identity/protocols/oauth2",
|
||||||
|
supportsDiscovery: true,
|
||||||
|
authMethod: "body"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discord",
|
||||||
|
displayName: "Discord",
|
||||||
|
type: "oauth2",
|
||||||
|
icon: "FaDiscord",
|
||||||
|
enabled: false,
|
||||||
|
issuerUrl: "https://discord.com",
|
||||||
|
authorizationEndpoint: "/oauth2/authorize",
|
||||||
|
tokenEndpoint: "/api/oauth2/token",
|
||||||
|
userInfoEndpoint: "/api/users/@me",
|
||||||
|
scope: "identify email",
|
||||||
|
sortOrder: 2,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
description: "Sign in with your Discord account",
|
||||||
|
docs: "https://discord.com/developers/docs/topics/oauth2",
|
||||||
|
supportsDiscovery: false,
|
||||||
|
authMethod: "body"
|
||||||
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "github",
|
name: "github",
|
||||||
displayName: "GitHub",
|
displayName: "GitHub",
|
||||||
@@ -162,7 +200,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/access_token",
|
tokenEndpoint: "/access_token",
|
||||||
userInfoEndpoint: "https://api.github.com/user", // GitHub usa URL absoluta para userInfo
|
userInfoEndpoint: "https://api.github.com/user", // GitHub usa URL absoluta para userInfo
|
||||||
scope: "user:email",
|
scope: "user:email",
|
||||||
sortOrder: 1,
|
sortOrder: 3,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with your GitHub account",
|
description: "Sign in with your GitHub account",
|
||||||
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps",
|
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps",
|
||||||
@@ -180,7 +218,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/oauth/token",
|
tokenEndpoint: "/oauth/token",
|
||||||
userInfoEndpoint: "/userinfo",
|
userInfoEndpoint: "/userinfo",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
sortOrder: 2,
|
sortOrder: 4,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with Auth0 - Replace 'your-tenant' with your Auth0 domain",
|
description: "Sign in with Auth0 - Replace 'your-tenant' with your Auth0 domain",
|
||||||
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow",
|
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow",
|
||||||
@@ -198,7 +236,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/oauth2/token",
|
tokenEndpoint: "/oauth2/token",
|
||||||
userInfoEndpoint: "/oauth2/user_profile",
|
userInfoEndpoint: "/oauth2/user_profile",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
sortOrder: 3,
|
sortOrder: 5,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with Kinde - Replace 'your-tenant' with your Kinde domain",
|
description: "Sign in with Kinde - Replace 'your-tenant' with your Kinde domain",
|
||||||
docs: "https://kinde.com/docs/developer-tools/about/",
|
docs: "https://kinde.com/docs/developer-tools/about/",
|
||||||
@@ -216,7 +254,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/oauth/v2/token",
|
tokenEndpoint: "/oauth/v2/token",
|
||||||
userInfoEndpoint: "/oidc/v1/userinfo",
|
userInfoEndpoint: "/oidc/v1/userinfo",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
sortOrder: 4,
|
sortOrder: 6,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with Zitadel - Replace with your Zitadel instance URL",
|
description: "Sign in with Zitadel - Replace with your Zitadel instance URL",
|
||||||
docs: "https://zitadel.com/docs/guides/integrate/login/oidc",
|
docs: "https://zitadel.com/docs/guides/integrate/login/oidc",
|
||||||
@@ -235,7 +273,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/application/o/token/",
|
tokenEndpoint: "/application/o/token/",
|
||||||
userInfoEndpoint: "/application/o/userinfo/",
|
userInfoEndpoint: "/application/o/userinfo/",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
sortOrder: 5,
|
sortOrder: 7,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with Authentik - Replace with your Authentik instance URL",
|
description: "Sign in with Authentik - Replace with your Authentik instance URL",
|
||||||
docs: "https://goauthentik.io/docs/providers/oauth2",
|
docs: "https://goauthentik.io/docs/providers/oauth2",
|
||||||
@@ -253,7 +291,7 @@ const defaultAuthProviders = [
|
|||||||
tokenEndpoint: "/oauth/token",
|
tokenEndpoint: "/oauth/token",
|
||||||
userInfoEndpoint: "/identity/resources/users/v2/me",
|
userInfoEndpoint: "/identity/resources/users/v2/me",
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
sortOrder: 6,
|
sortOrder: 8,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
description: "Sign in with Frontegg - Replace 'your-tenant' with your Frontegg tenant",
|
description: "Sign in with Frontegg - Replace 'your-tenant' with your Frontegg tenant",
|
||||||
docs: "https://docs.frontegg.com",
|
docs: "https://docs.frontegg.com",
|
||||||
|
|||||||
@@ -166,8 +166,12 @@ export class AuthProvidersController {
|
|||||||
|
|
||||||
// Para providers customizados, aplica validação normal
|
// Para providers customizados, aplica validação normal
|
||||||
try {
|
try {
|
||||||
|
console.log(`[Controller] Updating custom provider with data:`, data);
|
||||||
|
|
||||||
// Valida usando o schema do Zod
|
// Valida usando o schema do Zod
|
||||||
const validatedData = UpdateAuthProviderSchema.parse(data);
|
const validatedData = UpdateAuthProviderSchema.parse(data);
|
||||||
|
console.log(`[Controller] Validation passed, validated data:`, validatedData);
|
||||||
|
|
||||||
const provider = await this.authProvidersService.updateProvider(id, validatedData);
|
const provider = await this.authProvidersService.updateProvider(id, validatedData);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
@@ -176,6 +180,7 @@ export class AuthProvidersController {
|
|||||||
});
|
});
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
console.error("Validation error for custom provider:", validationError);
|
console.error("Validation error for custom provider:", validationError);
|
||||||
|
console.error("Raw data that failed validation:", data);
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Invalid data provided",
|
error: "Invalid data provided",
|
||||||
@@ -297,26 +302,53 @@ export class AuthProvidersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async callback(request: FastifyRequest<{ Params: { provider: string }; Querystring: any }>, reply: FastifyReply) {
|
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"],
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { provider: providerName } = request.params;
|
const { provider: providerName } = request.params;
|
||||||
const query = request.query as any;
|
const query = request.query as any;
|
||||||
const { code, state, error } = query;
|
const { code, state, error } = query;
|
||||||
|
|
||||||
|
console.log(`[Controller] Extracted params:`, { providerName, code, state, error });
|
||||||
|
console.log(`[Controller] All query params:`, query);
|
||||||
|
|
||||||
const requestContext = {
|
const requestContext = {
|
||||||
protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol,
|
protocol: (request.headers["x-forwarded-proto"] as string) || request.protocol,
|
||||||
host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string),
|
host: (request.headers["x-forwarded-host"] as string) || (request.headers.host as string),
|
||||||
};
|
};
|
||||||
const baseUrl = `${requestContext.protocol}://${requestContext.host}`;
|
const baseUrl = `${requestContext.protocol}://${requestContext.host}`;
|
||||||
|
|
||||||
|
console.log(`[Controller] Request context:`, requestContext);
|
||||||
|
console.log(`[Controller] Base URL:`, baseUrl);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`OAuth error from ${providerName}:`, error);
|
console.error(`OAuth error from ${providerName}:`, error);
|
||||||
return reply.redirect(`${baseUrl}/login?error=oauth_error&provider=${providerName}`);
|
return reply.redirect(`${baseUrl}/login?error=oauth_error&provider=${providerName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
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);
|
||||||
return reply.redirect(`${baseUrl}/login?error=missing_parameters&provider=${providerName}`);
|
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 result = await this.authProvidersService.handleCallback(providerName, code, state, requestContext);
|
||||||
|
|
||||||
const jwt = await request.jwtSign({
|
const jwt = await request.jwtSign({
|
||||||
@@ -334,6 +366,7 @@ export class AuthProvidersController {
|
|||||||
|
|
||||||
const redirectUrl = result.redirectUrl || "/dashboard";
|
const redirectUrl = result.redirectUrl || "/dashboard";
|
||||||
const fullRedirectUrl = redirectUrl.startsWith("http") ? redirectUrl : `${baseUrl}${redirectUrl}`;
|
const fullRedirectUrl = redirectUrl.startsWith("http") ? redirectUrl : `${baseUrl}${redirectUrl}`;
|
||||||
|
console.log(`[Controller] Redirecting to:`, fullRedirectUrl);
|
||||||
return reply.redirect(fullRedirectUrl);
|
return reply.redirect(fullRedirectUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in ${request.params.provider} callback:`, error);
|
console.error(`Error in ${request.params.provider} callback:`, error);
|
||||||
|
|||||||
@@ -36,21 +36,35 @@ export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({
|
|||||||
// Schema principal que aceita ambos os modos
|
// Schema principal que aceita ambos os modos
|
||||||
export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({
|
export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({
|
||||||
issuerUrl: z.string().url("Invalid issuer URL").optional(),
|
issuerUrl: z.string().url("Invalid issuer URL").optional(),
|
||||||
authorizationEndpoint: z.string().min(1).optional(),
|
authorizationEndpoint: z.string().optional(),
|
||||||
tokenEndpoint: z.string().min(1).optional(),
|
tokenEndpoint: z.string().optional(),
|
||||||
userInfoEndpoint: z.string().min(1).optional(),
|
userInfoEndpoint: z.string().optional(),
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// Modo discovery: deve ter issuerUrl e não ter endpoints customizados
|
|
||||||
const hasIssuerUrl = !!data.issuerUrl;
|
const hasIssuerUrl = !!data.issuerUrl;
|
||||||
const hasCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
|
const hasAnyCustomEndpoint = !!(
|
||||||
|
data.authorizationEndpoint?.trim() ||
|
||||||
|
data.tokenEndpoint?.trim() ||
|
||||||
|
data.userInfoEndpoint?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
// Deve ter ou issuerUrl OU todos os endpoints customizados
|
// Deve ter pelo menos issuerUrl OU todos os endpoints customizados
|
||||||
return hasIssuerUrl || hasCustomEndpoints;
|
if (hasIssuerUrl && !hasAnyCustomEndpoint) return true; // Modo discovery
|
||||||
|
|
||||||
|
if (hasAnyCustomEndpoint) {
|
||||||
|
const hasAllCustomEndpoints = !!(
|
||||||
|
data.authorizationEndpoint?.trim() &&
|
||||||
|
data.tokenEndpoint?.trim() &&
|
||||||
|
data.userInfoEndpoint?.trim()
|
||||||
|
);
|
||||||
|
return hasAllCustomEndpoints; // Precisa ter todos os 3 endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Precisa ter pelo menos um dos dois modos
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
|
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo).",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,25 +82,40 @@ export const UpdateAuthProviderSchema = z
|
|||||||
clientId: z.string().min(1).optional(),
|
clientId: z.string().min(1).optional(),
|
||||||
clientSecret: z.string().min(1).optional(),
|
clientSecret: z.string().min(1).optional(),
|
||||||
issuerUrl: z.string().url().optional(),
|
issuerUrl: z.string().url().optional(),
|
||||||
authorizationEndpoint: z.string().min(1).optional(),
|
authorizationEndpoint: z.string().optional(),
|
||||||
tokenEndpoint: z.string().min(1).optional(),
|
tokenEndpoint: z.string().optional(),
|
||||||
userInfoEndpoint: z.string().min(1).optional(),
|
userInfoEndpoint: z.string().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// Se está atualizando endpoints, deve seguir a mesma regra
|
// Se não está alterando nenhum campo de configuração, permite
|
||||||
const hasIssuerUrl = !!data.issuerUrl;
|
const hasIssuerUrl = !!data.issuerUrl;
|
||||||
const hasCustomEndpoints = !!(data.authorizationEndpoint || data.tokenEndpoint || data.userInfoEndpoint);
|
const hasAnyCustomEndpoint = !!(
|
||||||
|
data.authorizationEndpoint?.trim() ||
|
||||||
|
data.tokenEndpoint?.trim() ||
|
||||||
|
data.userInfoEndpoint?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
// Se nenhum dos dois foi fornecido, está OK (não está alterando o modo)
|
// Se não está alterando nenhum campo de configuração, permite
|
||||||
if (!hasIssuerUrl && !hasCustomEndpoints) return true;
|
if (!hasIssuerUrl && !hasAnyCustomEndpoint) return true;
|
||||||
|
|
||||||
// Se forneceu um, não pode fornecer o outro
|
// Se está fornecendo apenas issuerUrl, permite (modo discovery)
|
||||||
return hasIssuerUrl !== hasCustomEndpoints;
|
if (hasIssuerUrl && !hasAnyCustomEndpoint) return true;
|
||||||
|
|
||||||
|
// Se está fornecendo endpoints customizados, deve fornecer todos os 3
|
||||||
|
if (hasAnyCustomEndpoint) {
|
||||||
|
const hasAllCustomEndpoints = !!(
|
||||||
|
data.authorizationEndpoint?.trim() &&
|
||||||
|
data.tokenEndpoint?.trim() &&
|
||||||
|
data.userInfoEndpoint?.trim()
|
||||||
|
);
|
||||||
|
return hasAllCustomEndpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message: "When providing custom endpoints, all three endpoints (authorization, token, userInfo) are required.",
|
||||||
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -461,6 +461,8 @@ export class ProviderManager {
|
|||||||
ping: ["openid", "profile", "email"],
|
ping: ["openid", "profile", "email"],
|
||||||
azure: ["openid", "profile", "email", "User.Read"],
|
azure: ["openid", "profile", "email", "User.Read"],
|
||||||
aws: ["openid", "profile", "email"],
|
aws: ["openid", "profile", "email"],
|
||||||
|
kinde: ["openid", "profile", "email"],
|
||||||
|
zitadel: ["openid", "profile", "email"],
|
||||||
|
|
||||||
// Communication
|
// Communication
|
||||||
slack: ["identity.basic", "identity.email", "identity.avatar"],
|
slack: ["identity.basic", "identity.email", "identity.avatar"],
|
||||||
@@ -559,15 +561,8 @@ export class ProviderManager {
|
|||||||
{ pattern: "monday.com", type: "monday" },
|
{ pattern: "monday.com", type: "monday" },
|
||||||
{ pattern: "clickup.com", type: "clickup" },
|
{ pattern: "clickup.com", type: "clickup" },
|
||||||
{ pattern: "linear.app", type: "linear" },
|
{ pattern: "linear.app", type: "linear" },
|
||||||
{ pattern: "jira", type: "jira" },
|
{ pattern: "kinde.com", type: "kinde" },
|
||||||
{ pattern: "confluence", type: "confluence" },
|
{ pattern: "zitadel.com", type: "zitadel" },
|
||||||
{ pattern: "bamboo", type: "bamboo" },
|
|
||||||
{ pattern: "bitbucket", type: "bitbucket" },
|
|
||||||
{ pattern: "crowd", type: "crowd" },
|
|
||||||
{ pattern: "fisheye", type: "fisheye" },
|
|
||||||
{ pattern: "crucible", type: "crucible" },
|
|
||||||
{ pattern: "statuspage", type: "statuspage" },
|
|
||||||
{ pattern: "opsgenie", type: "opsgenie" },
|
|
||||||
{ pattern: "jira", type: "jira" },
|
{ pattern: "jira", type: "jira" },
|
||||||
{ pattern: "confluence", type: "confluence" },
|
{ pattern: "confluence", type: "confluence" },
|
||||||
{ pattern: "bamboo", type: "bamboo" },
|
{ pattern: "bamboo", type: "bamboo" },
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
import { ProviderConfig, ProvidersConfigFile } from "./types";
|
import { ProviderConfig, ProvidersConfigFile } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração técnica oficial do Discord
|
||||||
|
* OAuth2 com mapeamentos específicos do Discord
|
||||||
|
* Endpoints vêm do banco de dados
|
||||||
|
*/
|
||||||
|
const discordConfig: ProviderConfig = {
|
||||||
|
supportsDiscovery: false,
|
||||||
|
authMethod: "body",
|
||||||
|
fieldMappings: {
|
||||||
|
id: ["id"],
|
||||||
|
email: ["email"],
|
||||||
|
name: ["global_name", "username"],
|
||||||
|
firstName: ["global_name"],
|
||||||
|
lastName: [],
|
||||||
|
avatar: ["avatar"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração técnica oficial do Google
|
||||||
|
* OAuth2 com discovery automático
|
||||||
|
* Endpoints vêm do banco de dados
|
||||||
|
*/
|
||||||
|
const googleConfig: ProviderConfig = {
|
||||||
|
supportsDiscovery: true,
|
||||||
|
discoveryEndpoint: "/.well-known/openid_configuration",
|
||||||
|
authMethod: "body",
|
||||||
|
fieldMappings: {
|
||||||
|
id: ["sub"],
|
||||||
|
email: ["email"],
|
||||||
|
name: ["name"],
|
||||||
|
firstName: ["given_name"],
|
||||||
|
lastName: ["family_name"],
|
||||||
|
avatar: ["picture"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuração técnica oficial do GitHub
|
* Configuração técnica oficial do GitHub
|
||||||
* OAuth2 com busca separada de email
|
* OAuth2 com busca separada de email
|
||||||
@@ -153,6 +190,8 @@ const genericProviderTemplate: ProviderConfig = {
|
|||||||
*/
|
*/
|
||||||
export const providersConfig: ProvidersConfigFile = {
|
export const providersConfig: ProvidersConfigFile = {
|
||||||
officialProviders: {
|
officialProviders: {
|
||||||
|
google: googleConfig,
|
||||||
|
discord: discordConfig,
|
||||||
github: githubConfig,
|
github: githubConfig,
|
||||||
auth0: auth0Config,
|
auth0: auth0Config,
|
||||||
kinde: kindeConfig,
|
kinde: kindeConfig,
|
||||||
@@ -167,6 +206,8 @@ export const providersConfig: ProvidersConfigFile = {
|
|||||||
* Exportações individuais para facilitar importação
|
* Exportações individuais para facilitar importação
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
discordConfig,
|
||||||
|
googleConfig,
|
||||||
githubConfig,
|
githubConfig,
|
||||||
auth0Config,
|
auth0Config,
|
||||||
kindeConfig,
|
kindeConfig,
|
||||||
|
|||||||
@@ -193,13 +193,16 @@ export class AuthProvidersService {
|
|||||||
|
|
||||||
async handleCallback(providerName: string, code: string, state: string, requestContext?: any) {
|
async handleCallback(providerName: string, code: string, state: string, requestContext?: any) {
|
||||||
console.log(`[AuthProvidersService] Handling callback for provider: ${providerName}`);
|
console.log(`[AuthProvidersService] Handling callback for provider: ${providerName}`);
|
||||||
|
console.log(`[AuthProvidersService] State received: ${state}`);
|
||||||
|
|
||||||
const pendingState = this.pendingStates.get(state);
|
const pendingState = this.pendingStates.get(state);
|
||||||
|
|
||||||
if (!pendingState) {
|
if (!pendingState) {
|
||||||
|
console.error(`[AuthProvidersService] No valid pending state found for ${providerName}`);
|
||||||
throw new Error("Invalid or expired state");
|
throw new Error("Invalid or expired state");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingStates.delete(state);
|
console.log(`[AuthProvidersService] Using pending state for ${providerName}`);
|
||||||
|
|
||||||
const provider = await this.getProviderByName(providerName);
|
const provider = await this.getProviderByName(providerName);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -285,31 +288,33 @@ export class AuthProvidersService {
|
|||||||
|
|
||||||
const callbackUrl = provider.redirectUri || `${baseUrl}/api/auth/providers/${provider.name}/callback`;
|
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
|
// Prepara headers
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepara body
|
// Configura autenticação basic se necessário
|
||||||
const body = new URLSearchParams({
|
if (authMethod === "basic" && provider.clientSecret) {
|
||||||
client_id: provider.clientId,
|
|
||||||
code,
|
|
||||||
redirect_uri: callbackUrl,
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adiciona code_verifier se for OIDC
|
|
||||||
if (provider.type === "oidc" && codeVerifier) {
|
|
||||||
body.append("code_verifier", codeVerifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configura autenticação baseada no método
|
|
||||||
if (authMethod === "basic") {
|
|
||||||
const auth = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString("base64");
|
const auth = Buffer.from(`${provider.clientId}:${provider.clientSecret}`).toString("base64");
|
||||||
headers["Authorization"] = `Basic ${auth}`;
|
headers["Authorization"] = `Basic ${auth}`;
|
||||||
} else {
|
|
||||||
body.append("client_secret", provider.clientSecret);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AuthProvidersService] Token exchange request:`, {
|
console.log(`[AuthProvidersService] Token exchange request:`, {
|
||||||
@@ -335,6 +340,7 @@ export class AuthProvidersService {
|
|||||||
statusText: tokenResponse.statusText,
|
statusText: tokenResponse.statusText,
|
||||||
error: errorText,
|
error: errorText,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`);
|
throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +374,6 @@ export class AuthProvidersService {
|
|||||||
status: userInfoResponse.status,
|
status: userInfoResponse.status,
|
||||||
statusText: userInfoResponse.statusText,
|
statusText: userInfoResponse.statusText,
|
||||||
url: endpoints.userInfoEndpoint,
|
url: endpoints.userInfoEndpoint,
|
||||||
headers: Object.fromEntries(userInfoResponse.headers.entries()),
|
|
||||||
error: errorText.substring(0, 500), // Limita o tamanho do log
|
error: errorText.substring(0, 500), // Limita o tamanho do log
|
||||||
});
|
});
|
||||||
throw new Error(`UserInfo request failed: ${userInfoResponse.status} - ${errorText.substring(0, 200)}`);
|
throw new Error(`UserInfo request failed: ${userInfoResponse.status} - ${errorText.substring(0, 200)}`);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface AuthProviderDeleteModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
provider: { id: string; name: string; displayName: string } | null;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProviderDeleteModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
provider,
|
||||||
|
onConfirm,
|
||||||
|
isDeleting,
|
||||||
|
}: AuthProviderDeleteModalProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={() => !isDeleting && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<IconTrash size={20} />
|
||||||
|
Delete Authentication Provider
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Are you sure you want to delete the "{provider?.displayName}" provider? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{provider && (
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/30">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">{provider.displayName}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">Provider ID: {provider.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<IconTrash className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconTrash className="h-4 w-4 mr-2" />
|
||||||
|
Delete Provider
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { TagsInput } from "@/components/ui/tags-input";
|
import { TagsInput } from "@/components/ui/tags-input";
|
||||||
|
import { AuthProviderDeleteModal } from "./auth-provider-delete-modal";
|
||||||
|
|
||||||
interface AuthProvider {
|
interface AuthProvider {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -76,6 +77,10 @@ export function AuthProvidersSettings() {
|
|||||||
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
|
||||||
const [editingFormData, setEditingFormData] = useState<Record<string, any>>({});
|
const [editingFormData, setEditingFormData] = useState<Record<string, any>>({});
|
||||||
const [hideDisabledProviders, setHideDisabledProviders] = useState<boolean>(false);
|
const [hideDisabledProviders, setHideDisabledProviders] = useState<boolean>(false);
|
||||||
|
const [providerToDelete, setProviderToDelete] = useState<{ id: string; name: string; displayName: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const [newProvider, setNewProvider] = useState<NewProvider>({
|
const [newProvider, setNewProvider] = useState<NewProvider>({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -138,6 +143,8 @@ export function AuthProvidersSettings() {
|
|||||||
{ pattern: "monday.com", scopes: ["read"] },
|
{ pattern: "monday.com", scopes: ["read"] },
|
||||||
{ pattern: "clickup.com", scopes: ["read"] },
|
{ pattern: "clickup.com", scopes: ["read"] },
|
||||||
{ pattern: "linear.app", scopes: ["read"] },
|
{ pattern: "linear.app", scopes: ["read"] },
|
||||||
|
{ pattern: "kinde.com", scopes: ["openid", "profile", "email"] },
|
||||||
|
{ pattern: "zitadel.com", scopes: ["openid", "profile", "email"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Procura por padrões conhecidos
|
// Procura por padrões conhecidos
|
||||||
@@ -231,10 +238,8 @@ export function AuthProvidersSettings() {
|
|||||||
|
|
||||||
// Delete provider
|
// Delete provider
|
||||||
const deleteProvider = async (id: string, name: string) => {
|
const deleteProvider = async (id: string, name: string) => {
|
||||||
if (!confirm(`Delete "${name}" provider? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(id);
|
setIsDeleting(true);
|
||||||
const response = await fetch(`/api/auth/providers/manage/${id}`, {
|
const response = await fetch(`/api/auth/providers/manage/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
@@ -244,6 +249,7 @@ export function AuthProvidersSettings() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
setProviders((prev) => prev.filter((p) => p.id !== id));
|
setProviders((prev) => prev.filter((p) => p.id !== id));
|
||||||
toast.success("Provider deleted");
|
toast.success("Provider deleted");
|
||||||
|
setProviderToDelete(null);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to delete provider");
|
toast.error("Failed to delete provider");
|
||||||
}
|
}
|
||||||
@@ -251,10 +257,19 @@ export function AuthProvidersSettings() {
|
|||||||
console.error("Error deleting provider:", error);
|
console.error("Error deleting provider:", error);
|
||||||
toast.error("Failed to delete provider");
|
toast.error("Failed to delete provider");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Open delete confirmation modal
|
||||||
|
const openDeleteModal = (provider: AuthProvider) => {
|
||||||
|
setProviderToDelete({
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Add new provider
|
// Add new provider
|
||||||
const addProvider = async () => {
|
const addProvider = async () => {
|
||||||
if (!newProvider.name || !newProvider.displayName || !newProvider.clientId || !newProvider.clientSecret) {
|
if (!newProvider.name || !newProvider.displayName || !newProvider.clientId || !newProvider.clientSecret) {
|
||||||
@@ -802,7 +817,7 @@ export function AuthProvidersSettings() {
|
|||||||
setEditingProvider(provider);
|
setEditingProvider(provider);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={() => deleteProvider(provider.id, provider.displayName)}
|
onDelete={() => openDeleteModal(provider)}
|
||||||
saving={saving === provider.id}
|
saving={saving === provider.id}
|
||||||
getIcon={getProviderIcon}
|
getIcon={getProviderIcon}
|
||||||
editingProvider={editingProvider}
|
editingProvider={editingProvider}
|
||||||
@@ -843,7 +858,7 @@ export function AuthProvidersSettings() {
|
|||||||
setEditingProvider(provider);
|
setEditingProvider(provider);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={() => deleteProvider(provider.id, provider.displayName)}
|
onDelete={() => openDeleteModal(provider)}
|
||||||
saving={saving === provider.id}
|
saving={saving === provider.id}
|
||||||
getIcon={getProviderIcon}
|
getIcon={getProviderIcon}
|
||||||
editingProvider={editingProvider}
|
editingProvider={editingProvider}
|
||||||
@@ -876,6 +891,19 @@ export function AuthProvidersSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<AuthProviderDeleteModal
|
||||||
|
isOpen={!!providerToDelete}
|
||||||
|
onClose={() => setProviderToDelete(null)}
|
||||||
|
provider={providerToDelete}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (providerToDelete) {
|
||||||
|
await deleteProvider(providerToDelete.id, providerToDelete.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1047,36 +1075,10 @@ function EditProviderForm({
|
|||||||
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
|
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
|
||||||
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
|
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
|
||||||
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
|
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
|
||||||
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
|
|
||||||
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
|
|
||||||
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
|
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
|
||||||
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
|
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
|
||||||
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
|
{ pattern: "kinde.com", scopes: ["openid", "profile", "email"] },
|
||||||
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
|
{ pattern: "zitadel.com", scopes: ["openid", "profile", "email"] },
|
||||||
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
|
||||||
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
|
|
||||||
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
|
|
||||||
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
|
|
||||||
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
|
|
||||||
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
|
|
||||||
{ pattern: "zendesk.com", scopes: ["read"] },
|
|
||||||
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
|
|
||||||
{ pattern: "stripe.com", scopes: ["read"] },
|
|
||||||
{ pattern: "twilio.com", scopes: ["read"] },
|
|
||||||
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
|
|
||||||
{ pattern: "mailchimp.com", scopes: ["read"] },
|
|
||||||
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
|
|
||||||
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
|
|
||||||
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
|
|
||||||
{ pattern: "notion.so", scopes: ["read"] },
|
|
||||||
{ pattern: "figma.com", scopes: ["files:read"] },
|
|
||||||
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
|
|
||||||
{ pattern: "box.com", scopes: ["root_readwrite"] },
|
|
||||||
{ pattern: "trello.com", scopes: ["read"] },
|
|
||||||
{ pattern: "asana.com", scopes: ["default"] },
|
|
||||||
{ pattern: "monday.com", scopes: ["read"] },
|
|
||||||
{ pattern: "clickup.com", scopes: ["read"] },
|
|
||||||
{ pattern: "linear.app", scopes: ["read"] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Procura por padrões conhecidos
|
// Procura por padrões conhecidos
|
||||||
|
|||||||
Reference in New Issue
Block a user