improvement(oauth): remove unused scope hints (#2551)

* improvement(oauth): remove unused scope hints

* improvement(oauth): remove scopeHints and extraneous oauth provider data

* cleanup
This commit is contained in:
Waleed
2025-12-23 11:26:49 -08:00
committed by GitHub
parent 40e30a11e9
commit 6c1e4ff7d6
23 changed files with 1436 additions and 742 deletions

View File

@@ -70,7 +70,7 @@ describe('OAuth Connections API Route', () => {
})
)
vi.doMock('@/lib/oauth/oauth', () => ({
vi.doMock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))

View File

@@ -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')

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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')

View File

@@ -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()

View File

@@ -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'

View File

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

View File

@@ -24,7 +24,7 @@ import {
getProviderIdFromServiceId,
type OAuthProvider,
type OAuthService,
} from '@/lib/oauth/oauth'
} from '@/lib/oauth'
import {
CheckboxList,
Code,

View File

@@ -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,

View File

@@ -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 || [],
})

View File

@@ -1,6 +1,6 @@
'use client'
import type { Credential } from '@/lib/oauth/oauth'
import type { Credential } from '@/lib/oauth'
export interface OAuthScopeStatus {
requiresReauthorization: boolean

View File

@@ -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')

View File

@@ -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 {

View File

@@ -1 +1,3 @@
export * from '@/lib/oauth/oauth'
export * from './oauth'
export * from './types'
export * from './utils'

View File

@@ -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<T>(mockFetch: ReturnType<typeof vi.fn>, fn: () => Promise<T>): Promise<T> {
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)
})
})
})

File diff suppressed because it is too large Load Diff

136
apps/sim/lib/oauth/types.ts Normal file
View File

@@ -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<string, OAuthServiceConfig>
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
}

View File

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

157
apps/sim/lib/oauth/utils.ts Normal file
View File

@@ -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<string>()
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<string, { baseProvider: string; serviceKey: string }> =
{}
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',
}
}

View File

@@ -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'

View File

@@ -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'