mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
@@ -70,7 +70,7 @@ describe('OAuth Connections API Route', () => {
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock('@/lib/oauth/oauth', () => ({
|
||||
vi.doMock('@/lib/oauth/utils', () => ({
|
||||
parseProvider: mockParseProvider,
|
||||
evaluateScopeCoverage: mockEvaluateScopeCoverage,
|
||||
}))
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
getProviderIdFromServiceId,
|
||||
type OAuthProvider,
|
||||
type OAuthService,
|
||||
} from '@/lib/oauth/oauth'
|
||||
} from '@/lib/oauth'
|
||||
import {
|
||||
CheckboxList,
|
||||
Code,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || [],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { Credential } from '@/lib/oauth/oauth'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
|
||||
export interface OAuthScopeStatus {
|
||||
requiresReauthorization: boolean
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from '@/lib/oauth/oauth'
|
||||
export * from './oauth'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
|
||||
@@ -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
136
apps/sim/lib/oauth/types.ts
Normal 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
|
||||
}
|
||||
804
apps/sim/lib/oauth/utils.test.ts
Normal file
804
apps/sim/lib/oauth/utils.test.ts
Normal 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
157
apps/sim/lib/oauth/utils.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user