diff --git a/apps/docs/content/docs/integrations/microsoft_word.mdx b/apps/docs/content/docs/integrations/microsoft_word.mdx
index f7f58e7d7f3..b05df6af285 100644
--- a/apps/docs/content/docs/integrations/microsoft_word.mdx
+++ b/apps/docs/content/docs/integrations/microsoft_word.mdx
@@ -276,8 +276,8 @@ Sim requests these scopes when someone connects a Microsoft Word account. On a s
| Setting | Value |
| ------- | ----- |
-| Redirect URI | `/api/auth/oauth2/callback/microsoft-word` |
-| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
+| Redirect URI | Work or school account: `/api/auth/oauth2/callback/microsoft-word`
Personal Microsoft account: `/api/auth/oauth2/callback/microsoft-word-personal` |
+| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_PERSONAL_CLIENT_ID`, `MICROSOFT_PERSONAL_CLIENT_SECRET` |
| Scope | Description |
| ----- | ----------- |
diff --git a/apps/docs/content/docs/integrations/onedrive.mdx b/apps/docs/content/docs/integrations/onedrive.mdx
index e69ed98a7d7..0b926c7a911 100644
--- a/apps/docs/content/docs/integrations/onedrive.mdx
+++ b/apps/docs/content/docs/integrations/onedrive.mdx
@@ -240,8 +240,8 @@ Sim requests these scopes when someone connects an OneDrive account. On a self-h
| Setting | Value |
| ------- | ----- |
-| Redirect URI | `/api/auth/oauth2/callback/onedrive` |
-| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
+| Redirect URI | Work or school account: `/api/auth/oauth2/callback/onedrive`
Personal Microsoft account: `/api/auth/oauth2/callback/onedrive-personal` |
+| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_PERSONAL_CLIENT_ID`, `MICROSOFT_PERSONAL_CLIENT_SECRET` |
| Scope | Description |
| ----- | ----------- |
diff --git a/apps/docs/content/docs/integrations/outlook.mdx b/apps/docs/content/docs/integrations/outlook.mdx
index 6c85d68babb..acbe9f03956 100644
--- a/apps/docs/content/docs/integrations/outlook.mdx
+++ b/apps/docs/content/docs/integrations/outlook.mdx
@@ -728,8 +728,8 @@ Sim requests these scopes when someone connects an Outlook account. On a self-ho
| Setting | Value |
| ------- | ----- |
-| Redirect URI | `/api/auth/oauth2/callback/outlook` |
-| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
+| Redirect URI | Work or school account: `/api/auth/oauth2/callback/outlook`
Personal Microsoft account: `/api/auth/oauth2/callback/outlook-personal` |
+| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_PERSONAL_CLIENT_ID`, `MICROSOFT_PERSONAL_CLIENT_SECRET` |
| Scope | Description |
| ----- | ----------- |
diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
index 5623dd5bd8b..2ac28e53a6c 100644
--- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
+++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx
@@ -117,6 +117,20 @@ One app registration in [Entra ID](https://entra.microsoft.com) covers all of th
The same variables also power "Sign in with Microsoft".
+#### Personal Microsoft accounts
+
+OneDrive, Outlook (mail and calendar), and Word files stored in OneDrive also support personal Microsoft accounts. Configure **one separate app registration** for all three, with personal Microsoft accounts enabled and access token version `2`. Keep the existing Microsoft app and its credentials unchanged: saved connections and Microsoft sign-in continue using them.
+
+| Environment variables | Provider IDs |
+|---|---|
+| `MICROSOFT_PERSONAL_CLIENT_ID`
`MICROSOFT_PERSONAL_CLIENT_SECRET` | `onedrive-personal`, `outlook-personal`, `microsoft-word-personal` |
+
+Register each provider's callback as a **Web** redirect URI, for example `https:///api/auth/oauth2/callback/onedrive-personal`. Use delegated Microsoft Graph permissions from the integration's OAuth Scopes section. Personal connections use Microsoft's `/consumers` authority. Set the new credentials on both the web app and workers that refresh tokens, then restart them. The personal account option appears when both values are configured; reconnecting a saved connection preserves its original app.
+
+For Connected Accounts, request the optional ID token claims `email`, `xms_edov`, `verified_primary_email`, and `verified_secondary_email` on the new registration. Sim requires a verified email signal when enrolling a managed account. See Microsoft's [optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference).
+
+Excel's workbook API, SharePoint, Teams, Planner, Power BI, Dataverse, Intune, and Active Directory integrations require organizational accounts. Enabling personal accounts on an app registration does not change those API restrictions. See Microsoft's [Excel permissions](https://learn.microsoft.com/en-us/graph/api/workbook-createsession?view=graph-rest-1.0#permissions) and [supported account types](https://learn.microsoft.com/en-us/entra/identity-platform/v2-supported-account-types).
+
For Power BI, register `/api/auth/oauth2/callback/microsoft-powerbi` as a **Web** redirect URI. Sim requests the delegated Power BI API scopes `Workspace.Read.All`, `Report.Read.All`, and `Dataset.ReadWrite.All` with the explicit `https://analysis.windows.net/powerbi/api/` resource prefix, plus identity and offline-access scopes. Connect an organizational account from its home tenant. Power BI uses a separate connection from Microsoft Graph integrations; Credential Groups and service-principal authentication are not supported in this version. See the [Power BI integration](/integrations/powerbi) for permissions and tenant settings.
### GitHub Search
diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json
index ba518619df0..ff968407dde 100644
--- a/apps/docs/openapi-v2-resources.json
+++ b/apps/docs/openapi-v2-resources.json
@@ -12612,8 +12612,11 @@
"microsoft-powerbi",
"microsoft-teams",
"microsoft-word",
+ "microsoft-word-personal",
"outlook",
+ "outlook-personal",
"onedrive",
+ "onedrive-personal",
"sharepoint",
"x",
"tiktok",
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index e624f434d0e..ba5982b0aa0 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -1,6 +1,7 @@
'use client'
import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react'
+import { isMicrosoftPersonalProvider } from '@sim/deployment-config/env-capabilities'
import {
Badge,
ChipModal,
@@ -37,6 +38,7 @@ import {
} from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment'
import { withBrandIcon } from '@/blocks/brand-icon'
import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
+import { useIntegrationAvailability } from '@/hooks/queries/integration-availability'
import {
assertMicrosoftDataverseWebOAuthAvailable,
useConnectMicrosoftDataverseOAuthService,
@@ -176,6 +178,8 @@ export type ConnectOAuthModalProps =
export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
const { open, onOpenChange, mode, docsUrl } = props
const isConnect = mode === 'connect'
+ const { data: integrationAvailability, isPending: isAvailabilityPending } =
+ useIntegrationAvailability()
const declaredProviderId = useMemo(
() => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''),
@@ -187,23 +191,37 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
* more than one (Salesforce production vs sandbox). Offered on connect only:
* a reauthorize must return to the server that issued the credential.
*/
- const { authServerOptions, authServerHint } = useMemo(() => {
+ const { authServerOptions, authServerHint, hasPersonalAccountOption } = useMemo(() => {
const service = isConnect ? getServiceConfigByProviderId(declaredProviderId) : null
const labels = service?.providerIdLabels
if (!service?.additionalProviderIds?.length || !labels) {
- return { authServerOptions: [], authServerHint: undefined }
+ return { authServerOptions: [], authServerHint: undefined, hasPersonalAccountOption: false }
}
+ const supportsPersonalAccounts = service.additionalProviderIds.some(isMicrosoftPersonalProvider)
return {
- authServerOptions: [service.providerId, ...service.additionalProviderIds].map((value) => ({
- value,
- label: labels[value] ?? value,
- })),
+ authServerOptions: [service.providerId, ...service.additionalProviderIds]
+ .filter(
+ (providerId) =>
+ !supportsPersonalAccounts ||
+ (integrationAvailability?.oauthServiceAvailability.find(
+ (entry) => entry.providerId === providerId
+ )?.available ??
+ !isMicrosoftPersonalProvider(providerId))
+ )
+ .map((value) => ({
+ value,
+ label: labels[value] ?? value,
+ })),
authServerHint: service.providerIdPickerHint,
+ hasPersonalAccountOption: supportsPersonalAccounts,
}
- }, [isConnect, declaredProviderId])
+ }, [isConnect, declaredProviderId, integrationAvailability])
const [selectedProviderId, setSelectedProviderId] = useState(null)
- const providerId = selectedProviderId ?? declaredProviderId
+ const defaultOption =
+ authServerOptions.find((option) => option.value === declaredProviderId) ??
+ (!isMicrosoftPersonalProvider(declaredProviderId) ? authServerOptions[0] : undefined)
+ const providerId = selectedProviderId ?? defaultOption?.value ?? declaredProviderId
const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES
const [displayName, setDisplayName] = useState('')
@@ -492,6 +510,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
connectMicrosoftDataverseOAuthService.isPending
const isDisabled = isConnect
? !displayName.trim() ||
+ (hasPersonalAccountOption && isAvailabilityPending) ||
!dataverseEnvironmentForm.isComplete ||
Boolean(clientConfiguration?.fields.some((field) => !oauthClientFields[field.id]?.trim())) ||
isPending ||
@@ -536,10 +555,14 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
)}
- {authServerOptions.length > 0 && (
+ {authServerOptions.length > 1 && (
isMicrosoftPersonalProvider(option.value))
+ ? 'Account type'
+ : 'Environment'
+ }
value={providerId}
onChange={setSelectedProviderId}
options={authServerOptions}
diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts
index b0287708383..58d0fea47fe 100644
--- a/apps/sim/lib/api/contracts/v2/credentials.ts
+++ b/apps/sim/lib/api/contracts/v2/credentials.ts
@@ -257,8 +257,11 @@ export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [
'microsoft-powerbi',
'microsoft-teams',
'microsoft-word',
+ 'microsoft-word-personal',
'outlook',
+ 'outlook-personal',
'onedrive',
+ 'onedrive-personal',
'sharepoint',
'x',
'tiktok',
diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts
index 756736e14b1..7678c8a6840 100644
--- a/apps/sim/lib/auth/connectors/managed-oauth.ts
+++ b/apps/sim/lib/auth/connectors/managed-oauth.ts
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
+import { MICROSOFT_PERSONAL_PROVIDERS } from '@sim/deployment-config/env-capabilities'
import { isRecordLike, toRecord } from '@sim/utils/object'
import type { OAuth2Tokens } from 'better-auth/oauth2'
import type { GenericOAuthConfig } from 'better-auth/plugins'
@@ -31,6 +32,7 @@ const MICROSOFT_OIDC_USER_INFO_TIMEOUT_MS = 10_000
const MICROSOFT_GRAPH_SCOPE_PREFIX = 'https://graph.microsoft.com/'
/** The Microsoft providers whose accounts a Credential Group can collect per person. */
const MICROSOFT_MANAGED_OAUTH_PROVIDER_IDS = new Set([
+ ...Object.keys(MICROSOFT_PERSONAL_PROVIDERS),
'microsoft-teams',
'outlook',
'onedrive',
diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts
index 794c77ce038..14a14d57426 100644
--- a/apps/sim/lib/auth/connectors/providers.ts
+++ b/apps/sim/lib/auth/connectors/providers.ts
@@ -1,4 +1,5 @@
import { createHash } from 'crypto'
+import { MICROSOFT_PERSONAL_PROVIDERS } from '@sim/deployment-config/env-capabilities'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -194,6 +195,21 @@ function salesforceConnector(providerId: string, loginHost: string): GenericOAut
*/
export function buildConnectorProviders(): GenericOAuthConfig[] {
const providers: GenericOAuthConfig[] = [
+ ...Object.keys(MICROSOFT_PERSONAL_PROVIDERS).map(
+ (providerId): GenericOAuthConfig => ({
+ providerId,
+ clientId: env.MICROSOFT_PERSONAL_CLIENT_ID as string,
+ clientSecret: env.MICROSOFT_PERSONAL_CLIENT_SECRET as string,
+ authorizationUrl: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize',
+ tokenUrl: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
+ scopes: getCanonicalScopesForProvider(providerId),
+ responseType: 'code',
+ accessType: 'offline',
+ pkce: true,
+ redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/${providerId}`,
+ getUserInfo: async (tokens) => getMicrosoftUserInfoFromIdToken(tokens, providerId),
+ })
+ ),
createGitHubRepositoriesProvider({
clientId: env.GITHUB_APP_CLIENT_ID as string,
clientSecret: env.GITHUB_APP_CLIENT_SECRET as string,
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index b351cefe995..9a56e5b78b2 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -541,6 +541,8 @@ export const env = createEnv({
DOCUSIGN_AUTH_HOST: z.string().optional(), // DocuSign auth host: account-d.docusign.com (demo, default) or account.docusign.com (production)
MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams
MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret
+ MICROSOFT_PERSONAL_CLIENT_ID: z.string().optional(),
+ MICROSOFT_PERSONAL_CLIENT_SECRET: z.string().optional(),
HUBSPOT_MCP_CLIENT_ID: z.string().optional(), // HubSpot member MCP OAuth client ID
HUBSPOT_MCP_CLIENT_SECRET: z.string().optional(), // HubSpot member MCP OAuth client secret
HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID
diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts
index 2b999980c59..7b88d2430c2 100644
--- a/apps/sim/lib/credential-groups/provider-registry.ts
+++ b/apps/sim/lib/credential-groups/provider-registry.ts
@@ -21,6 +21,10 @@ const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record<
'microsoft-teams': createStandardOAuthCredentialGroupProviderAdapter('microsoft-teams'),
outlook: createStandardOAuthCredentialGroupProviderAdapter('outlook'),
onedrive: createStandardOAuthCredentialGroupProviderAdapter('onedrive'),
+ 'onedrive-personal': createStandardOAuthCredentialGroupProviderAdapter('onedrive-personal'),
+ 'outlook-personal': createStandardOAuthCredentialGroupProviderAdapter('outlook-personal'),
+ 'microsoft-word-personal':
+ createStandardOAuthCredentialGroupProviderAdapter('microsoft-word-personal'),
sharepoint: createStandardOAuthCredentialGroupProviderAdapter('sharepoint'),
'microsoft-excel': createStandardOAuthCredentialGroupProviderAdapter('microsoft-excel'),
confluence: createStandardOAuthCredentialGroupProviderAdapter('confluence'),
diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts
index e7d0fb36cdd..93cdac63440 100644
--- a/apps/sim/lib/credential-groups/providers.ts
+++ b/apps/sim/lib/credential-groups/providers.ts
@@ -1,3 +1,4 @@
+import { isMicrosoftPersonalProvider } from '@sim/deployment-config/env-capabilities'
import type { OAuthServiceConfig } from '@/lib/oauth'
import { getServiceConfigByServiceId } from '@/lib/oauth'
@@ -12,7 +13,10 @@ export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = [
'google-sheets',
'microsoft-teams',
'outlook',
+ 'outlook-personal',
'onedrive',
+ 'onedrive-personal',
+ 'microsoft-word-personal',
'sharepoint',
'microsoft-excel',
'confluence',
@@ -113,6 +117,21 @@ const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record<
description: 'Let each person connect one OneDrive account',
configuration: 'oauth',
},
+ 'onedrive-personal': {
+ serviceId: 'onedrive',
+ description: 'Let each person connect one personal OneDrive account',
+ configuration: 'oauth',
+ },
+ 'outlook-personal': {
+ serviceId: 'outlook',
+ description: 'Let each person connect one personal Outlook account',
+ configuration: 'oauth',
+ },
+ 'microsoft-word-personal': {
+ serviceId: 'microsoft-word',
+ description: 'Let each person connect one personal Microsoft Word account',
+ configuration: 'oauth',
+ },
sharepoint: {
serviceId: 'sharepoint',
description: 'Let each person connect one SharePoint account',
@@ -255,7 +274,14 @@ export function getCredentialGroupProviderService(
`Credential Group provider ${provider} references missing OAuth service ${support.serviceId}`
)
}
- return service
+ return isMicrosoftPersonalProvider(provider)
+ ? {
+ ...service,
+ providerId: provider,
+ name: `${service.name} (personal)`,
+ additionalProviderIds: undefined,
+ }
+ : service
}
export function getCredentialGroupProviderId(provider: CredentialGroupProvider): string {
diff --git a/apps/sim/lib/credentials/application/organization-credentials.test.ts b/apps/sim/lib/credentials/application/organization-credentials.test.ts
index 2a65197d8a9..6ef387887b1 100644
--- a/apps/sim/lib/credentials/application/organization-credentials.test.ts
+++ b/apps/sim/lib/credentials/application/organization-credentials.test.ts
@@ -16,7 +16,7 @@ import {
organizationAuthorizationMock,
organizationAuthorizationMockFns,
} from '@sim/testing/mocks/organization-authorization.mock'
-import { and, eq, isNull, or } from 'drizzle-orm'
+import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -151,7 +151,7 @@ describe('organization connection application boundary', () => {
and(eq(credential.type, 'oauth'), eq(credential.createdBy, 'admin-1'))
),
eq(credential.type, 'oauth'),
- eq(credential.providerId, 'google-drive')
+ inArray(credential.providerId, ['google-drive'])
)
)
})
diff --git a/apps/sim/lib/credentials/application/organization-credentials.ts b/apps/sim/lib/credentials/application/organization-credentials.ts
index 97ea3207545..2b07c506217 100644
--- a/apps/sim/lib/credentials/application/organization-credentials.ts
+++ b/apps/sim/lib/credentials/application/organization-credentials.ts
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
-import { and, desc, eq, getTableColumns, or } from 'drizzle-orm'
+import { and, desc, eq, getTableColumns, inArray, or } from 'drizzle-orm'
import type {
CreateOrganizationCredentialBody,
CreateOrganizationCredentialDraftBody,
@@ -38,7 +38,7 @@ import {
type ServiceAccountTokenResult,
} from '@/lib/oauth/credential-service'
import type { Credential, OAuthProvider } from '@/lib/oauth/types'
-import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
+import { getServiceConfigByProviderId, providerIdsForService } from '@/lib/oauth/utils'
export const organizationCredentialOperations = {
list: defineOrganizationOperation({
@@ -113,7 +113,9 @@ export const listOrganizationCredentials: OperationUseCase<
and(eq(credential.type, 'oauth'), eq(credential.createdBy, context.userId))
),
...(input.type ? [eq(credential.type, input.type)] : []),
- ...(input.providerId ? [eq(credential.providerId, input.providerId)] : [])
+ ...(input.providerId
+ ? [inArray(credential.providerId, providerIdsForService(input.providerId))]
+ : [])
)
)
.orderBy(desc(credential.createdAt))
diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts
index 17046dd1622..ddefa756def 100644
--- a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts
+++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts
@@ -85,6 +85,25 @@ describe('prepareCredentialConnection', () => {
})
})
+ it('preserves an explicitly selected OAuth client instead of substituting the service default', async () => {
+ mocks.listCatalog.mockResolvedValue([
+ {
+ ...gmailProvider,
+ serviceId: 'onedrive',
+ name: 'OneDrive',
+ authorizationOptions: [
+ { providerId: 'onedrive', label: 'Work or school account' },
+ { providerId: 'onedrive-personal', label: 'Personal Microsoft account' },
+ ],
+ },
+ ])
+ const result = await prepareCredentialConnection.execute({
+ principal,
+ input: { workspaceId: 'workspace-1', providerName: 'onedrive-personal' },
+ })
+ expect(result.providerId).toBe('onedrive-personal')
+ })
+
it('prepares personal GitLab setup without inventing an OAuth provider', async () => {
const result = await prepareCredentialConnection.execute({
principal,
diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts
index 195e5ff0be2..110b7471ac8 100644
--- a/apps/sim/lib/credentials/application/prepare-credential-connection.ts
+++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts
@@ -1,4 +1,5 @@
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
+import { isMicrosoftPersonalProvider } from '@sim/deployment-config/env-capabilities'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { getBlockVisibility } from '@/lib/core/config/block-visibility'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -39,10 +40,14 @@ function resolveRequestedProvider(
const requested = providerName.toLowerCase().trim()
if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required')
+ const exactProvider = providers.find((entry) =>
+ entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested)
+ )
+ if (isMicrosoftPersonalProvider(requested) && !exactProvider) {
+ throw new OrchestrationError('conflict', 'Personal Microsoft accounts are not configured')
+ }
const provider =
- providers.find((entry) =>
- entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested)
- ) ??
+ exactProvider ??
providers.find(
(entry) =>
entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested
@@ -132,7 +137,10 @@ export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({
)
).filter((entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth')
const requestedProvider = resolveRequestedProvider(providers, input.providerName)
- const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId
+ const requestedProviderId =
+ requestedProvider.authorizationOptions.find(
+ (option) => option.providerId.toLowerCase() === input.providerName.toLowerCase().trim()
+ )?.providerId ?? requestedProvider.authorizationOptions[0]?.providerId
if (!requestedProviderId) {
throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`)
}
@@ -192,7 +200,7 @@ export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({
})
if (
!credentialProviderMatchesService(target.providerId, {
- providerId: requestedProviderId,
+ providerId: requestedProvider.authorizationOptions[0].providerId,
additionalProviderIds: requestedProvider.authorizationOptions
.slice(1)
.map((option) => option.providerId),
diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts
index bc04f1e9282..9a310bf6274 100644
--- a/apps/sim/lib/credentials/application/provider-catalog.test.ts
+++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts
@@ -62,6 +62,7 @@ import {
requireAvailableServiceAccountCredentialProvider,
type ServiceAccountCredentialProviderCatalogEntry,
} from '@/lib/credentials/application/provider-catalog'
+import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
const mocks = {
...hoisted,
@@ -128,18 +129,12 @@ describe('listCredentialProviderCatalog', () => {
isCredentialVisible: ({ providerId }: { providerId: string }) =>
providerId === 'claude-platform-service-account',
})
- mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => {
- if (serviceId === 'salesforce') {
- return {
- providerIdLabels: {
- salesforce: 'Production',
- 'salesforce-sandbox': 'Sandbox',
- },
- }
- }
- if (serviceId === 'trello') return {}
- return null
- })
+ mocks.getServiceConfigByServiceId.mockImplementation(
+ (serviceId: string) =>
+ Object.values(OAUTH_PROVIDERS).find((provider) => provider.services[serviceId])?.services[
+ serviceId
+ ] ?? null
+ )
})
it('applies the narrower permission-group allowlist to OAuth availability', async () => {
diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts
index a1085d2fb4d..f3ed4a74b5c 100644
--- a/apps/sim/lib/credentials/application/provider-catalog.ts
+++ b/apps/sim/lib/credentials/application/provider-catalog.ts
@@ -1,6 +1,9 @@
import type { Principal } from '@sim/auth/principal'
+import { isMicrosoftPersonalProvider } from '@sim/deployment-config/env-capabilities'
import { getBlockVisibility } from '@/lib/core/config/block-visibility'
+import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers'
import {
CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS,
type ClientCredentialAccountField,
@@ -272,46 +275,58 @@ export async function listCredentialProviderCatalog(
oauthServices: services,
})
- const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => {
+ const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.flatMap((service) => {
const config = getServiceConfigByServiceId(service.serviceId)
if (!config) {
throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`)
}
- const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])]
+ const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])].filter(
+ (providerId) =>
+ (!service.additionalProviderIds?.some(isMicrosoftPersonalProvider) ||
+ inspectConfiguredOAuthClient(providerId).state === 'ready') &&
+ (oauthType !== 'managed_oauth' || findCredentialGroupProviderFromProviderId(providerId))
+ )
+ if (providerIds.length === 0) return []
if (providerIds.length > 1 && !config.providerIdLabels) {
throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`)
}
const authorizationOptions = providerIds.map((providerId) => {
- const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId]
+ const label =
+ config.providerIdLabels?.[providerId] ??
+ (providerIds.length === 1 ? service.name : undefined)
if (!label) {
throw new Error(`OAuth provider ${providerId} is missing its authorization option label`)
}
return { providerId, label }
})
- return {
- type: 'oauth',
- serviceId: service.serviceId,
- name: service.name,
- description: service.description,
- providerFamily: service.baseProvider,
- available:
- oauthType === 'managed_oauth'
- ? visibility.isCredentialVisible({ providerId: service.providerId, type: oauthType })
- : visibility.isOAuthServiceVisible(service),
- supportsReconnect: true,
- authorizationOptions,
- fields: (service.clientConfiguration?.fields ?? []).map((field) => ({
- id: field.id,
- label: field.label,
- placeholder: field.placeholder,
- required: true,
- secret: field.secret,
- multiline: false,
- ...(field.options ? { options: [...field.options] } : {}),
- ...(field.hint ? { hint: field.hint } : {}),
- })),
- }
+ return [
+ {
+ type: 'oauth',
+ serviceId: service.serviceId,
+ name: service.name,
+ description: service.description,
+ providerFamily: service.baseProvider,
+ available:
+ oauthType === 'managed_oauth'
+ ? providerIds.some((providerId) =>
+ visibility.isCredentialVisible({ providerId, type: oauthType })
+ )
+ : visibility.isOAuthServiceVisible(service),
+ supportsReconnect: true,
+ authorizationOptions,
+ fields: (service.clientConfiguration?.fields ?? []).map((field) => ({
+ id: field.id,
+ label: field.label,
+ placeholder: field.placeholder,
+ required: true,
+ secret: field.secret,
+ multiline: false,
+ ...(field.options ? { options: [...field.options] } : {}),
+ ...(field.hint ? { hint: field.hint } : {}),
+ })),
+ },
+ ]
})
const serviceAccountOwners = new Map()
diff --git a/apps/sim/lib/credentials/managed-oauth.test.ts b/apps/sim/lib/credentials/managed-oauth.test.ts
index 74b8427d31d..e5c9ac91661 100644
--- a/apps/sim/lib/credentials/managed-oauth.test.ts
+++ b/apps/sim/lib/credentials/managed-oauth.test.ts
@@ -175,6 +175,49 @@ describe('managed OAuth token resolution', () => {
}
)
+ it.each([
+ { stored: 'onedrive-personal', requested: 'onedrive', allowed: true },
+ { stored: 'onedrive', requested: 'onedrive-personal', allowed: false },
+ { stored: 'outlook-personal', requested: 'onedrive', allowed: false },
+ ] as const)(
+ 'resolves $stored only for compatible service $requested',
+ async ({ stored, requested, allowed }) => {
+ setEnv({
+ MICROSOFT_CLIENT_ID: 'work-client',
+ MICROSOFT_CLIENT_SECRET: 'work-secret',
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-secret',
+ })
+ const adapter = createStandardOAuthCredentialGroupProviderAdapter(stored)
+ const policy = await adapter.getPolicy(undefined, {})
+ mocks.getAdapter.mockReturnValue(adapter)
+ dbChainMockFns.limit.mockResolvedValue([
+ {
+ ...mondayCredentialRow(),
+ providerId: stored,
+ authorizationAppId: policy.authorizationAppId,
+ managedOauthScopeVersion: policy.scopeVersion,
+ grantedScopes: policy.requiredScopes,
+ accessTokenExpiresAt: new Date('2026-09-01T13:00:00Z'),
+ },
+ ])
+ const resolution = resolveManagedOAuthToken({
+ ...mondayTokenResolutionParams(),
+ expectedProviderId: requested,
+ requiredScopes: ['Files.Read'],
+ })
+ if (allowed)
+ await expect(resolution).resolves.toMatchObject({
+ accessToken: 'xoxp-slack-token',
+ refreshed: false,
+ })
+ else
+ await expect(resolution).rejects.toMatchObject({
+ code: 'MANAGED_CREDENTIAL_PROVIDER_MISMATCH',
+ })
+ }
+ )
+
it('rejects missing granted-scope metadata', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ ...mondayCredentialRow(), grantedScopes: null }])
await expect(resolveManagedOAuthToken(mondayTokenResolutionParams())).rejects.toMatchObject({
diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts
index 51a12431fcb..c4393cf05db 100644
--- a/apps/sim/lib/credentials/managed-oauth.ts
+++ b/apps/sim/lib/credentials/managed-oauth.ts
@@ -23,6 +23,7 @@ import {
import { getCredentialGroupProviderAdapterByProviderId } from '@/lib/credential-groups/provider-registry'
import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability'
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
+import { providerIdsForService } from '@/lib/oauth/utils'
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
const logger = createLogger('ManagedOAuthCredential')
@@ -219,7 +220,7 @@ async function assertManagedCredentialUsable(
expectedProviderId: string,
requiredScopes: string[]
): Promise {
- if (row.providerId !== expectedProviderId) {
+ if (!row.providerId || !providerIdsForService(expectedProviderId).includes(row.providerId)) {
throw new ManagedOAuthCredentialError(
'MANAGED_CREDENTIAL_PROVIDER_MISMATCH',
'Managed credential belongs to a different provider',
@@ -320,7 +321,8 @@ export async function rejectManagedOAuthToken(input: {
const current = await getManagedCredential(db, input.credentialId, input)
if (
!current ||
- current.providerId !== input.expectedProviderId ||
+ !current.providerId ||
+ !providerIdsForService(input.expectedProviderId).includes(current.providerId) ||
current.managedOauthStatus !== 'active' ||
!current.encryptedOauthTokenSet
)
@@ -339,7 +341,7 @@ export async function rejectManagedOAuthToken(input: {
and(
eq(credential.id, current.id),
resourceScopeCondition(credential, resourceScopeFromOwner(input)),
- eq(credential.providerId, input.expectedProviderId),
+ eq(credential.providerId, current.providerId),
eq(credential.managedOauthStatus, 'active'),
eq(credential.encryptedOauthTokenSet, current.encryptedOauthTokenSet)
)
diff --git a/apps/sim/lib/credentials/organization-managed.test.ts b/apps/sim/lib/credentials/organization-managed.test.ts
index 24fbe99bd05..338e7e74dd4 100644
--- a/apps/sim/lib/credentials/organization-managed.test.ts
+++ b/apps/sim/lib/credentials/organization-managed.test.ts
@@ -1,6 +1,6 @@
import { credential, credentialGroup, user } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, inArray } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
resourceScopeCondition,
@@ -50,7 +50,7 @@ describe('own organization managed browsing credentials', () => {
eq(credential.createdBy, 'user-1'),
eq(user.id, 'user-1'),
eq(user.emailVerified, true),
- eq(credential.providerId, 'jira'),
+ inArray(credential.providerId, ['jira']),
undefined
)
)
diff --git a/apps/sim/lib/credentials/organization-managed.ts b/apps/sim/lib/credentials/organization-managed.ts
index ee25b9b2cad..cc74dde73cd 100644
--- a/apps/sim/lib/credentials/organization-managed.ts
+++ b/apps/sim/lib/credentials/organization-managed.ts
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { credential, credentialGroup, credentialGroupEnrollment, user } from '@sim/db/schema'
-import { and, desc, eq } from 'drizzle-orm'
+import { and, desc, eq, inArray } from 'drizzle-orm'
import {
resourceScopeCondition,
sameResourceScopeCondition,
} from '@/lib/core/resource-scope.server'
import { isManagedCredentialGroupBindingLive } from '@/lib/credential-groups/credentials'
+import { providerIdsForService } from '@/lib/oauth/utils'
/** Lists only a verified person's currently usable organization grants, without token material. */
export async function getOwnOrganizationManagedOAuthCredentials(input: {
@@ -44,7 +45,9 @@ export async function getOwnOrganizationManagedOAuthCredentials(input: {
eq(credential.createdBy, input.userId),
eq(user.id, input.userId),
eq(user.emailVerified, true),
- input.providerId ? eq(credential.providerId, input.providerId) : undefined,
+ input.providerId
+ ? inArray(credential.providerId, providerIdsForService(input.providerId))
+ : undefined,
input.credentialId ? eq(credential.id, input.credentialId) : undefined
)
)
diff --git a/apps/sim/lib/integrations/availability.server.test.ts b/apps/sim/lib/integrations/availability.server.test.ts
index b976b30ef18..b5cd065e9be 100644
--- a/apps/sim/lib/integrations/availability.server.test.ts
+++ b/apps/sim/lib/integrations/availability.server.test.ts
@@ -20,6 +20,7 @@ import {
getOAuthServiceAvailability,
isIntegrationDeploymentAvailable,
isIntegrationDeploymentAvailableForVisibility,
+ isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth/utils'
@@ -44,6 +45,49 @@ function availabilityFor(
}
describe('integration availability', () => {
+ it('exposes personal-capable integrations without an organizational client', () => {
+ const values = {
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-secret',
+ }
+ for (const type of ['onedrive', 'outlook', 'microsoft_word']) {
+ expect(availabilityFor(type, values)).toMatchObject({
+ state: 'ready',
+ oauthAvailable: true,
+ missingFields: [],
+ })
+ }
+ expect(availabilityFor('microsoft_excel_v2', values)).toMatchObject({
+ state: 'unavailable',
+ oauthAvailable: false,
+ })
+ expect(
+ availabilityFor('onedrive', { MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client' })
+ ).toMatchObject({ state: 'misconfigured', oauthAvailable: false })
+ })
+
+ it('separates service readiness from individual Microsoft client readiness', () => {
+ setEnv({
+ MICROSOFT_CLIENT_ID: undefined,
+ MICROSOFT_CLIENT_SECRET: undefined,
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-secret',
+ })
+ expect(isOAuthServiceDeploymentAvailable('onedrive')).toBe(true)
+ expect(
+ getOAuthServiceAvailability([
+ {
+ providerId: 'onedrive',
+ additionalProviderIds: ['onedrive-personal'],
+ authType: 'oauth',
+ },
+ ])
+ ).toEqual([
+ { providerId: 'onedrive', available: false },
+ { providerId: 'onedrive-personal', available: true },
+ ])
+ })
+
it('does not infer GitHub repository OAuth readiness from its API-key workflow block', () => {
expect(availabilityFor('github_v2')).toMatchObject({ state: 'ready', oauthAvailable: false })
expect(
diff --git a/apps/sim/lib/integrations/availability.server.ts b/apps/sim/lib/integrations/availability.server.ts
index e490cd6be9a..aa8066d718d 100644
--- a/apps/sim/lib/integrations/availability.server.ts
+++ b/apps/sim/lib/integrations/availability.server.ts
@@ -11,6 +11,7 @@ import {
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
import type { OAuthServiceMetadata } from '@/lib/oauth/types'
+import { providerIdsForService } from '@/lib/oauth/utils'
export type {
IntegrationAvailability,
@@ -26,14 +27,19 @@ export function getIntegrationAvailability() {
/** OAuth clients are independent of the authentication method a workflow block exposes. */
export function getOAuthServiceAvailability(
- services: readonly Pick[]
+ services: readonly Pick<
+ OAuthServiceMetadata,
+ 'providerId' | 'authType' | 'additionalProviderIds'
+ >[]
): { providerId: string; available: boolean }[] {
return services
.filter((service) => service.authType === 'oauth')
- .map((service) => ({
- providerId: service.providerId,
- available: isOAuthServiceDeploymentAvailable(service.providerId),
- }))
+ .flatMap((service) =>
+ [service.providerId, ...(service.additionalProviderIds ?? [])].map((providerId) => ({
+ providerId,
+ available: isOAuthProviderDeploymentAvailable(providerId),
+ }))
+ )
}
function getIntegrationAvailabilityByType(): ReadonlyMap {
@@ -82,7 +88,12 @@ export function isIntegrationDeploymentAvailableForVisibility(
}
export function isOAuthServiceDeploymentAvailable(serviceId: string): boolean {
- const normalized = serviceId.toLowerCase()
+ return providerIdsForService(serviceId.toLowerCase()).some(isOAuthProviderDeploymentAvailable)
+}
+
+/** Whether the exact app that issues a provider's grants is configured. */
+export function isOAuthProviderDeploymentAvailable(providerId: string): boolean {
+ const normalized = providerId.toLowerCase()
const cached = oauthServiceAvailability.get(normalized)
if (cached !== undefined) return cached
const capabilityId = resolveOAuthClientCapabilityId(normalized)
diff --git a/apps/sim/lib/integrations/credential-visibility.server.test.ts b/apps/sim/lib/integrations/credential-visibility.server.test.ts
index 566ce32c374..b10269cbcf2 100644
--- a/apps/sim/lib/integrations/credential-visibility.server.test.ts
+++ b/apps/sim/lib/integrations/credential-visibility.server.test.ts
@@ -54,6 +54,7 @@ function availability(
describe('integration credential visibility', () => {
beforeEach(() => {
+ integrationsAvailabilityMockFns.mockIsOAuthProviderDeploymentAvailable.mockReset()
getBlockMock.mockImplementation((type: string) => ({ type }) as never)
getIntegrationAvailabilityMock.mockReturnValue([
availability('notion_v2', 'limited', {
@@ -67,6 +68,34 @@ describe('integration credential visibility', () => {
])
})
+ it('uses the issuing client when a service has independently configured OAuth apps', () => {
+ getIntegrationAvailabilityMock.mockReturnValue([
+ availability('onedrive', 'ready', { oauthAvailable: true, serviceAccountAvailable: false }),
+ ])
+ integrationsAvailabilityMockFns.mockIsOAuthProviderDeploymentAvailable.mockImplementation(
+ (providerId) => providerId === 'onedrive-personal'
+ )
+ const visibility = createIntegrationCredentialVisibility({
+ allowedIntegrationTypes: null,
+ blockVisibility: null,
+ oauthServices: [
+ {
+ serviceId: 'onedrive',
+ providerId: 'onedrive',
+ additionalProviderIds: ['onedrive-personal'],
+ authType: 'oauth',
+ name: 'OneDrive',
+ description: 'OneDrive files',
+ baseProvider: 'microsoft',
+ },
+ ],
+ })
+ expect(visibility.isCredentialVisible({ providerId: 'onedrive-personal', type: 'oauth' })).toBe(
+ true
+ )
+ expect(visibility.isCredentialVisible({ providerId: 'onedrive', type: 'oauth' })).toBe(false)
+ })
+
it.each([
{
serviceId: 'coda',
diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts
index a38ae4bcc05..c44c28c67e2 100644
--- a/apps/sim/lib/integrations/credential-visibility.server.ts
+++ b/apps/sim/lib/integrations/credential-visibility.server.ts
@@ -7,6 +7,7 @@ import {
} from '@/lib/integrations/availability'
import {
getIntegrationAvailability,
+ isOAuthProviderDeploymentAvailable,
isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import { getGitHubInstallationConfiguration } from '@/lib/oauth/github-installation'
@@ -171,7 +172,9 @@ export function createIntegrationCredentialVisibility({
}
if (type !== 'service_account') {
const owners = oauthOwnersByProviderId.get(providerId)
- if (owners) return owners.some(isOAuthServiceVisible)
+ if (owners) {
+ return isOAuthProviderDeploymentAvailable(providerId) && owners.some(isOAuthServiceVisible)
+ }
}
if (type !== 'oauth') {
diff --git a/apps/sim/lib/knowledge/connectors/member-access.test.ts b/apps/sim/lib/knowledge/connectors/member-access.test.ts
index 7ffb49c90a0..5aa059b3168 100644
--- a/apps/sim/lib/knowledge/connectors/member-access.test.ts
+++ b/apps/sim/lib/knowledge/connectors/member-access.test.ts
@@ -307,6 +307,28 @@ describe('knowledge connector member access', () => {
}
const group = { status: 'active' as const, options: [driveOption] }
+ it.each([
+ { provider: 'onedrive-personal', scopes: ['Files.Read'], denial: null },
+ { provider: 'outlook-personal', scopes: ['Files.Read'], denial: 'needs onedrive' },
+ { provider: 'onedrive-personal', scopes: [], denial: 'every permission' },
+ ])(
+ 'validates a $provider member grant without relaxing source permissions',
+ ({ provider, scopes, denial }) => {
+ const result = validateKnowledgeConnectorMembersBinding({
+ connectorMeta: {
+ ...driveMeta,
+ name: 'OneDrive',
+ auth: { mode: 'oauth', provider: 'onedrive', requiredScopes: ['Files.Read'] },
+ },
+ group: { ...group, options: [{ ...driveOption, provider, requiredScopes: scopes }] },
+ credentialGroupOptionId: driveOption.id,
+ sourceConfig: {},
+ })
+ expect(result.ok).toBe(denial === null)
+ if (!result.ok) expect(result.message).toContain(denial)
+ }
+ )
+
describe('a Slack option, whose members authorize through the workspace custom app', () => {
const slackMeta = {
name: 'Slack',
diff --git a/apps/sim/lib/knowledge/connectors/member-access.ts b/apps/sim/lib/knowledge/connectors/member-access.ts
index 06f7fd1b3e5..9b4c9d7581d 100644
--- a/apps/sim/lib/knowledge/connectors/member-access.ts
+++ b/apps/sim/lib/knowledge/connectors/member-access.ts
@@ -46,6 +46,7 @@ import {
connectorIsLive,
MEMBER_LOCKABLE_CONNECTOR_STATUSES,
} from '@/lib/knowledge/connectors/sync-lock'
+import { providerIdsForService } from '@/lib/oauth/utils'
import {
CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION,
type ResourcePolicyBindingFor,
@@ -558,7 +559,9 @@ export function validateKnowledgeConnectorMembersBinding(input: {
}
if (
!isCredentialGroupProvider(option.provider) ||
- getCredentialGroupProviderId(option.provider) !== connectorMeta.auth.provider
+ !providerIdsForService(connectorMeta.auth.provider).includes(
+ getCredentialGroupProviderId(option.provider)
+ )
) {
return {
ok: false,
diff --git a/apps/sim/lib/knowledge/connectors/member-provisioning.ts b/apps/sim/lib/knowledge/connectors/member-provisioning.ts
index 5a791842bec..121f5262295 100644
--- a/apps/sim/lib/knowledge/connectors/member-provisioning.ts
+++ b/apps/sim/lib/knowledge/connectors/member-provisioning.ts
@@ -29,6 +29,7 @@ import {
import { inviteCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments'
import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup'
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
+import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability'
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
import {
findCredentialGroupProviderFromProviderId,
@@ -42,6 +43,7 @@ import {
resolveKnowledgeAccessAvailability,
} from '@/lib/knowledge/access/availability'
import { validateKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-access'
+import { providerIdsForService } from '@/lib/oauth/utils'
import { getConnectorMeta } from '@/connectors/registry'
import type { ConnectorMeta } from '@/connectors/types'
@@ -92,17 +94,32 @@ export async function provisionKnowledgeConnectorMembersBinding(input: {
if (connectorMeta.auth.mode !== 'oauth') {
throw new OrchestrationError('validation', 'Only an OAuth connector can sync per member')
}
- const providerId = connectorMeta.auth.provider
- const provider = findCredentialGroupProviderFromProviderId(providerId)
- if (!provider) {
+ const providers = providerIdsForService(connectorMeta.auth.provider)
+ .map(findCredentialGroupProviderFromProviderId)
+ .filter((provider) => provider !== null)
+ const defaultProvider = providers[0]
+ if (!defaultProvider) {
throw new OrchestrationError(
'validation',
`${connectorMeta.name} accounts cannot be collected through a Credential Group yet`
)
}
+ const scope = resourceScopeFromOwner(input)
+ const existing = await loadScopedAccountsCredentialListContext(scope)
+ const existingOptions =
+ existing?.options.filter((option) =>
+ providers.some((provider) => provider === option.provider)
+ ) ?? []
+ const activeOptions = existingOptions.filter((option) => option.status === 'active')
+ const existingOption = activeOptions[0] ?? existingOptions[0]
+ const configuredProviders = listConfiguredCredentialGroupProviders()
+ const provider =
+ providers.find((candidate) => candidate === existingOption?.provider) ??
+ providers.find((candidate) => configuredProviders.includes(candidate)) ??
+ defaultProvider
const group = await ensureWorkspaceAccountsGroup(
- resourceScopeFromOwner(input),
+ scope,
input.userId,
isCredentialGroupStandardOAuthProvider(provider)
? { provider, label: connectorMeta.name, required: false }
@@ -110,7 +127,7 @@ export async function provisionKnowledgeConnectorMembersBinding(input: {
)
const options = group.options.filter(
(option) =>
- option.provider === provider &&
+ providers.some((candidate) => candidate === option.provider) &&
option.status === 'active' &&
option.configurationStatus === 'ready'
)
diff --git a/apps/sim/lib/knowledge/connectors/microsoft-personal-sources.integration.ts b/apps/sim/lib/knowledge/connectors/microsoft-personal-sources.integration.ts
new file mode 100644
index 00000000000..02a6b82865c
--- /dev/null
+++ b/apps/sim/lib/knowledge/connectors/microsoft-personal-sources.integration.ts
@@ -0,0 +1,237 @@
+import { db } from '@sim/db'
+import {
+ account,
+ credential,
+ credentialGroup,
+ credentialGroupEnrollment,
+ member,
+ organization,
+ user,
+ workspace,
+} from '@sim/db/schema'
+import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
+import { generateId } from '@sim/utils/id'
+import { eq, inArray, or } from 'drizzle-orm'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { env } from '@/lib/core/config/env'
+import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
+import { listOrganizationOAuthCredentials } from '@/lib/credentials/application/organization-credentials'
+import { provisionKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-provisioning'
+import { onedriveConnectorMeta } from '@/connectors/onedrive/meta'
+
+/** Real storage and authorization; CI writes the shared INTEGRATION_REPORT_PATH JSON artifact. */
+describe('personal Microsoft sources', () => {
+ let ownerId: string
+ let otherId: string
+ let organizationId: string
+ let foreignOrganizationId: string
+ let workspaceId: string
+
+ beforeEach(async () => {
+ Object.assign(env, {
+ MICROSOFT_CLIENT_ID: 'existing-client',
+ MICROSOFT_CLIENT_SECRET: 'existing-client-secret',
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-client-secret',
+ })
+ ownerId = generateId()
+ otherId = generateId()
+ organizationId = generateId()
+ foreignOrganizationId = generateId()
+ workspaceId = generateId()
+ await db.insert(user).values(
+ [ownerId, otherId].map((id) => ({
+ id,
+ name: 'OAuth fixture',
+ email: `${id}@fixture.test`,
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ )
+ await db.insert(organization).values(
+ [organizationId, foreignOrganizationId].map((id) => ({
+ id,
+ name: 'OAuth fixture organization',
+ slug: id,
+ }))
+ )
+ await db.insert(member).values({
+ id: generateId(),
+ organizationId,
+ userId: ownerId,
+ role: 'owner',
+ })
+ await db.insert(workspace).values({
+ id: workspaceId,
+ name: 'OAuth fixture workspace',
+ organizationId,
+ ownerId,
+ billedAccountUserId: ownerId,
+ })
+ })
+
+ afterEach(async () => {
+ await db
+ .delete(credentialGroup)
+ .where(
+ or(
+ eq(credentialGroup.workspaceId, workspaceId),
+ inArray(credentialGroup.organizationId, [organizationId, foreignOrganizationId])
+ )
+ )
+ await db.delete(workspace).where(eq(workspace.id, workspaceId))
+ await db
+ .delete(organization)
+ .where(inArray(organization.id, [organizationId, foreignOrganizationId]))
+ await db.delete(user).where(inArray(user.id, [ownerId, otherId]))
+ })
+
+ const provision = () =>
+ provisionKnowledgeConnectorMembersBinding({
+ workspaceId,
+ userId: ownerId,
+ connectorMeta: onedriveConnectorMeta,
+ })
+
+ it.each([false, true])(
+ 'keeps the existing personal source binding when the work app is configured: %s',
+ async (workAppConfigured) => {
+ const group = await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
+ provider: 'onedrive-personal',
+ label: 'OneDrive',
+ required: false,
+ })
+ if (!workAppConfigured) {
+ Object.assign(env, { MICROSOFT_CLIENT_ID: undefined, MICROSOFT_CLIENT_SECRET: undefined })
+ }
+ expect(await provision()).toEqual({
+ credentialGroupId: group.id,
+ credentialGroupOptionId: group.options[0].id,
+ })
+ const [stored] = await db
+ .select()
+ .from(credentialGroup)
+ .where(eq(credentialGroup.id, group.id))
+ expect(stored.options.map((option) => option.provider)).toEqual(['onedrive-personal'])
+ }
+ )
+
+ it('provisions the personal provider when only that app is configured', async () => {
+ Object.assign(env, { MICROSOFT_CLIENT_ID: undefined, MICROSOFT_CLIENT_SECRET: undefined })
+ const binding = await provision()
+ const [stored] = await db
+ .select()
+ .from(credentialGroup)
+ .where(eq(credentialGroup.id, binding.credentialGroupId))
+ expect(stored.options).toHaveLength(1)
+ expect(stored.options[0]).toMatchObject({
+ id: binding.credentialGroupOptionId,
+ provider: 'onedrive-personal',
+ })
+ })
+
+ it('refuses to guess between two active account types', async () => {
+ await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
+ provider: 'onedrive-personal',
+ label: 'Personal OneDrive',
+ required: false,
+ })
+ await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
+ provider: 'onedrive',
+ label: 'Work OneDrive',
+ required: false,
+ })
+ await expect(provision()).rejects.toThrow('member sign-in')
+ })
+
+ async function addOAuth(providerId: string, createdBy = ownerId, orgId = organizationId) {
+ const accountId = generateId()
+ const credentialId = generateId()
+ await db.insert(account).values({
+ id: accountId,
+ accountId,
+ providerId,
+ userId: createdBy,
+ scope: 'openid Files.Read',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ await db.insert(credential).values({
+ id: credentialId,
+ organizationId: orgId,
+ accountId,
+ type: 'oauth',
+ displayName: 'Fixture connection',
+ providerId,
+ createdBy,
+ })
+ return credentialId
+ }
+
+ const list = (providerId: string, purpose?: 'browsing') =>
+ listOrganizationOAuthCredentials.execute({
+ principal: createSessionPrincipal({ userId: ownerId }),
+ input: { organizationId, providerId, purpose },
+ })
+
+ it('lists compatible personal grants without crossing user, organization or service boundaries', async () => {
+ const workId = await addOAuth('onedrive')
+ const personalId = await addOAuth('onedrive-personal')
+ await addOAuth('onedrive-personal', otherId)
+ await addOAuth('onedrive-personal', ownerId, foreignOrganizationId)
+ await addOAuth('outlook-personal')
+ expect((await list('onedrive')).credentials.map(({ id }) => id).sort()).toEqual(
+ [workId, personalId].sort()
+ )
+ expect((await list('onedrive-personal')).credentials.map(({ id }) => id)).toEqual([personalId])
+ })
+
+ it('includes the caller’s live personal enrollment for browsing, without allowing indexing', async () => {
+ const group = await ensureWorkspaceAccountsGroup(
+ { kind: 'organization', organizationId },
+ ownerId,
+ { provider: 'onedrive-personal', label: 'Personal OneDrive', required: false }
+ )
+ const [stored] = await db.select().from(credentialGroup).where(eq(credentialGroup.id, group.id))
+ const option = stored.options[0]
+ const enrollmentId = generateId()
+ const credentialId = generateId()
+ await db.insert(credentialGroupEnrollment).values({
+ id: enrollmentId,
+ credentialGroupId: group.id,
+ email: `${ownerId}@fixture.test`,
+ userId: ownerId,
+ status: 'completed',
+ invitationTokenHash: 'a'.repeat(64),
+ invitationExpiresAt: new Date(Date.now() + 60_000),
+ invitedAt: new Date(),
+ })
+ await db.insert(credential).values({
+ id: credentialId,
+ organizationId,
+ type: 'managed_oauth',
+ displayName: 'Personal OneDrive',
+ providerId: 'onedrive-personal',
+ createdBy: ownerId,
+ credentialGroupEnrollmentId: enrollmentId,
+ credentialGroupOptionId: option.id,
+ authorizationAppId: option.authorizationAppId,
+ managedOauthScopeVersion: option.scopeVersion,
+ providerSubjectId: 'fixture-subject',
+ managedOauthStatus: 'active',
+ grantedScopes: option.requiredScopes,
+ encryptedOauthTokenSet: 'not-read-by-credential-lists',
+ grantedAt: new Date(),
+ })
+ expect((await list('onedrive', 'browsing')).credentials.map(({ id }) => id)).toEqual([
+ credentialId,
+ ])
+ expect((await list('onedrive')).credentials).toEqual([])
+ await db
+ .update(credentialGroupEnrollment)
+ .set({ status: 'revoked' })
+ .where(eq(credentialGroupEnrollment.id, enrollmentId))
+ expect((await list('onedrive', 'browsing')).credentials).toEqual([])
+ })
+})
diff --git a/apps/sim/lib/oauth/microsoft-personal.integration.ts b/apps/sim/lib/oauth/microsoft-personal.integration.ts
new file mode 100644
index 00000000000..afe4f7e95de
--- /dev/null
+++ b/apps/sim/lib/oauth/microsoft-personal.integration.ts
@@ -0,0 +1,251 @@
+import { createHash } from 'node:crypto'
+import { writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { getErrorMessage } from '@sim/utils/errors'
+import { exportJWK, generateKeyPair, SignJWT } from 'jose'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { env } from '@/lib/core/config/env'
+import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments'
+import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
+import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
+import { refreshOAuthToken } from '@/lib/oauth'
+
+/** Exercises real token exchange, JWT verification and refresh over HTTP with an isolated issuer. */
+const clients = {
+ onedrive: 'existing-client',
+ outlook: 'existing-client',
+ 'onedrive-personal': 'personal-client',
+ 'outlook-personal': 'personal-client',
+ 'microsoft-word-personal': 'personal-client',
+} as const
+const originalFetch = globalThis.fetch
+const grants = new Map()
+const report: { name: string; status: string; durationMs: number; error?: string }[] = []
+const keyPair = await generateKeyPair('RS256')
+const jwk = { ...(await exportJWK(keyPair.publicKey)), kid: 'fixture-key' }
+let serverOrigin: string
+let rejectedRequests = 0
+
+const server = createServer(async (request, response) => {
+ response.setHeader('content-type', 'application/json')
+ try {
+ if (request.url === '/common/discovery/v2.0/keys') {
+ response.end(JSON.stringify({ keys: [jwk] }))
+ return
+ }
+ if (request.url === '/oidc/userinfo') {
+ response.end(JSON.stringify({ sub: 'fixture-subject' }))
+ return
+ }
+ const chunks: Buffer[] = []
+ let size = 0
+ for await (const chunk of request) {
+ const bytes = Buffer.from(chunk)
+ size += bytes.length
+ if (size > 16384) throw new Error('Oversized token request')
+ chunks.push(bytes)
+ }
+ const body = new URLSearchParams(Buffer.concat(chunks).toString())
+ const grant = grants.get(body.get('code') ?? body.get('refresh_token') ?? '')
+ if (!grant) throw new Error('Unknown grant')
+ const redirectUri = grant.searchParams.get('redirect_uri')
+ if (!redirectUri) throw new Error('Grant has no callback URI')
+ const callback = new URL(redirectUri)
+ const provider = callback.pathname.split('/').at(-1) as keyof typeof clients
+ const client = clients[provider]
+ const basic = request.headers.authorization?.startsWith('Basic ')
+ ? Buffer.from(request.headers.authorization.slice(6), 'base64').toString().split(':')
+ : undefined
+ const authority = provider.endsWith('-personal') ? 'consumers' : 'common'
+ if (
+ request.url !== `/${authority}/oauth2/v2.0/token` ||
+ (basic?.[0] ?? body.get('client_id')) !== client ||
+ (basic?.[1] ?? body.get('client_secret')) !== `${client}-secret`
+ )
+ throw new Error('Grant was sent to the wrong client or authority')
+ const codeExchange = body.get('grant_type') === 'authorization_code'
+ if (
+ codeExchange &&
+ (body.get('redirect_uri') !== callback.toString() ||
+ createHash('sha256')
+ .update(body.get('code_verifier') ?? '')
+ .digest('base64url') !== grant.searchParams.get('code_challenge'))
+ )
+ throw new Error('Callback or PKCE binding was lost')
+ const idToken = await new SignJWT({
+ oid: 'fixture-person',
+ tid: 'fixture-tenant',
+ sub: 'fixture-subject',
+ email: 'person@fixture.test',
+ email_verified: true,
+ name: 'Fixture Person',
+ nonce: grant.searchParams.get('nonce'),
+ })
+ .setProtectedHeader({ alg: 'RS256', kid: jwk.kid })
+ .setAudience(client)
+ .setIssuer('https://login.microsoftonline.com/fixture-tenant/v2.0')
+ .setIssuedAt()
+ .setExpirationTime('5m')
+ .sign(keyPair.privateKey)
+ const refreshToken = `${provider}-${codeExchange ? 'refresh' : 'rotated'}`
+ grants.set(refreshToken, grant)
+ response.end(
+ JSON.stringify({
+ token_type: 'Bearer',
+ access_token: `${provider}-access`,
+ refresh_token: refreshToken,
+ expires_in: 3600,
+ scope: grant.searchParams.get('scope'),
+ id_token: idToken,
+ })
+ )
+ } catch (error) {
+ rejectedRequests++
+ response
+ .writeHead(400)
+ .end(JSON.stringify({ error: 'invalid_grant', error_description: getErrorMessage(error) }))
+ }
+})
+
+beforeAll(async () => {
+ Object.assign(env, {
+ MICROSOFT_CLIENT_ID: 'existing-client',
+ MICROSOFT_CLIENT_SECRET: 'existing-client-secret',
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-client-secret',
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Issuer did not bind')
+ serverOrigin = `http://127.0.0.1:${address.port}`
+ /** Only transport destinations change; provider construction, exchange and verification stay real. */
+ globalThis.fetch = (input, init) => {
+ const remote = new URL(input instanceof Request ? input.url : input)
+ if (!['login.microsoftonline.com', 'graph.microsoft.com'].includes(remote.hostname)) {
+ throw new Error('Unexpected provider destination')
+ }
+ const localUrl = new URL(`${remote.pathname}${remote.search}`, serverOrigin)
+ return originalFetch(input instanceof Request ? new Request(localUrl, input) : localUrl, init)
+ }
+})
+
+afterAll(async () => {
+ globalThis.fetch = originalFetch
+ await new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()))
+ server.closeAllConnections()
+ })
+ if (process.env.MICROSOFT_PERSONAL_OAUTH_REPORT_PATH) {
+ await writeFile(
+ process.env.MICROSOFT_PERSONAL_OAUTH_REPORT_PATH,
+ JSON.stringify(report, null, 2)
+ )
+ }
+})
+
+describe('Microsoft client isolation over HTTP', () => {
+ it.each(Object.keys(clients) as (keyof typeof clients)[])(
+ '%s authorizes and rotates only through its issuing app',
+ async (provider) => {
+ const started = performance.now()
+ try {
+ const adapter = getCredentialGroupProviderAdapter(provider)
+ const policy = await adapter.getPolicy(undefined, {})
+ const context: CredentialGroupOAuthContext = {
+ enrollmentId: 'enrollment',
+ credentialGroupId: 'group',
+ credentialGroupName: 'Group',
+ workspaceName: 'Workspace',
+ workspaceOwnerId: null,
+ email: 'person@fixture.test',
+ enrollmentStatus: 'in_progress',
+ options: [],
+ option: { id: 'option', ...policy, label: 'Account', required: true, status: 'active' },
+ }
+ const prepared = await adapter.prepareAuthorization(context, policy)
+ const authorization = new URL(
+ await prepared.buildAuthorizationUrl({ state: 'fixture-state', nonce: 'fixture-nonce' })
+ )
+ expect(authorization.searchParams.get('client_id')).toBe(clients[provider])
+ expect(authorization.pathname).toBe(
+ `/${provider.endsWith('-personal') ? 'consumers' : 'common'}/oauth2/v2.0/authorize`
+ )
+ expect(authorization.searchParams.get('code_challenge_method')).toBe('S256')
+ grants.set(`${provider}-code`, authorization)
+ const attempt: CredentialGroupOAuthAttempt = {
+ userId: 'fixture-user',
+ state: 'fixture-state',
+ provider,
+ nonceHash: createHash('sha256').update('fixture-nonce').digest('hex'),
+ email: context.email,
+ enrollmentId: context.enrollmentId,
+ credentialGroupId: context.credentialGroupId,
+ optionId: 'option',
+ authorizationAppId: policy.authorizationAppId,
+ scopeVersion: policy.scopeVersion,
+ requiredScopes: policy.requiredScopes,
+ redirectUri: prepared.redirectUri,
+ codeVerifier: prepared.codeVerifier,
+ invitationToken: 'fixture-invitation',
+ createdAt: Date.now(),
+ }
+ const grant = await adapter.exchangeAndVerify({
+ context,
+ attempt,
+ code: `${provider}-code`,
+ policy,
+ })
+ expect(grant.providerId).toBe(provider)
+ expect(grant.providerSubjectId).toBe('fixture-person')
+ expect(grant.refreshToken).toBe(`${provider}-refresh`)
+ const refreshToken = grant.refreshToken
+ if (!refreshToken) throw new Error('Provider omitted the refresh token')
+ const refreshed = await adapter.refreshToken(refreshToken)
+ expect(refreshed).toMatchObject({
+ ok: true,
+ refreshToken: `${provider}-rotated`,
+ accessToken: `${provider}-access`,
+ })
+ await expect(
+ adapter.exchangeAndVerify({
+ context,
+ attempt: { ...attempt, nonceHash: 'wrong-nonce' },
+ code: `${provider}-code`,
+ policy,
+ })
+ ).rejects.toThrow('invalid identity token')
+ const wrongProvider = provider.endsWith('-personal') ? 'onedrive' : 'onedrive-personal'
+ expect(await refreshOAuthToken(wrongProvider, refreshToken)).toMatchObject({
+ ok: false,
+ })
+ if (provider.endsWith('-personal')) {
+ const rejectedBefore = rejectedRequests
+ Object.assign(env, {
+ MICROSOFT_PERSONAL_CLIENT_ID: undefined,
+ MICROSOFT_PERSONAL_CLIENT_SECRET: undefined,
+ })
+ try {
+ expect(await refreshOAuthToken(provider, refreshToken)).toMatchObject({
+ ok: false,
+ })
+ expect(rejectedRequests).toBe(rejectedBefore)
+ } finally {
+ Object.assign(env, {
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-client-secret',
+ })
+ }
+ }
+ report.push({ name: provider, status: 'passed', durationMs: performance.now() - started })
+ } catch (error) {
+ report.push({
+ name: provider,
+ status: 'failed',
+ durationMs: performance.now() - started,
+ error: getErrorMessage(error),
+ })
+ throw error
+ }
+ }
+ )
+})
diff --git a/apps/sim/lib/oauth/microsoft.ts b/apps/sim/lib/oauth/microsoft.ts
index 197519c90d2..9d1ccd666a8 100644
--- a/apps/sim/lib/oauth/microsoft.ts
+++ b/apps/sim/lib/oauth/microsoft.ts
@@ -1,3 +1,4 @@
+import { MICROSOFT_PERSONAL_PROVIDERS } from '@sim/deployment-config/env-capabilities'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
@@ -12,6 +13,7 @@ const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
export const MICROSOFT_PROVIDERS = new Set([
+ ...Object.keys(MICROSOFT_PERSONAL_PROVIDERS),
'microsoft-ad',
'microsoft-dataverse',
'microsoft-excel',
diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts
index 1554aab2b6e..5f2630e17a9 100644
--- a/apps/sim/lib/oauth/oauth.test.ts
+++ b/apps/sim/lib/oauth/oauth.test.ts
@@ -813,3 +813,54 @@ describe('OAuth Token Refresh', () => {
})
})
})
+
+describe('Microsoft account client isolation', () => {
+ const cases = [['microsoft-word', 'microsoft_client_id', 'microsoft_client_secret']] as const
+
+ it.each(cases)(
+ 'redeems %s refresh tokens with their issuing client',
+ async (providerId, clientId, clientSecret) => {
+ setEnv({
+ MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
+ MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-secret',
+ })
+ vi.stubGlobal('fetch', async (_input: unknown, init?: RequestInit) => {
+ const body = new URLSearchParams(String(init?.body))
+ const accepted =
+ body.get('client_id') === clientId &&
+ body.get('client_secret') === clientSecret &&
+ body.get('refresh_token') === 'issued-refresh'
+ return new Response(
+ JSON.stringify(
+ accepted
+ ? {
+ access_token: 'renewed-access',
+ refresh_token: 'rotated-refresh',
+ expires_in: 3600,
+ }
+ : { error: 'invalid_client' }
+ ),
+ { status: accepted ? 200 : 400 }
+ )
+ })
+ await expect(refreshOAuthToken(providerId, 'issued-refresh')).resolves.toMatchObject({
+ ok: true,
+ accessToken: 'renewed-access',
+ refreshToken: 'rotated-refresh',
+ })
+ }
+ )
+
+ it('does not redeem personal tokens through the enterprise client when personal configuration is missing', async () => {
+ setEnv({ MICROSOFT_PERSONAL_CLIENT_ID: undefined, MICROSOFT_PERSONAL_CLIENT_SECRET: undefined })
+ let requestMade = false
+ vi.stubGlobal('fetch', async () => {
+ requestMade = true
+ throw new Error('A token must never be sent to another client')
+ })
+ await expect(refreshOAuthToken('onedrive-personal', 'issued-refresh')).resolves.toMatchObject({
+ ok: false,
+ })
+ expect(requestMade).toBe(false)
+ })
+})
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index f3a2863ec03..cfa3038ccbe 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -513,6 +513,11 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Microsoft Word',
description: 'Connect to Microsoft Word and manage documents.',
providerId: 'microsoft-word',
+ additionalProviderIds: ['microsoft-word-personal'],
+ providerIdLabels: {
+ 'microsoft-word': 'Work or school account',
+ 'microsoft-word-personal': 'Personal Microsoft account',
+ },
icon: MicrosoftWordIcon,
baseProviderIcon: MicrosoftIcon,
/**
@@ -544,6 +549,11 @@ export const OAUTH_PROVIDERS: Record = {
name: 'Outlook',
description: 'Connect to Outlook and manage emails and calendar events.',
providerId: 'outlook',
+ additionalProviderIds: ['outlook-personal'],
+ providerIdLabels: {
+ outlook: 'Work or school account',
+ 'outlook-personal': 'Personal Microsoft account',
+ },
icon: OutlookIcon,
baseProviderIcon: MicrosoftIcon,
/**
@@ -582,6 +592,11 @@ export const OAUTH_PROVIDERS: Record = {
name: 'OneDrive',
description: 'Connect to OneDrive and manage files.',
providerId: 'onedrive',
+ additionalProviderIds: ['onedrive-personal'],
+ providerIdLabels: {
+ onedrive: 'Work or school account',
+ 'onedrive-personal': 'Personal Microsoft account',
+ },
icon: MicrosoftOneDriveIcon,
baseProviderIcon: MicrosoftIcon,
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
@@ -1801,6 +1816,22 @@ function getProviderAuthConfig(
useJsonBody: true,
}
}
+ case 'onedrive-personal':
+ case 'outlook-personal':
+ case 'microsoft-word-personal': {
+ const { clientId, clientSecret } = getConfiguredClientCredentials(
+ 'microsoft-personal',
+ 'MICROSOFT_PERSONAL_CLIENT_ID',
+ 'MICROSOFT_PERSONAL_CLIENT_SECRET'
+ )
+ return {
+ tokenEndpoint: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
+ clientId,
+ clientSecret,
+ useBasicAuth: false,
+ supportsRefreshTokenRotation: true,
+ }
+ }
case 'microsoft':
case 'outlook':
case 'onedrive':
diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts
index ab9a576cf4c..69eb1d5b684 100644
--- a/apps/sim/lib/oauth/types.ts
+++ b/apps/sim/lib/oauth/types.ts
@@ -60,6 +60,9 @@ export type OAuthProvider =
| 'atlassian-service-account'
| 'box'
| 'dropbox'
+ | 'onedrive-personal'
+ | 'outlook-personal'
+ | 'microsoft-word-personal'
| 'microsoft'
| 'microsoft-ad'
| 'microsoft-dataverse'
diff --git a/apps/sim/lib/sim-search/connectors.test.ts b/apps/sim/lib/sim-search/connectors.test.ts
index 03412e683fc..6a647520052 100644
--- a/apps/sim/lib/sim-search/connectors.test.ts
+++ b/apps/sim/lib/sim-search/connectors.test.ts
@@ -146,6 +146,27 @@ const ready = {
}
describe('isSearchConnectorAvailable', () => {
+ it('enables a source through a configured personal client without enabling other services', () => {
+ const context = {
+ ...ready,
+ oauthServiceAvailability: new Map([['onedrive-personal', true]]),
+ }
+ expect(
+ isSearchConnectorAvailable(
+ { type: 'onedrive', blockType: 'onedrive', providerId: 'onedrive' },
+ new Map(),
+ context
+ )
+ ).toBe(true)
+ expect(
+ isSearchConnectorAvailable(
+ { type: 'outlook', blockType: 'outlook', providerId: 'outlook' },
+ new Map(),
+ context
+ )
+ ).toBe(false)
+ })
+
it('refuses unknown or unconfigured OAuth services even when the workflow block is ready', () => {
const jira = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira')!
const blockAvailability = new Map([['jira', { oauthAvailable: true }]])
diff --git a/apps/sim/lib/sim-search/connectors.ts b/apps/sim/lib/sim-search/connectors.ts
index 8bff18561db..f79b40d6b61 100644
--- a/apps/sim/lib/sim-search/connectors.ts
+++ b/apps/sim/lib/sim-search/connectors.ts
@@ -7,6 +7,7 @@ import {
getServiceConfigByProviderId,
getServiceConfigByServiceId,
} from '@/lib/oauth'
+import { providerIdsForService } from '@/lib/oauth/utils'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
@@ -193,5 +194,7 @@ export function isSearchConnectorAvailable(
if (connector.type === 'slack') {
return availability?.state === 'ready' || availability?.state === 'limited'
}
- return context.oauthServiceAvailability.get(connector.providerId.toLowerCase()) === true
+ return providerIdsForService(connector.providerId.toLowerCase()).some(
+ (providerId) => context.oauthServiceAvailability.get(providerId) === true
+ )
}
diff --git a/apps/sim/scripts/test-workflow-stop-after-e2e.ts b/apps/sim/scripts/test-workflow-stop-after-e2e.ts
index d889a1e0943..6d1c3d3659e 100644
--- a/apps/sim/scripts/test-workflow-stop-after-e2e.ts
+++ b/apps/sim/scripts/test-workflow-stop-after-e2e.ts
@@ -338,6 +338,22 @@ async function run(
return data
}
+/** Source runs must finish deferred log persistence before a later run can reuse their state. */
+async function waitForPersistedRun(workflowId: string, runId: string): Promise {
+ const deadline = performance.now() + REQUEST_TIMEOUT_MS
+ while (performance.now() < deadline) {
+ const [saved] = await sql`
+ select status, ended_at from workflow_execution_logs
+ where workflow_id = ${workflowId} and execution_id = ${runId}
+ `
+ if (saved?.status === 'completed' && saved.ended_at) return
+ assert.notEqual(saved?.status, 'failed', `Source run ${runId} failed while persisting`)
+ assert.notEqual(saved?.status, 'cancelled', `Source run ${runId} was cancelled`)
+ await sleep(100)
+ }
+ throw new Error(`Source run ${runId} did not persist within ${REQUEST_TIMEOUT_MS} ms`)
+}
+
async function pause(fixture: PipelineFixture, stopAfterBlockId?: string) {
const result = v2ExecuteWorkflowDataSchema.parse(
record(
@@ -525,6 +541,7 @@ try {
`a full run must include Slow's ${SLOW_MS} ms, took ${full.durationMs} ms`
)
sourceRunId = full.runId
+ await waitForPersistedRun(pipeline.workflowId, sourceRunId)
})
await check(
@@ -798,6 +815,7 @@ try {
await check('a source run from another workflow is not accepted', async () => {
const foreign = await run(otherPipeline.workflowId, { run: { source: 'manual' } })
+ await waitForPersistedRun(otherPipeline.workflowId, foreign.runId)
await expectBadRequest(
pipeline.workflowId,
{
diff --git a/packages/deployment-config/src/env-capabilities.ts b/packages/deployment-config/src/env-capabilities.ts
index 31b475013d4..891c6c31572 100644
--- a/packages/deployment-config/src/env-capabilities.ts
+++ b/packages/deployment-config/src/env-capabilities.ts
@@ -1473,6 +1473,20 @@ export const KNOWLEDGE_EMBEDDINGS_CAPABILITY = defineCapability({
],
} as const)
+/** Separate provider ids keep personal grants bound to their issuing Microsoft OAuth app. */
+export const MICROSOFT_PERSONAL_PROVIDERS = {
+ 'onedrive-personal': 'onedrive',
+ 'outlook-personal': 'outlook',
+ 'microsoft-word-personal': 'microsoft-word',
+} as const
+
+/** Checks whether the exact provider ID belongs to the separate personal Microsoft app. */
+export function isMicrosoftPersonalProvider(
+ providerId: string
+): providerId is keyof typeof MICROSOFT_PERSONAL_PROVIDERS {
+ return Object.hasOwn(MICROSOFT_PERSONAL_PROVIDERS, providerId)
+}
+
export const OAUTH_CLIENT_CAPABILITIES = {
google: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'],
x: ['X_CLIENT_ID', 'X_CLIENT_SECRET'],
@@ -1485,6 +1499,7 @@ export const OAUTH_CLIENT_CAPABILITIES = {
'github-repositories': ['GITHUB_APP_CLIENT_ID', 'GITHUB_APP_CLIENT_SECRET'],
notion: ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET'],
microsoft: ['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET'],
+ 'microsoft-personal': ['MICROSOFT_PERSONAL_CLIENT_ID', 'MICROSOFT_PERSONAL_CLIENT_SECRET'],
clickup: ['CLICKUP_CLIENT_ID', 'CLICKUP_CLIENT_SECRET'],
linear: ['LINEAR_CLIENT_ID', 'LINEAR_CLIENT_SECRET'],
attio: ['ATTIO_CLIENT_ID', 'ATTIO_CLIENT_SECRET'],
@@ -1622,6 +1637,9 @@ export function resolveOAuthClientCapabilityId(serviceId: string): OAuthClientCa
const normalized = serviceId.toLowerCase().replace(/_/g, '-')
if (GOOGLE_OAUTH_SERVICES.has(normalized)) return 'google'
if (MICROSOFT_OAUTH_SERVICES.has(normalized)) return 'microsoft'
+ if (isMicrosoftPersonalProvider(normalized)) {
+ return 'microsoft-personal'
+ }
if (normalized === 'zoho') return 'zoho-desk'
// ServiceDesk Plus Cloud authenticates through Zoho. Scopes are chosen per
// authorization request rather than per API-console client, so the same
diff --git a/packages/deployment-config/src/integration-availability.ts b/packages/deployment-config/src/integration-availability.ts
index 8dcc11a6f4d..9a9c56711ef 100644
--- a/packages/deployment-config/src/integration-availability.ts
+++ b/packages/deployment-config/src/integration-availability.ts
@@ -1,9 +1,13 @@
+import {
+ type EnvCapabilityValues,
+ inspectOAuthClientCapability,
+ MICROSOFT_PERSONAL_PROVIDERS,
+ resolveOAuthClientCapabilityId,
+} from '@sim/deployment-config/env-capabilities'
import {
INTEGRATION_METADATA,
type IntegrationMetadata,
} from '@sim/deployment-config/integration-metadata'
-import type { EnvCapabilityValues } from './env-capabilities'
-import { inspectOAuthClientCapability, resolveOAuthClientCapabilityId } from './env-capabilities'
import { getServiceAccountMetadata } from './service-account-metadata'
import { CREDENTIAL_CONFIGURED_OAUTH_SERVICE_IDS } from './service-account-providers.generated'
@@ -107,8 +111,16 @@ function resolveOAuthIntegrationAvailability(
)
}
- const oauth = inspectOAuthClientCapability(capabilityId, values)
- const setupCommand = `npx sim-setup add integration ${capabilityId}`
+ let oauth = inspectOAuthClientCapability(capabilityId, values)
+ if (
+ oauth.state !== 'ready' &&
+ Object.values(MICROSOFT_PERSONAL_PROVIDERS).some((serviceId) => serviceId === oauthServiceId)
+ ) {
+ const personal = inspectOAuthClientCapability('microsoft-personal', values)
+ if (personal.state === 'ready' || (oauth.state === 'absent' && personal.state !== 'absent')) {
+ oauth = personal
+ }
+ }
const serviceAccountAvailable = Boolean(
serviceAccount &&
serviceAccount.deploymentRequirement !== 'preview-gated' &&
@@ -131,7 +143,7 @@ function resolveOAuthIntegrationAvailability(
oauthAvailable: oauth.state === 'ready',
serviceAccountAvailable,
missingFields: oauth.missingFields,
- setupCommand,
+ setupCommand: oauth.setupCommand,
}
}
diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts
index 7c8ebdbde70..d14a0f99bc8 100644
--- a/packages/sim-cli/src/generated/v2-api.ts
+++ b/packages/sim-cli/src/generated/v2-api.ts
@@ -1710,8 +1710,11 @@ export type CreateCredentialConnectionBody =
| 'microsoft-powerbi'
| 'microsoft-teams'
| 'microsoft-word'
+ | 'microsoft-word-personal'
| 'outlook'
+ | 'outlook-personal'
| 'onedrive'
+ | 'onedrive-personal'
| 'sharepoint'
| 'x'
| 'tiktok'
diff --git a/packages/sim-setup/src/capability-config.ts b/packages/sim-setup/src/capability-config.ts
index fef44d408d8..c4cc15d35fa 100644
--- a/packages/sim-setup/src/capability-config.ts
+++ b/packages/sim-setup/src/capability-config.ts
@@ -1053,6 +1053,10 @@ export const OAUTH_CLIENT_SETUP_FIELDS = {
NOTION_CLIENT_ID: { input: 'text' },
NOTION_CLIENT_SECRET: { input: 'secret' },
},
+ 'microsoft-personal': {
+ MICROSOFT_PERSONAL_CLIENT_ID: { input: 'text' },
+ MICROSOFT_PERSONAL_CLIENT_SECRET: { input: 'secret' },
+ },
microsoft: {
MICROSOFT_CLIENT_ID: { input: 'text' },
MICROSOFT_CLIENT_SECRET: { input: 'secret' },
diff --git a/packages/testing/src/mocks/integrations-availability.mock.ts b/packages/testing/src/mocks/integrations-availability.mock.ts
index 61f38a0d4cf..c88be4f9c7c 100644
--- a/packages/testing/src/mocks/integrations-availability.mock.ts
+++ b/packages/testing/src/mocks/integrations-availability.mock.ts
@@ -1,6 +1,7 @@
import { vi } from 'vitest'
const mockIsOAuthServiceDeploymentAvailable = vi.fn((_serviceId: string): boolean => true)
+const mockIsOAuthProviderDeploymentAvailable = vi.fn((_providerId: string): boolean => true)
/**
* Controllable mock functions for `@/lib/integrations/availability.server`.
@@ -8,9 +9,9 @@ const mockIsOAuthServiceDeploymentAvailable = vi.fn((_serviceId: string): boolea
* Defaults: every integration and OAuth service is available in the deployment.
* - `mockGetIntegrationAvailability` returns `[]`.
* - `mockIsIntegrationDeploymentAvailable`, `mockIsIntegrationDeploymentAvailableForVisibility`,
- * `mockIsOAuthServiceDeploymentAvailable` return `true`.
+ * `mockIsOAuthServiceDeploymentAvailable`, and `mockIsOAuthProviderDeploymentAvailable` return `true`.
* - `mockGetOAuthServiceAvailability` is the real projection: keeps `authType === 'oauth'`
- * services and asks `mockIsOAuthServiceDeploymentAvailable` for each.
+ * services and asks `mockIsOAuthProviderDeploymentAvailable` for each provider option.
*
* @example
* ```ts
@@ -25,20 +26,27 @@ export const integrationsAvailabilityMockFns = {
mockGetIntegrationAvailability: vi.fn((): unknown[] => []),
mockGetOAuthServiceAvailability: vi.fn(
(
- services: readonly { providerId: string; authType?: string }[]
+ services: readonly {
+ providerId: string
+ additionalProviderIds?: readonly string[]
+ authType?: string
+ }[]
): { providerId: string; available: boolean }[] =>
services
.filter((service) => service.authType === 'oauth')
- .map((service) => ({
- providerId: service.providerId,
- available: mockIsOAuthServiceDeploymentAvailable(service.providerId),
- }))
+ .flatMap((service) =>
+ [service.providerId, ...(service.additionalProviderIds ?? [])].map((providerId) => ({
+ providerId,
+ available: mockIsOAuthProviderDeploymentAvailable(providerId),
+ }))
+ )
),
mockIsIntegrationDeploymentAvailable: vi.fn((_blockType: string): boolean => true),
mockIsIntegrationDeploymentAvailableForVisibility: vi.fn(
(_blockType: string, _visibility: unknown): boolean => true
),
mockIsOAuthServiceDeploymentAvailable,
+ mockIsOAuthProviderDeploymentAvailable,
}
/**
@@ -58,4 +66,6 @@ export const integrationsAvailabilityMock = {
integrationsAvailabilityMockFns.mockIsIntegrationDeploymentAvailableForVisibility,
isOAuthServiceDeploymentAvailable:
integrationsAvailabilityMockFns.mockIsOAuthServiceDeploymentAvailable,
+ isOAuthProviderDeploymentAvailable:
+ integrationsAvailabilityMockFns.mockIsOAuthProviderDeploymentAvailable,
}
diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts
index 3e0f8962a79..b11d1e14ed4 100755
--- a/scripts/generate-docs.ts
+++ b/scripts/generate-docs.ts
@@ -4599,6 +4599,7 @@ interface OAuthDocsSources {
getSlackApprovalGatedScopes: (enabled: boolean) => readonly string[]
getOAuthClientCapabilityFields: (serviceId: string) => readonly string[] | null
salesforceLoginHosts: Readonly>
+ microsoftPersonalProviders: Readonly>
}
let oauthDocsSources: OAuthDocsSources | null = null
@@ -4624,6 +4625,7 @@ async function loadOAuthDocsSources(): Promise {
getSlackApprovalGatedScopes: oauth.getSlackApprovalGatedScopes,
getOAuthClientCapabilityFields: capabilities.getOAuthClientCapabilityFields,
salesforceLoginHosts: salesforce.SALESFORCE_LOGIN_HOSTS,
+ microsoftPersonalProviders: capabilities.MICROSOFT_PERSONAL_PROVIDERS,
}
return oauthDocsSources
}
@@ -4631,14 +4633,15 @@ async function loadOAuthDocsSources(): Promise {
/**
* The path a provider returns to after consent, or null when the service does
* not use an OAuth 2.0 redirect (Trello authorizes with an API key). Better
- * Auth connectors declare their redirect literally (Salesforce builds one per
- * login host); Instagram, Shopify, and QuickBooks own a route at that path.
+ * Auth connectors declare their redirect literally or through provider registries;
+ * Instagram, Shopify, and QuickBooks own a route at that path.
*/
function oauthCallbackPath(providerId: string, sources: OAuthDocsSources): string | null {
const callbackPath = `/api/auth/oauth2/callback/${providerId}`
const registered =
readSourceFile(CONNECTOR_PROVIDERS_PATH).includes(`${callbackPath}\``) ||
providerId in sources.salesforceLoginHosts ||
+ providerId in sources.microsoftPersonalProviders ||
fs.existsSync(path.join(OAUTH_CALLBACK_ROUTES_PATH, providerId, 'route.ts'))
return registered ? callbackPath : null
}
@@ -4714,7 +4717,11 @@ async function buildOAuthScopesSection(
const uri = `\`${callbackPath}\``
return [label ? `${escapeMdxCell(label)}: ${uri}` : uri]
})
- const envFields = sources.getOAuthClientCapabilityFields(oauthServiceId)
+ const envFields = [
+ ...new Set(
+ providerIds.flatMap((providerId) => sources.getOAuthClientCapabilityFields(providerId) ?? [])
+ ),
+ ]
const settings: string[] = []
if (redirects.length) settings.push(`| Redirect URI | ${redirects.join('
')} |`)
if (envFields?.length) {