mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
* fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records (#2441) * fix(oauth): updated oauth providers that had unstable reference IDs leading to duplicate oauth records * ack PR comments * ack PR comments * cleanup salesforce refresh logic * ack more PR comments
1735 lines
48 KiB
TypeScript
1735 lines
48 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import {
|
|
AirtableIcon,
|
|
AsanaIcon,
|
|
ConfluenceIcon,
|
|
// DiscordIcon,
|
|
DropboxIcon,
|
|
GithubIcon,
|
|
GmailIcon,
|
|
GoogleCalendarIcon,
|
|
GoogleDocsIcon,
|
|
GoogleDriveIcon,
|
|
GoogleFormsIcon,
|
|
GoogleGroupsIcon,
|
|
GoogleIcon,
|
|
GoogleSheetsIcon,
|
|
HubspotIcon,
|
|
JiraIcon,
|
|
LinearIcon,
|
|
LinkedInIcon,
|
|
MicrosoftExcelIcon,
|
|
MicrosoftIcon,
|
|
MicrosoftOneDriveIcon,
|
|
MicrosoftPlannerIcon,
|
|
MicrosoftSharepointIcon,
|
|
MicrosoftTeamsIcon,
|
|
NotionIcon,
|
|
OutlookIcon,
|
|
PipedriveIcon,
|
|
RedditIcon,
|
|
SalesforceIcon,
|
|
ShopifyIcon,
|
|
SlackIcon,
|
|
SpotifyIcon,
|
|
// SupabaseIcon,
|
|
TrelloIcon,
|
|
WealthboxIcon,
|
|
WebflowIcon,
|
|
WordpressIcon,
|
|
xIcon,
|
|
ZoomIcon,
|
|
} from '@/components/icons'
|
|
import { env } from '@/lib/core/config/env'
|
|
import { createLogger } from '@/lib/logs/console/logger'
|
|
|
|
const logger = createLogger('OAuth')
|
|
|
|
export type OAuthProvider =
|
|
| 'google'
|
|
| 'github'
|
|
| 'x'
|
|
// | 'supabase'
|
|
| 'confluence'
|
|
| 'airtable'
|
|
| 'notion'
|
|
| 'jira'
|
|
// | 'discord'
|
|
| 'dropbox'
|
|
| 'microsoft'
|
|
| 'linear'
|
|
| 'slack'
|
|
| 'reddit'
|
|
| 'trello'
|
|
| 'wealthbox'
|
|
| 'webflow'
|
|
| 'asana'
|
|
| 'pipedrive'
|
|
| 'hubspot'
|
|
| 'salesforce'
|
|
| 'linkedin'
|
|
| 'shopify'
|
|
| 'zoom'
|
|
| 'wordpress'
|
|
| 'spotify'
|
|
| string
|
|
|
|
export type OAuthService =
|
|
| 'google'
|
|
| 'google-email'
|
|
| 'google-drive'
|
|
| 'google-docs'
|
|
| 'google-sheets'
|
|
| 'google-calendar'
|
|
| 'google-vault'
|
|
| 'google-forms'
|
|
| 'google-groups'
|
|
| 'github'
|
|
| 'x'
|
|
// | 'supabase'
|
|
| 'confluence'
|
|
| 'airtable'
|
|
| 'notion'
|
|
| 'jira'
|
|
// | 'discord'
|
|
| 'dropbox'
|
|
| 'microsoft-excel'
|
|
| 'microsoft-teams'
|
|
| 'microsoft-planner'
|
|
| 'sharepoint'
|
|
| 'outlook'
|
|
| 'linear'
|
|
| 'slack'
|
|
| 'reddit'
|
|
| 'wealthbox'
|
|
| 'onedrive'
|
|
| 'webflow'
|
|
| 'trello'
|
|
| 'asana'
|
|
| 'pipedrive'
|
|
| 'hubspot'
|
|
| 'salesforce'
|
|
| 'linkedin'
|
|
| 'shopify'
|
|
| 'zoom'
|
|
| 'wordpress'
|
|
| 'spotify'
|
|
|
|
export interface OAuthProviderConfig {
|
|
id: OAuthProvider
|
|
name: string
|
|
icon: (props: { className?: string }) => ReactNode
|
|
services: Record<string, OAuthServiceConfig>
|
|
defaultService: string
|
|
}
|
|
|
|
export interface OAuthServiceConfig {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
providerId: string
|
|
icon: (props: { className?: string }) => ReactNode
|
|
baseProviderIcon: (props: { className?: string }) => ReactNode
|
|
scopes: string[]
|
|
scopeHints?: string[]
|
|
}
|
|
|
|
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
|
google: {
|
|
id: 'google',
|
|
name: 'Google',
|
|
icon: (props) => GoogleIcon(props),
|
|
services: {
|
|
gmail: {
|
|
id: 'gmail',
|
|
name: 'Gmail',
|
|
description: 'Automate email workflows and enhance communication efficiency.',
|
|
providerId: 'google-email',
|
|
icon: (props) => GmailIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/gmail.send',
|
|
'https://www.googleapis.com/auth/gmail.modify',
|
|
'https://www.googleapis.com/auth/gmail.labels',
|
|
],
|
|
scopeHints: ['gmail', 'mail'],
|
|
},
|
|
'google-drive': {
|
|
id: 'google-drive',
|
|
name: 'Google Drive',
|
|
description: 'Streamline file organization and document workflows.',
|
|
providerId: 'google-drive',
|
|
icon: (props) => GoogleDriveIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/drive.file',
|
|
'https://www.googleapis.com/auth/drive',
|
|
],
|
|
scopeHints: ['drive'],
|
|
},
|
|
'google-docs': {
|
|
id: 'google-docs',
|
|
name: 'Google Docs',
|
|
description: 'Create, read, and edit Google Documents programmatically.',
|
|
providerId: 'google-docs',
|
|
icon: (props) => GoogleDocsIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/drive.file',
|
|
'https://www.googleapis.com/auth/drive',
|
|
],
|
|
scopeHints: ['docs'],
|
|
},
|
|
'google-sheets': {
|
|
id: 'google-sheets',
|
|
name: 'Google Sheets',
|
|
description: 'Manage and analyze data with Google Sheets integration.',
|
|
providerId: 'google-sheets',
|
|
icon: (props) => GoogleSheetsIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/drive.file',
|
|
'https://www.googleapis.com/auth/drive',
|
|
],
|
|
scopeHints: ['sheets'],
|
|
},
|
|
'google-forms': {
|
|
id: 'google-forms',
|
|
name: 'Google Forms',
|
|
description: 'Retrieve Google Form responses.',
|
|
providerId: 'google-forms',
|
|
icon: (props) => GoogleFormsIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/userinfo.email',
|
|
'https://www.googleapis.com/auth/userinfo.profile',
|
|
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
],
|
|
scopeHints: ['forms'],
|
|
},
|
|
'google-calendar': {
|
|
id: 'google-calendar',
|
|
name: 'Google Calendar',
|
|
description: 'Schedule and manage events with Google Calendar.',
|
|
providerId: 'google-calendar',
|
|
icon: (props) => GoogleCalendarIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: ['https://www.googleapis.com/auth/calendar'],
|
|
scopeHints: ['calendar'],
|
|
},
|
|
'google-vault': {
|
|
id: 'google-vault',
|
|
name: 'Google Vault',
|
|
description: 'Search, export, and manage matters/holds via Google Vault.',
|
|
providerId: 'google-vault',
|
|
icon: (props) => GoogleIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/ediscovery',
|
|
'https://www.googleapis.com/auth/devstorage.read_only',
|
|
],
|
|
scopeHints: ['ediscovery', 'devstorage'],
|
|
},
|
|
'google-groups': {
|
|
id: 'google-groups',
|
|
name: 'Google Groups',
|
|
description: 'Manage Google Workspace Groups and their members.',
|
|
providerId: 'google-groups',
|
|
icon: (props) => GoogleGroupsIcon(props),
|
|
baseProviderIcon: (props) => GoogleIcon(props),
|
|
scopes: [
|
|
'https://www.googleapis.com/auth/admin.directory.group',
|
|
'https://www.googleapis.com/auth/admin.directory.group.member',
|
|
],
|
|
scopeHints: ['admin.directory.group'],
|
|
},
|
|
},
|
|
defaultService: 'gmail',
|
|
},
|
|
microsoft: {
|
|
id: 'microsoft',
|
|
name: 'Microsoft',
|
|
icon: (props) => MicrosoftIcon(props),
|
|
services: {
|
|
'microsoft-excel': {
|
|
id: 'microsoft-excel',
|
|
name: 'Microsoft Excel',
|
|
description: 'Connect to Microsoft Excel and manage spreadsheets.',
|
|
providerId: 'microsoft-excel',
|
|
icon: (props) => MicrosoftExcelIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
|
|
},
|
|
'microsoft-planner': {
|
|
id: 'microsoft-planner',
|
|
name: 'Microsoft Planner',
|
|
description: 'Connect to Microsoft Planner and manage tasks.',
|
|
providerId: 'microsoft-planner',
|
|
icon: (props) => MicrosoftPlannerIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
'Group.ReadWrite.All',
|
|
'Group.Read.All',
|
|
'Tasks.ReadWrite',
|
|
'offline_access',
|
|
],
|
|
},
|
|
'microsoft-teams': {
|
|
id: 'microsoft-teams',
|
|
name: 'Microsoft Teams',
|
|
description: 'Connect to Microsoft Teams and manage messages.',
|
|
providerId: 'microsoft-teams',
|
|
icon: (props) => MicrosoftTeamsIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
'User.Read',
|
|
'Chat.Read',
|
|
'Chat.ReadWrite',
|
|
'Chat.ReadBasic',
|
|
'ChatMessage.Send',
|
|
'Channel.ReadBasic.All',
|
|
'ChannelMessage.Send',
|
|
'ChannelMessage.Read.All',
|
|
'ChannelMessage.ReadWrite',
|
|
'ChannelMember.Read.All',
|
|
'Group.Read.All',
|
|
'Group.ReadWrite.All',
|
|
'Team.ReadBasic.All',
|
|
'TeamMember.Read.All',
|
|
'offline_access',
|
|
'Files.Read',
|
|
'Sites.Read.All',
|
|
],
|
|
},
|
|
outlook: {
|
|
id: 'outlook',
|
|
name: 'Outlook',
|
|
description: 'Connect to Outlook and manage emails.',
|
|
providerId: 'outlook',
|
|
icon: (props) => OutlookIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
'Mail.ReadWrite',
|
|
'Mail.ReadBasic',
|
|
'Mail.Read',
|
|
'Mail.Send',
|
|
'offline_access',
|
|
],
|
|
},
|
|
onedrive: {
|
|
id: 'onedrive',
|
|
name: 'OneDrive',
|
|
description: 'Connect to OneDrive and manage files.',
|
|
providerId: 'onedrive',
|
|
icon: (props) => MicrosoftOneDriveIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
|
|
},
|
|
sharepoint: {
|
|
id: 'sharepoint',
|
|
name: 'SharePoint',
|
|
description: 'Connect to SharePoint and manage sites.',
|
|
providerId: 'sharepoint',
|
|
icon: (props) => MicrosoftSharepointIcon(props),
|
|
baseProviderIcon: (props) => MicrosoftIcon(props),
|
|
scopes: [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
'Sites.Read.All',
|
|
'Sites.ReadWrite.All',
|
|
'Sites.Manage.All',
|
|
'offline_access',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'microsoft',
|
|
},
|
|
github: {
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
icon: (props) => GithubIcon(props),
|
|
services: {
|
|
github: {
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
description: 'Manage repositories, issues, and pull requests.',
|
|
providerId: 'github-repo',
|
|
icon: (props) => GithubIcon(props),
|
|
baseProviderIcon: (props) => GithubIcon(props),
|
|
scopes: ['repo', 'user:email', 'read:user', 'workflow'],
|
|
},
|
|
},
|
|
defaultService: 'github',
|
|
},
|
|
x: {
|
|
id: 'x',
|
|
name: 'X',
|
|
icon: (props) => xIcon(props),
|
|
services: {
|
|
x: {
|
|
id: 'x',
|
|
name: 'X',
|
|
description: 'Read and post tweets on X (formerly Twitter).',
|
|
providerId: 'x',
|
|
icon: (props) => xIcon(props),
|
|
baseProviderIcon: (props) => xIcon(props),
|
|
scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
|
|
},
|
|
},
|
|
defaultService: 'x',
|
|
},
|
|
// supabase: {
|
|
// id: 'supabase',
|
|
// name: 'Supabase',
|
|
// icon: (props) => SupabaseIcon(props),
|
|
// services: {
|
|
// supabase: {
|
|
// id: 'supabase',
|
|
// name: 'Supabase',
|
|
// description: 'Connect to your Supabase projects and manage data.',
|
|
// providerId: 'supabase',
|
|
// icon: (props) => SupabaseIcon(props),
|
|
// baseProviderIcon: (props) => SupabaseIcon(props),
|
|
// scopes: ['database.read', 'database.write', 'projects.read'],
|
|
// },
|
|
// },
|
|
// defaultService: 'supabase',
|
|
// },
|
|
confluence: {
|
|
id: 'confluence',
|
|
name: 'Confluence',
|
|
icon: (props) => ConfluenceIcon(props),
|
|
services: {
|
|
confluence: {
|
|
id: 'confluence',
|
|
name: 'Confluence',
|
|
description: 'Access Confluence content and documentation.',
|
|
providerId: 'confluence',
|
|
icon: (props) => ConfluenceIcon(props),
|
|
baseProviderIcon: (props) => ConfluenceIcon(props),
|
|
scopes: [
|
|
'read:confluence-content.all',
|
|
'read:confluence-space.summary',
|
|
'read:space:confluence',
|
|
'read:space-details:confluence',
|
|
'write:confluence-content',
|
|
'write:confluence-space',
|
|
'write:confluence-file',
|
|
'read:page:confluence',
|
|
'write:page:confluence',
|
|
'read:comment:confluence',
|
|
'write:comment:confluence',
|
|
'delete:comment:confluence',
|
|
'delete:attachment:confluence',
|
|
'read:content:confluence',
|
|
'delete:page:confluence',
|
|
'read:label:confluence',
|
|
'write:label:confluence',
|
|
'read:attachment:confluence',
|
|
'write:attachment:confluence',
|
|
'search:confluence',
|
|
'read:me',
|
|
'offline_access',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'confluence',
|
|
},
|
|
jira: {
|
|
id: 'jira',
|
|
name: 'Jira',
|
|
icon: (props) => JiraIcon(props),
|
|
services: {
|
|
jira: {
|
|
id: 'jira',
|
|
name: 'Jira',
|
|
description: 'Access Jira projects and issues.',
|
|
providerId: 'jira',
|
|
icon: (props) => JiraIcon(props),
|
|
baseProviderIcon: (props) => JiraIcon(props),
|
|
scopes: [
|
|
'read:jira-user',
|
|
'read:jira-work',
|
|
'write:jira-work',
|
|
'write:issue:jira',
|
|
'read:project:jira',
|
|
'read:issue-type:jira',
|
|
'read:me',
|
|
'offline_access',
|
|
'read:issue-meta:jira',
|
|
'read:issue-security-level:jira',
|
|
'read:issue.vote:jira',
|
|
'read:issue.changelog:jira',
|
|
'read:avatar:jira',
|
|
'read:issue:jira',
|
|
'read:status:jira',
|
|
'read:user:jira',
|
|
'read:field-configuration:jira',
|
|
'read:issue-details:jira',
|
|
'read:issue-event:jira',
|
|
'delete:issue:jira',
|
|
'write:comment:jira',
|
|
'read:comment:jira',
|
|
'delete:comment:jira',
|
|
'read:attachment:jira',
|
|
'delete:attachment:jira',
|
|
'write:issue-worklog:jira',
|
|
'read:issue-worklog:jira',
|
|
'delete:issue-worklog:jira',
|
|
'write:issue-link:jira',
|
|
'delete:issue-link:jira',
|
|
'manage:jira-webhook',
|
|
'read:webhook:jira',
|
|
'write:webhook:jira',
|
|
'delete:webhook:jira',
|
|
'read:issue.property:jira',
|
|
'read:comment.property:jira',
|
|
'read:jql:jira',
|
|
'read:field:jira',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'jira',
|
|
},
|
|
airtable: {
|
|
id: 'airtable',
|
|
name: 'Airtable',
|
|
icon: (props) => AirtableIcon(props),
|
|
services: {
|
|
airtable: {
|
|
id: 'airtable',
|
|
name: 'Airtable',
|
|
description: 'Manage Airtable bases, tables, and records.',
|
|
providerId: 'airtable',
|
|
icon: (props) => AirtableIcon(props),
|
|
baseProviderIcon: (props) => AirtableIcon(props),
|
|
scopes: ['data.records:read', 'data.records:write', 'user.email:read', 'webhook:manage'],
|
|
},
|
|
},
|
|
defaultService: 'airtable',
|
|
},
|
|
// discord: {
|
|
// id: 'discord',
|
|
// name: 'Discord',
|
|
// icon: (props) => DiscordIcon(props),
|
|
// services: {
|
|
// discord: {
|
|
// id: 'discord',
|
|
// name: 'Discord',
|
|
// description: 'Read and send messages to Discord channels and interact with servers.',
|
|
// providerId: 'discord',
|
|
// icon: (props) => DiscordIcon(props),
|
|
// baseProviderIcon: (props) => DiscordIcon(props),
|
|
// scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'],
|
|
// },
|
|
// },
|
|
// defaultService: 'discord',
|
|
// },
|
|
notion: {
|
|
id: 'notion',
|
|
name: 'Notion',
|
|
icon: (props) => NotionIcon(props),
|
|
services: {
|
|
notion: {
|
|
id: 'notion',
|
|
name: 'Notion',
|
|
description: 'Connect to your Notion workspace to manage pages and databases.',
|
|
providerId: 'notion',
|
|
icon: (props) => NotionIcon(props),
|
|
baseProviderIcon: (props) => NotionIcon(props),
|
|
scopes: ['workspace.content', 'workspace.name', 'page.read', 'page.write'],
|
|
},
|
|
},
|
|
defaultService: 'notion',
|
|
},
|
|
linear: {
|
|
id: 'linear',
|
|
name: 'Linear',
|
|
icon: (props) => LinearIcon(props),
|
|
services: {
|
|
linear: {
|
|
id: 'linear',
|
|
name: 'Linear',
|
|
description: 'Manage issues and projects in Linear.',
|
|
providerId: 'linear',
|
|
icon: (props) => LinearIcon(props),
|
|
baseProviderIcon: (props) => LinearIcon(props),
|
|
scopes: ['read', 'write'],
|
|
},
|
|
},
|
|
defaultService: 'linear',
|
|
},
|
|
dropbox: {
|
|
id: 'dropbox',
|
|
name: 'Dropbox',
|
|
icon: (props) => DropboxIcon(props),
|
|
services: {
|
|
dropbox: {
|
|
id: 'dropbox',
|
|
name: 'Dropbox',
|
|
description: 'Upload, download, share, and manage files in Dropbox.',
|
|
providerId: 'dropbox',
|
|
icon: (props) => DropboxIcon(props),
|
|
baseProviderIcon: (props) => DropboxIcon(props),
|
|
scopes: [
|
|
'account_info.read',
|
|
'files.metadata.read',
|
|
'files.metadata.write',
|
|
'files.content.read',
|
|
'files.content.write',
|
|
'sharing.read',
|
|
'sharing.write',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'dropbox',
|
|
},
|
|
shopify: {
|
|
id: 'shopify',
|
|
name: 'Shopify',
|
|
icon: (props) => ShopifyIcon(props),
|
|
services: {
|
|
shopify: {
|
|
id: 'shopify',
|
|
name: 'Shopify',
|
|
description: 'Manage products, orders, and customers in your Shopify store.',
|
|
providerId: 'shopify',
|
|
icon: (props) => ShopifyIcon(props),
|
|
baseProviderIcon: (props) => ShopifyIcon(props),
|
|
scopes: [
|
|
'write_products',
|
|
'write_orders',
|
|
'write_customers',
|
|
'write_inventory',
|
|
'read_locations',
|
|
'write_merchant_managed_fulfillment_orders',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'shopify',
|
|
},
|
|
slack: {
|
|
id: 'slack',
|
|
name: 'Slack',
|
|
icon: (props) => SlackIcon(props),
|
|
services: {
|
|
slack: {
|
|
id: 'slack',
|
|
name: 'Slack',
|
|
description: 'Send messages using a Slack bot.',
|
|
providerId: 'slack',
|
|
icon: (props) => SlackIcon(props),
|
|
baseProviderIcon: (props) => SlackIcon(props),
|
|
scopes: [
|
|
'channels:read',
|
|
'channels:history',
|
|
'groups:read',
|
|
'groups:history',
|
|
'chat:write',
|
|
'chat:write.public',
|
|
'im:write',
|
|
'im:history',
|
|
'im:read',
|
|
'users:read',
|
|
'files:write',
|
|
'files:read',
|
|
'canvases:write',
|
|
'reactions:write',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'slack',
|
|
},
|
|
reddit: {
|
|
id: 'reddit',
|
|
name: 'Reddit',
|
|
icon: (props) => RedditIcon(props),
|
|
services: {
|
|
reddit: {
|
|
id: 'reddit',
|
|
name: 'Reddit',
|
|
description: 'Access Reddit data and content from subreddits.',
|
|
providerId: 'reddit',
|
|
icon: (props) => RedditIcon(props),
|
|
baseProviderIcon: (props) => RedditIcon(props),
|
|
scopes: [
|
|
'identity',
|
|
'read',
|
|
'submit',
|
|
'vote',
|
|
'save',
|
|
'edit',
|
|
'subscribe',
|
|
'history',
|
|
'privatemessages',
|
|
'account',
|
|
'mysubreddits',
|
|
'flair',
|
|
'report',
|
|
'modposts',
|
|
'modflair',
|
|
'modmail',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'reddit',
|
|
},
|
|
wealthbox: {
|
|
id: 'wealthbox',
|
|
name: 'Wealthbox',
|
|
icon: (props) => WealthboxIcon(props),
|
|
services: {
|
|
wealthbox: {
|
|
id: 'wealthbox',
|
|
name: 'Wealthbox',
|
|
description: 'Manage contacts, notes, and tasks in your Wealthbox CRM.',
|
|
providerId: 'wealthbox',
|
|
icon: (props) => WealthboxIcon(props),
|
|
baseProviderIcon: (props) => WealthboxIcon(props),
|
|
scopes: ['login', 'data'],
|
|
},
|
|
},
|
|
defaultService: 'wealthbox',
|
|
},
|
|
webflow: {
|
|
id: 'webflow',
|
|
name: 'Webflow',
|
|
icon: (props) => WebflowIcon(props),
|
|
services: {
|
|
webflow: {
|
|
id: 'webflow',
|
|
name: 'Webflow',
|
|
description: 'Manage Webflow CMS collections, sites, and content.',
|
|
providerId: 'webflow',
|
|
icon: (props) => WebflowIcon(props),
|
|
baseProviderIcon: (props) => WebflowIcon(props),
|
|
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'],
|
|
},
|
|
},
|
|
defaultService: 'webflow',
|
|
},
|
|
trello: {
|
|
id: 'trello',
|
|
name: 'Trello',
|
|
icon: (props) => TrelloIcon(props),
|
|
services: {
|
|
trello: {
|
|
id: 'trello',
|
|
name: 'Trello',
|
|
description: 'Manage Trello boards, cards, and workflows.',
|
|
providerId: 'trello',
|
|
icon: (props) => TrelloIcon(props),
|
|
baseProviderIcon: (props) => TrelloIcon(props),
|
|
scopes: ['read', 'write'],
|
|
},
|
|
},
|
|
defaultService: 'trello',
|
|
},
|
|
asana: {
|
|
id: 'asana',
|
|
name: 'Asana',
|
|
icon: (props) => AsanaIcon(props),
|
|
services: {
|
|
asana: {
|
|
id: 'asana',
|
|
name: 'Asana',
|
|
description: 'Manage Asana projects, tasks, and workflows.',
|
|
providerId: 'asana',
|
|
icon: (props) => AsanaIcon(props),
|
|
baseProviderIcon: (props) => AsanaIcon(props),
|
|
scopes: ['default'],
|
|
},
|
|
},
|
|
defaultService: 'asana',
|
|
},
|
|
pipedrive: {
|
|
id: 'pipedrive',
|
|
name: 'Pipedrive',
|
|
icon: (props) => PipedriveIcon(props),
|
|
services: {
|
|
pipedrive: {
|
|
id: 'pipedrive',
|
|
name: 'Pipedrive',
|
|
description: 'Manage deals, contacts, and sales pipeline in Pipedrive CRM.',
|
|
providerId: 'pipedrive',
|
|
icon: (props) => PipedriveIcon(props),
|
|
baseProviderIcon: (props) => PipedriveIcon(props),
|
|
scopes: [
|
|
'base',
|
|
'deals:full',
|
|
'contacts:full',
|
|
'leads:full',
|
|
'activities:full',
|
|
'mail:full',
|
|
'projects:full',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'pipedrive',
|
|
},
|
|
hubspot: {
|
|
id: 'hubspot',
|
|
name: 'HubSpot',
|
|
icon: (props) => HubspotIcon(props),
|
|
services: {
|
|
hubspot: {
|
|
id: 'hubspot',
|
|
name: 'HubSpot',
|
|
description: 'Access and manage your HubSpot CRM data.',
|
|
providerId: 'hubspot',
|
|
icon: (props) => HubspotIcon(props),
|
|
baseProviderIcon: (props) => HubspotIcon(props),
|
|
scopes: [
|
|
'crm.objects.contacts.read',
|
|
'crm.objects.contacts.write',
|
|
'crm.objects.companies.read',
|
|
'crm.objects.companies.write',
|
|
'crm.objects.deals.read',
|
|
'crm.objects.deals.write',
|
|
'crm.objects.owners.read',
|
|
'crm.objects.users.read',
|
|
'crm.objects.users.write',
|
|
'crm.objects.marketing_events.read',
|
|
'crm.objects.marketing_events.write',
|
|
'crm.objects.line_items.read',
|
|
'crm.objects.line_items.write',
|
|
'crm.objects.quotes.read',
|
|
'crm.objects.quotes.write',
|
|
'crm.objects.appointments.read',
|
|
'crm.objects.appointments.write',
|
|
'crm.objects.carts.read',
|
|
'crm.objects.carts.write',
|
|
'crm.import',
|
|
'crm.lists.read',
|
|
'crm.lists.write',
|
|
'tickets',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'hubspot',
|
|
},
|
|
linkedin: {
|
|
id: 'linkedin',
|
|
name: 'LinkedIn',
|
|
icon: (props) => LinkedInIcon(props),
|
|
services: {
|
|
linkedin: {
|
|
id: 'linkedin',
|
|
name: 'LinkedIn',
|
|
description: 'Share posts and access profile data on LinkedIn.',
|
|
providerId: 'linkedin',
|
|
icon: (props) => LinkedInIcon(props),
|
|
baseProviderIcon: (props) => LinkedInIcon(props),
|
|
scopes: ['profile', 'openid', 'email', 'w_member_social'],
|
|
},
|
|
},
|
|
defaultService: 'linkedin',
|
|
},
|
|
salesforce: {
|
|
id: 'salesforce',
|
|
name: 'Salesforce',
|
|
icon: (props) => SalesforceIcon(props),
|
|
services: {
|
|
salesforce: {
|
|
id: 'salesforce',
|
|
name: 'Salesforce',
|
|
description: 'Access and manage your Salesforce CRM data.',
|
|
providerId: 'salesforce',
|
|
icon: (props) => SalesforceIcon(props),
|
|
baseProviderIcon: (props) => SalesforceIcon(props),
|
|
scopes: ['api', 'refresh_token', 'openid', 'offline_access'],
|
|
},
|
|
},
|
|
defaultService: 'salesforce',
|
|
},
|
|
zoom: {
|
|
id: 'zoom',
|
|
name: 'Zoom',
|
|
icon: (props) => ZoomIcon(props),
|
|
services: {
|
|
zoom: {
|
|
id: 'zoom',
|
|
name: 'Zoom',
|
|
description: 'Create and manage Zoom meetings, users, and recordings.',
|
|
providerId: 'zoom',
|
|
icon: (props) => ZoomIcon(props),
|
|
baseProviderIcon: (props) => ZoomIcon(props),
|
|
scopes: [
|
|
'user:read:user',
|
|
'meeting:write:meeting',
|
|
'meeting:read:meeting',
|
|
'meeting:read:list_meetings',
|
|
'meeting:update:meeting',
|
|
'meeting:delete:meeting',
|
|
'meeting:read:invitation',
|
|
'meeting:read:list_past_participants',
|
|
'cloud_recording:read:list_user_recordings',
|
|
'cloud_recording:read:list_recording_files',
|
|
'cloud_recording:delete:recording_file',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'zoom',
|
|
},
|
|
wordpress: {
|
|
id: 'wordpress',
|
|
name: 'WordPress',
|
|
icon: (props) => WordpressIcon(props),
|
|
services: {
|
|
wordpress: {
|
|
id: 'wordpress',
|
|
name: 'WordPress',
|
|
description: 'Manage posts, pages, media, comments, and more on WordPress sites.',
|
|
providerId: 'wordpress',
|
|
icon: (props) => WordpressIcon(props),
|
|
baseProviderIcon: (props) => WordpressIcon(props),
|
|
scopes: ['global'],
|
|
},
|
|
},
|
|
defaultService: 'wordpress',
|
|
},
|
|
spotify: {
|
|
id: 'spotify',
|
|
name: 'Spotify',
|
|
icon: (props) => SpotifyIcon(props),
|
|
services: {
|
|
spotify: {
|
|
id: 'spotify',
|
|
name: 'Spotify',
|
|
description: 'Search music, manage playlists, control playback, and access your library.',
|
|
providerId: 'spotify',
|
|
icon: (props) => SpotifyIcon(props),
|
|
baseProviderIcon: (props) => SpotifyIcon(props),
|
|
scopes: [
|
|
'user-read-private',
|
|
'user-read-email',
|
|
'user-library-read',
|
|
'user-library-modify',
|
|
'playlist-read-private',
|
|
'playlist-read-collaborative',
|
|
'playlist-modify-public',
|
|
'playlist-modify-private',
|
|
'user-read-playback-state',
|
|
'user-modify-playback-state',
|
|
'user-read-currently-playing',
|
|
'user-read-recently-played',
|
|
'user-top-read',
|
|
'user-follow-read',
|
|
'user-follow-modify',
|
|
'user-read-playback-position',
|
|
'ugc-image-upload',
|
|
],
|
|
},
|
|
},
|
|
defaultService: 'spotify',
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Service metadata without React components - safe for server-side use
|
|
*/
|
|
export interface OAuthServiceMetadata {
|
|
providerId: string
|
|
name: string
|
|
description: string
|
|
baseProvider: string
|
|
}
|
|
|
|
/**
|
|
* Returns a flat list of all available OAuth services with metadata.
|
|
* This is safe to use on the server as it doesn't include React components.
|
|
*/
|
|
export function getAllOAuthServices(): OAuthServiceMetadata[] {
|
|
const services: OAuthServiceMetadata[] = []
|
|
|
|
for (const [baseProviderId, provider] of Object.entries(OAUTH_PROVIDERS)) {
|
|
for (const service of Object.values(provider.services)) {
|
|
services.push({
|
|
providerId: service.providerId,
|
|
name: service.name,
|
|
description: service.description,
|
|
baseProvider: baseProviderId,
|
|
})
|
|
}
|
|
}
|
|
|
|
return services
|
|
}
|
|
|
|
export function getServiceByProviderAndId(
|
|
provider: OAuthProvider,
|
|
serviceId?: string
|
|
): OAuthServiceConfig {
|
|
const providerConfig = OAUTH_PROVIDERS[provider]
|
|
if (!providerConfig) {
|
|
throw new Error(`Provider ${provider} not found`)
|
|
}
|
|
|
|
if (!serviceId) {
|
|
return providerConfig.services[providerConfig.defaultService]
|
|
}
|
|
|
|
return (
|
|
providerConfig.services[serviceId] || providerConfig.services[providerConfig.defaultService]
|
|
)
|
|
}
|
|
|
|
export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string {
|
|
const { baseProvider, featureType } = parseProvider(provider)
|
|
const providerConfig = OAUTH_PROVIDERS[baseProvider] || OAUTH_PROVIDERS[provider]
|
|
if (!providerConfig) {
|
|
return provider
|
|
}
|
|
|
|
if (featureType !== 'default' && providerConfig.services[featureType]) {
|
|
return featureType
|
|
}
|
|
|
|
const normalizedScopes = (scopes || []).map((s) => s.toLowerCase())
|
|
for (const service of Object.values(providerConfig.services)) {
|
|
const hints = (service.scopeHints || []).map((h) => h.toLowerCase())
|
|
if (hints.length === 0) continue
|
|
if (normalizedScopes.some((scope) => hints.some((hint) => scope.includes(hint)))) {
|
|
return service.id
|
|
}
|
|
}
|
|
|
|
return providerConfig.defaultService
|
|
}
|
|
|
|
export function getProviderIdFromServiceId(serviceId: string): string {
|
|
for (const provider of Object.values(OAUTH_PROVIDERS)) {
|
|
for (const [id, service] of Object.entries(provider.services)) {
|
|
if (id === serviceId) {
|
|
return service.providerId
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default fallback
|
|
return serviceId
|
|
}
|
|
|
|
export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null {
|
|
for (const provider of Object.values(OAUTH_PROVIDERS)) {
|
|
for (const service of Object.values(provider.services)) {
|
|
if (service.providerId === providerId || service.id === providerId) {
|
|
return service
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function getCanonicalScopesForProvider(providerId: string): string[] {
|
|
const service = getServiceConfigByProviderId(providerId)
|
|
return service?.scopes ? [...service.scopes] : []
|
|
}
|
|
|
|
export function normalizeScopes(scopes: string[]): string[] {
|
|
const seen = new Set<string>()
|
|
for (const scope of scopes) {
|
|
const trimmed = scope.trim()
|
|
if (trimmed && !seen.has(trimmed)) {
|
|
seen.add(trimmed)
|
|
}
|
|
}
|
|
return Array.from(seen)
|
|
}
|
|
|
|
export interface ScopeEvaluation {
|
|
canonicalScopes: string[]
|
|
grantedScopes: string[]
|
|
missingScopes: string[]
|
|
extraScopes: string[]
|
|
requiresReauthorization: boolean
|
|
}
|
|
|
|
export function evaluateScopeCoverage(
|
|
providerId: string,
|
|
grantedScopes: string[]
|
|
): ScopeEvaluation {
|
|
const canonicalScopes = getCanonicalScopesForProvider(providerId)
|
|
const normalizedGranted = normalizeScopes(grantedScopes)
|
|
|
|
const canonicalSet = new Set(canonicalScopes)
|
|
const grantedSet = new Set(normalizedGranted)
|
|
|
|
const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope))
|
|
const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope))
|
|
|
|
return {
|
|
canonicalScopes,
|
|
grantedScopes: normalizedGranted,
|
|
missingScopes,
|
|
extraScopes,
|
|
requiresReauthorization: missingScopes.length > 0,
|
|
}
|
|
}
|
|
|
|
export interface Credential {
|
|
id: string
|
|
name: string
|
|
provider: OAuthProvider
|
|
serviceId?: string
|
|
lastUsed?: string
|
|
isDefault?: boolean
|
|
scopes?: string[]
|
|
canonicalScopes?: string[]
|
|
missingScopes?: string[]
|
|
extraScopes?: string[]
|
|
requiresReauthorization?: boolean
|
|
}
|
|
|
|
export interface ProviderConfig {
|
|
baseProvider: string
|
|
featureType: string
|
|
}
|
|
|
|
/**
|
|
* Parse a provider string into its base provider and feature type
|
|
* This is a server-safe utility that can be used in both client and server code
|
|
*/
|
|
export function parseProvider(provider: OAuthProvider): ProviderConfig {
|
|
// Handle special cases first
|
|
if (provider === 'outlook') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'outlook',
|
|
}
|
|
}
|
|
if (provider === 'onedrive') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'onedrive',
|
|
}
|
|
}
|
|
if (provider === 'sharepoint') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'sharepoint',
|
|
}
|
|
}
|
|
if (provider === 'microsoft-teams' || provider === 'microsoftteams') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'microsoft-teams',
|
|
}
|
|
}
|
|
if (provider === 'microsoft-excel') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'microsoft-excel',
|
|
}
|
|
}
|
|
if (provider === 'microsoft-planner') {
|
|
return {
|
|
baseProvider: 'microsoft',
|
|
featureType: 'microsoft-planner',
|
|
}
|
|
}
|
|
|
|
// Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' })
|
|
const [base, feature] = provider.split('-')
|
|
|
|
if (feature) {
|
|
return {
|
|
baseProvider: base,
|
|
featureType: feature,
|
|
}
|
|
}
|
|
|
|
// For simple providers, use 'default' as feature type
|
|
return {
|
|
baseProvider: provider,
|
|
featureType: 'default',
|
|
}
|
|
}
|
|
|
|
interface ProviderAuthConfig {
|
|
tokenEndpoint: string
|
|
clientId: string
|
|
clientSecret: string
|
|
useBasicAuth: boolean
|
|
additionalHeaders?: Record<string, string>
|
|
supportsRefreshTokenRotation?: boolean
|
|
}
|
|
|
|
/**
|
|
* Get OAuth provider configuration for token refresh
|
|
*/
|
|
function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
|
const getCredentials = (clientId: string | undefined, clientSecret: string | undefined) => {
|
|
if (!clientId || !clientSecret) {
|
|
throw new Error(`Missing client credentials for provider: ${provider}`)
|
|
}
|
|
return { clientId, clientSecret }
|
|
}
|
|
|
|
switch (provider) {
|
|
case 'google': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.GOOGLE_CLIENT_ID,
|
|
env.GOOGLE_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'github': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.GITHUB_CLIENT_ID,
|
|
env.GITHUB_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
additionalHeaders: { Accept: 'application/json' },
|
|
}
|
|
}
|
|
case 'x': {
|
|
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
|
|
return {
|
|
tokenEndpoint: 'https://api.x.com/2/oauth2/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
}
|
|
}
|
|
case 'confluence': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.CONFLUENCE_CLIENT_ID,
|
|
env.CONFLUENCE_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://auth.atlassian.com/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'jira': {
|
|
const { clientId, clientSecret } = getCredentials(env.JIRA_CLIENT_ID, env.JIRA_CLIENT_SECRET)
|
|
return {
|
|
tokenEndpoint: 'https://auth.atlassian.com/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'airtable': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.AIRTABLE_CLIENT_ID,
|
|
env.AIRTABLE_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://airtable.com/oauth2/v1/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
// case 'supabase': {
|
|
// const { clientId, clientSecret } = getCredentials(
|
|
// env.SUPABASE_CLIENT_ID,
|
|
// env.SUPABASE_CLIENT_SECRET
|
|
// )
|
|
// return {
|
|
// tokenEndpoint: 'https://api.supabase.com/v1/oauth/token',
|
|
// clientId,
|
|
// clientSecret,
|
|
// useBasicAuth: false,
|
|
// }
|
|
// }
|
|
case 'notion': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.NOTION_CLIENT_ID,
|
|
env.NOTION_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://api.notion.com/v1/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
// case 'discord': {
|
|
// const { clientId, clientSecret } = getCredentials(
|
|
// env.DISCORD_CLIENT_ID,
|
|
// env.DISCORD_CLIENT_SECRET
|
|
// )
|
|
// return {
|
|
// tokenEndpoint: 'https://discord.com/api/v10/oauth2/token',
|
|
// clientId,
|
|
// clientSecret,
|
|
// useBasicAuth: true,
|
|
// }
|
|
// }
|
|
case 'microsoft': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.MICROSOFT_CLIENT_ID,
|
|
env.MICROSOFT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'outlook': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.MICROSOFT_CLIENT_ID,
|
|
env.MICROSOFT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'onedrive': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.MICROSOFT_CLIENT_ID,
|
|
env.MICROSOFT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'sharepoint': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.MICROSOFT_CLIENT_ID,
|
|
env.MICROSOFT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'linear': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.LINEAR_CLIENT_ID,
|
|
env.LINEAR_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
}
|
|
}
|
|
case 'dropbox': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.DROPBOX_CLIENT_ID,
|
|
env.DROPBOX_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://api.dropboxapi.com/oauth2/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
}
|
|
}
|
|
case 'slack': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.SLACK_CLIENT_ID,
|
|
env.SLACK_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'reddit': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.REDDIT_CLIENT_ID,
|
|
env.REDDIT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://www.reddit.com/api/v1/access_token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
}
|
|
}
|
|
case 'wealthbox': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.WEALTHBOX_CLIENT_ID,
|
|
env.WEALTHBOX_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://app.crmworkspace.com/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'webflow': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.WEBFLOW_CLIENT_ID,
|
|
env.WEBFLOW_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://api.webflow.com/oauth/access_token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
case 'asana': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.ASANA_CLIENT_ID,
|
|
env.ASANA_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://app.asana.com/-/oauth_token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'pipedrive': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.PIPEDRIVE_CLIENT_ID,
|
|
env.PIPEDRIVE_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://oauth.pipedrive.com/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'hubspot': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.HUBSPOT_CLIENT_ID,
|
|
env.HUBSPOT_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://api.hubapi.com/oauth/v1/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'linkedin': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.LINKEDIN_CLIENT_ID,
|
|
env.LINKEDIN_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
case 'salesforce': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.SALESFORCE_CLIENT_ID,
|
|
env.SALESFORCE_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: true,
|
|
}
|
|
}
|
|
case 'shopify': {
|
|
// Shopify access tokens don't expire and don't support refresh tokens
|
|
// This configuration is provided for completeness but won't be used for token refresh
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.SHOPIFY_CLIENT_ID,
|
|
env.SHOPIFY_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://accounts.shopify.com/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
case 'zoom': {
|
|
const { clientId, clientSecret } = getCredentials(env.ZOOM_CLIENT_ID, env.ZOOM_CLIENT_SECRET)
|
|
return {
|
|
tokenEndpoint: 'https://zoom.us/oauth/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
case 'wordpress': {
|
|
// WordPress.com does NOT support refresh tokens
|
|
// Users will need to re-authorize when tokens expire (~2 weeks)
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.WORDPRESS_CLIENT_ID,
|
|
env.WORDPRESS_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://public-api.wordpress.com/oauth2/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: false,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
case 'spotify': {
|
|
const { clientId, clientSecret } = getCredentials(
|
|
env.SPOTIFY_CLIENT_ID,
|
|
env.SPOTIFY_CLIENT_SECRET
|
|
)
|
|
return {
|
|
tokenEndpoint: 'https://accounts.spotify.com/api/token',
|
|
clientId,
|
|
clientSecret,
|
|
useBasicAuth: true,
|
|
supportsRefreshTokenRotation: false,
|
|
}
|
|
}
|
|
default:
|
|
throw new Error(`Unsupported provider: ${provider}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the authentication request headers and body for OAuth token refresh
|
|
*/
|
|
function buildAuthRequest(
|
|
config: ProviderAuthConfig,
|
|
refreshToken: string
|
|
): { headers: Record<string, string>; bodyParams: Record<string, string> } {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
...config.additionalHeaders,
|
|
}
|
|
|
|
const bodyParams: Record<string, string> = {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
}
|
|
|
|
if (config.useBasicAuth) {
|
|
// Use Basic Authentication - credentials in Authorization header only
|
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')
|
|
headers.Authorization = `Basic ${basicAuth}`
|
|
} else {
|
|
// Use body credentials - include client credentials in request body
|
|
bodyParams.client_id = config.clientId
|
|
bodyParams.client_secret = config.clientSecret
|
|
}
|
|
|
|
return { headers, bodyParams }
|
|
}
|
|
|
|
/**
|
|
* Attempts to refresh a Salesforce token using multiple endpoints.
|
|
* Tries the original auth endpoint first (if provided), then production, then sandbox.
|
|
*/
|
|
async function refreshSalesforceToken(
|
|
refreshToken: string,
|
|
config: ProviderAuthConfig,
|
|
authBaseUrl?: string
|
|
): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
|
|
const endpoints: string[] = []
|
|
if (authBaseUrl) {
|
|
endpoints.push(`${authBaseUrl}/services/oauth2/token`)
|
|
}
|
|
endpoints.push('https://login.salesforce.com/services/oauth2/token')
|
|
endpoints.push('https://test.salesforce.com/services/oauth2/token')
|
|
|
|
const { headers, bodyParams } = buildAuthRequest(config, refreshToken)
|
|
|
|
for (const endpoint of endpoints) {
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
body: new URLSearchParams(bodyParams).toString(),
|
|
})
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
const accessToken = data.access_token
|
|
|
|
if (!accessToken) {
|
|
logger.warn('No access token in Salesforce refresh response', { endpoint })
|
|
continue
|
|
}
|
|
|
|
let newRefreshToken = null
|
|
if (config.supportsRefreshTokenRotation && data.refresh_token) {
|
|
newRefreshToken = data.refresh_token
|
|
logger.info('Received new refresh token from Salesforce')
|
|
}
|
|
|
|
const expiresIn = data.expires_in || data.expiresIn || 7200
|
|
|
|
logger.info('Salesforce token refreshed successfully', {
|
|
endpoint,
|
|
expiresIn,
|
|
hasNewRefreshToken: !!newRefreshToken,
|
|
})
|
|
|
|
return {
|
|
accessToken,
|
|
expiresIn,
|
|
refreshToken: newRefreshToken || refreshToken,
|
|
}
|
|
}
|
|
|
|
// Log failure but try next endpoint
|
|
const errorText = await response.text()
|
|
logger.warn('Salesforce token refresh failed for endpoint, trying next', {
|
|
endpoint,
|
|
status: response.status,
|
|
error: errorText.substring(0, 200),
|
|
})
|
|
} catch (error) {
|
|
logger.warn('Salesforce token refresh error for endpoint, trying next', {
|
|
endpoint,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
logger.error('Salesforce token refresh failed on all endpoints')
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Refresh an OAuth token
|
|
* This is a server-side utility function to refresh OAuth tokens
|
|
* @param providerId The provider ID (e.g., 'google-drive')
|
|
* @param refreshToken The refresh token to use
|
|
* @param idToken Optional idToken field (used by Salesforce to store auth metadata)
|
|
* @returns Object containing the new access token and expiration time in seconds, or null if refresh failed
|
|
*/
|
|
export async function refreshOAuthToken(
|
|
providerId: string,
|
|
refreshToken: string,
|
|
idToken?: string
|
|
): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
|
|
try {
|
|
const provider = providerId.split('-')[0]
|
|
|
|
const config = getProviderAuthConfig(provider)
|
|
|
|
// Salesforce needs special handling to try multiple endpoints
|
|
if (provider === 'salesforce') {
|
|
let authBaseUrl: string | undefined
|
|
if (idToken?.startsWith('{')) {
|
|
try {
|
|
authBaseUrl = JSON.parse(idToken).authBaseUrl
|
|
} catch {
|
|
// Not valid JSON, ignore
|
|
}
|
|
}
|
|
return await refreshSalesforceToken(refreshToken, config, authBaseUrl)
|
|
}
|
|
|
|
const { headers, bodyParams } = buildAuthRequest(config, refreshToken)
|
|
|
|
const response = await fetch(config.tokenEndpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
body: new URLSearchParams(bodyParams).toString(),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
let errorData = errorText
|
|
|
|
try {
|
|
errorData = JSON.parse(errorText)
|
|
} catch (_e) {
|
|
// Not JSON, keep as text
|
|
}
|
|
|
|
logger.error('Token refresh failed:', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: errorText,
|
|
parsedError: errorData,
|
|
providerId,
|
|
tokenEndpoint: config.tokenEndpoint,
|
|
hasClientId: !!config.clientId,
|
|
hasClientSecret: !!config.clientSecret,
|
|
hasRefreshToken: !!refreshToken,
|
|
refreshTokenPrefix: refreshToken ? `${refreshToken.substring(0, 10)}...` : 'none',
|
|
})
|
|
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
|
|
const accessToken = data.access_token
|
|
|
|
let newRefreshToken = null
|
|
if (config.supportsRefreshTokenRotation && data.refresh_token) {
|
|
newRefreshToken = data.refresh_token
|
|
logger.info(`Received new refresh token from ${provider}`)
|
|
}
|
|
|
|
const expiresIn = data.expires_in || data.expiresIn || 3600
|
|
|
|
if (!accessToken) {
|
|
logger.warn('No access token found in refresh response', data)
|
|
return null
|
|
}
|
|
|
|
logger.info('Token refreshed successfully with expiration', {
|
|
expiresIn,
|
|
hasNewRefreshToken: !!newRefreshToken,
|
|
provider,
|
|
})
|
|
|
|
return {
|
|
accessToken,
|
|
expiresIn,
|
|
refreshToken: newRefreshToken || refreshToken, // Return new refresh token if available
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error refreshing token:', { error })
|
|
return null
|
|
}
|
|
}
|