Skip to content

Commit d757dd4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(integrations): harden Dynamics environment requests
1 parent a21900a commit d757dd4

16 files changed

Lines changed: 83 additions & 35 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/microsoft-dataverse-environment.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ describe('useMicrosoftDataverseEnvironmentForm', () => {
5151
expect(hook.result().error).toContain('supported public-cloud Microsoft Dynamics host')
5252

5353
act(() => hook.result().setValue(' https://contoso.crm4.dynamics.com/ '))
54-
act(() => expect(hook.result().validate()).toBe('https://contoso.crm4.dynamics.com'))
54+
act(() => expect(hook.result().validate()).toBe('https://contoso.api.crm4.dynamics.com'))
5555
expect(hook.result().effectiveScopes).toEqual([
5656
'openid',
5757
'profile',
5858
'email',
59-
'https://contoso.crm4.dynamics.com/.default',
59+
'https://contoso.api.crm4.dynamics.com/.default',
6060
'offline_access',
6161
])
6262
hook.unmount()
@@ -77,7 +77,7 @@ describe('useMicrosoftDataverseEnvironmentForm', () => {
7777
isLocked: true,
7878
value: 'https://contoso.crm.dynamics.com',
7979
})
80-
expect(hook.result().validate()).toBe('https://contoso.crm.dynamics.com')
80+
expect(hook.result().validate()).toBe('https://contoso.api.crm.dynamics.com')
8181
hook.unmount()
8282
})
8383

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getMicrosoftDataverseRequiredScope } from '@/lib/oauth/microsoft-datave
66
import { resolveMicrosoftDataverseCredentialPolicy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/microsoft-dataverse-policy'
77

88
const ENVIRONMENT = 'https://contoso.crm.dynamics.com'
9+
const CANONICAL_ENVIRONMENT = 'https://contoso.api.crm.dynamics.com'
910

1011
function resolve(scopes?: string[], environmentUrl: unknown = ENVIRONMENT) {
1112
return resolveMicrosoftDataverseCredentialPolicy({
@@ -43,12 +44,22 @@ describe('resolveMicrosoftDataverseCredentialPolicy', () => {
4344
expect(resolve([getMicrosoftDataverseRequiredScope(ENVIRONMENT)])).toMatchObject({
4445
applies: true,
4546
bindingState: 'matching',
46-
environmentUrl: ENVIRONMENT,
47+
environmentUrl: CANONICAL_ENVIRONMENT,
4748
requiredScopes: [getMicrosoftDataverseRequiredScope(ENVIRONMENT)],
4849
requiresSeparateCredential: false,
4950
})
5051
})
5152

53+
it('matches a credential across documented environment and Web API host aliases', () => {
54+
expect(
55+
resolve([getMicrosoftDataverseRequiredScope(CANONICAL_ENVIRONMENT)], ENVIRONMENT)
56+
).toMatchObject({
57+
bindingState: 'matching',
58+
environmentUrl: CANONICAL_ENVIRONMENT,
59+
requiresSeparateCredential: false,
60+
})
61+
})
62+
5263
it.each([
5364
['legacy', ['https://dynamics.microsoft.com/user_impersonation'], 'unbound'],
5465
[

apps/sim/hooks/queries/oauth/microsoft-dataverse-connections.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ describe('Microsoft Dataverse OAuth connections', () => {
8080
'openid',
8181
'profile',
8282
'email',
83-
'https://contoso.crm4.dynamics.com/.default',
83+
'https://contoso.api.crm4.dynamics.com/.default',
8484
'offline_access',
8585
])
8686
expect(new URL(request.callbackURL).searchParams.get('__sim_dataverse_environment')).toBe(
87-
'https://contoso.crm4.dynamics.com'
87+
'https://contoso.api.crm4.dynamics.com'
8888
)
8989
})
9090

@@ -102,12 +102,12 @@ describe('Microsoft Dataverse OAuth connections', () => {
102102
expect(mockLink).toHaveBeenCalledWith({
103103
providerId: 'microsoft-dataverse',
104104
callbackURL:
105-
'https://sim.test/workflow?__sim_dataverse_environment=https%3A%2F%2Fcontoso.crm.dynamics.com',
105+
'https://sim.test/workflow?__sim_dataverse_environment=https%3A%2F%2Fcontoso.api.crm.dynamics.com',
106106
scopes: [
107107
'openid',
108108
'profile',
109109
'email',
110-
'https://contoso.crm.dynamics.com/.default',
110+
'https://contoso.api.crm.dynamics.com/.default',
111111
'offline_access',
112112
],
113113
})
@@ -164,7 +164,7 @@ describe('Microsoft Dataverse OAuth connections', () => {
164164

165165
expect(hook.result().state).toBe(state)
166166
if (state === 'bound') {
167-
expect(hook.result().environmentUrl).toBe('https://contoso.crm.dynamics.com')
167+
expect(hook.result().environmentUrl).toBe('https://contoso.api.crm.dynamics.com')
168168
}
169169
hook.unmount()
170170
})

apps/sim/lib/oauth/microsoft-dataverse.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ function userInfoFor(oid: string) {
3636

3737
describe('Microsoft Dataverse OAuth environment binding', () => {
3838
it.each([
39-
['https://contoso.crm.dynamics.com', 'https://contoso.crm.dynamics.com'],
40-
[' https://contoso.crm4.dynamics.com/ ', 'https://contoso.crm4.dynamics.com'],
39+
['https://contoso.crm.dynamics.com', 'https://contoso.api.crm.dynamics.com'],
40+
[' https://contoso.crm4.dynamics.com/ ', 'https://contoso.api.crm4.dynamics.com'],
4141
['https://contoso.api.crm12.dynamics.com', 'https://contoso.api.crm12.dynamics.com'],
4242
])('normalizes a documented public-cloud environment root', (input, expected) => {
4343
expect(normalizeMicrosoftDataverseEnvironmentUrl(input)).toBe(expected)
@@ -64,7 +64,7 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
6464
'openid',
6565
'profile',
6666
'email',
67-
'https://contoso.crm4.dynamics.com/.default',
67+
'https://contoso.api.crm4.dynamics.com/.default',
6868
'offline_access',
6969
])
7070
})
@@ -80,11 +80,11 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
8080
)
8181

8282
expect(new URL(absolute).searchParams.get('__sim_dataverse_environment')).toBe(
83-
'https://contoso.crm4.dynamics.com'
83+
'https://contoso.api.crm4.dynamics.com'
8484
)
8585
expect(
8686
new URL(relative, 'https://sim.test').searchParams.get('__sim_dataverse_environment')
87-
).toBe('https://contoso.crm4.dynamics.com')
87+
).toBe('https://contoso.api.crm4.dynamics.com')
8888
expect(stripMicrosoftDataverseEnvironmentFromOAuthCallback(absolute)).toBe(
8989
'https://sim.test/workspace?existing=1'
9090
)
@@ -253,13 +253,13 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
253253
it('extracts one trusted environment from space-, comma-, or array-delimited scopes', () => {
254254
const marker = getMicrosoftDataverseRequiredScope('https://contoso.crm4.dynamics.com')
255255
expect(extractMicrosoftDataverseEnvironmentUrl(`openid ${marker} offline_access`)).toBe(
256-
'https://contoso.crm4.dynamics.com'
256+
'https://contoso.api.crm4.dynamics.com'
257257
)
258258
expect(extractMicrosoftDataverseEnvironmentUrl(`openid,${marker},offline_access`)).toBe(
259-
'https://contoso.crm4.dynamics.com'
259+
'https://contoso.api.crm4.dynamics.com'
260260
)
261261
expect(extractMicrosoftDataverseEnvironmentUrl(['openid', marker])).toBe(
262-
'https://contoso.crm4.dynamics.com'
262+
'https://contoso.api.crm4.dynamics.com'
263263
)
264264
})
265265

@@ -327,8 +327,8 @@ describe('Microsoft Dataverse OAuth environment binding', () => {
327327

328328
expect(dev.id.replace(UUID_SUFFIX_RE, '')).toBe(devReconnect.id.replace(UUID_SUFFIX_RE, ''))
329329
expect(dev.id.replace(UUID_SUFFIX_RE, '')).not.toBe(prod.id.replace(UUID_SUFFIX_RE, ''))
330-
expect(dev.id).toContain(':dev.crm.dynamics.com-')
331-
expect(prod.id).toContain(':prod.crm.dynamics.com-')
330+
expect(dev.id).toContain(':dev.api.crm.dynamics.com-')
331+
expect(prod.id).toContain(':prod.api.crm.dynamics.com-')
332332
})
333333

334334
it('rejects callback tokens without an environment audience', () => {

apps/sim/lib/oauth/microsoft-dataverse.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export const MICROSOFT_DATAVERSE_PROVIDER_ID = 'microsoft-dataverse'
22

33
const DATAVERSE_ORGANIZATION_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
44
const DATAVERSE_PUBLIC_HOST_PATTERN = new RegExp(
5-
`^(${DATAVERSE_ORGANIZATION_LABEL})(?:\\.api)?\\.crm\\d*\\.dynamics\\.com$`
5+
`^(${DATAVERSE_ORGANIZATION_LABEL})(?:\\.api)?\\.(crm\\d*)\\.dynamics\\.com$`
66
)
77
const RESERVED_DATAVERSE_ORGANIZATION_LABELS = new Set(['disco', 'globaldisco'])
88
const DATAVERSE_REQUEST_SCOPE_SUFFIX = '/.default'
@@ -31,8 +31,10 @@ export function normalizeMicrosoftDataverseEnvironmentUrl(environmentUrl: unknow
3131

3232
const hostMatch = url.hostname.match(DATAVERSE_PUBLIC_HOST_PATTERN)
3333
const organizationLabel = hostMatch?.[1]
34+
const regionLabel = hostMatch?.[2]
3435
const hasTrustedHost =
3536
organizationLabel !== undefined &&
37+
regionLabel !== undefined &&
3638
!RESERVED_DATAVERSE_ORGANIZATION_LABELS.has(organizationLabel)
3739

3840
if (
@@ -50,7 +52,7 @@ export function normalizeMicrosoftDataverseEnvironmentUrl(environmentUrl: unknow
5052
)
5153
}
5254

53-
return url.origin
55+
return `https://${organizationLabel}.api.${regionLabel}.dynamics.com`
5456
}
5557

5658
/** Builds the exact delegated OAuth grant Microsoft documents for one Dataverse environment. */

apps/sim/lib/oauth/token-resolution.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ describe('resolveCredentialToken', () => {
160160
ok: true,
161161
token: {
162162
accessToken: 'fresh',
163-
instanceUrl: 'https://contoso.crm.dynamics.com',
163+
idToken: undefined,
164+
instanceUrl: 'https://contoso.api.crm.dynamics.com',
164165
},
165166
})
166167
})

apps/sim/tools/microsoft_dynamics_365/close_case.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const microsoftDynamics365CloseCaseTool: ToolConfig<
8181
url: (params) =>
8282
`${getDynamics365BaseUrl(params.environmentUrl, params.instanceUrl)}/api/data/v9.2/CloseIncident`,
8383
method: 'POST',
84+
stripAuthOnRedirect: true,
8485
headers: (params) => ({
8586
Authorization: `Bearer ${params.accessToken}`,
8687
'Content-Type': 'application/json',

apps/sim/tools/microsoft_dynamics_365/close_opportunity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const microsoftDynamics365CloseOpportunityTool: ToolConfig<
9292
return `${baseUrl}/api/data/v9.2/${outcome === 'won' ? 'WinOpportunity' : 'LoseOpportunity'}`
9393
},
9494
method: 'POST',
95+
stripAuthOnRedirect: true,
9596
headers: (params) => ({
9697
Authorization: `Bearer ${params.accessToken}`,
9798
'Content-Type': 'application/json',

apps/sim/tools/microsoft_dynamics_365/create_record.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const microsoftDynamics365CreateRecordTool: ToolConfig<
6868
return `${baseUrl}/api/data/v9.2/${entitySetName}`
6969
},
7070
method: 'POST',
71+
stripAuthOnRedirect: true,
7172
headers: (params) => ({
7273
Authorization: `Bearer ${params.accessToken}`,
7374
'Content-Type': 'application/json',

apps/sim/tools/microsoft_dynamics_365/dynamics_crm.test.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
normalizeDataverseGuid,
2626
} from '@/tools/microsoft_dynamics_365/utils'
2727

28-
const ENVIRONMENT_URL = 'https://contoso.crm.dynamics.com'
28+
const ENVIRONMENT_ALIAS = 'https://contoso.crm.dynamics.com'
29+
const ENVIRONMENT_URL = 'https://contoso.api.crm.dynamics.com'
2930
const ACCESS_TOKEN = 'test-access-token'
3031
const LEAD_ID = '11111111-1111-4111-8111-111111111111'
3132
const OPPORTUNITY_ID = '22222222-2222-4222-8222-222222222222'
@@ -38,21 +39,21 @@ const PROCESS_INSTANCE_ID = '77777777-7777-4777-8777-777777777777'
3839
const LIST_PARAMS: DataverseListRecordsParams = {
3940
accessToken: ACCESS_TOKEN,
4041
instanceUrl: ENVIRONMENT_URL,
41-
environmentUrl: ENVIRONMENT_URL,
42+
environmentUrl: ENVIRONMENT_ALIAS,
4243
entitySetName: 'accounts',
4344
}
4445

4546
const SEARCH_PARAMS: DataverseSearchParams = {
4647
accessToken: ACCESS_TOKEN,
4748
instanceUrl: ENVIRONMENT_URL,
48-
environmentUrl: ENVIRONMENT_URL,
49+
environmentUrl: ENVIRONMENT_ALIAS,
4950
searchTerm: 'Contoso',
5051
}
5152

5253
const QUALIFY_PARAMS: DataverseQualifyLeadParams = {
5354
accessToken: ACCESS_TOKEN,
5455
instanceUrl: ENVIRONMENT_URL,
55-
environmentUrl: ENVIRONMENT_URL,
56+
environmentUrl: ENVIRONMENT_ALIAS,
5657
leadId: LEAD_ID,
5758
createAccount: true,
5859
createContact: true,
@@ -62,7 +63,7 @@ const QUALIFY_PARAMS: DataverseQualifyLeadParams = {
6263
const CLOSE_OPPORTUNITY_PARAMS: DataverseCloseOpportunityParams = {
6364
accessToken: ACCESS_TOKEN,
6465
instanceUrl: ENVIRONMENT_URL,
65-
environmentUrl: ENVIRONMENT_URL,
66+
environmentUrl: ENVIRONMENT_ALIAS,
6667
opportunityId: OPPORTUNITY_ID,
6768
outcome: 'won',
6869
subject: 'Opportunity won',
@@ -71,7 +72,7 @@ const CLOSE_OPPORTUNITY_PARAMS: DataverseCloseOpportunityParams = {
7172
const CLOSE_CASE_PARAMS: DataverseCloseCaseParams = {
7273
accessToken: ACCESS_TOKEN,
7374
instanceUrl: ENVIRONMENT_URL,
74-
environmentUrl: ENVIRONMENT_URL,
75+
environmentUrl: ENVIRONMENT_ALIAS,
7576
caseId: CASE_ID,
7677
subject: 'Case resolved',
7778
}
@@ -104,10 +105,23 @@ describe('Microsoft Dataverse Dynamics CRM shared safety', () => {
104105
})
105106

106107
it.each([
107-
['https://contoso.crm.dynamics.com/', 'https://contoso.crm.dynamics.com'],
108-
['https://contoso.crm4.dynamics.com', 'https://contoso.crm4.dynamics.com'],
108+
microsoftDynamics365ListRecordsTool,
109+
microsoftDynamics365GetRecordTool,
110+
microsoftDynamics365CreateRecordTool,
111+
microsoftDynamics365UpdateRecordTool,
112+
microsoftDynamics365SearchRecordsTool,
113+
microsoftDynamics365QualifyLeadTool,
114+
microsoftDynamics365CloseOpportunityTool,
115+
microsoftDynamics365CloseCaseTool,
116+
])('$id strips OAuth authorization before following redirects', (tool) => {
117+
expect(tool.request.stripAuthOnRedirect).toBe(true)
118+
})
119+
120+
it.each([
121+
['https://contoso.crm.dynamics.com/', 'https://contoso.api.crm.dynamics.com'],
122+
['https://contoso.crm4.dynamics.com', 'https://contoso.api.crm4.dynamics.com'],
109123
['https://contoso.api.crm4.dynamics.com', 'https://contoso.api.crm4.dynamics.com'],
110-
['https://contoso.crm9.dynamics.com', 'https://contoso.crm9.dynamics.com'],
124+
['https://contoso.crm9.dynamics.com', 'https://contoso.api.crm9.dynamics.com'],
111125
])('accepts a public-cloud Dataverse environment host', (input, expected) => {
112126
expect(getDynamics365BaseUrl(input, expected)).toBe(expected)
113127
})
@@ -195,6 +209,7 @@ describe('microsoft_dynamics_365_list_records response validation', () => {
195209

196210
it('uses only a validated same-table continuation URL for the next page', () => {
197211
const nextLink = `${ENVIRONMENT_URL}/api/data/v9.2/accounts?$skiptoken=opaque`
212+
const aliasNextLink = `${ENVIRONMENT_ALIAS}/api/data/v9.2/accounts?$skiptoken=opaque`
198213
expect(
199214
resolveUrl(microsoftDynamics365ListRecordsTool.request.url, {
200215
...LIST_PARAMS,
@@ -203,6 +218,12 @@ describe('microsoft_dynamics_365_list_records response validation', () => {
203218
filter: 'statecode eq 0',
204219
})
205220
).toBe(nextLink)
221+
expect(
222+
resolveUrl(microsoftDynamics365ListRecordsTool.request.url, {
223+
...LIST_PARAMS,
224+
nextLink: aliasNextLink,
225+
})
226+
).toBe(aliasNextLink)
206227

207228
for (const invalidNextLink of [
208229
'https://attacker.example/api/data/v9.2/accounts?$skiptoken=opaque',

0 commit comments

Comments
 (0)