diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 9ab1fd8c7..f3aceda58 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -70,7 +70,7 @@ describe('OAuth Connections API Route', () => { }) ) - vi.doMock('@/lib/oauth/oauth', () => ({ + vi.doMock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, evaluateScopeCoverage: mockEvaluateScopeCoverage, })) diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 8ec7c8599..783f3d2ce 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -5,8 +5,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import type { OAuthProvider } from '@/lib/oauth/oauth' -import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth' +import type { OAuthProvider } from '@/lib/oauth' +import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsAPI') diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 0b17ea290..1e0a2889a 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -42,7 +42,7 @@ describe('OAuth Credentials API Route', () => { getSession: mockGetSession, })) - vi.doMock('@/lib/oauth/oauth', () => ({ + vi.doMock('@/lib/oauth/utils', () => ({ parseProvider: mockParseProvider, evaluateScopeCoverage: mockEvaluateScopeCoverage, })) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 6f5f40de8..04f5e9c5b 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth/oauth' +import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -132,7 +132,7 @@ export async function GET(request: NextRequest) { } // Parse the provider to get base provider and feature type (if provider is present) - const { baseProvider } = parseProvider(providerParam || 'google-default') + const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider) let accountsData diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 2c61b903f..f53402f5b 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -26,6 +26,7 @@ vi.mock('@sim/db', () => ({ vi.mock('@/lib/oauth/oauth', () => ({ refreshOAuthToken: vi.fn(), + OAUTH_PROVIDERS: {}, })) vi.mock('@/lib/logs/console/logger', () => ({ @@ -38,7 +39,7 @@ vi.mock('@/lib/logs/console/logger', () => ({ })) import { db } from '@sim/db' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { refreshOAuthToken } from '@/lib/oauth' import { getCredential, getUserId, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index b23cf06da..85b63961d 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -3,7 +3,7 @@ import { account, workflow } from '@sim/db/schema' import { and, desc, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { refreshOAuthToken } from '@/lib/oauth' const logger = createLogger('OAuthUtilsAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 2744a2b23..2fc0f8c0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -7,7 +7,6 @@ import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getProviderIdFromServiceId, - getServiceIdFromScopes, OAUTH_PROVIDERS, type OAuthProvider, parseProvider, @@ -21,7 +20,7 @@ export interface OAuthRequiredModalProps { provider: OAuthProvider toolName: string requiredScopes?: string[] - serviceId?: string + serviceId: string newScopes?: string[] } @@ -301,7 +300,6 @@ export function OAuthRequiredModal({ serviceId, newScopes = [], }: OAuthRequiredModalProps) { - const effectiveServiceId = serviceId || getServiceIdFromScopes(provider, requiredScopes) const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] @@ -309,8 +307,8 @@ export function OAuthRequiredModal({ let ProviderIcon = baseProviderConfig?.icon || (() => null) if (baseProviderConfig) { - for (const service of Object.values(baseProviderConfig.services)) { - if (service.id === effectiveServiceId || service.providerId === provider) { + for (const [key, service] of Object.entries(baseProviderConfig.services)) { + if (key === serviceId || service.providerId === provider) { providerName = service.name ProviderIcon = service.icon break @@ -343,7 +341,7 @@ export function OAuthRequiredModal({ const handleConnectDirectly = async () => { try { - const providerId = getProviderIdFromServiceId(effectiveServiceId) + const providerId = getProviderIdFromServiceId(serviceId) onClose() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx index 9267ff174..9a7e4ebfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { Tooltip } from '@/components/emcn' -import { getProviderIdFromServiceId } from '@/lib/oauth/oauth' +import { getProviderIdFromServiceId } from '@/lib/oauth' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index e92adee1f..542eb2c57 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -4,7 +4,6 @@ import { Button, Combobox } from '@/components/emcn/components' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, - getServiceIdFromScopes, OAUTH_PROVIDERS, type OAuthProvider, type OAuthService, @@ -45,7 +44,7 @@ interface ToolCredentialSelectorProps { provider: OAuthProvider requiredScopes?: string[] label?: string - serviceId?: OAuthService + serviceId: OAuthService disabled?: boolean } @@ -65,15 +64,7 @@ export function ToolCredentialSelector({ const selectedId = value || '' - const effectiveServiceId = useMemo( - () => serviceId || getServiceIdFromScopes(provider, requiredScopes), - [provider, requiredScopes, serviceId] - ) - - const effectiveProviderId = useMemo( - () => getProviderIdFromServiceId(effectiveServiceId), - [effectiveServiceId] - ) + const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const { data: credentials = [], @@ -240,7 +231,7 @@ export function ToolCredentialSelector({ toolName={getProviderName(provider)} requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} newScopes={missingRequiredScopes} - serviceId={effectiveServiceId} + serviceId={serviceId} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 7d209c115..2911fed8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -24,7 +24,7 @@ import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { CheckboxList, Code, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx index 614d18283..7f0aa9753 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx @@ -15,7 +15,7 @@ import { import { Input, Skeleton } from '@/components/ui' import { cn } from '@/lib/core/utils/cn' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS } from '@/lib/oauth' import { type ServiceInfo, useConnectOAuthService, diff --git a/apps/sim/hooks/queries/oauth-connections.ts b/apps/sim/hooks/queries/oauth-connections.ts index f4e5eef3f..fbda55963 100644 --- a/apps/sim/hooks/queries/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth-connections.ts @@ -1,7 +1,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsQuery') @@ -14,9 +14,11 @@ export const oauthConnectionsKeys = { } /** - * Service info type + * Service info type - extends OAuthServiceConfig with connection status and the service key */ export interface ServiceInfo extends OAuthServiceConfig { + /** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */ + id: string isConnected: boolean lastConnected?: string accounts?: { id: string; name: string }[] @@ -29,9 +31,10 @@ function defineServices(): ServiceInfo[] { const servicesList: ServiceInfo[] = [] Object.entries(OAUTH_PROVIDERS).forEach(([_providerKey, provider]) => { - Object.values(provider.services).forEach((service) => { + Object.entries(provider.services).forEach(([serviceKey, service]) => { servicesList.push({ ...service, + id: serviceKey, isConnected: false, scopes: service.scopes || [], }) diff --git a/apps/sim/hooks/use-oauth-scope-status.ts b/apps/sim/hooks/use-oauth-scope-status.ts index 5ea4e65dc..d2576e098 100644 --- a/apps/sim/hooks/use-oauth-scope-status.ts +++ b/apps/sim/hooks/use-oauth-scope-status.ts @@ -1,6 +1,6 @@ 'use client' -import type { Credential } from '@/lib/oauth/oauth' +import type { Credential } from '@/lib/oauth' export interface OAuthScopeStatus { requiresReauthorization: boolean diff --git a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts index bcd0cafd7..b3aaddced 100644 --- a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts +++ b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts @@ -5,7 +5,7 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' +import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthRequestAccessClientTool') diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts index c9bd25c5b..473326ff8 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/server/user/get-credentials.ts @@ -7,7 +7,7 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { generateRequestId } from '@/lib/core/utils/request' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' -import { getAllOAuthServices } from '@/lib/oauth/oauth' +import { getAllOAuthServices } from '@/lib/oauth' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' interface GetCredentialsParams { diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index 036e7ec1b..a8f9ce585 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1 +1,3 @@ -export * from '@/lib/oauth/oauth' +export * from './oauth' +export * from './types' +export * from './utils' diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 877baa5af..cc64ea45f 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env', () => ({ env: { @@ -14,12 +14,8 @@ vi.mock('@/lib/core/config/env', () => ({ JIRA_CLIENT_SECRET: 'jira_client_secret', AIRTABLE_CLIENT_ID: 'airtable_client_id', AIRTABLE_CLIENT_SECRET: 'airtable_client_secret', - SUPABASE_CLIENT_ID: 'supabase_client_id', - SUPABASE_CLIENT_SECRET: 'supabase_client_secret', NOTION_CLIENT_ID: 'notion_client_id', NOTION_CLIENT_SECRET: 'notion_client_secret', - // DISCORD_CLIENT_ID: 'discord_client_id', - // DISCORD_CLIENT_SECRET: 'discord_client_secret', MICROSOFT_CLIENT_ID: 'microsoft_client_id', MICROSOFT_CLIENT_SECRET: 'microsoft_client_secret', LINEAR_CLIENT_ID: 'linear_client_id', @@ -28,6 +24,30 @@ vi.mock('@/lib/core/config/env', () => ({ SLACK_CLIENT_SECRET: 'slack_client_secret', REDDIT_CLIENT_ID: 'reddit_client_id', REDDIT_CLIENT_SECRET: 'reddit_client_secret', + DROPBOX_CLIENT_ID: 'dropbox_client_id', + DROPBOX_CLIENT_SECRET: 'dropbox_client_secret', + WEALTHBOX_CLIENT_ID: 'wealthbox_client_id', + WEALTHBOX_CLIENT_SECRET: 'wealthbox_client_secret', + WEBFLOW_CLIENT_ID: 'webflow_client_id', + WEBFLOW_CLIENT_SECRET: 'webflow_client_secret', + ASANA_CLIENT_ID: 'asana_client_id', + ASANA_CLIENT_SECRET: 'asana_client_secret', + PIPEDRIVE_CLIENT_ID: 'pipedrive_client_id', + PIPEDRIVE_CLIENT_SECRET: 'pipedrive_client_secret', + HUBSPOT_CLIENT_ID: 'hubspot_client_id', + HUBSPOT_CLIENT_SECRET: 'hubspot_client_secret', + LINKEDIN_CLIENT_ID: 'linkedin_client_id', + LINKEDIN_CLIENT_SECRET: 'linkedin_client_secret', + SALESFORCE_CLIENT_ID: 'salesforce_client_id', + SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret', + SHOPIFY_CLIENT_ID: 'shopify_client_id', + SHOPIFY_CLIENT_SECRET: 'shopify_client_secret', + ZOOM_CLIENT_ID: 'zoom_client_id', + ZOOM_CLIENT_SECRET: 'zoom_client_secret', + WORDPRESS_CLIENT_ID: 'wordpress_client_id', + WORDPRESS_CLIENT_SECRET: 'wordpress_client_secret', + SPOTIFY_CLIENT_ID: 'spotify_client_id', + SPOTIFY_CLIENT_SECRET: 'spotify_client_secret', }, })) @@ -40,28 +60,28 @@ vi.mock('@/lib/logs/console/logger', () => ({ }), })) -const mockFetch = vi.fn() -global.fetch = mockFetch +import { refreshOAuthToken } from '@/lib/oauth' -import { refreshOAuthToken } from '@/lib/oauth/oauth' +function createMockFetch() { + return vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'new_access_token', + expires_in: 3600, + refresh_token: 'new_refresh_token', + }), + }) +} + +function withMockFetch(mockFetch: ReturnType, fn: () => Promise): Promise { + const originalFetch = global.fetch + global.fetch = mockFetch + return fn().finally(() => { + global.fetch = originalFetch + }) +} describe('OAuth Token Refresh', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expires_in: 3600, - refresh_token: 'new_refresh_token', - }), - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - describe('Basic Auth Providers', () => { const basicAuthProviders = [ { @@ -76,64 +96,73 @@ describe('OAuth Token Refresh', () => { endpoint: 'https://auth.atlassian.com/oauth/token', }, { name: 'Jira', providerId: 'jira', endpoint: 'https://auth.atlassian.com/oauth/token' }, - // Discord is currently disabled - // { - // name: 'Discord', - // providerId: 'discord', - // endpoint: 'https://discord.com/api/v10/oauth2/token', - // }, { name: 'Linear', providerId: 'linear', endpoint: 'https://api.linear.app/oauth/token' }, { name: 'Reddit', providerId: 'reddit', endpoint: 'https://www.reddit.com/api/v1/access_token', }, + { + name: 'Asana', + providerId: 'asana', + endpoint: 'https://app.asana.com/-/oauth_token', + }, + { + name: 'Zoom', + providerId: 'zoom', + endpoint: 'https://zoom.us/oauth/token', + }, + { + name: 'Spotify', + providerId: 'spotify', + endpoint: 'https://accounts.spotify.com/api/token', + }, ] basicAuthProviders.forEach(({ name, providerId, endpoint }) => { - it(`should send ${name} request with Basic Auth header and no credentials in body`, async () => { - const refreshToken = 'test_refresh_token' + it.concurrent( + `should send ${name} request with Basic Auth header and no credentials in body`, + async () => { + const mockFetch = createMockFetch() + const refreshToken = 'test_refresh_token' - await refreshOAuthToken(providerId, refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken)) - expect(mockFetch).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: expect.stringMatching(/^Basic /), - }), - body: expect.any(String), - }) - ) + expect(mockFetch).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: expect.stringMatching(/^Basic /), + }), + body: expect.any(String), + }) + ) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] - // Verify Basic Auth header - const authHeader = requestOptions.headers.Authorization - expect(authHeader).toMatch(/^Basic /) + const authHeader = requestOptions.headers.Authorization + expect(authHeader).toMatch(/^Basic /) - // Decode and verify credentials - const base64Credentials = authHeader.replace('Basic ', '') - const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8') - const [clientId, clientSecret] = credentials.split(':') + const base64Credentials = authHeader.replace('Basic ', '') + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8') + const [clientId, clientSecret] = credentials.split(':') - expect(clientId).toBe(`${providerId}_client_id`) - expect(clientSecret).toBe(`${providerId}_client_secret`) + expect(clientId).toBe(`${providerId}_client_id`) + expect(clientSecret).toBe(`${providerId}_client_secret`) - // Verify body contains only required parameters - const bodyParams = new URLSearchParams(requestOptions.body) - const bodyKeys = Array.from(bodyParams.keys()) + const bodyParams = new URLSearchParams(requestOptions.body) + const bodyKeys = Array.from(bodyParams.keys()) - expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) - expect(bodyParams.get('grant_type')).toBe('refresh_token') - expect(bodyParams.get('refresh_token')).toBe(refreshToken) + expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) + expect(bodyParams.get('grant_type')).toBe('refresh_token') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) - // Verify client credentials are NOT in the body - expect(bodyParams.get('client_id')).toBeNull() - expect(bodyParams.get('client_secret')).toBeNull() - }) + expect(bodyParams.get('client_id')).toBeNull() + expect(bodyParams.get('client_secret')).toBeNull() + } + ) }) }) @@ -155,72 +184,114 @@ describe('OAuth Token Refresh', () => { providerId: 'outlook', endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', }, - // Supabase is currently disabled - // { - // name: 'Supabase', - // providerId: 'supabase', - // endpoint: 'https://api.supabase.com/v1/oauth/token', - // }, { name: 'Notion', providerId: 'notion', endpoint: 'https://api.notion.com/v1/oauth/token' }, { name: 'Slack', providerId: 'slack', endpoint: 'https://slack.com/api/oauth.v2.access' }, + { + name: 'Dropbox', + providerId: 'dropbox', + endpoint: 'https://api.dropboxapi.com/oauth2/token', + }, + { + name: 'Wealthbox', + providerId: 'wealthbox', + endpoint: 'https://app.crmworkspace.com/oauth/token', + }, + { + name: 'Webflow', + providerId: 'webflow', + endpoint: 'https://api.webflow.com/oauth/access_token', + }, + { + name: 'Pipedrive', + providerId: 'pipedrive', + endpoint: 'https://oauth.pipedrive.com/oauth/token', + }, + { + name: 'HubSpot', + providerId: 'hubspot', + endpoint: 'https://api.hubapi.com/oauth/v1/token', + }, + { + name: 'LinkedIn', + providerId: 'linkedin', + endpoint: 'https://www.linkedin.com/oauth/v2/accessToken', + }, + { + name: 'Salesforce', + providerId: 'salesforce', + endpoint: 'https://login.salesforce.com/services/oauth2/token', + }, + { + name: 'Shopify', + providerId: 'shopify', + endpoint: 'https://accounts.shopify.com/oauth/token', + }, + { + name: 'WordPress', + providerId: 'wordpress', + endpoint: 'https://public-api.wordpress.com/oauth2/token', + }, ] bodyCredentialProviders.forEach(({ name, providerId, endpoint }) => { - it(`should send ${name} request with credentials in body and no Basic Auth`, async () => { - const refreshToken = 'test_refresh_token' + it.concurrent( + `should send ${name} request with credentials in body and no Basic Auth`, + async () => { + const mockFetch = createMockFetch() + const refreshToken = 'test_refresh_token' - await refreshOAuthToken(providerId, refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken(providerId, refreshToken)) - expect(mockFetch).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: expect.any(String), - }) - ) + expect(mockFetch).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + body: expect.any(String), + }) + ) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] - // Verify no Basic Auth header - expect(requestOptions.headers.Authorization).toBeUndefined() + expect(requestOptions.headers.Authorization).toBeUndefined() - // Verify body contains all required parameters - const bodyParams = new URLSearchParams(requestOptions.body) - const bodyKeys = Array.from(bodyParams.keys()).sort() + const bodyParams = new URLSearchParams(requestOptions.body) + const bodyKeys = Array.from(bodyParams.keys()).sort() - expect(bodyKeys).toEqual(['client_id', 'client_secret', 'grant_type', 'refresh_token']) - expect(bodyParams.get('grant_type')).toBe('refresh_token') - expect(bodyParams.get('refresh_token')).toBe(refreshToken) + expect(bodyKeys).toEqual(['client_id', 'client_secret', 'grant_type', 'refresh_token']) + expect(bodyParams.get('grant_type')).toBe('refresh_token') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) - // Verify client credentials are in the body - const expectedClientId = - providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id` - const expectedClientSecret = - providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret` + const expectedClientId = + providerId === 'outlook' ? 'microsoft_client_id' : `${providerId}_client_id` + const expectedClientSecret = + providerId === 'outlook' ? 'microsoft_client_secret' : `${providerId}_client_secret` - expect(bodyParams.get('client_id')).toBe(expectedClientId) - expect(bodyParams.get('client_secret')).toBe(expectedClientSecret) - }) + expect(bodyParams.get('client_id')).toBe(expectedClientId) + expect(bodyParams.get('client_secret')).toBe(expectedClientSecret) + } + ) }) - it('should include Accept header for GitHub requests', async () => { + it.concurrent('should include Accept header for GitHub requests', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - await refreshOAuthToken('github', refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken)) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] expect(requestOptions.headers.Accept).toBe('application/json') }) - it('should include User-Agent header for Reddit requests', async () => { + it.concurrent('should include User-Agent header for Reddit requests', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - await refreshOAuthToken('reddit', refreshToken) + await withMockFetch(mockFetch, () => refreshOAuthToken('reddit', refreshToken)) - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] + const [, requestOptions] = mockFetch.mock.calls[0] expect(requestOptions.headers['User-Agent']).toBe( 'sim-studio/1.0 (https://github.com/simstudioai/sim)' ) @@ -228,18 +299,19 @@ describe('OAuth Token Refresh', () => { }) describe('Error Handling', () => { - it('should return null for unsupported provider', async () => { + it.concurrent('should return null for unsupported provider', async () => { + const mockFetch = createMockFetch() const refreshToken = 'test_refresh_token' - const result = await refreshOAuthToken('unsupported', refreshToken) + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('unsupported', refreshToken) + ) expect(result).toBeNull() }) - it('should return null for API error responses', async () => { - const refreshToken = 'test_refresh_token' - - mockFetch.mockResolvedValueOnce({ + it.concurrent('should return null for API error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400, text: async () => @@ -248,29 +320,29 @@ describe('OAuth Token Refresh', () => { error_description: 'Invalid refresh token', }), }) + const refreshToken = 'test_refresh_token' - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) - it('should return null for network errors', async () => { + it.concurrent('should return null for network errors', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) const refreshToken = 'test_refresh_token' - mockFetch.mockRejectedValueOnce(new Error('Network error')) - - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) }) describe('Token Response Handling', () => { - it('should handle providers that return new refresh tokens', async () => { + it.concurrent('should handle providers that return new refresh tokens', async () => { const refreshToken = 'old_refresh_token' const newRefreshToken = 'new_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', @@ -279,7 +351,9 @@ describe('OAuth Token Refresh', () => { }), }) - const result = await refreshOAuthToken('airtable', refreshToken) + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('airtable', refreshToken) + ) expect(result).toEqual({ accessToken: 'new_access_token', @@ -288,19 +362,18 @@ describe('OAuth Token Refresh', () => { }) }) - it('should use original refresh token when new one is not provided', async () => { + it.concurrent('should use original refresh token when new one is not provided', async () => { const refreshToken = 'original_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', expires_in: 3600, - // No refresh_token in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toEqual({ accessToken: 'new_access_token', @@ -309,34 +382,32 @@ describe('OAuth Token Refresh', () => { }) }) - it('should return null when access token is missing', async () => { + it.concurrent('should return null when access token is missing', async () => { const refreshToken = 'test_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ expires_in: 3600, - // No access_token in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toBeNull() }) - it('should use default expiration when not provided', async () => { + it.concurrent('should use default expiration when not provided', async () => { const refreshToken = 'test_refresh_token' - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'new_access_token', - // No expires_in in response }), }) - const result = await refreshOAuthToken('google', refreshToken) + const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken)) expect(result).toEqual({ accessToken: 'new_access_token', @@ -345,44 +416,4 @@ describe('OAuth Token Refresh', () => { }) }) }) - - describe('Airtable Tests', () => { - it('should not have duplicate client ID issue', async () => { - const refreshToken = 'test_refresh_token' - - await refreshOAuthToken('airtable', refreshToken) - - const [, requestOptions] = (mockFetch as Mock).mock.calls[0] - - // Verify Authorization header is present and correct - expect(requestOptions.headers.Authorization).toMatch(/^Basic /) - - // Parse body and verify client credentials are NOT present - const bodyParams = new URLSearchParams(requestOptions.body) - expect(bodyParams.get('client_id')).toBeNull() - expect(bodyParams.get('client_secret')).toBeNull() - - // Verify only expected parameters are present - const bodyKeys = Array.from(bodyParams.keys()) - expect(bodyKeys).toEqual(['grant_type', 'refresh_token']) - }) - - it('should handle Airtable refresh token rotation', async () => { - const refreshToken = 'old_refresh_token' - const newRefreshToken = 'rotated_refresh_token' - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expires_in: 3600, - refresh_token: newRefreshToken, - }), - }) - - const result = await refreshOAuthToken('airtable', refreshToken) - - expect(result?.refreshToken).toBe(newRefreshToken) - }) - }) }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index f9937f3be..5c1e99b93 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { AirtableIcon, AsanaIcon, @@ -41,238 +40,131 @@ import { } from '@/components/icons' import { env } from '@/lib/core/config/env' import { createLogger } from '@/lib/logs/console/logger' +import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') -export type OAuthProvider = - | 'google' - | 'github' - | 'x' - | 'confluence' - | 'airtable' - | 'notion' - | 'jira' - | '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' - | 'vertex-ai' - | 'github' - | 'x' - | 'confluence' - | 'airtable' - | 'notion' - | 'jira' - | '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 - 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 = { google: { - id: 'google', name: 'Google', - icon: (props) => GoogleIcon(props), + icon: GoogleIcon, 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), + icon: GmailIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleDriveIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleDocsIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleSheetsIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleFormsIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleCalendarIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleIcon, + baseProviderIcon: GoogleIcon, 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), + icon: GoogleGroupsIcon, + baseProviderIcon: GoogleIcon, scopes: [ 'https://www.googleapis.com/auth/admin.directory.group', 'https://www.googleapis.com/auth/admin.directory.group.member', ], - scopeHints: ['admin.directory.group'], }, 'vertex-ai': { - id: 'vertex-ai', name: 'Vertex AI', description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.', providerId: 'vertex-ai', - icon: (props) => VertexIcon(props), - baseProviderIcon: (props) => VertexIcon(props), + icon: VertexIcon, + baseProviderIcon: VertexIcon, scopes: ['https://www.googleapis.com/auth/cloud-platform'], - scopeHints: ['cloud-platform', 'vertex', 'aiplatform'], }, }, defaultService: 'gmail', }, microsoft: { - id: 'microsoft', name: 'Microsoft', - icon: (props) => MicrosoftIcon(props), + icon: MicrosoftIcon, 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), + icon: MicrosoftExcelIcon, + baseProviderIcon: MicrosoftIcon, 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), + icon: MicrosoftPlannerIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -284,12 +176,11 @@ export const OAUTH_PROVIDERS: Record = { ], }, '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), + icon: MicrosoftTeamsIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -314,12 +205,11 @@ export const OAUTH_PROVIDERS: Record = { ], }, outlook: { - id: 'outlook', name: 'Outlook', description: 'Connect to Outlook and manage emails.', providerId: 'outlook', - icon: (props) => OutlookIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: OutlookIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -332,21 +222,19 @@ export const OAUTH_PROVIDERS: Record = { ], }, onedrive: { - id: 'onedrive', name: 'OneDrive', description: 'Connect to OneDrive and manage files.', providerId: 'onedrive', - icon: (props) => MicrosoftOneDriveIcon(props), - baseProviderIcon: (props) => MicrosoftIcon(props), + icon: MicrosoftOneDriveIcon, + baseProviderIcon: MicrosoftIcon, 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), + icon: MicrosoftSharepointIcon, + baseProviderIcon: MicrosoftIcon, scopes: [ 'openid', 'profile', @@ -358,54 +246,48 @@ export const OAUTH_PROVIDERS: Record = { ], }, }, - defaultService: 'microsoft', + defaultService: 'outlook', }, github: { - id: 'github', name: 'GitHub', - icon: (props) => GithubIcon(props), + icon: GithubIcon, services: { github: { - id: 'github', name: 'GitHub', description: 'Manage repositories, issues, and pull requests.', providerId: 'github-repo', - icon: (props) => GithubIcon(props), - baseProviderIcon: (props) => GithubIcon(props), + icon: GithubIcon, + baseProviderIcon: GithubIcon, scopes: ['repo', 'user:email', 'read:user', 'workflow'], }, }, defaultService: 'github', }, x: { - id: 'x', name: 'X', - icon: (props) => xIcon(props), + icon: xIcon, 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), + icon: xIcon, + baseProviderIcon: xIcon, scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], }, }, defaultService: 'x', }, confluence: { - id: 'confluence', name: 'Confluence', - icon: (props) => ConfluenceIcon(props), + icon: ConfluenceIcon, services: { confluence: { - id: 'confluence', name: 'Confluence', description: 'Access Confluence content and documentation.', providerId: 'confluence', - icon: (props) => ConfluenceIcon(props), - baseProviderIcon: (props) => ConfluenceIcon(props), + icon: ConfluenceIcon, + baseProviderIcon: ConfluenceIcon, scopes: [ 'read:confluence-content.all', 'read:confluence-space.summary', @@ -435,17 +317,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'confluence', }, jira: { - id: 'jira', name: 'Jira', - icon: (props) => JiraIcon(props), + icon: JiraIcon, services: { jira: { - id: 'jira', name: 'Jira', description: 'Access Jira projects and issues.', providerId: 'jira', - icon: (props) => JiraIcon(props), - baseProviderIcon: (props) => JiraIcon(props), + icon: JiraIcon, + baseProviderIcon: JiraIcon, scopes: [ 'read:jira-user', 'read:jira-work', @@ -491,68 +371,60 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'jira', }, airtable: { - id: 'airtable', name: 'Airtable', - icon: (props) => AirtableIcon(props), + icon: AirtableIcon, services: { airtable: { - id: 'airtable', name: 'Airtable', description: 'Manage Airtable bases, tables, and records.', providerId: 'airtable', - icon: (props) => AirtableIcon(props), - baseProviderIcon: (props) => AirtableIcon(props), + icon: AirtableIcon, + baseProviderIcon: AirtableIcon, scopes: ['data.records:read', 'data.records:write', 'user.email:read', 'webhook:manage'], }, }, defaultService: 'airtable', }, notion: { - id: 'notion', name: 'Notion', - icon: (props) => NotionIcon(props), + icon: NotionIcon, 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), + icon: NotionIcon, + baseProviderIcon: NotionIcon, scopes: [], }, }, defaultService: 'notion', }, linear: { - id: 'linear', name: 'Linear', - icon: (props) => LinearIcon(props), + icon: LinearIcon, services: { linear: { - id: 'linear', name: 'Linear', description: 'Manage issues and projects in Linear.', providerId: 'linear', - icon: (props) => LinearIcon(props), - baseProviderIcon: (props) => LinearIcon(props), + icon: LinearIcon, + baseProviderIcon: LinearIcon, scopes: ['read', 'write'], }, }, defaultService: 'linear', }, dropbox: { - id: 'dropbox', name: 'Dropbox', - icon: (props) => DropboxIcon(props), + icon: DropboxIcon, 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), + icon: DropboxIcon, + baseProviderIcon: DropboxIcon, scopes: [ 'account_info.read', 'files.metadata.read', @@ -567,17 +439,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'dropbox', }, shopify: { - id: 'shopify', name: 'Shopify', - icon: (props) => ShopifyIcon(props), + icon: ShopifyIcon, 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), + icon: ShopifyIcon, + baseProviderIcon: ShopifyIcon, scopes: [ 'write_products', 'write_orders', @@ -591,17 +461,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'shopify', }, slack: { - id: 'slack', name: 'Slack', - icon: (props) => SlackIcon(props), + icon: SlackIcon, services: { slack: { - id: 'slack', name: 'Slack', description: 'Send messages using a Slack bot.', providerId: 'slack', - icon: (props) => SlackIcon(props), - baseProviderIcon: (props) => SlackIcon(props), + icon: SlackIcon, + baseProviderIcon: SlackIcon, scopes: [ 'channels:read', 'channels:history', @@ -623,17 +491,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'slack', }, reddit: { - id: 'reddit', name: 'Reddit', - icon: (props) => RedditIcon(props), + icon: RedditIcon, services: { reddit: { - id: 'reddit', name: 'Reddit', description: 'Access Reddit data and content from subreddits.', providerId: 'reddit', - icon: (props) => RedditIcon(props), - baseProviderIcon: (props) => RedditIcon(props), + icon: RedditIcon, + baseProviderIcon: RedditIcon, scopes: [ 'identity', 'read', @@ -657,85 +523,75 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'reddit', }, wealthbox: { - id: 'wealthbox', name: 'Wealthbox', - icon: (props) => WealthboxIcon(props), + icon: WealthboxIcon, 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), + icon: WealthboxIcon, + baseProviderIcon: WealthboxIcon, scopes: ['login', 'data'], }, }, defaultService: 'wealthbox', }, webflow: { - id: 'webflow', name: 'Webflow', - icon: (props) => WebflowIcon(props), + icon: WebflowIcon, services: { webflow: { - id: 'webflow', name: 'Webflow', description: 'Manage Webflow CMS collections, sites, and content.', providerId: 'webflow', - icon: (props) => WebflowIcon(props), - baseProviderIcon: (props) => WebflowIcon(props), + icon: WebflowIcon, + baseProviderIcon: WebflowIcon, scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'], }, }, defaultService: 'webflow', }, trello: { - id: 'trello', name: 'Trello', - icon: (props) => TrelloIcon(props), + icon: TrelloIcon, services: { trello: { - id: 'trello', name: 'Trello', description: 'Manage Trello boards, cards, and workflows.', providerId: 'trello', - icon: (props) => TrelloIcon(props), - baseProviderIcon: (props) => TrelloIcon(props), + icon: TrelloIcon, + baseProviderIcon: TrelloIcon, scopes: ['read', 'write'], }, }, defaultService: 'trello', }, asana: { - id: 'asana', name: 'Asana', - icon: (props) => AsanaIcon(props), + icon: AsanaIcon, services: { asana: { - id: 'asana', name: 'Asana', description: 'Manage Asana projects, tasks, and workflows.', providerId: 'asana', - icon: (props) => AsanaIcon(props), - baseProviderIcon: (props) => AsanaIcon(props), + icon: AsanaIcon, + baseProviderIcon: AsanaIcon, scopes: ['default'], }, }, defaultService: 'asana', }, pipedrive: { - id: 'pipedrive', name: 'Pipedrive', - icon: (props) => PipedriveIcon(props), + icon: PipedriveIcon, 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), + icon: PipedriveIcon, + baseProviderIcon: PipedriveIcon, scopes: [ 'base', 'deals:full', @@ -750,17 +606,15 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'pipedrive', }, hubspot: { - id: 'hubspot', name: 'HubSpot', - icon: (props) => HubspotIcon(props), + icon: HubspotIcon, services: { hubspot: { - id: 'hubspot', name: 'HubSpot', description: 'Access and manage your HubSpot CRM data.', providerId: 'hubspot', - icon: (props) => HubspotIcon(props), - baseProviderIcon: (props) => HubspotIcon(props), + icon: HubspotIcon, + baseProviderIcon: HubspotIcon, scopes: [ 'crm.objects.contacts.read', 'crm.objects.contacts.write', @@ -791,51 +645,45 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'hubspot', }, linkedin: { - id: 'linkedin', name: 'LinkedIn', - icon: (props) => LinkedInIcon(props), + icon: LinkedInIcon, 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), + icon: LinkedInIcon, + baseProviderIcon: LinkedInIcon, scopes: ['profile', 'openid', 'email', 'w_member_social'], }, }, defaultService: 'linkedin', }, salesforce: { - id: 'salesforce', name: 'Salesforce', - icon: (props) => SalesforceIcon(props), + icon: SalesforceIcon, services: { salesforce: { - id: 'salesforce', name: 'Salesforce', description: 'Access and manage your Salesforce CRM data.', providerId: 'salesforce', - icon: (props) => SalesforceIcon(props), - baseProviderIcon: (props) => SalesforceIcon(props), + icon: SalesforceIcon, + baseProviderIcon: SalesforceIcon, scopes: ['api', 'refresh_token', 'openid', 'offline_access'], }, }, defaultService: 'salesforce', }, zoom: { - id: 'zoom', name: 'Zoom', - icon: (props) => ZoomIcon(props), + icon: ZoomIcon, 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), + icon: ZoomIcon, + baseProviderIcon: ZoomIcon, scopes: [ 'user:read:user', 'meeting:write:meeting', @@ -854,34 +702,30 @@ export const OAUTH_PROVIDERS: Record = { defaultService: 'zoom', }, wordpress: { - id: 'wordpress', name: 'WordPress', - icon: (props) => WordpressIcon(props), + icon: WordpressIcon, 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), + icon: WordpressIcon, + baseProviderIcon: WordpressIcon, scopes: ['global'], }, }, defaultService: 'wordpress', }, spotify: { - id: 'spotify', name: 'Spotify', - icon: (props) => SpotifyIcon(props), + icon: SpotifyIcon, 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), + icon: SpotifyIcon, + baseProviderIcon: SpotifyIcon, scopes: [ 'user-read-private', 'user-read-email', @@ -907,234 +751,6 @@ export const OAUTH_PROVIDERS: Record = { }, } -/** - * 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() - 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', - } - } - if (provider === 'vertex-ai') { - return { - baseProvider: 'google', - featureType: 'vertex-ai', - } - } - - // 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 @@ -1226,18 +842,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { 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, @@ -1250,42 +854,9 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { useBasicAuth: false, } } - 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 'microsoft': + case 'outlook': + case 'onedrive': case 'sharepoint': { const { clientId, clientSecret } = getCredentials( env.MICROSOFT_CLIENT_ID, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts new file mode 100644 index 000000000..055568881 --- /dev/null +++ b/apps/sim/lib/oauth/types.ts @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' + +export type OAuthProvider = + | 'google' + | 'google-email' + | 'google-drive' + | 'google-docs' + | 'google-sheets' + | 'google-calendar' + | 'google-vault' + | 'google-forms' + | 'google-groups' + | 'vertex-ai' + | 'github' + | 'github-repo' + | 'x' + | 'confluence' + | 'airtable' + | 'notion' + | 'jira' + | 'dropbox' + | 'microsoft' + | 'microsoft-excel' + | 'microsoft-planner' + | 'microsoft-teams' + | 'outlook' + | 'onedrive' + | 'sharepoint' + | 'linear' + | 'slack' + | 'reddit' + | 'trello' + | 'wealthbox' + | 'webflow' + | 'asana' + | 'pipedrive' + | 'hubspot' + | 'salesforce' + | 'linkedin' + | 'shopify' + | 'zoom' + | 'wordpress' + | 'spotify' + +export type OAuthService = + | 'google' + | 'google-email' + | 'google-drive' + | 'google-docs' + | 'google-sheets' + | 'google-calendar' + | 'google-vault' + | 'google-forms' + | 'google-groups' + | 'vertex-ai' + | 'github' + | 'x' + | 'confluence' + | 'airtable' + | 'notion' + | 'jira' + | '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 { + name: string + icon: (props: { className?: string }) => ReactNode + services: Record + defaultService: string +} + +export interface OAuthServiceConfig { + name: string + description: string + providerId: string + icon: (props: { className?: string }) => ReactNode + baseProviderIcon: (props: { className?: string }) => ReactNode + scopes: string[] +} + +/** + * Service metadata without React components - safe for server-side use + */ +export interface OAuthServiceMetadata { + providerId: string + name: string + description: string + baseProvider: string +} + +export interface ScopeEvaluation { + canonicalScopes: string[] + grantedScopes: string[] + missingScopes: string[] + extraScopes: string[] + requiresReauthorization: boolean +} + +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 +} diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts new file mode 100644 index 000000000..08fa08a22 --- /dev/null +++ b/apps/sim/lib/oauth/utils.test.ts @@ -0,0 +1,804 @@ +import { describe, expect, it } from 'vitest' +import type { OAuthProvider, OAuthServiceMetadata } from './types' +import { + evaluateScopeCoverage, + getAllOAuthServices, + getCanonicalScopesForProvider, + getProviderIdFromServiceId, + getServiceByProviderAndId, + getServiceConfigByProviderId, + normalizeScopes, + parseProvider, +} from './utils' + +describe('getAllOAuthServices', () => { + it.concurrent('should return an array of OAuth services', () => { + const services = getAllOAuthServices() + + expect(services).toBeInstanceOf(Array) + expect(services.length).toBeGreaterThan(0) + }) + + it.concurrent('should include all required metadata fields for each service', () => { + const services = getAllOAuthServices() + + services.forEach((service) => { + expect(service).toHaveProperty('providerId') + expect(service).toHaveProperty('name') + expect(service).toHaveProperty('description') + expect(service).toHaveProperty('baseProvider') + + expect(typeof service.providerId).toBe('string') + expect(typeof service.name).toBe('string') + expect(typeof service.description).toBe('string') + expect(typeof service.baseProvider).toBe('string') + }) + }) + + it.concurrent('should include Google services', () => { + const services = getAllOAuthServices() + + const gmailService = services.find((s) => s.providerId === 'google-email') + expect(gmailService).toBeDefined() + expect(gmailService?.name).toBe('Gmail') + expect(gmailService?.baseProvider).toBe('google') + + const driveService = services.find((s) => s.providerId === 'google-drive') + expect(driveService).toBeDefined() + expect(driveService?.name).toBe('Google Drive') + expect(driveService?.baseProvider).toBe('google') + }) + + it.concurrent('should include Microsoft services', () => { + const services = getAllOAuthServices() + + const outlookService = services.find((s) => s.providerId === 'outlook') + expect(outlookService).toBeDefined() + expect(outlookService?.name).toBe('Outlook') + expect(outlookService?.baseProvider).toBe('microsoft') + + const excelService = services.find((s) => s.providerId === 'microsoft-excel') + expect(excelService).toBeDefined() + expect(excelService?.name).toBe('Microsoft Excel') + expect(excelService?.baseProvider).toBe('microsoft') + }) + + it.concurrent('should include single-service providers', () => { + const services = getAllOAuthServices() + + const githubService = services.find((s) => s.providerId === 'github-repo') + expect(githubService).toBeDefined() + expect(githubService?.name).toBe('GitHub') + expect(githubService?.baseProvider).toBe('github') + + const slackService = services.find((s) => s.providerId === 'slack') + expect(slackService).toBeDefined() + expect(slackService?.name).toBe('Slack') + expect(slackService?.baseProvider).toBe('slack') + }) + + it.concurrent('should not include duplicate services', () => { + const services = getAllOAuthServices() + const providerIds = services.map((s) => s.providerId) + const uniqueProviderIds = new Set(providerIds) + + expect(providerIds.length).toBe(uniqueProviderIds.size) + }) + + it.concurrent('should return services that match the OAuthServiceMetadata interface', () => { + const services = getAllOAuthServices() + + services.forEach((service) => { + const metadata: OAuthServiceMetadata = service + expect(metadata.providerId).toBeDefined() + expect(metadata.name).toBeDefined() + expect(metadata.description).toBeDefined() + expect(metadata.baseProvider).toBeDefined() + }) + }) +}) + +describe('getServiceByProviderAndId', () => { + it.concurrent('should return default service when no serviceId is provided', () => { + const service = getServiceByProviderAndId('google') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-email') + expect(service.name).toBe('Gmail') + }) + + it.concurrent('should return specific service when serviceId is provided', () => { + const service = getServiceByProviderAndId('google', 'google-drive') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-drive') + expect(service.name).toBe('Google Drive') + }) + + it.concurrent('should return default service when invalid serviceId is provided', () => { + const service = getServiceByProviderAndId('google', 'invalid-service') + + expect(service).toBeDefined() + expect(service.providerId).toBe('google-email') + expect(service.name).toBe('Gmail') + }) + + it.concurrent('should throw error for invalid provider', () => { + expect(() => { + getServiceByProviderAndId('invalid-provider' as OAuthProvider) + }).toThrow('Provider invalid-provider not found') + }) + + it.concurrent('should work with Microsoft provider', () => { + const service = getServiceByProviderAndId('microsoft') + + expect(service).toBeDefined() + expect(service.providerId).toBe('outlook') + expect(service.name).toBe('Outlook') + }) + + it.concurrent('should work with Microsoft Excel serviceId', () => { + const service = getServiceByProviderAndId('microsoft', 'microsoft-excel') + + expect(service).toBeDefined() + expect(service.providerId).toBe('microsoft-excel') + expect(service.name).toBe('Microsoft Excel') + }) + + it.concurrent('should work with single-service providers', () => { + const service = getServiceByProviderAndId('github') + + expect(service).toBeDefined() + expect(service.providerId).toBe('github-repo') + expect(service.name).toBe('GitHub') + }) + + it.concurrent('should include scopes in returned service config', () => { + const service = getServiceByProviderAndId('google', 'gmail') + + expect(service.scopes).toBeDefined() + expect(Array.isArray(service.scopes)).toBe(true) + expect(service.scopes.length).toBeGreaterThan(0) + expect(service.scopes).toContain('https://www.googleapis.com/auth/gmail.send') + }) +}) + +describe('getProviderIdFromServiceId', () => { + it.concurrent('should return correct providerId for Gmail', () => { + const providerId = getProviderIdFromServiceId('gmail') + + expect(providerId).toBe('google-email') + }) + + it.concurrent('should return correct providerId for Google Drive', () => { + const providerId = getProviderIdFromServiceId('google-drive') + + expect(providerId).toBe('google-drive') + }) + + it.concurrent('should return correct providerId for Outlook', () => { + const providerId = getProviderIdFromServiceId('outlook') + + expect(providerId).toBe('outlook') + }) + + it.concurrent('should return correct providerId for GitHub', () => { + const providerId = getProviderIdFromServiceId('github') + + expect(providerId).toBe('github-repo') + }) + + it.concurrent('should return correct providerId for Microsoft Excel', () => { + const providerId = getProviderIdFromServiceId('microsoft-excel') + + expect(providerId).toBe('microsoft-excel') + }) + + it.concurrent('should return serviceId as fallback for unknown service', () => { + const providerId = getProviderIdFromServiceId('unknown-service') + + expect(providerId).toBe('unknown-service') + }) + + it.concurrent('should handle empty string', () => { + const providerId = getProviderIdFromServiceId('') + + expect(providerId).toBe('') + }) + + it.concurrent('should work for all Google services', () => { + const googleServices = [ + { serviceId: 'gmail', expectedProviderId: 'google-email' }, + { serviceId: 'google-drive', expectedProviderId: 'google-drive' }, + { serviceId: 'google-docs', expectedProviderId: 'google-docs' }, + { serviceId: 'google-sheets', expectedProviderId: 'google-sheets' }, + { serviceId: 'google-forms', expectedProviderId: 'google-forms' }, + { serviceId: 'google-calendar', expectedProviderId: 'google-calendar' }, + { serviceId: 'google-vault', expectedProviderId: 'google-vault' }, + { serviceId: 'google-groups', expectedProviderId: 'google-groups' }, + { serviceId: 'vertex-ai', expectedProviderId: 'vertex-ai' }, + ] + + googleServices.forEach(({ serviceId, expectedProviderId }) => { + expect(getProviderIdFromServiceId(serviceId)).toBe(expectedProviderId) + }) + }) +}) + +describe('getServiceConfigByProviderId', () => { + it.concurrent('should return service config for valid providerId', () => { + const service = getServiceConfigByProviderId('google-email') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('google-email') + expect(service?.name).toBe('Gmail') + }) + + it.concurrent('should return service config for service key', () => { + const service = getServiceConfigByProviderId('gmail') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('google-email') + expect(service?.name).toBe('Gmail') + }) + + it.concurrent('should return null for invalid providerId', () => { + const service = getServiceConfigByProviderId('invalid-provider') + + expect(service).toBeNull() + }) + + it.concurrent('should work for Microsoft services', () => { + const outlookService = getServiceConfigByProviderId('outlook') + + expect(outlookService).toBeDefined() + expect(outlookService?.providerId).toBe('outlook') + expect(outlookService?.name).toBe('Outlook') + + const excelService = getServiceConfigByProviderId('microsoft-excel') + + expect(excelService).toBeDefined() + expect(excelService?.providerId).toBe('microsoft-excel') + expect(excelService?.name).toBe('Microsoft Excel') + }) + + it.concurrent('should work for GitHub', () => { + const service = getServiceConfigByProviderId('github-repo') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('github-repo') + expect(service?.name).toBe('GitHub') + }) + + it.concurrent('should work for Slack', () => { + const service = getServiceConfigByProviderId('slack') + + expect(service).toBeDefined() + expect(service?.providerId).toBe('slack') + expect(service?.name).toBe('Slack') + }) + + it.concurrent('should return service with scopes', () => { + const service = getServiceConfigByProviderId('google-drive') + + expect(service).toBeDefined() + expect(service?.scopes).toBeDefined() + expect(Array.isArray(service?.scopes)).toBe(true) + expect(service?.scopes.length).toBeGreaterThan(0) + }) + + it.concurrent('should handle empty string', () => { + const service = getServiceConfigByProviderId('') + + expect(service).toBeNull() + }) +}) + +describe('getCanonicalScopesForProvider', () => { + it.concurrent('should return scopes for valid providerId', () => { + const scopes = getCanonicalScopesForProvider('google-email') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send') + expect(scopes).toContain('https://www.googleapis.com/auth/gmail.modify') + }) + + it.concurrent('should return new array instance (not reference)', () => { + const scopes1 = getCanonicalScopesForProvider('google-email') + const scopes2 = getCanonicalScopesForProvider('google-email') + + expect(scopes1).not.toBe(scopes2) + expect(scopes1).toEqual(scopes2) + }) + + it.concurrent('should return empty array for invalid providerId', () => { + const scopes = getCanonicalScopesForProvider('invalid-provider') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) + + it.concurrent('should work for service key', () => { + const scopes = getCanonicalScopesForProvider('gmail') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBeGreaterThan(0) + }) + + it.concurrent('should return scopes for Microsoft services', () => { + const outlookScopes = getCanonicalScopesForProvider('outlook') + + expect(outlookScopes.length).toBeGreaterThan(0) + expect(outlookScopes).toContain('Mail.ReadWrite') + + const excelScopes = getCanonicalScopesForProvider('microsoft-excel') + + expect(excelScopes.length).toBeGreaterThan(0) + expect(excelScopes).toContain('Files.Read') + }) + + it.concurrent('should return scopes for GitHub', () => { + const scopes = getCanonicalScopesForProvider('github-repo') + + expect(scopes.length).toBeGreaterThan(0) + expect(scopes).toContain('repo') + expect(scopes).toContain('user:email') + }) + + it.concurrent('should handle providers with empty scopes array', () => { + const scopes = getCanonicalScopesForProvider('notion') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) + + it.concurrent('should return empty array for empty string', () => { + const scopes = getCanonicalScopesForProvider('') + + expect(Array.isArray(scopes)).toBe(true) + expect(scopes.length).toBe(0) + }) +}) + +describe('normalizeScopes', () => { + it.concurrent('should remove duplicates from scope array', () => { + const scopes = ['scope1', 'scope2', 'scope1', 'scope3', 'scope2'] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toContain('scope1') + expect(normalized).toContain('scope2') + expect(normalized).toContain('scope3') + }) + + it.concurrent('should trim whitespace from scopes', () => { + const scopes = [' scope1 ', 'scope2', ' scope3 '] + const normalized = normalizeScopes(scopes) + + expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) + }) + + it.concurrent('should remove empty strings', () => { + const scopes = ['scope1', '', 'scope2', ' ', 'scope3'] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toEqual(['scope1', 'scope2', 'scope3']) + }) + + it.concurrent('should handle empty array', () => { + const normalized = normalizeScopes([]) + + expect(Array.isArray(normalized)).toBe(true) + expect(normalized.length).toBe(0) + }) + + it.concurrent('should handle array with only empty strings', () => { + const normalized = normalizeScopes(['', ' ', ' ']) + + expect(Array.isArray(normalized)).toBe(true) + expect(normalized.length).toBe(0) + }) + + it.concurrent('should preserve order of first occurrence', () => { + const scopes = ['scope3', 'scope1', 'scope2', 'scope1', 'scope3'] + const normalized = normalizeScopes(scopes) + + expect(normalized).toEqual(['scope3', 'scope1', 'scope2']) + }) + + it.concurrent('should handle scopes with special characters', () => { + const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.send', + ] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(2) + expect(normalized).toContain('https://www.googleapis.com/auth/gmail.send') + expect(normalized).toContain('https://www.googleapis.com/auth/gmail.modify') + }) + + it.concurrent('should handle single scope', () => { + const normalized = normalizeScopes(['scope1']) + + expect(normalized).toEqual(['scope1']) + }) + + it.concurrent('should handle scopes with mixed whitespace', () => { + const scopes = ['scope1', '\tscope2\t', '\nscope3\n', ' scope1 '] + const normalized = normalizeScopes(scopes) + + expect(normalized.length).toBe(3) + expect(normalized).toContain('scope1') + expect(normalized).toContain('scope2') + expect(normalized).toContain('scope3') + }) +}) + +describe('evaluateScopeCoverage', () => { + it.concurrent('should identify missing scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.missingScopes).toContain('https://www.googleapis.com/auth/gmail.modify') + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should identify extra scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/calendar', + ]) + + expect(evaluation.extraScopes.length).toBe(1) + expect(evaluation.extraScopes).toContain('https://www.googleapis.com/auth/calendar') + }) + + it.concurrent('should return no missing scopes when all are present', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + ]) + + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should normalize granted scopes before evaluation', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + ' https://www.googleapis.com/auth/gmail.send ', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation.grantedScopes.length).toBe(3) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle empty granted scopes', () => { + const evaluation = evaluateScopeCoverage('google-email', []) + + expect(evaluation.grantedScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should return correct structure', () => { + const evaluation = evaluateScopeCoverage('google-email', [ + 'https://www.googleapis.com/auth/gmail.send', + ]) + + expect(evaluation).toHaveProperty('canonicalScopes') + expect(evaluation).toHaveProperty('grantedScopes') + expect(evaluation).toHaveProperty('missingScopes') + expect(evaluation).toHaveProperty('extraScopes') + expect(evaluation).toHaveProperty('requiresReauthorization') + + expect(Array.isArray(evaluation.canonicalScopes)).toBe(true) + expect(Array.isArray(evaluation.grantedScopes)).toBe(true) + expect(Array.isArray(evaluation.missingScopes)).toBe(true) + expect(Array.isArray(evaluation.extraScopes)).toBe(true) + expect(typeof evaluation.requiresReauthorization).toBe('boolean') + }) + + it.concurrent('should handle provider with no scopes', () => { + const evaluation = evaluateScopeCoverage('notion', []) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle provider with no scopes but granted scopes present', () => { + const evaluation = evaluateScopeCoverage('notion', ['some.scope', 'another.scope']) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(2) + expect(evaluation.extraScopes).toContain('some.scope') + expect(evaluation.extraScopes).toContain('another.scope') + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should handle invalid provider', () => { + const evaluation = evaluateScopeCoverage('invalid-provider', ['scope1', 'scope2']) + + expect(evaluation.canonicalScopes.length).toBe(0) + expect(evaluation.grantedScopes.length).toBe(2) + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(2) + expect(evaluation.requiresReauthorization).toBe(false) + }) + + it.concurrent('should work with Microsoft services', () => { + const evaluation = evaluateScopeCoverage('outlook', [ + 'openid', + 'profile', + 'email', + 'Mail.ReadWrite', + 'Mail.Send', + ]) + + expect(evaluation.canonicalScopes.length).toBeGreaterThan(0) + expect(evaluation.missingScopes.length).toBeGreaterThan(0) + expect(evaluation.requiresReauthorization).toBe(true) + }) + + it.concurrent('should handle exact match with no extra or missing scopes', () => { + const canonicalScopes = getCanonicalScopesForProvider('linear') + const evaluation = evaluateScopeCoverage('linear', [...canonicalScopes]) + + expect(evaluation.missingScopes.length).toBe(0) + expect(evaluation.extraScopes.length).toBe(0) + expect(evaluation.requiresReauthorization).toBe(false) + }) +}) + +describe('parseProvider', () => { + it.concurrent('should parse simple provider without hyphen', () => { + const config = parseProvider('slack' as OAuthProvider) + + expect(config.baseProvider).toBe('slack') + expect(config.featureType).toBe('slack') + }) + + it.concurrent('should parse compound provider', () => { + const config = parseProvider('google-email' as OAuthProvider) + + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe('gmail') + }) + + it.concurrent('should use mapping for known providerId', () => { + const config = parseProvider('google-drive' as OAuthProvider) + + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe('google-drive') + }) + + it.concurrent('should parse Microsoft services', () => { + const outlookConfig = parseProvider('outlook' as OAuthProvider) + expect(outlookConfig.baseProvider).toBe('microsoft') + expect(outlookConfig.featureType).toBe('outlook') + + const excelConfig = parseProvider('microsoft-excel' as OAuthProvider) + expect(excelConfig.baseProvider).toBe('microsoft') + expect(excelConfig.featureType).toBe('microsoft-excel') + + const teamsConfig = parseProvider('microsoft-teams' as OAuthProvider) + expect(teamsConfig.baseProvider).toBe('microsoft') + expect(teamsConfig.featureType).toBe('microsoft-teams') + }) + + it.concurrent('should parse GitHub provider', () => { + const config = parseProvider('github-repo' as OAuthProvider) + + expect(config.baseProvider).toBe('github') + expect(config.featureType).toBe('github') + }) + + it.concurrent('should parse Slack provider', () => { + const config = parseProvider('slack' as OAuthProvider) + + expect(config.baseProvider).toBe('slack') + expect(config.featureType).toBe('slack') + }) + + it.concurrent('should parse X provider', () => { + const config = parseProvider('x' as OAuthProvider) + + expect(config.baseProvider).toBe('x') + expect(config.featureType).toBe('x') + }) + + it.concurrent('should parse all Google services correctly', () => { + const googleServices: Array<{ provider: OAuthProvider; expectedFeature: string }> = [ + { provider: 'google-email', expectedFeature: 'gmail' }, + { provider: 'google-drive', expectedFeature: 'google-drive' }, + { provider: 'google-docs', expectedFeature: 'google-docs' }, + { provider: 'google-sheets', expectedFeature: 'google-sheets' }, + { provider: 'google-forms', expectedFeature: 'google-forms' }, + { provider: 'google-calendar', expectedFeature: 'google-calendar' }, + { provider: 'google-vault', expectedFeature: 'google-vault' }, + { provider: 'google-groups', expectedFeature: 'google-groups' }, + { provider: 'vertex-ai', expectedFeature: 'vertex-ai' }, + ] + + googleServices.forEach(({ provider, expectedFeature }) => { + const config = parseProvider(provider) + expect(config.baseProvider).toBe('google') + expect(config.featureType).toBe(expectedFeature) + }) + }) + + it.concurrent('should parse Confluence provider', () => { + const config = parseProvider('confluence' as OAuthProvider) + + expect(config.baseProvider).toBe('confluence') + expect(config.featureType).toBe('confluence') + }) + + it.concurrent('should parse Jira provider', () => { + const config = parseProvider('jira' as OAuthProvider) + + expect(config.baseProvider).toBe('jira') + expect(config.featureType).toBe('jira') + }) + + it.concurrent('should parse Airtable provider', () => { + const config = parseProvider('airtable' as OAuthProvider) + + expect(config.baseProvider).toBe('airtable') + expect(config.featureType).toBe('airtable') + }) + + it.concurrent('should parse Notion provider', () => { + const config = parseProvider('notion' as OAuthProvider) + + expect(config.baseProvider).toBe('notion') + expect(config.featureType).toBe('notion') + }) + + it.concurrent('should parse Linear provider', () => { + const config = parseProvider('linear' as OAuthProvider) + + expect(config.baseProvider).toBe('linear') + expect(config.featureType).toBe('linear') + }) + + it.concurrent('should parse Dropbox provider', () => { + const config = parseProvider('dropbox' as OAuthProvider) + + expect(config.baseProvider).toBe('dropbox') + expect(config.featureType).toBe('dropbox') + }) + + it.concurrent('should parse Shopify provider', () => { + const config = parseProvider('shopify' as OAuthProvider) + + expect(config.baseProvider).toBe('shopify') + expect(config.featureType).toBe('shopify') + }) + + it.concurrent('should parse Reddit provider', () => { + const config = parseProvider('reddit' as OAuthProvider) + + expect(config.baseProvider).toBe('reddit') + expect(config.featureType).toBe('reddit') + }) + + it.concurrent('should parse Wealthbox provider', () => { + const config = parseProvider('wealthbox' as OAuthProvider) + + expect(config.baseProvider).toBe('wealthbox') + expect(config.featureType).toBe('wealthbox') + }) + + it.concurrent('should parse Webflow provider', () => { + const config = parseProvider('webflow' as OAuthProvider) + + expect(config.baseProvider).toBe('webflow') + expect(config.featureType).toBe('webflow') + }) + + it.concurrent('should parse Trello provider', () => { + const config = parseProvider('trello' as OAuthProvider) + + expect(config.baseProvider).toBe('trello') + expect(config.featureType).toBe('trello') + }) + + it.concurrent('should parse Asana provider', () => { + const config = parseProvider('asana' as OAuthProvider) + + expect(config.baseProvider).toBe('asana') + expect(config.featureType).toBe('asana') + }) + + it.concurrent('should parse Pipedrive provider', () => { + const config = parseProvider('pipedrive' as OAuthProvider) + + expect(config.baseProvider).toBe('pipedrive') + expect(config.featureType).toBe('pipedrive') + }) + + it.concurrent('should parse HubSpot provider', () => { + const config = parseProvider('hubspot' as OAuthProvider) + + expect(config.baseProvider).toBe('hubspot') + expect(config.featureType).toBe('hubspot') + }) + + it.concurrent('should parse LinkedIn provider', () => { + const config = parseProvider('linkedin' as OAuthProvider) + + expect(config.baseProvider).toBe('linkedin') + expect(config.featureType).toBe('linkedin') + }) + + it.concurrent('should parse Salesforce provider', () => { + const config = parseProvider('salesforce' as OAuthProvider) + + expect(config.baseProvider).toBe('salesforce') + expect(config.featureType).toBe('salesforce') + }) + + it.concurrent('should parse Zoom provider', () => { + const config = parseProvider('zoom' as OAuthProvider) + + expect(config.baseProvider).toBe('zoom') + expect(config.featureType).toBe('zoom') + }) + + it.concurrent('should parse WordPress provider', () => { + const config = parseProvider('wordpress' as OAuthProvider) + + expect(config.baseProvider).toBe('wordpress') + expect(config.featureType).toBe('wordpress') + }) + + it.concurrent('should parse Spotify provider', () => { + const config = parseProvider('spotify' as OAuthProvider) + + expect(config.baseProvider).toBe('spotify') + expect(config.featureType).toBe('spotify') + }) + + it.concurrent('should fallback to default for unknown compound provider', () => { + const config = parseProvider('unknown-provider' as OAuthProvider) + + expect(config.baseProvider).toBe('unknown') + expect(config.featureType).toBe('provider') + }) + + it.concurrent('should use default featureType for simple unknown provider', () => { + const config = parseProvider('unknown' as OAuthProvider) + + expect(config.baseProvider).toBe('unknown') + expect(config.featureType).toBe('default') + }) + + it.concurrent('should parse OneDrive provider correctly', () => { + const config = parseProvider('onedrive' as OAuthProvider) + + expect(config.baseProvider).toBe('microsoft') + expect(config.featureType).toBe('onedrive') + }) + + it.concurrent('should parse SharePoint provider correctly', () => { + const config = parseProvider('sharepoint' as OAuthProvider) + + expect(config.baseProvider).toBe('microsoft') + expect(config.featureType).toBe('sharepoint') + }) +}) diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts new file mode 100644 index 000000000..989b0c3ce --- /dev/null +++ b/apps/sim/lib/oauth/utils.ts @@ -0,0 +1,157 @@ +import { OAUTH_PROVIDERS } from './oauth' +import type { + OAuthProvider, + OAuthServiceConfig, + OAuthServiceMetadata, + ProviderConfig, + ScopeEvaluation, +} from './types' + +/** + * 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 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 [key, service] of Object.entries(provider.services)) { + if (service.providerId === providerId || key === 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() + for (const scope of scopes) { + const trimmed = scope.trim() + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed) + } + } + return Array.from(seen) +} + +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, + } +} + +/** + * Build a mapping of providerId -> { baseProvider, serviceKey } from OAUTH_PROVIDERS + * This is computed once at module load time + */ +const PROVIDER_ID_TO_BASE_PROVIDER: Record = + {} + +for (const [baseProviderId, providerConfig] of Object.entries(OAUTH_PROVIDERS)) { + for (const [serviceKey, service] of Object.entries(providerConfig.services)) { + PROVIDER_ID_TO_BASE_PROVIDER[service.providerId] = { + baseProvider: baseProviderId, + serviceKey, + } + } +} + +/** + * Parse a provider string into its base provider and feature type. + * Uses the pre-computed mapping from OAUTH_PROVIDERS for accuracy. + */ +export function parseProvider(provider: OAuthProvider): ProviderConfig { + // First, check if this is a known providerId from our config + const mapping = PROVIDER_ID_TO_BASE_PROVIDER[provider] + if (mapping) { + return { + baseProvider: mapping.baseProvider, + featureType: mapping.serviceKey, + } + } + + // 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', + } +} diff --git a/apps/sim/lib/workflows/credentials/credential-resolver.ts b/apps/sim/lib/workflows/credentials/credential-resolver.ts index 1f7af6021..1658de015 100644 --- a/apps/sim/lib/workflows/credentials/credential-resolver.ts +++ b/apps/sim/lib/workflows/credentials/credential-resolver.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console/logger' -import { getProviderIdFromServiceId } from '@/lib/oauth/oauth' +import { getProviderIdFromServiceId } from '@/lib/oauth' import { getBlock } from '@/blocks/index' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 2fb71e09b..324f254e0 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -1,4 +1,4 @@ -import type { OAuthService } from '@/lib/oauth/oauth' +import type { OAuthService } from '@/lib/oauth' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'