Files
sim/apps/sim/lib/oauth.ts
Adam Gough 79b761c022 feat(tools): created outlook tools/block (#409)
* feat: first round of tools for outlook

* added more

* outlook finished

* added bun and docs

* fix: added greptile comments

* added greptile and bun lint

* got rid of HTML

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-05-24 20:57:49 -07:00

678 lines
20 KiB
TypeScript

import type { ReactNode } from 'react'
import {
AirtableIcon,
ConfluenceIcon,
DiscordIcon,
GithubIcon,
GmailIcon,
GoogleCalendarIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleIcon,
GoogleSheetsIcon,
JiraIcon,
MicrosoftIcon,
MicrosoftTeamsIcon,
NotionIcon,
OutlookIcon,
SupabaseIcon,
xIcon,
} from '@/components/icons'
import { createLogger } from '@/lib/logs/console-logger'
import { env } from './env'
const logger = createLogger('OAuth')
// Define the base OAuth provider type
export type OAuthProvider =
| 'google'
| 'github'
| 'x'
| 'supabase'
| 'confluence'
| 'airtable'
| 'notion'
| 'jira'
| 'discord'
| 'microsoft'
| string
export type OAuthService =
| 'google'
| 'google-email'
| 'google-drive'
| 'google-docs'
| 'google-sheets'
| 'github'
| 'x'
| 'supabase'
| 'confluence'
| 'airtable'
| 'notion'
| 'jira'
| 'discord'
| 'microsoft-teams'
| 'outlook'
// Define the interface for OAuth provider configuration
export interface OAuthProviderConfig {
id: OAuthProvider
name: string
icon: (props: { className?: string }) => ReactNode
services: Record<string, OAuthServiceConfig>
defaultService: string
}
// Define the interface for OAuth service configuration
export interface OAuthServiceConfig {
id: string
name: string
description: string
providerId: string
icon: (props: { className?: string }) => ReactNode
baseProviderIcon: (props: { className?: string }) => ReactNode
scopes: string[]
}
// Define the available OAuth providers
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.readonly',
'https://www.googleapis.com/auth/gmail.labels',
],
},
'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'],
},
'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'],
},
'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/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
],
},
'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'],
},
},
defaultService: 'gmail',
},
microsoft: {
id: 'microsoft',
name: 'Microsoft',
icon: (props) => MicrosoftIcon(props),
services: {
'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',
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',
'offline_access',
],
},
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',
],
},
},
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:page:confluence', 'write:page: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',
'read:project:jira',
'read:issue-type:jira',
'read:me',
'offline_access',
],
},
},
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'],
},
},
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',
},
}
// Helper function to get a service by provider and service ID
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]
)
}
// Helper function to determine service ID from scopes
export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]): string {
const providerConfig = OAUTH_PROVIDERS[provider]
if (!providerConfig) {
return provider
}
if (provider === 'google') {
if (scopes.some((scope) => scope.includes('gmail') || scope.includes('mail'))) {
return 'gmail'
}
if (scopes.some((scope) => scope.includes('drive'))) {
return 'google-drive'
}
if (scopes.some((scope) => scope.includes('docs'))) {
return 'google-docs'
}
if (scopes.some((scope) => scope.includes('sheets'))) {
return 'google-sheets'
}
if (scopes.some((scope) => scope.includes('calendar'))) {
return 'google-calendar'
}
} else if (provider === 'microsoft-teams') {
return 'microsoft-teams'
} else if (provider === 'outlook') {
return 'outlook'
} else if (provider === 'github') {
return 'github'
} else if (provider === 'supabase') {
return 'supabase'
} else if (provider === 'x') {
return 'x'
} else if (provider === 'confluence') {
return 'confluence'
} else if (provider === 'jira') {
return 'jira'
} else if (provider === 'airtable') {
return 'airtable'
} else if (provider === 'notion') {
return 'notion'
} else if (provider === 'discord') {
return 'discord'
}
return providerConfig.defaultService
}
// Helper function to get provider ID from service ID
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
}
// Interface for credential objects
export interface Credential {
id: string
name: string
provider: OAuthProvider
serviceId?: string
lastUsed?: string
isDefault?: boolean
}
// Interface for provider configuration
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',
}
}
// 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',
}
}
/**
* 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
* @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
): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> {
try {
// Get the provider from the providerId (e.g., 'google-drive' -> 'google')
const provider = providerId.split('-')[0]
// Determine the token endpoint based on the provider
let tokenEndpoint: string
let clientId: string | undefined
let clientSecret: string | undefined
let useBasicAuth = false
switch (provider) {
case 'google':
tokenEndpoint = 'https://oauth2.googleapis.com/token'
clientId = env.GOOGLE_CLIENT_ID
clientSecret = env.GOOGLE_CLIENT_SECRET
break
case 'github':
tokenEndpoint = 'https://github.com/login/oauth/access_token'
clientId = env.GITHUB_CLIENT_ID
clientSecret = env.GITHUB_CLIENT_SECRET
break
case 'x':
tokenEndpoint = 'https://api.x.com/2/oauth2/token'
clientId = env.X_CLIENT_ID
clientSecret = env.X_CLIENT_SECRET
useBasicAuth = true
break
case 'confluence':
tokenEndpoint = 'https://auth.atlassian.com/oauth/token'
clientId = env.CONFLUENCE_CLIENT_ID
clientSecret = env.CONFLUENCE_CLIENT_SECRET
useBasicAuth = true
break
case 'jira':
tokenEndpoint = 'https://auth.atlassian.com/oauth/token'
clientId = env.JIRA_CLIENT_ID
clientSecret = env.JIRA_CLIENT_SECRET
useBasicAuth = true
break
case 'airtable':
tokenEndpoint = 'https://airtable.com/oauth2/v1/token'
clientId = env.AIRTABLE_CLIENT_ID
clientSecret = env.AIRTABLE_CLIENT_SECRET
useBasicAuth = true
break
case 'supabase':
tokenEndpoint = 'https://api.supabase.com/v1/oauth/token'
clientId = env.SUPABASE_CLIENT_ID
clientSecret = env.SUPABASE_CLIENT_SECRET
break
case 'notion':
tokenEndpoint = 'https://api.notion.com/v1/oauth/token'
clientId = env.NOTION_CLIENT_ID
clientSecret = env.NOTION_CLIENT_SECRET
break
case 'discord':
tokenEndpoint = 'https://discord.com/api/v10/oauth2/token'
clientId = env.DISCORD_CLIENT_ID
clientSecret = env.DISCORD_CLIENT_SECRET
useBasicAuth = true
break
case 'microsoft':
tokenEndpoint = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
clientId = env.MICROSOFT_CLIENT_ID
clientSecret = env.MICROSOFT_CLIENT_SECRET
break
case 'outlook':
tokenEndpoint = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
clientId = env.MICROSOFT_CLIENT_ID
clientSecret = env.MICROSOFT_CLIENT_SECRET
break
default:
throw new Error(`Unsupported provider: ${provider}`)
}
if (!clientId || !clientSecret) {
throw new Error(`Missing client credentials for provider: ${provider}`)
}
// Prepare request headers and body
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
...(provider === 'github' && {
Accept: 'application/json',
}),
}
// Prepare request body
const bodyParams: Record<string, string | undefined> = {
grant_type: 'refresh_token',
refresh_token: refreshToken,
}
// For Airtable, check if we have both client ID and secret
if (provider === 'airtable') {
// Airtable requires Basic Auth with client ID and secret in the Authorization header
// Do not include client_id or client_secret in the body when using Basic Auth
if (clientId && clientSecret) {
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
headers.Authorization = `Basic ${basicAuth}`
// Make sure to include refresh_token in body params but not client_id/client_secret
// This ensures we're not sending credentials in both header and body
bodyParams.client_id = undefined
bodyParams.client_secret = undefined
} else {
throw new Error('Both client ID and client secret are required for Airtable OAuth')
}
} else if (
provider === 'x' ||
provider === 'confluence' ||
provider === 'jira' ||
provider === 'discord'
) {
const authString = `${clientId}:${clientSecret}`
const basicAuth = Buffer.from(authString).toString('base64')
headers.Authorization = `Basic ${basicAuth}`
// When using Basic Auth, don't include client_id in body
bodyParams.client_id = undefined
bodyParams.client_secret = undefined
} else {
// For other providers, use the general approach
if (useBasicAuth) {
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
headers.Authorization = `Basic ${basicAuth}`
}
if (!useBasicAuth) {
bodyParams.client_id = clientId
bodyParams.client_secret = clientSecret
}
}
// Refresh the token
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers,
body: new URLSearchParams(bodyParams as Record<string, string>).toString(),
})
if (!response.ok) {
const errorText = await response.text()
let errorData = errorText
// Try to parse the error as JSON for better diagnostics
try {
errorData = JSON.parse(errorText)
} catch (_e) {
// Not JSON, keep as text
}
logger.error('Token refresh failed:', {
status: response.status,
error: errorText,
parsedError: errorData,
provider,
})
throw new Error(`Failed to refresh token: ${response.status} ${errorText}`)
}
const data = await response.json()
// Extract token and expiration (different providers may use different field names)
const accessToken = data.access_token
// For Airtable, also capture the new refresh token if provided
// Airtable may rotate refresh tokens
let newRefreshToken = null
if (provider === 'airtable' && data.refresh_token) {
newRefreshToken = data.refresh_token
logger.info('Received new refresh token from Airtable')
}
// For Confluence and Jira, check if we got a new refresh token
if ((provider === 'confluence' || provider === 'jira') && data.refresh_token) {
newRefreshToken = data.refresh_token
logger.info(`Received new refresh token from ${provider}`)
}
// Get expiration time - use provider's value or default to 1 hour (3600 seconds)
// Different providers use different names for this field
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
}
}