chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,105 @@
/**
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
import {
clearDeadFlag,
getRecentTerminalError,
isTerminalRefreshError,
markCredentialDead,
} from '@/lib/oauth/terminal-errors'
interface FakeRedis {
store: Map<string, string>
set: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
del: ReturnType<typeof vi.fn>
}
function createFakeRedis(): FakeRedis {
const store = new Map<string, string>()
return {
store,
set: vi.fn(async (key: string, value: string) => {
store.set(key, value)
return 'OK'
}),
get: vi.fn(async (key: string) => store.get(key) ?? null),
del: vi.fn(async (key: string) => (store.delete(key) ? 1 : 0)),
}
}
beforeEach(() => {
vi.clearAllMocks()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
})
describe('isTerminalRefreshError', () => {
it.each([
'invalid_refresh_token',
'invalid_grant',
'access_denied',
'bad_client_secret',
'invalid_client_id',
'invalid_client',
'bad_redirect_uri',
])('returns true for %s', (code) => {
expect(isTerminalRefreshError(code)).toBe(true)
})
it.each(['ratelimited', 'internal_error', 'service_unavailable', undefined, null, ''])(
'returns false for %s',
(code) => {
expect(isTerminalRefreshError(code as string | undefined | null)).toBe(false)
}
)
})
describe('markCredentialDead / getRecentTerminalError / clearDeadFlag', () => {
it('roundtrips a code through Redis', async () => {
const redis = createFakeRedis()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis as never)
await markCredentialDead('acc-1', 'invalid_refresh_token')
expect(await getRecentTerminalError('acc-1')).toBe('invalid_refresh_token')
})
it('clearDeadFlag removes the entry', async () => {
const redis = createFakeRedis()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis as never)
await markCredentialDead('acc-1', 'invalid_refresh_token')
await clearDeadFlag('acc-1')
expect(await getRecentTerminalError('acc-1')).toBeNull()
})
it('all functions are no-ops when Redis is unavailable', async () => {
await expect(markCredentialDead('acc-1', 'code')).resolves.toBeUndefined()
await expect(getRecentTerminalError('acc-1')).resolves.toBeNull()
await expect(clearDeadFlag('acc-1')).resolves.toBeUndefined()
})
it('absorbs Redis errors without throwing', async () => {
const redis = createFakeRedis()
redis.set.mockRejectedValueOnce(new Error('boom'))
redis.get.mockRejectedValueOnce(new Error('boom'))
redis.del.mockRejectedValueOnce(new Error('boom'))
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis as never)
await expect(markCredentialDead('acc-1', 'code')).resolves.toBeUndefined()
await expect(getRecentTerminalError('acc-1')).resolves.toBeNull()
await expect(clearDeadFlag('acc-1')).resolves.toBeUndefined()
})
it('uses a 1-hour TTL on the dead flag', async () => {
const redis = createFakeRedis()
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis as never)
await markCredentialDead('acc-1', 'invalid_refresh_token')
expect(redis.set).toHaveBeenCalledWith('oauth:dead:acc-1', 'invalid_refresh_token', 'EX', 3600)
})
})
+108
View File
@@ -0,0 +1,108 @@
import { createLogger } from '@sim/logger'
const logger = createLogger('GooglePagination')
/**
* Thrown by {@link drainGooglePagedList} when a page request returns a non-OK
* HTTP status. Carries the status and parsed error body so callers can shape
* their existing error responses unchanged.
*/
export class GooglePageError extends Error {
readonly status: number
readonly body: unknown
constructor(status: number, body: unknown) {
super(`Google API error: ${status}`)
this.name = 'GooglePageError'
this.status = status
this.body = body
}
}
/**
* Result of draining a token-paginated Google REST list endpoint.
*/
export interface GoogleDrainResult<T> {
/** All items accumulated across every fetched page. */
items: T[]
/** True when the page cap was reached before the API stopped returning a token. */
truncated: boolean
}
/**
* Options for {@link drainGooglePagedList}.
*/
export interface DrainGooglePagedListOptions<T, R> {
/**
* Builds the request URL for a given page. `pageToken` is `undefined` for the
* first page, then the value of the previous response's `nextPageToken`.
*/
buildUrl: (pageToken: string | undefined) => string
/** Performs the HTTP request for a built URL. */
fetch: (url: string) => Promise<Response>
/** Parses an error body from a non-OK response (used to build {@link GooglePageError}). */
parseError: (response: Response) => Promise<unknown>
/** Extracts the array of items from a single page's JSON body. */
getItems: (body: R) => T[] | undefined
/** Reads the continuation token from a single page's JSON body. */
getNextPageToken: (body: R) => string | undefined
/** Maximum number of pages to fetch before stopping and flagging `truncated`. */
maxPages: number
/** Label used in the cap-reached warning log. */
label: string
}
/**
* Drains a token-paginated Google REST list endpoint, following each response's
* `nextPageToken` until it is absent or the `maxPages` cap is hit.
*
* Mirrors the bounded-loop pattern used by the Slack channels selector: the loop
* is bounded and emits a `logger.warn` (and sets `truncated`) when the cap is
* reached rather than silently dropping items. A non-OK page response throws a
* {@link GooglePageError} carrying the status and parsed body so callers preserve
* their existing error-response shapes.
*/
export async function drainGooglePagedList<T, R>(
options: DrainGooglePagedListOptions<T, R>
): Promise<GoogleDrainResult<T>> {
const {
buildUrl,
fetch: fetchPage,
parseError,
getItems,
getNextPageToken,
maxPages,
label,
} = options
const items: T[] = []
let pageToken: string | undefined
let truncated = false
for (let page = 0; page < maxPages; page++) {
const response = await fetchPage(buildUrl(pageToken))
if (!response.ok) {
throw new GooglePageError(response.status, await parseError(response))
}
const body = (await response.json()) as R
const pageItems = getItems(body)
if (pageItems?.length) {
items.push(...pageItems)
}
pageToken = getNextPageToken(body)?.trim() || undefined
if (!pageToken) {
return { items, truncated }
}
if (page === maxPages - 1) {
truncated = true
logger.warn(`${label}: hit pagination cap of ${maxPages} pages; results may be incomplete`)
}
}
return { items, truncated }
}
+4
View File
@@ -0,0 +1,4 @@
export * from './microsoft'
export * from './oauth'
export * from './types'
export * from './utils'
+84
View File
@@ -0,0 +1,84 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { deriveMicrosoftEmailVerified, isMicrosoftProvider } from '@/lib/oauth/microsoft'
const EMAIL = 'user@contoso.com'
describe('deriveMicrosoftEmailVerified', () => {
it('honors an explicit email_verified=true claim', () => {
expect(deriveMicrosoftEmailVerified({ email_verified: true }, EMAIL)).toBe(true)
})
it('honors an explicit email_verified=false claim over verified-email claims', () => {
expect(
deriveMicrosoftEmailVerified(
{ email_verified: false, verified_primary_email: [EMAIL] },
EMAIL
)
).toBe(false)
})
it('treats a verified primary email matching the email as verified', () => {
expect(deriveMicrosoftEmailVerified({ verified_primary_email: [EMAIL] }, EMAIL)).toBe(true)
})
it('treats a verified secondary email matching the email as verified', () => {
expect(
deriveMicrosoftEmailVerified({ verified_secondary_email: ['x@y.com', EMAIL] }, EMAIL)
).toBe(true)
})
it('does not verify when the verified-email claims do not include the email', () => {
expect(
deriveMicrosoftEmailVerified(
{
verified_primary_email: ['other@contoso.com'],
verified_secondary_email: ['another@contoso.com'],
},
EMAIL
)
).toBe(false)
})
it('defaults to false when no verification claim is present (typical Azure AD token)', () => {
expect(deriveMicrosoftEmailVerified({ name: 'User', oid: 'abc' }, EMAIL)).toBe(false)
})
it('defaults to false for an empty claim set', () => {
expect(deriveMicrosoftEmailVerified({}, EMAIL)).toBe(false)
})
it('coerces a truthy non-boolean email_verified claim', () => {
expect(deriveMicrosoftEmailVerified({ email_verified: 'true' }, EMAIL)).toBe(true)
})
it('treats malformed (non-array) verified-email claims as unverified without throwing', () => {
expect(deriveMicrosoftEmailVerified({ verified_primary_email: 'not-an-array' }, EMAIL)).toBe(
false
)
expect(deriveMicrosoftEmailVerified({ verified_primary_email: 123 }, EMAIL)).toBe(false)
expect(deriveMicrosoftEmailVerified({ verified_secondary_email: { foo: 'bar' } }, EMAIL)).toBe(
false
)
expect(deriveMicrosoftEmailVerified({ verified_primary_email: null }, EMAIL)).toBe(false)
})
it('does not treat a string claim equal to the email as verified (guards the old unsafe cast)', () => {
expect(deriveMicrosoftEmailVerified({ verified_primary_email: EMAIL }, EMAIL)).toBe(false)
expect(deriveMicrosoftEmailVerified({ verified_secondary_email: EMAIL }, EMAIL)).toBe(false)
})
})
describe('isMicrosoftProvider', () => {
it('recognizes Microsoft connector provider IDs', () => {
expect(isMicrosoftProvider('microsoft-ad')).toBe(true)
expect(isMicrosoftProvider('outlook')).toBe(true)
})
it('rejects non-Microsoft provider IDs', () => {
expect(isMicrosoftProvider('google')).toBe(false)
expect(isMicrosoftProvider('microsoft')).toBe(false)
})
})
+45
View File
@@ -0,0 +1,45 @@
const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
export const MICROSOFT_PROVIDERS = new Set([
'microsoft-ad',
'microsoft-dataverse',
'microsoft-excel',
'microsoft-planner',
'microsoft-teams',
'outlook',
'onedrive',
'sharepoint',
])
export function isMicrosoftProvider(providerId: string): boolean {
return MICROSOFT_PROVIDERS.has(providerId)
}
export function getMicrosoftRefreshTokenExpiry(): Date {
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
}
/**
* Derives whether a Microsoft ID token proves ownership of `email`. Azure AD's
* `email`/`upn` claims are unverified and mutable on multi-tenant (`/common/`)
* endpoints, so the email is trusted only when the token explicitly proves it via
* the `email_verified` claim or the verified-email claims, mirroring Better
* Auth's built-in Microsoft provider. Defaults to `false` when no claim asserts
* verification, so an attacker-controlled tenant can never assert a verified
* email it does not own.
*/
export function deriveMicrosoftEmailVerified(
claims: Record<string, unknown>,
email: string
): boolean {
if (claims.email_verified !== undefined) {
return Boolean(claims.email_verified)
}
const { verified_primary_email: verifiedPrimary, verified_secondary_email: verifiedSecondary } =
claims
return (
(Array.isArray(verifiedPrimary) && verifiedPrimary.includes(email)) ||
(Array.isArray(verifiedSecondary) && verifiedSecondary.includes(email))
)
}
+510
View File
@@ -0,0 +1,510 @@
import { createEnvMock, createMockFetch } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/env', () =>
createEnvMock({
GOOGLE_CLIENT_ID: 'google_client_id',
GOOGLE_CLIENT_SECRET: 'google_client_secret',
GITHUB_CLIENT_ID: 'github_client_id',
GITHUB_CLIENT_SECRET: 'github_client_secret',
X_CLIENT_ID: 'x_client_id',
X_CLIENT_SECRET: 'x_client_secret',
CONFLUENCE_CLIENT_ID: 'confluence_client_id',
CONFLUENCE_CLIENT_SECRET: 'confluence_client_secret',
JIRA_CLIENT_ID: 'jira_client_id',
JIRA_CLIENT_SECRET: 'jira_client_secret',
AIRTABLE_CLIENT_ID: 'airtable_client_id',
AIRTABLE_CLIENT_SECRET: 'airtable_client_secret',
NOTION_CLIENT_ID: 'notion_client_id',
NOTION_CLIENT_SECRET: 'notion_client_secret',
MICROSOFT_CLIENT_ID: 'microsoft_client_id',
MICROSOFT_CLIENT_SECRET: 'microsoft_client_secret',
LINEAR_CLIENT_ID: 'linear_client_id',
LINEAR_CLIENT_SECRET: 'linear_client_secret',
SLACK_CLIENT_ID: 'slack_client_id',
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',
})
)
import { refreshOAuthToken } from '@/lib/oauth'
/**
* Default OAuth token response for successful requests.
*/
const defaultOAuthResponse = {
ok: true,
json: {
access_token: 'new_access_token',
expires_in: 3600,
refresh_token: 'new_refresh_token',
},
}
/**
* Helper to run a function with a mocked global fetch.
*/
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', () => {
describe('Basic Auth Providers', () => {
const basicAuthProviders = [
{
name: 'Airtable',
providerId: 'airtable',
endpoint: 'https://airtable.com/oauth2/v1/token',
},
{ name: 'X (Twitter)', providerId: 'x', endpoint: 'https://api.x.com/2/oauth2/token' },
{
name: 'Confluence',
providerId: 'confluence',
endpoint: 'https://auth.atlassian.com/oauth/token',
},
{ name: 'Jira', providerId: 'jira', endpoint: 'https://auth.atlassian.com/oauth/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.concurrent(
`should send ${name} request with Basic Auth header and no credentials in body`,
async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
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),
})
)
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
const authHeader = requestOptions.headers.Authorization
expect(authHeader).toMatch(/^Basic /)
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`)
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(bodyParams.get('client_id')).toBeNull()
expect(bodyParams.get('client_secret')).toBeNull()
}
)
})
})
describe('Body Credential Providers', () => {
const bodyCredentialProviders = [
{ name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
{
name: 'Microsoft',
providerId: 'microsoft',
endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
},
{
name: 'Outlook',
providerId: 'outlook',
endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/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.concurrent(
`should send ${name} request with credentials in body and no Basic Auth`,
async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
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),
})
)
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(requestOptions.headers.Authorization).toBeUndefined()
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)
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)
}
)
})
it.concurrent('should send Notion request with Basic Auth header and JSON body', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('notion', refreshToken))
expect(mockFetch).toHaveBeenCalledWith(
'https://api.notion.com/v1/oauth/token',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: expect.stringMatching(/^Basic /),
}),
body: expect.any(String),
})
)
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
const authHeader = requestOptions.headers.Authorization
const base64Credentials = authHeader.replace('Basic ', '')
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8')
const [clientId, clientSecret] = credentials.split(':')
expect(clientId).toBe('notion_client_id')
expect(clientSecret).toBe('notion_client_secret')
const bodyParams = JSON.parse(requestOptions.body)
expect(bodyParams).toEqual({
grant_type: 'refresh_token',
refresh_token: refreshToken,
})
})
it.concurrent('should include User-Agent header for Reddit requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('reddit', refreshToken))
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(requestOptions.headers['User-Agent']).toBe(
'sim-studio/1.0 (https://github.com/simstudioai/sim)'
)
})
})
describe('Error Handling', () => {
it.concurrent('should return failure for unsupported provider', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
const result = await withMockFetch(mockFetch, () =>
refreshOAuthToken('unsupported', refreshToken)
)
expect(result.ok).toBe(false)
})
it.concurrent('should return failure with errorCode for HTTP error responses', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () =>
JSON.stringify({
error: 'invalid_request',
error_description: 'Invalid refresh token',
}),
})
const refreshToken = 'test_refresh_token'
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.errorCode).toBe('invalid_request')
}
})
it.concurrent('should return failure for Slack-style body errors', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ ok: false, error: 'invalid_refresh_token' }),
})
const refreshToken = 'test_refresh_token'
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('slack', refreshToken))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.errorCode).toBe('invalid_refresh_token')
}
})
it.concurrent('should return failure for network errors', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
const refreshToken = 'test_refresh_token'
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))
expect(result.ok).toBe(false)
})
})
describe('Token Response Handling', () => {
it.concurrent('should handle providers that return new refresh tokens', async () => {
const refreshToken = 'old_refresh_token'
const newRefreshToken = 'new_refresh_token'
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
expires_in: 3600,
refresh_token: newRefreshToken,
}),
})
const result = await withMockFetch(mockFetch, () =>
refreshOAuthToken('airtable', refreshToken)
)
expect(result).toEqual({
ok: true,
accessToken: 'new_access_token',
expiresIn: 3600,
refreshToken: newRefreshToken,
})
})
it.concurrent(
'should rotate refresh token for Microsoft providers (microsoft, outlook, onedrive, sharepoint)',
async () => {
const microsoftProviders = [
'microsoft',
'outlook',
'onedrive',
'sharepoint',
'microsoft-excel',
'microsoft-teams',
'microsoft-planner',
'microsoft-ad',
'microsoft-dataverse',
]
const oldRefreshToken = 'old_microsoft_refresh_token'
const rotatedRefreshToken = 'rotated_microsoft_refresh_token'
for (const providerId of microsoftProviders) {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
expires_in: 3600,
refresh_token: rotatedRefreshToken,
}),
})
const result = await withMockFetch(mockFetch, () =>
refreshOAuthToken(providerId, oldRefreshToken)
)
expect(result).toEqual({
ok: true,
accessToken: 'new_access_token',
expiresIn: 3600,
refreshToken: rotatedRefreshToken,
})
}
}
)
it.concurrent('should use original refresh token when new one is not provided', async () => {
const refreshToken = 'original_refresh_token'
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
expires_in: 3600,
}),
})
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))
expect(result).toEqual({
ok: true,
accessToken: 'new_access_token',
expiresIn: 3600,
refreshToken: refreshToken,
})
})
it.concurrent('should return failure when access token is missing', async () => {
const refreshToken = 'test_refresh_token'
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
expires_in: 3600,
}),
})
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))
expect(result.ok).toBe(false)
})
it.concurrent('should use default expiration when not provided', async () => {
const refreshToken = 'test_refresh_token'
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'new_access_token',
}),
})
const result = await withMockFetch(mockFetch, () => refreshOAuthToken('google', refreshToken))
expect(result).toEqual({
ok: true,
accessToken: 'new_access_token',
expiresIn: 3600,
refreshToken: refreshToken,
})
})
})
})
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('OAuthTerminalErrors')
const TERMINAL_ERRORS = new Set<string>([
'invalid_refresh_token',
'invalid_grant',
'access_denied',
'bad_client_secret',
'invalid_client_id',
'invalid_client',
'bad_redirect_uri',
])
const DEAD_CACHE_TTL_SEC = 60 * 60
function deadKey(accountId: string): string {
return `oauth:dead:${accountId}`
}
export function isTerminalRefreshError(code: string | undefined | null): boolean {
if (!code) return false
return TERMINAL_ERRORS.has(code)
}
export async function markCredentialDead(accountId: string, code: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
try {
await redis.set(deadKey(accountId), code, 'EX', DEAD_CACHE_TTL_SEC)
} catch (error) {
logger.warn('Failed to mark credential dead in Redis', {
accountId,
code,
error: toError(error).message,
})
}
}
export async function getRecentTerminalError(accountId: string): Promise<string | null> {
const redis = getRedisClient()
if (!redis) return null
try {
return await redis.get(deadKey(accountId))
} catch (error) {
logger.warn('Failed to read terminal error flag from Redis', {
accountId,
error: toError(error).message,
})
return null
}
}
export async function clearDeadFlag(accountId: string): Promise<void> {
const redis = getRedisClient()
if (!redis) return
try {
await redis.del(deadKey(accountId))
} catch (error) {
logger.warn('Failed to clear terminal error flag from Redis', {
accountId,
error: toError(error).message,
})
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { ReactNode } from 'react'
/**
* Stable identifier for the Atlassian service account provider. Used as the
* `providerId` on credential rows and as the `serviceAccountProviderId` on
* Jira/Confluence service configs.
*/
export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' as const
/**
* Stable identifier for a Google service account credential (JSON key → JWT
* token exchange). Used as the `providerId` on credential rows and the
* `serviceAccountProviderId` on Google service configs.
*/
export const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const
/**
* Discriminator stored inside the encrypted Atlassian service account secret blob.
*/
export const ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE = 'atlassian_service_account' as const
/**
* Stable identifier for a reusable custom Slack bot credential — a
* `service_account`-type credential whose encrypted blob holds the bring-your-own
* app's signing secret + bot token + derived team_id/bot_user_id. Used as the
* `providerId` on credential rows and the `serviceAccountProviderId` on the Slack
* service config, so it folds into the same Slack credential dropdown.
*/
export const SLACK_CUSTOM_BOT_PROVIDER_ID = 'slack-custom-bot' as const
/** Discriminator stored inside the encrypted Slack custom bot secret blob. */
export const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' as const
export type OAuthProvider =
| 'google'
| 'google-email'
| 'google-drive'
| 'google-docs'
| 'google-sheets'
| 'google-calendar'
| 'google-contacts'
| 'google-ads'
| 'google-bigquery'
| 'google-tasks'
| 'google-vault'
| 'google-forms'
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'x'
| 'tiktok'
| 'confluence'
| 'airtable'
| 'notion'
| 'jira'
| 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft'
| 'microsoft-ad'
| 'microsoft-dataverse'
| 'microsoft-excel'
| 'microsoft-planner'
| 'microsoft-teams'
| 'outlook'
| 'onedrive'
| 'sharepoint'
| 'linear'
| 'slack'
| 'reddit'
| 'trello'
| 'wealthbox'
| 'webflow'
| 'asana'
| 'attio'
| 'pipedrive'
| 'hubspot'
| 'salesforce'
| 'linkedin'
| 'shopify'
| 'zoom'
| 'wordpress'
| 'spotify'
| 'calcom'
| 'docusign'
export type OAuthService =
| 'google'
| 'google-email'
| 'google-drive'
| 'google-docs'
| 'google-sheets'
| 'google-calendar'
| 'google-contacts'
| 'google-ads'
| 'google-bigquery'
| 'google-tasks'
| 'google-vault'
| 'google-forms'
| 'google-groups'
| 'google-meet'
| 'vertex-ai'
| 'x'
| 'tiktok'
| 'confluence'
| 'airtable'
| 'notion'
| 'jira'
| 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft-ad'
| 'microsoft-dataverse'
| 'microsoft-excel'
| 'microsoft-teams'
| 'microsoft-planner'
| 'sharepoint'
| 'outlook'
| 'linear'
| 'slack'
| 'reddit'
| 'wealthbox'
| 'onedrive'
| 'webflow'
| 'trello'
| 'asana'
| 'attio'
| 'pipedrive'
| 'hubspot'
| 'salesforce'
| 'linkedin'
| 'shopify'
| 'zoom'
| 'wordpress'
| 'spotify'
| 'calcom'
| 'docusign'
| 'github'
| 'monday'
export interface OAuthProviderConfig {
name: string
icon: (props: { className?: string }) => ReactNode
services: Record<string, OAuthServiceConfig>
defaultService: string
}
export type OAuthAuthType = 'oauth' | 'service_account'
export interface OAuthServiceConfig {
name: string
description: string
providerId: string
icon: (props: { className?: string }) => ReactNode
baseProviderIcon: (props: { className?: string }) => ReactNode
scopes: string[]
authType?: OAuthAuthType
serviceAccountProviderId?: string
}
/**
* Service metadata without React components - safe for server-side use
*/
export interface OAuthServiceMetadata {
providerId: string
name: string
description: string
baseProvider: string
}
export interface Credential {
id: string
name: string
provider: OAuthProvider
type?: 'oauth' | 'service_account'
serviceId?: string
lastUsed?: string
isDefault?: boolean
scopes?: string[]
}
export interface ProviderConfig {
baseProvider: string
featureType: string
}
+698
View File
@@ -0,0 +1,698 @@
import { describe, expect, it } from 'vitest'
import type { OAuthProvider, OAuthServiceMetadata } from './types'
import {
getAllOAuthServices,
getCanonicalScopesForProvider,
getMissingRequiredScopes,
getProviderIdFromServiceId,
getScopesForService,
getServiceByProviderAndId,
getServiceConfigByProviderId,
getServiceConfigByServiceId,
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 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 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 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 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('getServiceConfigByServiceId', () => {
it.concurrent('should return service config for a service key', () => {
const service = getServiceConfigByServiceId('gmail')
expect(service).toBeDefined()
expect(service?.providerId).toBe('google-email')
expect(service?.name).toBe('Gmail')
})
it.concurrent('should resolve the shared Jira service used by Jira Service Management', () => {
const service = getServiceConfigByServiceId('jira')
expect(service).toBeDefined()
expect(service?.providerId).toBe('jira')
expect(service?.name).toBe('Jira')
})
it.concurrent('should not match on providerId values that are not service keys', () => {
const service = getServiceConfigByServiceId('google-email')
expect(service).toBeNull()
})
it.concurrent('should return null for unknown service id', () => {
const service = getServiceConfigByServiceId('invalid-service')
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 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('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 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')
})
})
describe('getScopesForService', () => {
it.concurrent('should return scopes for a valid serviceId', () => {
const scopes = getScopesForService('gmail')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send')
})
it.concurrent('should return empty array for unknown serviceId', () => {
const scopes = getScopesForService('nonexistent-service')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
it.concurrent('should return new array instance (not reference)', () => {
const scopes1 = getScopesForService('gmail')
const scopes2 = getScopesForService('gmail')
expect(scopes1).not.toBe(scopes2)
expect(scopes1).toEqual(scopes2)
})
it.concurrent('should work for Microsoft services', () => {
const scopes = getScopesForService('outlook')
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('Mail.ReadWrite')
})
it.concurrent('should return empty array for empty string', () => {
const scopes = getScopesForService('')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
})
describe('getMissingRequiredScopes', () => {
it.concurrent('should return empty array when all scopes are granted', () => {
const credential = { scopes: ['read', 'write'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual([])
})
it.concurrent('should return missing scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual(['write'])
})
it.concurrent('should return all required scopes when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should return all required scopes when credential has undefined scopes', () => {
const missing = getMissingRequiredScopes({ scopes: undefined }, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should ignore offline_access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline_access'])
expect(missing).toEqual([])
})
it.concurrent('should ignore refresh_token in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'refresh_token'])
expect(missing).toEqual([])
})
it.concurrent('should ignore offline.access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline.access'])
expect(missing).toEqual([])
})
it.concurrent('should filter ignored scopes even when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'offline_access', 'refresh_token'])
expect(missing).toEqual(['read'])
})
it.concurrent('should return empty array when requiredScopes is empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, [])
expect(missing).toEqual([])
})
it.concurrent('should return empty array when requiredScopes defaults to empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential)
expect(missing).toEqual([])
})
})
+638
View File
@@ -0,0 +1,638 @@
import { OAUTH_PROVIDERS } from './oauth'
import type {
OAuthProvider,
OAuthServiceConfig,
OAuthServiceMetadata,
ProviderConfig,
} from './types'
/**
* Centralized human-readable descriptions for OAuth scopes.
* Used by the OAuth Required Modal and available for any UI that needs to display scope info.
*/
export const SCOPE_DESCRIPTIONS: Record<string, string> = {
// Google scopes
'https://www.googleapis.com/auth/gmail.send': 'Send emails',
'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels',
'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages',
'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files',
'https://www.googleapis.com/auth/drive': 'Access all Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts',
'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/adwords': 'Manage Google Ads campaigns and reporting',
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member':
'Manage Google Workspace group memberships',
'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member.readonly':
'View Google Workspace group memberships',
'https://www.googleapis.com/auth/meetings.space.created':
'Create and manage Google Meet meeting spaces',
'https://www.googleapis.com/auth/meetings.space.readonly':
'View Google Meet meeting space details',
'https://www.googleapis.com/auth/cloud-platform':
'Full access to Google Cloud resources for Vertex AI',
// Confluence scopes
'read:confluence-content.all': 'Read all Confluence content',
'read:confluence-space.summary': 'Read Confluence space information',
'read:space:confluence': 'View Confluence spaces',
'read:space-details:confluence': 'View detailed Confluence space information',
'write:confluence-content': 'Create and edit Confluence pages',
'write:confluence-space': 'Manage Confluence spaces',
'write:confluence-file': 'Upload files to Confluence',
'read:content:confluence': 'Read Confluence content',
'read:page:confluence': 'View Confluence pages',
'write:page:confluence': 'Create and update Confluence pages',
'read:comment:confluence': 'View comments on Confluence pages',
'write:comment:confluence': 'Create and update comments',
'delete:comment:confluence': 'Delete comments from Confluence pages',
'read:attachment:confluence': 'View attachments on Confluence pages',
'write:attachment:confluence': 'Upload and manage attachments',
'delete:attachment:confluence': 'Delete attachments from Confluence pages',
'delete:page:confluence': 'Delete Confluence pages',
'read:label:confluence': 'View labels on Confluence content',
'write:label:confluence': 'Add and remove labels',
'search:confluence': 'Search Confluence content',
'readonly:content.attachment:confluence': 'View attachments',
'read:blogpost:confluence': 'View Confluence blog posts',
'write:blogpost:confluence': 'Create and update Confluence blog posts',
'read:content.property:confluence': 'View properties on Confluence content',
'write:content.property:confluence': 'Create and manage content properties',
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
'read:user:confluence': 'View Confluence user profiles',
'read:confluence-user': 'View Confluence user profiles (v1 API)',
'read:task:confluence': 'View Confluence inline tasks',
'write:task:confluence': 'Update Confluence inline tasks',
'delete:blogpost:confluence': 'Delete Confluence blog posts',
'write:space:confluence': 'Create and update Confluence spaces',
'delete:space:confluence': 'Delete Confluence spaces',
'read:space.property:confluence': 'View Confluence space properties',
'write:space.property:confluence': 'Create and manage space properties',
'read:space.permission:confluence': 'View Confluence space permissions',
// Common scopes
'read:me': 'Read profile information',
offline_access: 'Access account when not using the application',
openid: 'Standard authentication',
profile: 'Access profile information',
email: 'Access email address',
// Notion scopes
'database.read': 'Read database',
'database.write': 'Write to database',
'projects.read': 'Read projects',
'page.read': 'Read Notion pages',
'page.write': 'Write to Notion pages',
'workspace.content': 'Read Notion content',
'workspace.name': 'Read Notion workspace name',
'workspace.read': 'Read Notion workspace',
'workspace.write': 'Write to Notion workspace',
'user.email:read': 'Read email address',
// GitHub scopes
repo: 'Access repositories',
workflow: 'Manage repository workflows',
'read:user': 'Read public user information',
'user:email': 'Access email address',
// X (Twitter) scopes
'tweet.read': 'Read tweets and timeline',
'tweet.write': 'Post and delete tweets',
'tweet.moderate.write': 'Hide and unhide replies to tweets',
'users.read': 'Read user profiles and account information',
'follows.read': 'View followers and following lists',
'follows.write': 'Follow and unfollow users',
'bookmark.read': 'View bookmarked tweets',
'bookmark.write': 'Add and remove bookmarks',
'like.read': 'View liked tweets and liking users',
'like.write': 'Like and unlike tweets',
'block.read': 'View blocked users',
'block.write': 'Block and unblock users',
'mute.read': 'View muted users',
'mute.write': 'Mute and unmute users',
'offline.access': 'Access account when not using the application',
// TikTok scopes
'user.info.basic': "Read a user's profile info (open id, avatar, display name)",
'user.info.profile': "Read a user's profile info (bio, verification status, username)",
'user.info.stats': "Read a user's stats (follower, following, likes, and video counts)",
'video.publish': "Directly post content to a user's TikTok profile",
'video.upload': "Share content to a creator's account as a draft for further edit and post",
'video.list': "Read a user's public TikTok videos",
// Airtable scopes
'data.records:read': 'Read records',
'data.records:write': 'Write to records',
'schema.bases:read': 'View bases and tables',
'webhook:manage': 'Manage webhooks',
// Jira scopes
'read:jira-user': 'Read Jira user',
'read:jira-work': 'Read Jira work',
'write:jira-work': 'Write to Jira work',
'manage:jira-webhook': 'Register and manage Jira webhooks',
'read:webhook:jira': 'View Jira webhooks',
'write:webhook:jira': 'Create and update Jira webhooks',
'delete:webhook:jira': 'Delete Jira webhooks',
'read:issue-event:jira': 'Read Jira issue events',
'write:issue:jira': 'Write to Jira issues',
'read:project:jira': 'Read Jira projects',
'read:issue-type:jira': 'Read Jira issue types',
'read:issue-meta:jira': 'Read Jira issue meta',
'read:issue-security-level:jira': 'Read Jira issue security level',
'read:issue.vote:jira': 'Read Jira issue votes',
'read:issue.changelog:jira': 'Read Jira issue changelog',
'read:avatar:jira': 'Read Jira avatar',
'read:issue:jira': 'Read Jira issues',
'read:status:jira': 'Read Jira status',
'read:user:jira': 'Read Jira user',
'read:field-configuration:jira': 'Read Jira field configuration',
'read:issue-details:jira': 'Read Jira issue details',
'read:field:jira': 'Read Jira field configurations',
'read:jql:jira': 'Use JQL to filter Jira issues',
'read:comment.property:jira': 'Read Jira comment properties',
'read:issue.property:jira': 'Read Jira issue properties',
'delete:issue:jira': 'Delete Jira issues',
'write:comment:jira': 'Add and update comments on Jira issues',
'read:comment:jira': 'Read comments on Jira issues',
'delete:comment:jira': 'Delete comments from Jira issues',
'read:attachment:jira': 'Read attachments from Jira issues',
'write:attachment:jira': 'Add attachments to Jira issues',
'delete:attachment:jira': 'Delete attachments from Jira issues',
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
'write:issue-link:jira': 'Create links between Jira issues',
'delete:issue-link:jira': 'Delete links between Jira issues',
// Jira Service Management scopes
'read:servicedesk-request': 'View service desk requests',
'write:servicedesk-request': 'Create and update service desk requests',
'manage:servicedesk-customer': 'Manage service desk customers and organizations',
'read:servicedesk:jira-service-management': 'View service desks and their settings',
'read:requesttype:jira-service-management': 'View request types available in service desks',
'read:request:jira-service-management': 'View customer requests in service desks',
'write:request:jira-service-management': 'Create customer requests in service desks',
'read:request.comment:jira-service-management': 'View comments on customer requests',
'write:request.comment:jira-service-management': 'Add comments to customer requests',
'read:customer:jira-service-management': 'View customer information',
'write:customer:jira-service-management': 'Create and manage customers',
'read:servicedesk.customer:jira-service-management': 'View customers linked to service desks',
'write:servicedesk.customer:jira-service-management':
'Add and remove customers from service desks',
'read:organization:jira-service-management': 'View organizations',
'write:organization:jira-service-management': 'Create and manage organizations',
'read:servicedesk.organization:jira-service-management':
'View organizations linked to service desks',
'write:servicedesk.organization:jira-service-management':
'Add and remove organizations from service desks',
'read:organization.user:jira-service-management': 'View users in organizations',
'write:organization.user:jira-service-management': 'Add and remove users from organizations',
'read:organization.property:jira-service-management': 'View organization properties',
'write:organization.property:jira-service-management':
'Create and manage organization properties',
'read:organization.profile:jira-service-management': 'View organization profiles',
'write:organization.profile:jira-service-management': 'Update organization profiles',
'read:queue:jira-service-management': 'View service desk queues and their issues',
'read:request.sla:jira-service-management': 'View SLA information for customer requests',
'read:request.status:jira-service-management': 'View status of customer requests',
'write:request.status:jira-service-management': 'Transition customer request status',
'read:request.participant:jira-service-management': 'View participants on customer requests',
'write:request.participant:jira-service-management':
'Add and remove participants from customer requests',
'read:request.approval:jira-service-management': 'View approvals on customer requests',
'write:request.approval:jira-service-management': 'Approve or decline customer requests',
'read:cmdb-object:jira': 'View Assets objects and run AQL searches',
'write:cmdb-object:jira': 'Create and update Assets objects',
'delete:cmdb-object:jira': 'Delete Assets objects',
'read:cmdb-schema:jira': 'View Assets object schemas',
'read:cmdb-type:jira': 'View Assets object types',
'read:cmdb-attribute:jira': 'View Assets object type attributes',
// Microsoft scopes
'User.Read': 'Read Microsoft user',
'Chat.Read': 'Read Microsoft chats',
'Chat.ReadWrite': 'Write to Microsoft chats',
'Chat.ReadBasic': 'Read Microsoft chats',
'ChatMessage.Send': 'Send chat messages',
'Channel.ReadBasic.All': 'Read Microsoft channels',
'ChannelMessage.Send': 'Write to Microsoft channels',
'ChannelMessage.Read.All': 'Read Microsoft channels',
'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels',
'ChannelMember.Read.All': 'Read team channel members',
'Group.Read.All': 'Read Microsoft groups',
'Group.ReadWrite.All': 'Read and write all groups',
'Team.ReadBasic.All': 'Read Microsoft teams',
'TeamMember.Read.All': 'Read team members',
'Mail.ReadWrite': 'Write to Microsoft emails',
'Mail.ReadBasic': 'Read Microsoft emails',
'Mail.Read': 'Read Microsoft emails',
'Mail.Send': 'Send emails',
'Files.Read': 'Read OneDrive files',
'Files.ReadWrite': 'Read and write OneDrive files',
'Tasks.ReadWrite': 'Read and manage Planner tasks',
'Sites.Read.All': 'Read Sharepoint sites',
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
'Sites.Manage.All': 'Manage Sharepoint sites',
'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf',
'User.Read.All': 'Read all user profiles',
'User.ReadWrite.All': 'Read and write all user profiles',
'GroupMember.ReadWrite.All': 'Read and write all group memberships',
'Directory.Read.All': 'Read directory data',
// Reddit scopes
identity: 'Access Reddit identity',
submit: 'Submit posts and comments',
vote: 'Vote on posts and comments',
save: 'Save and unsave posts and comments',
edit: 'Edit posts and comments',
subscribe: 'Subscribe and unsubscribe from subreddits',
history: 'Access Reddit history',
privatemessages: 'Access inbox and send private messages',
account: 'Update account preferences and settings',
mysubreddits: 'Access subscribed and moderated subreddits',
flair: 'Manage user and post flair',
report: 'Report posts and comments for rule violations',
modposts: 'Approve, remove, and moderate posts in moderated subreddits',
modflair: 'Manage flair in moderated subreddits',
modmail: 'Access and respond to moderator mail',
// Wealthbox scopes
login: 'Access Wealthbox account',
data: 'Access Wealthbox data',
// Linear scopes
read: 'Read access to connected account data',
write: 'Write access to connected account data',
// Slack scopes
'channels:read': 'View public channels',
'channels:history': 'Read channel messages',
'channels:manage': 'Create, archive, and rename public channels',
'groups:read': 'View private channels',
'groups:history': 'Read private messages',
'groups:write': 'Create, archive, and manage private channels',
'chat:write': 'Send messages',
'chat:write.public': 'Post to public channels',
'assistant:write': 'Set assistant thread status, title, and suggested prompts',
'im:write': 'Send direct messages',
'im:history': 'Read direct message history',
'im:read': 'View direct message channels',
'users:read': 'View workspace users',
'users:read.email': 'View user email addresses',
'files:write': 'Upload files',
'files:read': 'Download and read files',
'canvases:read': 'Read canvas sections',
'canvases:write': 'Create, edit, and delete canvas documents',
'reactions:write': 'Add emoji reactions to messages',
'reactions:read': 'View emoji reactions on messages',
// Webflow scopes
'sites:read': 'View Webflow sites',
'sites:write': 'Manage webhooks and site settings',
'cms:read': 'View CMS content',
'cms:write': 'Manage CMS content',
'forms:read': 'View form submissions',
// HubSpot scopes
'crm.objects.contacts.read': 'Read HubSpot contacts',
'crm.objects.contacts.write': 'Create and update HubSpot contacts',
'crm.objects.companies.read': 'Read HubSpot companies',
'crm.objects.companies.write': 'Create and update HubSpot companies',
'crm.objects.deals.read': 'Read HubSpot deals',
'crm.objects.deals.write': 'Create and update HubSpot deals',
'crm.objects.owners.read': 'Read HubSpot object owners',
'crm.objects.users.read': 'Read HubSpot users',
'crm.objects.users.write': 'Create and update HubSpot users',
'crm.objects.marketing_events.read': 'Read HubSpot marketing events',
'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events',
'crm.objects.line_items.read': 'Read HubSpot line items',
'crm.objects.line_items.write': 'Create and update HubSpot line items',
'crm.objects.quotes.read': 'Read HubSpot quotes',
'crm.objects.quotes.write': 'Create and update HubSpot quotes',
'crm.objects.appointments.read': 'Read HubSpot appointments',
'crm.objects.appointments.write': 'Create and update HubSpot appointments',
'crm.objects.carts.read': 'Read HubSpot shopping carts',
'crm.objects.carts.write': 'Create and update HubSpot shopping carts',
'sales-email-read': 'Read the content of HubSpot email engagements',
'crm.import': 'Import data into HubSpot',
'crm.lists.read': 'Read HubSpot lists',
'crm.lists.write': 'Create and update HubSpot lists',
'crm.objects.tickets.read': 'Read HubSpot tickets',
'crm.objects.tickets.write': 'Create and update HubSpot tickets',
tickets: 'Access HubSpot tickets',
oauth: 'Authenticate with HubSpot OAuth',
// Salesforce scopes
api: 'Access Salesforce API',
refresh_token: 'Maintain long-term access to Salesforce account',
// Asana scopes
default: 'Access Asana workspace',
// Pipedrive scopes
base: 'Basic access to Pipedrive account',
'deals:read': 'Read Pipedrive deals',
'deals:full': 'Full access to manage Pipedrive deals',
'contacts:read': 'Read Pipedrive contacts',
'contacts:full': 'Full access to manage Pipedrive contacts',
'leads:read': 'Read Pipedrive leads',
'leads:full': 'Full access to manage Pipedrive leads',
'activities:read': 'Read Pipedrive activities',
'activities:full': 'Full access to manage Pipedrive activities',
'mail:read': 'Read Pipedrive emails',
'mail:full': 'Full access to manage Pipedrive emails',
'projects:read': 'Read Pipedrive projects',
'projects:full': 'Full access to manage Pipedrive projects',
'webhooks:full': 'Full access to manage Pipedrive webhooks',
// LinkedIn scopes
w_member_social: 'Access LinkedIn profile',
// Box scopes
root_readwrite: 'Read and write all files and folders in Box account',
root_readonly: 'Read all files and folders in Box account',
'sign_requests.readwrite': 'Create and manage Box Sign e-signature requests',
// Shopify scopes
write_products: 'Read and manage Shopify products',
write_orders: 'Read and manage Shopify orders',
write_customers: 'Read and manage Shopify customers',
write_inventory: 'Read and manage Shopify inventory levels',
read_locations: 'View store locations',
write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders',
// Zoom scopes
'user:read:user': 'View Zoom profile information',
'meeting:write:meeting': 'Create Zoom meetings',
'meeting:read:meeting': 'View Zoom meeting details',
'meeting:read:list_meetings': 'List Zoom meetings',
'meeting:update:meeting': 'Update Zoom meetings',
'meeting:delete:meeting': 'Delete Zoom meetings',
'meeting:read:invitation': 'View Zoom meeting invitations',
'meeting:read:list_past_participants': 'View past meeting participants',
'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings',
'cloud_recording:read:list_recording_files': 'View recording files',
'cloud_recording:delete:recording_file': 'Delete cloud recordings',
// Dropbox scopes
'account_info.read': 'View Dropbox account information',
'files.metadata.read': 'View file and folder names, sizes, and dates',
'files.metadata.write': 'Modify file and folder metadata',
'files.content.read': 'Download and read Dropbox files',
'files.content.write': 'Upload, copy, move, and delete files in Dropbox',
'sharing.read': 'View shared files and folders',
'sharing.write': 'Share files and folders with others',
// WordPress.com scopes
global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings',
// Spotify scopes
'user-read-private': 'View Spotify account details',
'user-read-email': 'View email address on Spotify',
'user-library-read': 'View saved tracks and albums',
'user-library-modify': 'Save and remove tracks and albums from library',
'playlist-read-private': 'View private playlists',
'playlist-read-collaborative': 'View collaborative playlists',
'playlist-modify-public': 'Create and manage public playlists',
'playlist-modify-private': 'Create and manage private playlists',
'user-read-playback-state': 'View current playback state',
'user-modify-playback-state': 'Control playback on Spotify devices',
'user-read-currently-playing': 'View currently playing track',
'user-read-recently-played': 'View recently played tracks',
'user-top-read': 'View top artists and tracks',
'user-follow-read': 'View followed artists and users',
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// DocuSign scopes
signature: 'Create and send envelopes for e-signature',
extended: 'Extended access to DocuSign account features',
// Attio scopes
'record_permission:read-write': 'Read and write CRM records',
'object_configuration:read-write': 'Read and manage object schemas',
'list_configuration:read-write': 'Read and manage list configurations',
'list_entry:read-write': 'Read and write list entries',
'note:read-write': 'Read and write notes',
'task:read-write': 'Read and write tasks',
'comment:read-write': 'Read and write comments and threads',
'user_management:read': 'View workspace members',
'webhook:read-write': 'Manage webhooks',
// Monday.com scopes
'boards:read': 'Read boards, items, and columns',
'boards:write': 'Create and modify boards, items, and groups',
'updates:read': 'Read updates and comments',
'updates:write': 'Create and edit updates and comments',
'webhooks:read': 'Read webhook subscriptions',
'webhooks:write': 'Create and manage webhook subscriptions',
'me:read': 'Read your user profile',
}
/**
* Get a human-readable description for a scope.
* Falls back to the raw scope string if no description is found.
*/
export function getScopeDescription(scope: string): string {
return SCOPE_DESCRIPTIONS[scope] || scope
}
/**
* 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
}
/**
* Looks up the OAuth service registered under the given service id (the key in
* a provider's `services` map). Returns `null` when no provider registers it.
*/
export function getServiceConfigByServiceId(serviceId: string): OAuthServiceConfig | null {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
const service = provider.services[serviceId]
if (service) return service
}
return null
}
export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
for (const [key, service] of Object.entries(provider.services)) {
// Also resolve a service-account provider id (e.g. `slack-custom-bot`) back
// to its owning service so its credentials group under that integration.
if (
service.providerId === providerId ||
key === providerId ||
service.serviceAccountProviderId === providerId
) {
return service
}
}
}
return null
}
export function getServiceAccountProviderForProviderId(providerId: string): string | undefined {
const serviceConfig = getServiceConfigByProviderId(providerId)
return serviceConfig?.serviceAccountProviderId
}
export function getCanonicalScopesForProvider(providerId: string): string[] {
const service = getServiceConfigByProviderId(providerId)
return service?.scopes ? [...service.scopes] : []
}
/**
* Get canonical scopes for a service by its serviceId key in OAUTH_PROVIDERS.
* Useful for block definitions to reference scopes from the single source of truth.
*/
export function getScopesForService(serviceId: string): string[] {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
const service = provider.services[serviceId]
if (service) {
return [...service.scopes]
}
}
return []
}
/**
* Scopes that control token behavior but are not returned in OAuth token responses.
* These should be ignored when validating credential scopes.
*/
const IGNORED_SCOPES = new Set([
'offline_access', // Microsoft - requests refresh token
'refresh_token', // Salesforce - requests refresh token
'offline.access', // Airtable - requests refresh token (note: dot not underscore)
])
/**
* Compute which of the provided requiredScopes are NOT granted by the credential.
* Note: Ignores special OAuth scopes that control token behavior (like offline_access)
* as they are not returned in the token response's scope list even when granted.
*/
export function getMissingRequiredScopes(
credential: { scopes?: string[] } | undefined,
requiredScopes: string[] = []
): string[] {
if (!credential) {
return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s))
}
const granted = new Set(credential.scopes || [])
const missing: string[] = []
for (const s of requiredScopes) {
if (IGNORED_SCOPES.has(s)) continue
if (!granted.has(s)) missing.push(s)
}
return missing
}
/**
* 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',
}
}