diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 2bbb4e1b378..6259951e256 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -47,7 +47,7 @@ describe('credential groups collection route', () => { user: { id: 'user-1' }, session: { id: 'session-1' }, }) - mocks.list.mockResolvedValue({ credentialGroups: [] }) + mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] }) }) it('authenticates before parsing the request body', async () => { @@ -64,7 +64,7 @@ describe('credential groups collection route', () => { const response = await GET(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ credentialGroups: [] }) + expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] }) expect(mocks.list).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index f6ad312fa57..0f31daaed49 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -74,7 +74,10 @@ describe('credential-groups prefetch', () => { createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } - mockExecute.mockResolvedValue({ credentialGroups: [{ ...credentialGroup, internal: true }] }) + mockExecute.mockResolvedValue({ + credentialGroups: [{ ...credentialGroup, internal: true }], + availableProviders: ['gmail'], + }) const queryClient = new QueryClient() await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { @@ -86,7 +89,15 @@ describe('credential-groups prefetch', () => { principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, input: { workspaceId: 'w1' }, }) - expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual([credentialGroup]) + /** + * The whole response envelope, not just the groups array: this key is shared with + * `fetchCredentialGroupSettings`, and seeding it with a narrower shape would leave every + * consumer reading an empty list for as long as the hydrated value stayed fresh. + */ + expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual({ + credentialGroups: [credentialGroup], + availableProviders: ['gmail'], + }) }) it('leaves the cache empty when the use case denies the viewer', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 91c58850cc2..596cb24a1ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -39,7 +39,13 @@ async function prefetchCredentialGroups( principal, input: { workspaceId }, }) - return listCredentialGroupsContract.response.schema.parse(result).credentialGroups + /** + * Hydrates the whole response envelope, matching what `fetchCredentialGroupSettings` caches + * under this key. Narrowing to the groups array here would seed the shared entry with a + * shape its consumers do not read, so every one of them would see an empty list until the + * first refetch replaced it. + */ + return listCredentialGroupsContract.response.schema.parse(result) }, staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index c95046d925f..7695deefb6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -116,6 +116,22 @@ export const credentialGroupTabUrlKeys = { clearOnDefault: true, } as const +/** + * Filters the account types offered inside a credential group's detail view. Separate from the + * settings-wide search so filtering the picker does not follow the user back out to the list of + * groups, where the same term would usually match nothing. + */ +export const credentialGroupProviderSearchParam = { + key: 'credential-group-provider', + parser: parseAsString.withDefault(''), +} as const + +/** A transient picker filter: no back-stack entry, and absent from the URL when empty. */ +export const credentialGroupProviderSearchUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + /** * `group-tab` is the active tab inside the deep-linked permission-group detail * view, so a shared `group-id` link can land on the same tab (mirrors diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 496ff8a6de0..ec84791f943 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -8,7 +8,7 @@ import type { BlockConfig } from '@/blocks/types' import { CREDENTIAL_GROUP_LIST_STALE_TIME, credentialGroupKeys, - fetchCredentialGroupList, + fetchCredentialGroupSettings, } from '@/hooks/queries/utils/credential-group-queries' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -30,11 +30,12 @@ async function fetchCachedCredentialGroups() { const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId if (!workspaceId) return [] - return getQueryClient().fetchQuery({ + const settings = await getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal), + queryFn: ({ signal }) => fetchCredentialGroupSettings(workspaceId, signal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) + return settings.credentialGroups } function resolveCredentialGroupIdForBlock(blockId: string): string | null { diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 08947b0812b..a6a76bf9ab4 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -15,6 +15,8 @@ import { getCredentialGroupProviderService } from '@/lib/credential-groups/provi import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { + credentialGroupProviderSearchParam, + credentialGroupProviderSearchUrlKeys, credentialGroupTabParam, credentialGroupTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' @@ -42,6 +44,7 @@ import { useUpdateCredentialGroup, } from '@/hooks/queries/credential-groups' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface CredentialGroupDetailProps { workspaceId: string @@ -111,6 +114,11 @@ export function CredentialGroupDetail({ groupId, enabled: activeTab === 'access', }) + const [providerSearch, setProviderSearchParam] = useQueryState( + credentialGroupProviderSearchParam.key, + { ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys } + ) + const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam) const [showInvite, setShowInvite] = useState(false) const [showDelete, setShowDelete] = useState(false) const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) @@ -262,6 +270,16 @@ export function CredentialGroupDetail({ title={credentialGroup?.name ?? 'Credential group'} description={credentialGroup?.description ?? undefined} actions={actions} + search={ + activeTab === 'details' + ? { + value: providerSearch, + onChange: setProviderSearch, + placeholder: 'Search account types...', + disabled: detail.isPending, + } + : undefined + } > {detail.error ? ( @@ -280,6 +298,7 @@ export function CredentialGroupDetail({ void @@ -61,12 +64,19 @@ function toOptionUpdateInput( export function CredentialGroupDetails({ credentialGroup, workspaceId, + providerSearch, name, onNameChange, description, onDescriptionChange, }: CredentialGroupDetailsProps) { const updateGroup = useUpdateCredentialGroup() + /** + * Reads the same cache entry the list view already populated, so the deployment's configured + * providers arrive without a second request. + */ + const credentialGroups = useCredentialGroups(workspaceId) + const availableProviders = credentialGroups.data?.availableProviders const slackBots = useWorkspaceCredentials({ workspaceId, type: 'service_account', @@ -132,6 +142,26 @@ export function CredentialGroupDetails({ if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null) } + /** + * A provider whose OAuth client this deployment has not configured can never finish an + * enrollment, so it is not offered — but one already on the group stays listed regardless, or + * the row that removes it would disappear along with it. + */ + const configuredProviders = new Set(credentialGroup.options.map((option) => option.provider)) + const offerableProviders = availableProviders ? new Set(availableProviders) : null + const providerQuery = providerSearch.trim().toLowerCase() + const shownProviders = CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => { + if ( + !configuredProviders.has(provider) && + offerableProviders && + !offerableProviders.has(provider) + ) { + return false + } + if (!providerQuery) return true + return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery) + }) + return ( <> @@ -161,8 +191,15 @@ export function CredentialGroupDetails({ + {shownProviders.length === 0 ? ( + + {providerSearch.trim() + ? `No account types found matching "${providerSearch}"` + : 'No account types are available. Configure an OAuth client to offer one.'} + + ) : null}
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + {shownProviders.map((provider) => { const service = getCredentialGroupProviderService(provider) const support = getCredentialGroupProviderSupport(provider) const option = credentialGroup.options.find( diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx index f6d81bf954f..0deb52501fb 100644 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -8,6 +8,8 @@ import { useQueryState } from 'nuqs' import { credentialGroupIdParam, credentialGroupIdUrlKeys, + credentialGroupProviderSearchParam, + credentialGroupProviderSearchUrlKeys, credentialGroupTabParam, credentialGroupTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' @@ -28,7 +30,9 @@ interface CredentialGroupsSettingsProps { } export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) { - const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId) + const { data, isPending, error } = useCredentialGroups(workspaceId) + const groups = data?.credentialGroups ?? [] + const availableProviders = data?.availableProviders ?? [] const [search, setSearch] = useSettingsSearch() const [showCreate, setShowCreate] = useState(false) const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, { @@ -45,13 +49,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin ...credentialGroupTabParam.parser, ...credentialGroupTabUrlKeys, }) + /** Scoped to one group's account types for the same reason, and reset on the same transitions. */ + const [, setProviderSearch] = useQueryState(credentialGroupProviderSearchParam.key, { + ...credentialGroupProviderSearchParam.parser, + ...credentialGroupProviderSearchUrlKeys, + }) const openGroup = (groupId: string) => { void setSelectedGroupId(groupId) void setSelectedTab(null) + void setProviderSearch(null) } const closeGroup = () => { void setSelectedGroupId(null, { history: 'replace' }) void setSelectedTab(null) + void setProviderSearch(null) } const selectedGroup = selectedGroupId ? groups.find((group) => group.id === selectedGroupId) diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index f8486694e96..cd18c3d79cf 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -22,15 +22,15 @@ import { CREDENTIAL_GROUP_DETAIL_STALE_TIME, CREDENTIAL_GROUP_LIST_STALE_TIME, credentialGroupKeys, - fetchCredentialGroupList, + fetchCredentialGroupSettings, } from '@/hooks/queries/utils/credential-group-queries' export function useCredentialGroups(workspaceId?: string) { return useQuery({ queryKey: credentialGroupKeys.list(workspaceId), queryFn: async ({ signal }) => { - if (!workspaceId) return [] - return fetchCredentialGroupList(workspaceId, signal) + if (!workspaceId) return { credentialGroups: [], availableProviders: [] } + return fetchCredentialGroupSettings(workspaceId, signal) }, enabled: Boolean(workspaceId), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts index e7964c04ad2..b1c645555aa 100644 --- a/apps/sim/hooks/queries/utils/credential-group-queries.ts +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -1,5 +1,5 @@ import { requestJson } from '@/lib/api/client/request' -import type { CredentialGroup } from '@/lib/api/contracts/credential-groups' +import type { CredentialGroupSettingsList } from '@/lib/api/contracts/credential-groups' import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups' export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY @@ -22,13 +22,14 @@ export const credentialGroupKeys = { ] as const, } -export async function fetchCredentialGroupList( +/** + * The workspace's credential groups together with the providers this deployment can enroll. One + * cache entry serves every consumer of the list, so the payload is cached whole and each caller + * reads the part it needs rather than caching two shapes under one key. + */ +export async function fetchCredentialGroupSettings( workspaceId: string, signal?: AbortSignal -): Promise { - const data = await requestJson(listCredentialGroupsContract, { - params: { id: workspaceId }, - signal, - }) - return data.credentialGroups +): Promise { + return requestJson(listCredentialGroupsContract, { params: { id: workspaceId }, signal }) } diff --git a/apps/sim/hooks/selectors/providers/workspace/selectors.ts b/apps/sim/hooks/selectors/providers/workspace/selectors.ts index 62f7314cc4a..55bacdf8a0f 100644 --- a/apps/sim/hooks/selectors/providers/workspace/selectors.ts +++ b/apps/sim/hooks/selectors/providers/workspace/selectors.ts @@ -8,7 +8,7 @@ import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes' import { CREDENTIAL_GROUP_LIST_STALE_TIME, credentialGroupKeys, - fetchCredentialGroupList, + fetchCredentialGroupSettings, } from '@/hooks/queries/utils/credential-group-queries' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { @@ -40,13 +40,14 @@ function workspaceCredentials(workspaceId: string) { }) } -function credentialGroups(workspaceId: string) { - return getQueryClient().fetchQuery({ +async function credentialGroups(workspaceId: string) { + const settings = await getQueryClient().fetchQuery({ queryKey: credentialGroupKeys.list(workspaceId), queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchCredentialGroupList(workspaceId, signal), + fetchCredentialGroupSettings(workspaceId, signal), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, }) + return settings.credentialGroups } function workspaceScoped( diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 770f6f2b65a..94fba1f1e92 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -357,14 +357,22 @@ export const updateCredentialGroupBodySchema = z export type UpdateCredentialGroupBody = z.input +const listCredentialGroupsResponseSchema = z.object({ + credentialGroups: z.array(credentialGroupSchema), + /** + * The providers this deployment has an OAuth client for. The settings picker offers only these, + * so an admin is never shown an account type nobody could finish connecting. + */ + availableProviders: z.array(credentialGroupProviderSchema), +}) + +export type CredentialGroupSettingsList = z.output + export const listCredentialGroupsContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/credential-groups', params: credentialGroupWorkspaceParamsSchema, - response: { - mode: 'json', - schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }), - }, + response: { mode: 'json', schema: listCredentialGroupsResponseSchema }, }) export const createCredentialGroupContract = defineRouteContract({ diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 6f37cbdd325..329278549c5 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createAtlassianManagedOAuthConnector } from '@/lib/auth/connectors/managed-oauth' +import { + createAtlassianManagedOAuthConnector, + getManagedOAuthConnectorPolicy, +} from '@/lib/auth/connectors/managed-oauth' const ATLASSIAN_SCOPES = ['read:me', 'read:jira-work', 'offline_access'] @@ -112,3 +115,275 @@ describe('Atlassian managed OAuth connector', () => { expect(jira.isTerminalRefreshError('temporarily_unavailable')).toBe(false) }) }) + +describe('userinfo-backed managed OAuth connectors', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubProfile(profile: unknown): ReturnType { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(profile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + return fetchMock + } + + function policyFor(providerId: string) { + const policy = getManagedOAuthConnectorPolicy(providerId) + if (!policy) throw new Error(`No managed OAuth policy registered for ${providerId}`) + return policy + } + + it.each([ + ['linkedin', { sub: 'sub-1', email: 'person@example.com', email_verified: false }], + ['zoom', { id: 'zoom-1', email: 'person@example.com', verified: 0, account_id: 'account-1' }], + ['dropbox', { account_id: 'dbid:1', email: 'person@example.com', email_verified: false }], + ['pipedrive', { data: { id: 7, email: 'person@example.com', activated: false } }], + ['wordpress', { ID: 12, email: 'person@example.com', email_verified: false }], + ['salesforce', { user_id: 'sf-1', email: 'person@example.com', email_verified: false }], + ])( + 'reports %s email verification from the provider rather than assuming it', + async (providerId, profile) => { + stubProfile(profile) + + const identity = await policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.email).toBe('person@example.com') + expect(identity.emailVerified).toBe(false) + } + ) + + it.each([ + ['linkedin', { sub: 'sub-1', email: 'person@example.com', email_verified: true }], + ['zoom', { id: 'zoom-1', email: 'person@example.com', verified: 1 }], + ['dropbox', { account_id: 'dbid:1', email: 'person@example.com', email_verified: true }], + ['pipedrive', { data: { id: 7, email: 'person@example.com', activated: true } }], + ])('accepts a verified %s identity', async (providerId, profile) => { + stubProfile(profile) + + const identity = await policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.emailVerified).toBe(true) + }) + + it('fails closed when the provider returns no email to bind the invitation to', async () => { + stubProfile({ sub: 'sub-1' }) + + await expect( + policyFor('linkedin').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('LinkedIn email') + }) + + it('fails closed when the identity request itself fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 401 }))) + + await expect( + policyFor('zoom').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('HTTP 401') + }) + + it('reads DocuSign granted scopes from userinfo and falls back to the token response', async () => { + stubProfile({ + sub: 'ds-1', + email: 'person@example.com', + scope: 'signature extended', + accounts: [{ account_id: 'acct-2', is_default: true }], + }) + const fromProfile = await policyFor('docusign').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['signature'] }, + clientId: 'client-1', + }) + expect(fromProfile.grantedScopes).toEqual(['signature', 'extended']) + expect(fromProfile.providerTenantId).toBe('acct-2') + + stubProfile({ sub: 'ds-1', email: 'person@example.com', accounts: [] }) + const fromTokens = await policyFor('docusign').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['signature', 'extended'] }, + clientId: 'client-1', + }) + expect(fromTokens.grantedScopes).toEqual(['signature', 'extended']) + }) + + it('falls back to the requested scope set for a provider that reports none', async () => { + stubProfile({ ID: 12, email: 'person@example.com', email_verified: true }) + + const identity = await policyFor('wordpress').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.grantedScopes).toEqual(['global']) + expect(policyFor('wordpress').hasRequiredScopes(identity.grantedScopes, ['global'])).toBe(true) + }) + + it('sends Salesforce identity lookups to the authorization server the provider id names', async () => { + const fetchMock = stubProfile({ + user_id: 'sf-1', + email: 'person@example.com', + email_verified: true, + organization_id: 'org-1', + }) + + const identity = await policyFor('salesforce').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['api'] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://login.salesforce.com/services/oauth2/userinfo' + ) + expect(identity.providerTenantId).toBe('org-1') + }) + + it('keeps a provider id that collides with an Object prototype member unresolved', () => { + expect(getManagedOAuthConnectorPolicy('toString')).toBeUndefined() + expect(getManagedOAuthConnectorPolicy('constructor')).toBeUndefined() + }) + it('refuses a Notion integration that identifies a workspace rather than a person', async () => { + stubProfile({ id: 'bot-1', bot: { owner: { type: 'workspace', workspace: true } } }) + + await expect( + policyFor('notion').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('identifies no person') + }) + + it('reads the authorizing human behind a Notion integration token', async () => { + stubProfile({ + id: 'bot-1', + bot: { + owner: { + type: 'user', + user: { id: 'user-1', name: 'Person', person: { email: 'person@example.com' } }, + }, + }, + }) + + const identity = await policyFor('notion').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity).toMatchObject({ + providerSubjectId: 'user-1', + email: 'person@example.com', + emailVerified: true, + }) + }) + + it.each(['notion', 'clickup', 'calcom'])( + 'declares %s scopeless so an empty scope policy is not read as a misconfiguration', + (providerId) => { + expect(policyFor(providerId).scopeless).toBe(true) + } + ) + + it.each(['linear', 'monday'])( + 'treats a partial %s GraphQL response as no identity at all', + async (providerId) => { + stubProfile({ data: { viewer: null, me: null }, errors: [{ message: 'denied' }] }) + + await expect( + policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('invalid user identity') + } + ) + + it('resolves the Attio member who authorized the token, not an arbitrary one', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + active: true, + workspace_id: 'workspace-1', + authorized_by_workspace_member_id: 'member-2', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: { workspace_id: 'workspace-1', workspace_member_id: 'member-2' }, + first_name: 'Person', + last_name: 'Example', + email_address: 'person@example.com', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const identity = await policyFor('attio').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['user_management:read'] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.attio.com/v2/workspace_members/member-2') + expect(identity).toMatchObject({ + providerSubjectId: 'member-2', + email: 'person@example.com', + providerTenantId: 'workspace-1', + displayName: 'Person Example', + }) + }) + + it('identifies a HubSpot seat through the token-metadata endpoint', async () => { + const fetchMock = stubProfile({ + user_id: 42, + user: 'person@example.com', + hub_id: 7, + scopes: ['crm.objects.contacts.read'], + }) + + const identity = await policyFor('hubspot').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://api.hubapi.com/oauth/v1/access-tokens/access-1' + ) + expect(identity).toMatchObject({ + providerSubjectId: '42', + email: 'person@example.com', + providerTenantId: '7', + grantedScopes: ['crm.objects.contacts.read'], + }) + }) + + it('takes Airtable granted scopes from whoami when the token response reports none', async () => { + stubProfile({ id: 'usr1', email: 'person@example.com', scopes: ['data.records:read'] }) + + const identity = await policyFor('airtable').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.grantedScopes).toEqual(['data.records:read']) + }) +}) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index 5bf528368a9..7c88e5da422 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -1,9 +1,15 @@ import { createHash } from 'node:crypto' import type { OAuth2Tokens } from '@better-auth/core/oauth2' +import { isRecordLike } from '@sim/utils/object' import type { GenericOAuthConfig } from 'better-auth/plugins' import { OAuth2Client, type TokenPayload } from 'google-auth-library' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' +import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce' +import { isTerminalRefreshError } from '@/lib/oauth/terminal-errors' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { MONDAY_API_URL, MONDAY_API_VERSION } from '@/tools/monday/utils' const GOOGLE_OPENID_SCOPE = 'openid' const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' @@ -33,6 +39,12 @@ export interface ManagedOAuthConnectorConfig { additionalScopes: string[] requiresRefreshToken: boolean pkce: boolean + /** + * Set when the provider takes no scopes at all (Notion, ClickUp, Cal.com all authorize with an + * empty scope list). Without it the empty scope policy is indistinguishable from a + * misconfigured connector, which is what the policy guard exists to catch. + */ + scopeless?: boolean nonceVerification: 'id_token' | 'state_only' includeLoginHint: boolean prompt?: string @@ -215,20 +227,827 @@ export function createAtlassianManagedOAuthConnector( } } +const USER_INFO_TIMEOUT_MS = 10_000 +const USER_INFO_MAX_BYTES = 256 * 1024 + +/** + * Identity a provider's own profile endpoint can establish. Deliberately narrower than + * {@link ManagedOAuthConnectorIdentity}: `nonce` is meaningless outside an OIDC id_token, and + * `grantedScopes` is recovered separately because most providers do not report it here. + */ +export interface ManagedOAuthProfileIdentity { + providerSubjectId: string + email: string + emailVerified: boolean + providerTenantId?: string | null + displayName?: string + avatarUrl?: string +} + +/** + * How the granted scope list is recovered. + * + * Better Auth derives `tokens.scopes` from the token response's `scope` field and splits it on + * spaces, so a provider that omits `scope` yields an empty list — and an empty granted list fails + * the scope check on every provider that requires any scope, rejecting a grant the user actually + * approved. + * + * - `token_response` — the provider reports `scope` on the token response. The honest default. + * - `profile` — the scope list comes back on the identity response instead (DocuSign's + * `/oauth/userinfo`, HubSpot's token-introspection endpoint). + * - `requested` — the provider reports scopes nowhere. Falls back to the scope set Sim asked for, + * which is sound only when the provider grants all-or-nothing and offers the user no way to + * deselect individual scopes at the consent screen. Verify that per provider before choosing it. + */ +export type ManagedOAuthScopeResolution = + | { from: 'token_response' } + | { from: 'profile'; read(profile: unknown, tokens: OAuth2Tokens): string[] } + | { from: 'requested' } + +export interface UserInfoManagedOAuthConnectorOptions { + providerId: string + userInfo: { + /** A function when the access token belongs in the path rather than the header. */ + url: string | ((tokens: OAuth2Tokens) => string) + method?: 'GET' | 'POST' + headers?(accessToken: string): Record + /** Request body, for the providers whose identity lives behind a GraphQL query. */ + body?: string + } + /** Must throw when the response does not establish an identity — never invent a fallback. */ + parse(profile: unknown, tokens: OAuth2Tokens): ManagedOAuthProfileIdentity + scopes: ManagedOAuthScopeResolution + requiresRefreshToken: boolean + pkce?: boolean + scopeless?: boolean + additionalScopes?: string[] + prompt?: string + authorizationUrlParams?: Record +} + +async function fetchManagedOAuthProfile( + options: UserInfoManagedOAuthConnectorOptions, + accessToken: string, + tokens: OAuth2Tokens +): Promise { + const { providerId, userInfo } = options + const url = typeof userInfo.url === 'function' ? userInfo.url(tokens) : userInfo.url + const response = await fetch(url, { + method: userInfo.method ?? 'GET', + headers: { + Accept: 'application/json', + ...(userInfo.headers?.(accessToken) ?? { Authorization: `Bearer ${accessToken}` }), + }, + ...(userInfo.body ? { body: userInfo.body } : {}), + signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS), + }) + const profile = await readResponseJsonWithLimit(response, { + maxBytes: USER_INFO_MAX_BYTES, + label: `${providerId} user identity response`, + }) + if (!response.ok) { + throw new Error(`${providerId} user identity request failed with HTTP ${response.status}`) + } + return profile +} + +function resolveGrantedScopes( + options: UserInfoManagedOAuthConnectorOptions, + profile: unknown, + tokens: OAuth2Tokens +): string[] { + switch (options.scopes.from) { + case 'profile': + return [...new Set(options.scopes.read(profile, tokens))] + case 'requested': + return [ + ...new Set([ + ...getCanonicalScopesForProvider(options.providerId), + ...(options.additionalScopes ?? []), + ]), + ] + default: + return [...new Set(tokens.scopes ?? [])] + } +} + +/** + * Managed enrollment policy for a provider whose identity comes from a plain profile endpoint + * rather than an OIDC id_token. + * + * The matching `getUserInfo` in `connectors/providers.ts` is not reusable here even though it + * calls the same endpoint: it exists to satisfy Better Auth's `email_is_missing` guard, so it + * substitutes a synthetic address when the provider returns none and asserts `emailVerified: true` + * in several places the provider never verified. Managed enrollment binds a credential to an + * invited person, so `parse` must report only what the provider actually proves. + */ +export function createUserInfoManagedOAuthConnector( + options: UserInfoManagedOAuthConnectorOptions +): ManagedOAuthConnectorConfig { + const { providerId } = options + return { + additionalScopes: options.additionalScopes ?? [], + requiresRefreshToken: options.requiresRefreshToken, + pkce: options.pkce ?? false, + nonceVerification: 'state_only', + includeLoginHint: false, + ...(options.scopeless ? { scopeless: true } : {}), + ...(options.prompt ? { prompt: options.prompt } : {}), + ...(options.authorizationUrlParams + ? { authorizationUrlParams: options.authorizationUrlParams } + : {}), + getAuthorizationAppId(clientId) { + return `${providerId}:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens }) { + const accessToken = tokens.accessToken + if (!accessToken) { + throw new Error(`${providerId} returned an incomplete authorization`) + } + const profile = await fetchManagedOAuthProfile(options, accessToken, tokens) + const identity = options.parse(profile, tokens) + if (!identity.providerSubjectId.trim() || !identity.email.trim()) { + throw new Error(`${providerId} returned an invalid user identity`) + } + return { + providerSubjectId: identity.providerSubjectId, + providerTenantId: identity.providerTenantId ?? null, + email: identity.email, + emailVerified: identity.emailVerified, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + grantedScopes: resolveGrantedScopes(options, profile, tokens), + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + const granted = new Set(grantedScopes) + return requiredScopes.every((scope) => granted.has(scope)) + }, + isTerminalRefreshError, + } +} + +function asProfileRecord(profile: unknown, providerName: string): Record { + if (!isRecordLike(profile)) { + throw new Error(`${providerName} returned an invalid user identity`) + } + return profile +} + +/** Reads a required identity field, accepting a numeric id as the string it stands for. */ +function requireIdentityField(value: unknown, label: string): string { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} is missing from the provider's user identity`) + } + return value +} + +function optionalIdentityField(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +function withOptionalIdentityFields( + identity: ManagedOAuthProfileIdentity, + fields: { displayName?: unknown; avatarUrl?: unknown; providerTenantId?: unknown } +): ManagedOAuthProfileIdentity { + const displayName = optionalIdentityField(fields.displayName) + const avatarUrl = optionalIdentityField(fields.avatarUrl) + const providerTenantId = + typeof fields.providerTenantId === 'number' && Number.isFinite(fields.providerTenantId) + ? String(fields.providerTenantId) + : optionalIdentityField(fields.providerTenantId) + return { + ...identity, + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + ...(providerTenantId ? { providerTenantId } : {}), + } +} + +/** + * Unwraps a GraphQL identity response, treating a partial success as a failure: a `data` payload + * accompanied by `errors` means the provider could not answer the whole query, and the fields it + * did return are not a complete identity. + */ +function readGraphQLIdentity( + profile: unknown, + path: string, + providerName: string +): Record { + const envelope = asProfileRecord(profile, providerName) + if (Array.isArray(envelope.errors) && envelope.errors.length > 0) { + throw new Error(`${providerName} returned an invalid user identity`) + } + const data = asProfileRecord(envelope.data, providerName) + return asProfileRecord(data[path], providerName) +} + +/** Splits a provider's space-delimited `scope` string, tolerating its absence. */ +function readScopeString(value: unknown): string[] { + return typeof value === 'string' ? value.split(/\s+/).filter(Boolean) : [] +} + +/** + * Salesforce registers one connector per authorization server, so the userinfo host has to follow + * the provider id rather than being fixed. + */ +function createSalesforceManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig { + const loginHost = SALESFORCE_LOGIN_HOSTS[providerId] + if (!loginHost) { + throw new Error(`Unknown Salesforce authorization server: ${providerId}`) + } + return createUserInfoManagedOAuthConnector({ + providerId, + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: `https://${loginHost}/services/oauth2/userinfo` }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Salesforce') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.user_id ?? user.sub, 'Salesforce user id'), + email: requireIdentityField(user.email, 'Salesforce email'), + emailVerified: user.email_verified === true, + }, + { displayName: user.name, avatarUrl: user.picture, providerTenantId: user.organization_id } + ) + }, + }) +} + +/** + * Attio's identity takes two calls: `/v2/self` names the member who authorized the token, and only + * the member record carries their email. Listing members instead would return them in no defined + * order, recording a stranger as the account's subject. + */ +function createAttioManagedOAuthConnector(): ManagedOAuthConnectorConfig { + return { + additionalScopes: [], + requiresRefreshToken: false, + pkce: false, + nonceVerification: 'state_only', + includeLoginHint: false, + getAuthorizationAppId(clientId) { + return `attio:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens }) { + if (!tokens.accessToken) { + throw new Error('Attio returned an incomplete authorization') + } + const headers = { + Accept: 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + } + const selfResponse = await fetch('https://api.attio.com/v2/self', { + headers, + signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS), + }) + const self = await readResponseJsonWithLimit(selfResponse, { + maxBytes: USER_INFO_MAX_BYTES, + label: 'Attio identity response', + }) + if (!selfResponse.ok) { + throw new Error(`Attio identity request failed with HTTP ${selfResponse.status}`) + } + const identity = asProfileRecord(self, 'Attio') + const memberId = requireIdentityField( + identity.authorized_by_workspace_member_id, + 'Attio workspace member id' + ) + const memberResponse = await fetch( + `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, + { headers, signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS) } + ) + const memberBody = await readResponseJsonWithLimit(memberResponse, { + maxBytes: USER_INFO_MAX_BYTES, + label: 'Attio workspace member response', + }) + if (!memberResponse.ok) { + throw new Error(`Attio workspace member request failed with HTTP ${memberResponse.status}`) + } + const member = asProfileRecord(asProfileRecord(memberBody, 'Attio').data, 'Attio') + const name = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() + const base = withOptionalIdentityFields( + { + providerSubjectId: memberId, + email: requireIdentityField(member.email_address, 'Attio email'), + /** An Attio workspace member is only created by accepting a mailed invitation. */ + emailVerified: true, + }, + { displayName: name, avatarUrl: member.avatar_url, providerTenantId: identity.workspace_id } + ) + /** + * Attio's token response omits `scope`, and its scope set is fixed by the OAuth app rather + * than chosen at consent, so the requested set is what was granted. + */ + const grantedScopes = tokens.scopes?.length + ? tokens.scopes + : getCanonicalScopesForProvider('attio') + return { + ...base, + providerTenantId: base.providerTenantId ?? null, + grantedScopes: [...new Set(grantedScopes)], + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + const granted = new Set(grantedScopes) + return requiredScopes.every((scope) => granted.has(scope)) + }, + isTerminalRefreshError, + } +} + +/** + * Managed enrollment policies for the providers whose identity endpoint reports an email the + * provider itself vouches for. Keyed by connector provider id. + * + * A `Map` rather than an object literal so a provider id that collides with an `Object.prototype` + * member cannot resolve to an inherited function and be invoked as a policy builder. + */ +const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map ManagedOAuthConnectorConfig>([ + [ + 'dropbox', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'dropbox', + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.dropboxapi.com/2/users/get_current_account', + method: 'POST', + }, + parse: (profile) => { + const account = asProfileRecord(profile, 'Dropbox') + const name = isRecordLike(account.name) ? account.name : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(account.account_id, 'Dropbox account id'), + email: requireIdentityField(account.email, 'Dropbox email'), + emailVerified: account.email_verified === true, + }, + { displayName: name.display_name, avatarUrl: account.profile_photo_url } + ) + }, + }), + ], + [ + 'zoom', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'zoom', + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.zoom.us/v2/users/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Zoom') + const displayName = `${user.first_name ?? ''} ${user.last_name ?? ''}`.trim() + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Zoom user id'), + email: requireIdentityField(user.email, 'Zoom email'), + /** Zoom reports `1` for an activated, email-confirmed account. */ + emailVerified: user.verified === 1, + }, + { displayName, avatarUrl: user.pic_url, providerTenantId: user.account_id } + ) + }, + }), + ], + ['salesforce', () => createSalesforceManagedOAuthConnector('salesforce')], + [ + 'notion', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'notion', + /** Notion authorizes without scopes and its integration tokens do not expire. */ + scopeless: true, + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.notion.com/v1/users/me', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': '2022-06-28', + }), + }, + parse: (profile) => { + const self = asProfileRecord(profile, 'Notion') + /** + * An integration token always resolves to a bot, so the human is reachable only through + * `bot.owner.user`. A workspace-owned internal integration reports + * `{ type: 'workspace' }` and identifies nobody, which cannot be bound to an invitation. + */ + const bot = isRecordLike(self.bot) ? self.bot : {} + const owner = isRecordLike(bot.owner) ? bot.owner : {} + if (owner.type !== 'user') { + throw new Error( + 'Notion returned a workspace-owned integration, which identifies no person to bind this invitation to' + ) + } + const user = asProfileRecord(owner.user, 'Notion') + const person = isRecordLike(user.person) ? user.person : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Notion user id'), + email: requireIdentityField(person.email, 'Notion email'), + /** Notion only exposes `person.email` for a confirmed workspace member. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: user.avatar_url } + ) + }, + }), + ], + [ + 'clickup', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'clickup', + /** ClickUp authorizes without scopes and its access tokens do not expire. */ + scopeless: true, + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.clickup.com/api/v2/user' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'ClickUp') + const user = asProfileRecord(envelope.user, 'ClickUp') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'ClickUp user id'), + email: requireIdentityField(user.email, 'ClickUp email'), + /** A ClickUp seat is only activated by confirming a mailed invitation. */ + emailVerified: true, + }, + { displayName: user.username, avatarUrl: user.profilePicture } + ) + }, + }), + ], + [ + 'calcom', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'calcom', + /** Cal.com's OAuth app authorizes without a scope list. */ + scopeless: true, + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.cal.com/v2/me', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'cal-api-version': '2024-08-13', + }), + }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Cal.com') + const user = asProfileRecord(envelope.data ?? envelope, 'Cal.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Cal.com user id'), + email: requireIdentityField(user.email, 'Cal.com email'), + /** A Cal.com account is only usable once its address has confirmed signup. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: user.avatarUrl } + ) + }, + }), + ], + ['attio', createAttioManagedOAuthConnector], + [ + 'hubspot', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'hubspot', + requiresRefreshToken: true, + /** + * HubSpot reports neither identity nor scopes on the token response. Its token-metadata + * endpoint carries both, so one call answers each. + */ + scopes: { + from: 'profile', + read: (profile) => { + const metadata = isRecordLike(profile) ? profile : {} + if (Array.isArray(metadata.scopes)) { + return metadata.scopes.filter((scope): scope is string => typeof scope === 'string') + } + return readScopeString(metadata.scope) + }, + }, + userInfo: { + /** The token identifies itself: it is the path, and the endpoint takes no credential. */ + url: (tokens) => + `https://api.hubapi.com/oauth/v1/access-tokens/${encodeURIComponent( + tokens.accessToken ?? '' + )}`, + headers: () => ({}), + }, + parse: (profile) => { + const metadata = asProfileRecord(profile, 'HubSpot') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(metadata.user_id, 'HubSpot user id'), + /** HubSpot reports the authorizing seat's address as `user`. */ + email: requireIdentityField(metadata.user, 'HubSpot user email'), + /** A HubSpot seat is only activated by confirming a mailed invitation. */ + emailVerified: true, + }, + { providerTenantId: metadata.hub_id } + ) + }, + }), + ], + [ + 'linear', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'linear', + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ query: '{ viewer { id email name avatarUrl } }' }), + }, + parse: (profile) => { + const viewer = readGraphQLIdentity(profile, 'viewer', 'Linear') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(viewer.id, 'Linear user id'), + email: requireIdentityField(viewer.email, 'Linear email'), + /** A Linear account only exists once its address has accepted a mailed invite. */ + emailVerified: true, + }, + { displayName: viewer.name, avatarUrl: viewer.avatarUrl } + ) + }, + }), + ], + [ + 'monday', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'monday', + /** monday.com access tokens do not expire and no refresh token is issued. */ + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { + url: MONDAY_API_URL, + method: 'POST', + headers: (accessToken) => ({ + Authorization: accessToken, + 'Content-Type': 'application/json', + 'API-Version': MONDAY_API_VERSION, + }), + body: JSON.stringify({ query: '{ me { id name email } }' }), + }, + parse: (profile) => { + const user = readGraphQLIdentity(profile, 'me', 'monday.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'monday.com user id'), + email: requireIdentityField(user.email, 'monday.com email'), + /** monday.com activates a seat only after its address confirms the invitation. */ + emailVerified: true, + }, + { displayName: user.name } + ) + }, + }), + ], + [ + 'box', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'box', + requiresRefreshToken: true, + /** + * Box's token response reports `restricted_to`, never `scope`, and the scope set is fixed + * by the Box app registration rather than chosen at consent — so the requested set is the + * granted set, and reading the token response would fail every enrollment on an empty list. + */ + scopes: { from: 'requested' }, + userInfo: { url: 'https://api.box.com/2.0/users/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Box') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Box user id'), + /** Box's `login` is the account's confirmed sign-in address. */ + email: requireIdentityField(user.login, 'Box login'), + emailVerified: user.status === 'active', + }, + { displayName: user.name, avatarUrl: user.avatar_url } + ) + }, + }), + ], + [ + 'asana', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'asana', + requiresRefreshToken: true, + scopes: { from: 'requested' }, + userInfo: { url: 'https://app.asana.com/api/1.0/users/me' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Asana') + const user = asProfileRecord(envelope.data, 'Asana') + const photo = isRecordLike(user.photo) ? user.photo : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.gid, 'Asana user id'), + email: requireIdentityField(user.email, 'Asana email'), + /** An Asana account is only usable once its address has confirmed the invitation. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: photo.image_128x128 } + ) + }, + }), + ], + [ + 'airtable', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'airtable', + pkce: true, + requiresRefreshToken: true, + /** Airtable's `whoami` reports the token's own scopes; the token response does not. */ + scopes: { + from: 'profile', + read: (profile, tokens) => { + const granted = + isRecordLike(profile) && Array.isArray(profile.scopes) + ? profile.scopes.filter((scope): scope is string => typeof scope === 'string') + : [] + return granted.length ? granted : (tokens.scopes ?? []) + }, + }, + userInfo: { url: 'https://api.airtable.com/v0/meta/whoami' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Airtable') + return { + providerSubjectId: requireIdentityField(user.id, 'Airtable user id'), + email: requireIdentityField(user.email, 'Airtable email'), + /** Airtable only returns `email` once the address has been confirmed. */ + emailVerified: true, + } + }, + }), + ], + [ + 'linkedin', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'linkedin', + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.linkedin.com/v2/userinfo' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'LinkedIn') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.sub, 'LinkedIn subject id'), + email: requireIdentityField(user.email, 'LinkedIn email'), + /** + * The OIDC claim, not a constant. The connector-layer `getUserInfo` asserts `true` + * unconditionally, which would let an unverified address satisfy an invitation. + */ + emailVerified: user.email_verified === true || user.email_verified === 'true', + }, + { displayName: user.name, avatarUrl: user.picture } + ) + }, + }), + ], + [ + 'pipedrive', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'pipedrive', + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.pipedrive.com/v1/users/me' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Pipedrive') + const user = asProfileRecord(envelope.data, 'Pipedrive') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Pipedrive user id'), + email: requireIdentityField(user.email, 'Pipedrive email'), + /** Pipedrive activates a seat only once its invitation email is accepted. */ + emailVerified: user.activated === true, + }, + { displayName: user.name, avatarUrl: user.icon_url, providerTenantId: user.company_id } + ) + }, + }), + ], + [ + 'wordpress', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'wordpress', + /** WordPress.com issues long-lived tokens and no refresh token. */ + requiresRefreshToken: false, + /** + * WordPress.com's only scope is `global`, granted whole or not at all, so the requested set + * is the granted set and there is nothing a consent screen could downgrade. + */ + scopes: { from: 'requested' }, + userInfo: { url: 'https://public-api.wordpress.com/rest/v1.1/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'WordPress.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.ID ?? user.id, 'WordPress.com user id'), + email: requireIdentityField(user.email, 'WordPress.com email'), + emailVerified: user.email_verified === true, + }, + { displayName: user.display_name ?? user.username, avatarUrl: user.avatar_URL } + ) + }, + }), + ], + [ + 'docusign', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'docusign', + requiresRefreshToken: true, + /** DocuSign reports the granted scopes on userinfo, not on the token response. */ + scopes: { + from: 'profile', + read: (profile, tokens) => { + const granted = isRecordLike(profile) ? readScopeString(profile.scope) : [] + return granted.length ? granted : (tokens.scopes ?? []) + }, + }, + userInfo: { url: getDocusignOAuthUrl('/oauth/userinfo') }, + parse: (profile) => { + const user = asProfileRecord(profile, 'DocuSign') + const accounts = Array.isArray(user.accounts) ? user.accounts.filter(isRecordLike) : [] + const defaultAccount = + accounts.find((account) => account.is_default === true) ?? accounts[0] + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.sub, 'DocuSign subject id'), + email: requireIdentityField(user.email, 'DocuSign email'), + /** A DocuSign account is only activated by following a link sent to this address. */ + emailVerified: true, + }, + { + displayName: user.name, + providerTenantId: defaultAccount?.account_id, + } + ) + }, + }), + ], +]) + +/** + * The managed enrollment policy for a provider, without the surrounding connector. Separated from + * {@link getManagedOAuthConnectorProviderConfig} so a policy can be inspected without a configured + * OAuth client, which `buildConnectorProviders` requires. + */ +export function getManagedOAuthConnectorPolicy( + providerId: string +): ManagedOAuthConnectorConfig | undefined { + return resolveManagedOAuthPolicy(providerId)?.() +} + +function resolveManagedOAuthPolicy( + providerId: string +): (() => ManagedOAuthConnectorConfig) | undefined { + if (providerId === 'google-email' || providerId === 'google-calendar') { + return () => createGoogleManagedOAuthConnector(providerId) + } + if (providerId === 'confluence' || providerId === 'jira') { + return () => createAtlassianManagedOAuthConnector(providerId) + } + return USER_INFO_MANAGED_OAUTH_CONNECTORS.get(providerId) +} + export function getManagedOAuthConnectorProviderConfig( providerId: string ): ConnectorProviderConfig | undefined { - const isGoogle = providerId === 'google-email' || providerId === 'google-calendar' - const isAtlassian = providerId === 'confluence' || providerId === 'jira' - if (!isGoogle && !isAtlassian) return undefined + const buildPolicy = resolveManagedOAuthPolicy(providerId) + if (!buildPolicy) return undefined const connector = buildConnectorProviders().find( (candidate) => candidate.providerId === providerId ) if (!connector) return undefined - return { - ...connector, - managedOAuth: isGoogle - ? createGoogleManagedOAuthConnector(providerId) - : createAtlassianManagedOAuthConnector(providerId), - } + return { ...connector, managedOAuth: buildPolicy() } } diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index fbc3a0d3a08..d871461244b 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -120,7 +120,12 @@ describe('Credential Group Settings application operations', () => { expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1') expect(mocks.list).toHaveBeenCalledWith('workspace-1') - expect(result).toEqual({ credentialGroups: [] }) + expect(result.credentialGroups).toEqual([]) + /** + * Which providers are offerable depends on the OAuth clients this environment configures, so + * the list is asserted as a shape rather than a fixed set. + */ + expect(Array.isArray(result.availableProviders)).toBe(true) }) it('derives created-by identity from the authenticated session principal', async () => { diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f3fbd3e6761..3825bf52b69 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -17,6 +17,7 @@ import { CredentialGroupEnrollmentError, listCredentialGroupEnrollments, } from '@/lib/credential-groups/enrollments' +import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { createCredentialGroup, deleteCredentialGroup, @@ -47,7 +48,10 @@ export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ authorizationOptions: {}, async execute({ context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) - return { credentialGroups: await listCredentialGroups(context.workspaceId) } + return { + credentialGroups: await listCredentialGroups(context.workspaceId), + availableProviders: listConfiguredCredentialGroupProviders(), + } }, }) diff --git a/apps/sim/lib/credential-groups/provider-availability.ts b/apps/sim/lib/credential-groups/provider-availability.ts new file mode 100644 index 00000000000..fcc2e806906 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-availability.ts @@ -0,0 +1,27 @@ +import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupProvider, + getCredentialGroupProviderId, + isCredentialGroupStandardOAuthProvider, +} from '@/lib/credential-groups/providers' + +/** + * The providers this deployment can actually enroll. + * + * A standard OAuth provider needs Sim's own OAuth client for that service to be configured; + * without it the connector is never built and starting an enrollment fails with a configuration + * error. Offering the option anyway leaves an admin with a row that only reports its own + * unavailability after they have already added it to a group and invited somebody. + * + * Slack is exempt because its credential is the workspace's own custom bot, configured per group + * after the fact — its readiness is already surfaced by the option's `configurationStatus`. + * + * Server-only: `inspectConfiguredOAuthClient` reads the server environment. + */ +export function listConfiguredCredentialGroupProviders(): CredentialGroupProvider[] { + return CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => { + if (!isCredentialGroupStandardOAuthProvider(provider)) return true + return inspectConfiguredOAuthClient(getCredentialGroupProviderId(provider)).state === 'ready' + }) +} diff --git a/apps/sim/lib/credential-groups/provider-registry.test.ts b/apps/sim/lib/credential-groups/provider-registry.test.ts index bc8930df8f5..b9a3bb612c6 100644 --- a/apps/sim/lib/credential-groups/provider-registry.test.ts +++ b/apps/sim/lib/credential-groups/provider-registry.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from 'vitest' import { createAtlassianManagedOAuthConnector, createGoogleManagedOAuthConnector, + getManagedOAuthConnectorPolicy, } from '@/lib/auth/connectors/managed-oauth' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, getCredentialGroupProviderFromProviderId, getCredentialGroupProviderService, getCredentialGroupStandardOAuthProviderFromProviderId, @@ -99,4 +101,33 @@ describe('Credential Group provider registry', () => { 'Unsupported managed OAuth provider' ) }) + it.each(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)( + 'backs %s with a managed OAuth policy and a round-trippable provider id', + (provider) => { + const service = getCredentialGroupProviderService(provider) + + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe(provider) + expect(getCredentialGroupStandardOAuthProviderFromProviderId(service.providerId)).toBe( + provider + ) + expect(getCredentialGroupProviderAdapter(provider).provider).toBe(provider) + /** + * The provider list and the connector policy catalog are maintained separately, so an entry + * added to one and not the other would otherwise only surface as a runtime configuration + * error the first time somebody tried to enroll. + */ + expect(getManagedOAuthConnectorPolicy(service.providerId)).toBeDefined() + } + ) + + it.each(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)( + 'gives %s either a scope policy or an explicit scopeless declaration', + (provider) => { + const service = getCredentialGroupProviderService(provider) + const policy = getManagedOAuthConnectorPolicy(service.providerId) + const requiredScopes = [...service.scopes, ...(policy?.additionalScopes ?? [])] + + expect(requiredScopes.length > 0 || policy?.scopeless === true).toBe(true) + } + ) }) diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts index f4517c1e598..08642b7e598 100644 --- a/apps/sim/lib/credential-groups/provider-registry.ts +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -14,6 +14,23 @@ const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< 'google-calendar': createStandardOAuthCredentialGroupProviderAdapter('google-calendar'), confluence: createStandardOAuthCredentialGroupProviderAdapter('confluence'), jira: createStandardOAuthCredentialGroupProviderAdapter('jira'), + airtable: createStandardOAuthCredentialGroupProviderAdapter('airtable'), + asana: createStandardOAuthCredentialGroupProviderAdapter('asana'), + attio: createStandardOAuthCredentialGroupProviderAdapter('attio'), + box: createStandardOAuthCredentialGroupProviderAdapter('box'), + calcom: createStandardOAuthCredentialGroupProviderAdapter('calcom'), + clickup: createStandardOAuthCredentialGroupProviderAdapter('clickup'), + docusign: createStandardOAuthCredentialGroupProviderAdapter('docusign'), + hubspot: createStandardOAuthCredentialGroupProviderAdapter('hubspot'), + linear: createStandardOAuthCredentialGroupProviderAdapter('linear'), + monday: createStandardOAuthCredentialGroupProviderAdapter('monday'), + notion: createStandardOAuthCredentialGroupProviderAdapter('notion'), + dropbox: createStandardOAuthCredentialGroupProviderAdapter('dropbox'), + linkedin: createStandardOAuthCredentialGroupProviderAdapter('linkedin'), + pipedrive: createStandardOAuthCredentialGroupProviderAdapter('pipedrive'), + salesforce: createStandardOAuthCredentialGroupProviderAdapter('salesforce'), + wordpress: createStandardOAuthCredentialGroupProviderAdapter('wordpress'), + zoom: createStandardOAuthCredentialGroupProviderAdapter('zoom'), slack: slackCredentialGroupProviderAdapter, } diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts index d3cc25e0612..07cce8fe04d 100644 --- a/apps/sim/lib/credential-groups/providers.ts +++ b/apps/sim/lib/credential-groups/providers.ts @@ -6,6 +6,23 @@ export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = [ 'google-calendar', 'confluence', 'jira', + 'airtable', + 'asana', + 'attio', + 'box', + 'calcom', + 'clickup', + 'docusign', + 'dropbox', + 'hubspot', + 'linear', + 'linkedin', + 'monday', + 'notion', + 'pipedrive', + 'salesforce', + 'wordpress', + 'zoom', ] as const export type CredentialGroupStandardOAuthProvider = @@ -48,6 +65,91 @@ const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< description: 'Let each person connect one Jira account', configuration: 'oauth', }, + airtable: { + serviceId: 'airtable', + description: 'Let each person connect one Airtable account', + configuration: 'oauth', + }, + asana: { + serviceId: 'asana', + description: 'Let each person connect one Asana account', + configuration: 'oauth', + }, + attio: { + serviceId: 'attio', + description: 'Let each person connect one Attio account', + configuration: 'oauth', + }, + box: { + serviceId: 'box', + description: 'Let each person connect one Box account', + configuration: 'oauth', + }, + calcom: { + serviceId: 'calcom', + description: 'Let each person connect one Cal.com account', + configuration: 'oauth', + }, + clickup: { + serviceId: 'clickup', + description: 'Let each person connect one ClickUp account', + configuration: 'oauth', + }, + hubspot: { + serviceId: 'hubspot', + description: 'Let each person connect one HubSpot account', + configuration: 'oauth', + }, + linear: { + serviceId: 'linear', + description: 'Let each person connect one Linear account', + configuration: 'oauth', + }, + monday: { + serviceId: 'monday', + description: 'Let each person connect one monday.com account', + configuration: 'oauth', + }, + notion: { + serviceId: 'notion', + description: 'Let each person connect one Notion account', + configuration: 'oauth', + }, + docusign: { + serviceId: 'docusign', + description: 'Let each person connect one DocuSign account', + configuration: 'oauth', + }, + dropbox: { + serviceId: 'dropbox', + description: 'Let each person connect one Dropbox account', + configuration: 'oauth', + }, + linkedin: { + serviceId: 'linkedin', + description: 'Let each person connect one LinkedIn account', + configuration: 'oauth', + }, + pipedrive: { + serviceId: 'pipedrive', + description: 'Let each person connect one Pipedrive account', + configuration: 'oauth', + }, + salesforce: { + serviceId: 'salesforce', + description: 'Let each person connect one Salesforce account', + configuration: 'oauth', + }, + wordpress: { + serviceId: 'wordpress', + description: 'Let each person connect one WordPress.com account', + configuration: 'oauth', + }, + zoom: { + serviceId: 'zoom', + description: 'Let each person connect one Zoom account', + configuration: 'oauth', + }, slack: { serviceId: 'slack', description: 'Let each person connect through your custom Slack app', diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index dfedb086bc8..f1c568f6d3a 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -296,4 +296,51 @@ describe('standard OAuth Credential Group provider', () => { codeVerifier: undefined, }) }) + it.each(['bearer', 'BEARER'])( + 'accepts the RFC 6749 case-insensitive %s token type', + async (tokenType) => { + mockGetToken.mockResolvedValueOnce({ + tokenType, + accessToken: 'access-1', + refreshToken: 'refresh-1', + accessTokenExpiresAt: new Date('2026-08-14T01:00:00Z'), + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + const grant = await adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + + expect(grant.accessToken).toBe('access-1') + } + ) + + it('still rejects a token type that is not bearer at all', async () => { + mockGetToken.mockResolvedValueOnce({ + tokenType: 'mac', + accessToken: 'access-1', + refreshToken: 'refresh-1', + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ statusCode: 502 }) + }) }) diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 5e312d2766d..c9e7c85c2e7 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -33,6 +33,15 @@ interface OAuthEndpoints { tokenEndpoint: string } +/** + * RFC 6749 §7.1 defines `token_type` as case-insensitive, and Better Auth passes the provider's + * raw `token_type` through untouched. Several providers answer with lowercase `bearer`, so an + * exact match would reject a perfectly valid grant as an incomplete authorization. + */ +function isBearerTokenType(tokenType: string | undefined): boolean { + return tokenType?.toLowerCase() === 'bearer' +} + interface CurrentStandardOAuthProvider { connector: ConnectorProviderConfig policy: CredentialGroupProviderPolicy @@ -138,7 +147,7 @@ function getCurrentProvider( const requiredScopes = [ ...new Set([...(connector.scopes ?? []), ...connector.managedOAuth.additionalScopes]), ] - if (requiredScopes.length === 0) { + if (requiredScopes.length === 0 && !connector.managedOAuth.scopeless) { throw new CredentialGroupProviderConfigurationError( `Managed ${service.name} authorization has no scope policy` ) @@ -290,7 +299,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( 502 ) } - if (tokens.tokenType !== 'Bearer' || !tokens.accessToken) { + if (!isBearerTokenType(tokens.tokenType) || !tokens.accessToken) { throw new CredentialGroupOAuthError( `${service.name} returned an incomplete authorization.`, 502