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:
Daniel Luiz Alves
2025-06-27 01:26:05 -03:00
parent 9a086d7b40
commit f1449f6b10
8 changed files with 305 additions and 88 deletions

View File

@@ -151,6 +151,44 @@ const defaultConfigs = [
];
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",
displayName: "GitHub",
@@ -162,7 +200,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/access_token",
userInfoEndpoint: "https://api.github.com/user", // GitHub usa URL absoluta para userInfo
scope: "user:email",
sortOrder: 1,
sortOrder: 3,
metadata: JSON.stringify({
description: "Sign in with your GitHub account",
docs: "https://docs.github.com/en/developers/apps/building-oauth-apps",
@@ -180,7 +218,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/userinfo",
scope: "openid profile email",
sortOrder: 2,
sortOrder: 4,
metadata: JSON.stringify({
description: "Sign in with Auth0 - Replace 'your-tenant' with your Auth0 domain",
docs: "https://auth0.com/docs/get-started/authentication-and-authorization-flow",
@@ -198,7 +236,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/oauth2/token",
userInfoEndpoint: "/oauth2/user_profile",
scope: "openid profile email",
sortOrder: 3,
sortOrder: 5,
metadata: JSON.stringify({
description: "Sign in with Kinde - Replace 'your-tenant' with your Kinde domain",
docs: "https://kinde.com/docs/developer-tools/about/",
@@ -216,7 +254,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/oauth/v2/token",
userInfoEndpoint: "/oidc/v1/userinfo",
scope: "openid profile email",
sortOrder: 4,
sortOrder: 6,
metadata: JSON.stringify({
description: "Sign in with Zitadel - Replace with your Zitadel instance URL",
docs: "https://zitadel.com/docs/guides/integrate/login/oidc",
@@ -235,7 +273,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/application/o/token/",
userInfoEndpoint: "/application/o/userinfo/",
scope: "openid profile email",
sortOrder: 5,
sortOrder: 7,
metadata: JSON.stringify({
description: "Sign in with Authentik - Replace with your Authentik instance URL",
docs: "https://goauthentik.io/docs/providers/oauth2",
@@ -253,7 +291,7 @@ const defaultAuthProviders = [
tokenEndpoint: "/oauth/token",
userInfoEndpoint: "/identity/resources/users/v2/me",
scope: "openid profile email",
sortOrder: 6,
sortOrder: 8,
metadata: JSON.stringify({
description: "Sign in with Frontegg - Replace 'your-tenant' with your Frontegg tenant",
docs: "https://docs.frontegg.com",

View File

@@ -166,8 +166,12 @@ export class AuthProvidersController {
// 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({
@@ -176,6 +180,7 @@ export class AuthProvidersController {
});
} 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",
@@ -297,26 +302,53 @@ export class AuthProvidersController {
}
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 {
const { provider: providerName } = request.params;
const query = request.query as any;
const { code, state, error } = 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);
if (error) {
console.error(`OAuth error from ${providerName}:`, error);
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}`);
}
console.log(`[Controller] Calling handleCallback for ${providerName}`);
const result = await this.authProvidersService.handleCallback(providerName, code, state, requestContext);
const jwt = await request.jwtSign({
@@ -334,6 +366,7 @@ export class AuthProvidersController {
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);

View File

@@ -36,21 +36,35 @@ export const ManualEndpointsSchema = BaseAuthProviderSchema.extend({
// Schema principal que aceita ambos os modos
export const CreateAuthProviderSchema = BaseAuthProviderSchema.extend({
issuerUrl: z.string().url("Invalid issuer URL").optional(),
authorizationEndpoint: z.string().min(1).optional(),
tokenEndpoint: z.string().min(1).optional(),
userInfoEndpoint: z.string().min(1).optional(),
authorizationEndpoint: z.string().optional(),
tokenEndpoint: z.string().optional(),
userInfoEndpoint: z.string().optional(),
}).refine(
(data) => {
// Modo discovery: deve ter issuerUrl e não ter endpoints customizados
const hasIssuerUrl = !!data.issuerUrl;
const hasCustomEndpoints = !!(data.authorizationEndpoint && data.tokenEndpoint && data.userInfoEndpoint);
const hasAnyCustomEndpoint = !!(
data.authorizationEndpoint?.trim() ||
data.tokenEndpoint?.trim() ||
data.userInfoEndpoint?.trim()
);
// Deve ter ou issuerUrl OU todos os endpoints customizados
return hasIssuerUrl || hasCustomEndpoints;
// Deve ter pelo menos issuerUrl OU todos os endpoints customizados
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:
"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(),
clientSecret: z.string().min(1).optional(),
issuerUrl: z.string().url().optional(),
authorizationEndpoint: z.string().min(1).optional(),
tokenEndpoint: z.string().min(1).optional(),
userInfoEndpoint: z.string().min(1).optional(),
authorizationEndpoint: z.string().optional(),
tokenEndpoint: z.string().optional(),
userInfoEndpoint: z.string().optional(),
})
.refine(
(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 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)
if (!hasIssuerUrl && !hasCustomEndpoints) return true;
// Se não está alterando nenhum campo de configuração, permite
if (!hasIssuerUrl && !hasAnyCustomEndpoint) return true;
// Se forneceu um, não pode fornecer o outro
return hasIssuerUrl !== hasCustomEndpoints;
// 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() &&
data.tokenEndpoint?.trim() &&
data.userInfoEndpoint?.trim()
);
return hasAllCustomEndpoints;
}
return true;
},
{
message:
"Either provide issuerUrl for automatic discovery OR all three custom endpoints (authorization, token, userInfo)",
message: "When providing custom endpoints, all three endpoints (authorization, token, userInfo) are required.",
}
);

View File

@@ -461,6 +461,8 @@ export class ProviderManager {
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"],
@@ -559,15 +561,8 @@ export class ProviderManager {
{ pattern: "monday.com", type: "monday" },
{ pattern: "clickup.com", type: "clickup" },
{ pattern: "linear.app", type: "linear" },
{ pattern: "jira", type: "jira" },
{ pattern: "confluence", type: "confluence" },
{ pattern: "bamboo", type: "bamboo" },
{ pattern: "bitbucket", type: "bitbucket" },
{ pattern: "crowd", type: "crowd" },
{ pattern: "fisheye", type: "fisheye" },
{ pattern: "crucible", type: "crucible" },
{ pattern: "statuspage", type: "statuspage" },
{ pattern: "opsgenie", type: "opsgenie" },
{ pattern: "kinde.com", type: "kinde" },
{ pattern: "zitadel.com", type: "zitadel" },
{ pattern: "jira", type: "jira" },
{ pattern: "confluence", type: "confluence" },
{ pattern: "bamboo", type: "bamboo" },

View File

@@ -1,5 +1,42 @@
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
* OAuth2 com busca separada de email
@@ -153,6 +190,8 @@ const genericProviderTemplate: ProviderConfig = {
*/
export const providersConfig: ProvidersConfigFile = {
officialProviders: {
google: googleConfig,
discord: discordConfig,
github: githubConfig,
auth0: auth0Config,
kinde: kindeConfig,
@@ -167,6 +206,8 @@ export const providersConfig: ProvidersConfigFile = {
* Exportações individuais para facilitar importação
*/
export {
discordConfig,
googleConfig,
githubConfig,
auth0Config,
kindeConfig,

View File

@@ -193,13 +193,16 @@ export class AuthProvidersService {
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");
}
this.pendingStates.delete(state);
console.log(`[AuthProvidersService] Using pending state for ${providerName}`);
const provider = await this.getProviderByName(providerName);
if (!provider) {
@@ -285,31 +288,33 @@ export class AuthProvidersService {
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<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
// Prepara body
const body = new URLSearchParams({
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") {
// 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}`;
} else {
body.append("client_secret", provider.clientSecret);
}
console.log(`[AuthProvidersService] Token exchange request:`, {
@@ -335,6 +340,7 @@ export class AuthProvidersService {
statusText: tokenResponse.statusText,
error: errorText,
});
throw new Error(`Token exchange failed: ${tokenResponse.status} - ${errorText}`);
}
@@ -368,7 +374,6 @@ export class AuthProvidersService {
status: userInfoResponse.status,
statusText: userInfoResponse.statusText,
url: endpoints.userInfoEndpoint,
headers: Object.fromEntries(userInfoResponse.headers.entries()),
error: errorText.substring(0, 500), // Limita o tamanho do log
});
throw new Error(`UserInfo request failed: ${userInfoResponse.status} - ${errorText.substring(0, 200)}`);

View File

@@ -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>
);
}

View File

@@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { TagsInput } from "@/components/ui/tags-input";
import { AuthProviderDeleteModal } from "./auth-provider-delete-modal";
interface AuthProvider {
id: string;
@@ -76,6 +77,10 @@ export function AuthProvidersSettings() {
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
const [editingFormData, setEditingFormData] = useState<Record<string, any>>({});
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>({
name: "",
@@ -138,6 +143,8 @@ export function AuthProvidersSettings() {
{ pattern: "monday.com", scopes: ["read"] },
{ pattern: "clickup.com", 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
@@ -231,10 +238,8 @@ export function AuthProvidersSettings() {
// Delete provider
const deleteProvider = async (id: string, name: string) => {
if (!confirm(`Delete "${name}" provider? This cannot be undone.`)) return;
try {
setSaving(id);
setIsDeleting(true);
const response = await fetch(`/api/auth/providers/manage/${id}`, {
method: "DELETE",
});
@@ -244,6 +249,7 @@ export function AuthProvidersSettings() {
if (data.success) {
setProviders((prev) => prev.filter((p) => p.id !== id));
toast.success("Provider deleted");
setProviderToDelete(null);
} else {
toast.error("Failed to delete provider");
}
@@ -251,10 +257,19 @@ export function AuthProvidersSettings() {
console.error("Error deleting provider:", error);
toast.error("Failed to delete provider");
} 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
const addProvider = async () => {
if (!newProvider.name || !newProvider.displayName || !newProvider.clientId || !newProvider.clientSecret) {
@@ -802,7 +817,7 @@ export function AuthProvidersSettings() {
setEditingProvider(provider);
}
}}
onDelete={() => deleteProvider(provider.id, provider.displayName)}
onDelete={() => openDeleteModal(provider)}
saving={saving === provider.id}
getIcon={getProviderIcon}
editingProvider={editingProvider}
@@ -843,7 +858,7 @@ export function AuthProvidersSettings() {
setEditingProvider(provider);
}
}}
onDelete={() => deleteProvider(provider.id, provider.displayName)}
onDelete={() => openDeleteModal(provider)}
saving={saving === provider.id}
getIcon={getProviderIcon}
editingProvider={editingProvider}
@@ -876,6 +891,19 @@ export function AuthProvidersSettings() {
</div>
)}
</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>
);
}
@@ -1047,36 +1075,10 @@ function EditProviderForm({
{ pattern: "facebook.com", scopes: ["public_profile", "email"] },
{ pattern: "twitter.com", scopes: ["tweet.read", "users.read"] },
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
{ pattern: "keycloak", scopes: ["openid", "profile", "email"] },
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
{ pattern: "onelogin.com", scopes: ["openid", "profile", "email"] },
{ pattern: "pingidentity.com", scopes: ["openid", "profile", "email"] },
{ pattern: "azure.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "aws.amazon.com", scopes: ["openid", "profile", "email"] },
{ pattern: "slack.com", scopes: ["identity.basic", "identity.email", "identity.avatar"] },
{ pattern: "bitbucket.org", scopes: ["account", "repository"] },
{ pattern: "atlassian.com", scopes: ["read:jira-user", "read:jira-work"] },
{ pattern: "salesforce.com", scopes: ["api", "refresh_token"] },
{ pattern: "zendesk.com", scopes: ["read"] },
{ pattern: "shopify.com", scopes: ["read_products", "read_customers"] },
{ pattern: "stripe.com", scopes: ["read"] },
{ pattern: "twilio.com", scopes: ["read"] },
{ pattern: "sendgrid.com", scopes: ["mail.send"] },
{ pattern: "mailchimp.com", scopes: ["read"] },
{ pattern: "hubspot.com", scopes: ["contacts", "crm.objects.contacts.read"] },
{ pattern: "zoom.us", scopes: ["user:read:admin"] },
{ pattern: "teams.microsoft.com", scopes: ["openid", "profile", "email", "User.Read"] },
{ pattern: "notion.so", scopes: ["read"] },
{ pattern: "figma.com", scopes: ["files:read"] },
{ pattern: "dropbox.com", scopes: ["files.content.read"] },
{ pattern: "box.com", scopes: ["root_readwrite"] },
{ pattern: "trello.com", scopes: ["read"] },
{ pattern: "asana.com", scopes: ["default"] },
{ pattern: "monday.com", scopes: ["read"] },
{ pattern: "clickup.com", scopes: ["read"] },
{ pattern: "linear.app", scopes: ["read"] },
{ pattern: "kinde.com", scopes: ["openid", "profile", "email"] },
{ pattern: "zitadel.com", scopes: ["openid", "profile", "email"] },
];
// Procura por padrões conhecidos