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
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './microsoft'
|
||||
export * from './oauth'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user