Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/content/docs/integrations/microsoft_word.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,8 @@ Sim requests these scopes when someone connects a Microsoft Word account. On a s

| Setting | Value |
| ------- | ----- |
| Redirect URI | `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/microsoft-word` |
| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
| Redirect URI | Work or school account: `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/microsoft-word`<br />Personal Microsoft account: `<NEXT_PUBLIC_APP_URL>/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 |
| ----- | ----------- |
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/integrations/onedrive.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ Sim requests these scopes when someone connects an OneDrive account. On a self-h

| Setting | Value |
| ------- | ----- |
| Redirect URI | `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/onedrive` |
| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
| Redirect URI | Work or school account: `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/onedrive`<br />Personal Microsoft account: `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/onedrive-personal` |
| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_PERSONAL_CLIENT_ID`, `MICROSOFT_PERSONAL_CLIENT_SECRET` |

| Scope | Description |
| ----- | ----------- |
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/integrations/outlook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -728,8 +728,8 @@ Sim requests these scopes when someone connects an Outlook account. On a self-ho

| Setting | Value |
| ------- | ----- |
| Redirect URI | `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/outlook` |
| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` |
| Redirect URI | Work or school account: `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/outlook`<br />Personal Microsoft account: `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/outlook-personal` |
| Environment variables | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_PERSONAL_CLIENT_ID`, `MICROSOFT_PERSONAL_CLIENT_SECRET` |

| Scope | Description |
| ----- | ----------- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br />`MICROSOFT_PERSONAL_CLIENT_SECRET` | `onedrive-personal`, `outlook-personal`, `microsoft-word-personal` |

Register each provider's callback as a **Web** redirect URI, for example `https://<your-domain>/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 `<NEXT_PUBLIC_APP_URL>/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
Expand Down
3 changes: 3 additions & 0 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -12612,8 +12612,11 @@
"microsoft-powerbi",
"microsoft-teams",
"microsoft-word",
"microsoft-word-personal",
"outlook",
"outlook-personal",
"onedrive",
"onedrive-personal",
"sharepoint",
"x",
"tiktok",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) : ''),
Expand All @@ -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<string | null>(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('')
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -536,10 +555,14 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
</p>
)}

{authServerOptions.length > 0 && (
{authServerOptions.length > 1 && (
<ChipModalField
type='dropdown'
title='Environment'
title={
authServerOptions.some((option) => isMicrosoftPersonalProvider(option.value))
? 'Account type'
: 'Environment'
}
value={providerId}
onChange={setSelectedProviderId}
options={authServerOptions}
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/api/contracts/v2/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/auth/connectors/managed-oauth.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/lib/auth/connectors/providers.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/credential-groups/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
28 changes: 27 additions & 1 deletion apps/sim/lib/credential-groups/providers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isMicrosoftPersonalProvider } from '@sim/deployment-config/env-capabilities'
import type { OAuthServiceConfig } from '@/lib/oauth'
import { getServiceConfigByServiceId } from '@/lib/oauth'

Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'])
)
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading