Skip to content

Commit 31503c5

Browse files
authored
fix(accounts): report managed OAuth failures accurately (#8552)
1 parent f7b47bc commit 31503c5

3 files changed

Lines changed: 166 additions & 24 deletions

File tree

‎apps/sim/app/api/mcp/oauth/callback/route.test.ts‎

Lines changed: 114 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import { runInNewContext } from 'node:vm'
2+
import { BroadcastChannel } from 'node:worker_threads'
13
import {
24
authMockFns,
35
dbChainMockFns,
46
mcpOauthMock,
57
mcpOauthMockFns,
68
resetDbChainMock,
79
} from '@sim/testing'
10+
import { flushMicrotasks } from '@sim/testing/helpers/async'
11+
import { emcnMock } from '@sim/testing/mocks/emcn.mock'
12+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
813
import { mcpServiceMock, mcpServiceMockFns } from '@sim/testing/mocks/mcp-service.mock'
914
import { NextRequest } from 'next/server'
1015
import { beforeEach, describe, expect, it, vi } from 'vitest'
1116
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
17+
import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup'
1218

1319
const {
1420
mockAuthenticateEnrollment,
@@ -24,6 +30,7 @@ const {
2430

2531
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
2632
vi.mock('@/lib/mcp/service', () => mcpServiceMock)
33+
vi.mock('@sim/emcn', () => emcnMock)
2734
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
2835
credentialGroupOAuthAttemptPrincipal: mockAuthenticateEnrollment,
2936
}))
@@ -88,30 +95,41 @@ describe('MCP OAuth callback route', () => {
8895
mockEnforceCallbackRateLimit.mockResolvedValue(null)
8996
})
9097

91-
it.each([undefined, 'denied'])(
92-
'finishes a direct connection without the invitation form: %s',
93-
async (error) => {
94-
const completionId = '00000000-0000-4000-8000-000000000002'
95-
mockConsumeManagedAttempt.mockResolvedValueOnce({
96-
state: 'mcp_cg_direct',
97-
organizationId: 'organization-1',
98-
invitationToken: 'invitation-token',
99-
mcpServerId: 'server-1',
100-
completionId,
101-
returnTo: 'integrations',
102-
})
103-
const response = await GET(
104-
new NextRequest(
105-
`http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${error ? 'error=denied' : 'code=code-1'}`
106-
)
98+
it.each([
99+
[undefined, null],
100+
['access_denied', 'denied'],
101+
['invalid_request', 'failed'],
102+
['invalid_scope', 'failed'],
103+
['server_error', 'provider_unavailable'],
104+
['temporarily_unavailable', 'provider_unavailable'],
105+
['unexpected-secret-value', 'failed'],
106+
])('finishes a direct connection without the invitation form: %s', async (error, expected) => {
107+
const completionId = '00000000-0000-4000-8000-000000000002'
108+
mockConsumeManagedAttempt.mockResolvedValueOnce({
109+
state: 'mcp_cg_direct',
110+
organizationId: 'organization-1',
111+
invitationToken: 'invitation-token',
112+
mcpServerId: 'server-1',
113+
completionId,
114+
returnTo: 'integrations',
115+
})
116+
const response = await GET(
117+
new NextRequest(
118+
`http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${error ? `error=${error}&error_description=private-provider-detail` : 'code=code-1'}`
107119
)
108-
const destination = new URL(response.headers.get('location')!, 'http://localhost:3000')
109-
expect(destination.pathname).toBe('/credential-groups/complete')
110-
expect(destination.searchParams.get('completionId')).toBe(completionId)
111-
expect(destination.searchParams.get('organizationId')).toBe('organization-1')
112-
expect(destination.searchParams.get('oauth')).toBe(error ?? null)
120+
)
121+
const destination = new URL(response.headers.get('location')!, 'http://localhost:3000')
122+
expect(destination.pathname).toBe('/credential-groups/complete')
123+
expect(destination.searchParams.get('completionId')).toBe(completionId)
124+
expect(destination.searchParams.get('organizationId')).toBe('organization-1')
125+
expect(destination.searchParams.get('oauth')).toBe(expected)
126+
if (error) {
127+
const warnings = JSON.stringify(getMockLogger('McpOauthCallbackAPI').warn.mock.calls)
128+
expect(warnings).toContain(error === 'unexpected-secret-value' ? 'unknown' : error)
129+
expect(warnings).not.toContain('unexpected-secret-value')
130+
expect(warnings).not.toContain('private-provider-detail')
113131
}
114-
)
132+
})
115133

116134
it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => {
117135
const request = new NextRequest(
@@ -185,4 +203,78 @@ describe('MCP OAuth callback route', () => {
185203
expect(mockAuthenticateEnrollment).not.toHaveBeenCalled()
186204
expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
187205
})
206+
207+
it.each(['missing', 'version'])(
208+
'delivers a %s managed-state failure to only the initiating popup',
209+
async (kind) => {
210+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
211+
vi.stubGlobal('BroadcastChannel', BroadcastChannel)
212+
vi.stubGlobal('window', {
213+
open: () => ({ closed: false, opener: null, location: { replace() {} }, close() {} }),
214+
})
215+
const controller = new AbortController()
216+
const completionId = '00000000-0000-4000-8000-000000000010'
217+
const secondId = '00000000-0000-4000-8000-000000000011'
218+
const state = 'mcp_cg_first'
219+
let first: string | undefined
220+
let second: string | undefined
221+
const start = (nonce: string) => async () => ({
222+
invitationLink: 'http://localhost:3000/credential-groups/enroll/fixture',
223+
authorizationUrl: `https://oauth.example.com/authorize?state=${nonce}`,
224+
})
225+
const outcomes = [
226+
connectCredentialGroupInPopup(completionId, start(state), controller.signal).then(
227+
() => {
228+
first = 'connected'
229+
},
230+
(error: Error) => {
231+
first = error.message
232+
}
233+
),
234+
connectCredentialGroupInPopup(secondId, start('mcp_cg_second'), controller.signal).then(
235+
() => {
236+
second = 'connected'
237+
},
238+
(error: Error) => {
239+
second = error.message
240+
}
241+
),
242+
]
243+
const observer = new BroadcastChannel('mcp-oauth')
244+
const success = new BroadcastChannel(`sim:credential-group-oauth:${secondId}`)
245+
try {
246+
await flushMicrotasks()
247+
if (kind === 'missing') mockConsumeManagedAttempt.mockResolvedValueOnce(null)
248+
else
249+
mockConsumeManagedAttempt.mockRejectedValueOnce(
250+
new CredentialGroupOAuthStateVersionError()
251+
)
252+
const response = await GET(
253+
new NextRequest(`http://localhost:3000/api/mcp/oauth/callback?state=${state}&code=unused`)
254+
)
255+
const body = await response.text()
256+
const script = body.match(/<script>([\s\S]*?)<\/script>/)?.[1]
257+
if (!script) throw new Error('Callback did not provide its popup handoff')
258+
const delivered = new Promise<void>((resolve) => {
259+
observer.onmessage = () => resolve()
260+
})
261+
runInNewContext(script, { BroadcastChannel, setTimeout() {} })
262+
await delivered
263+
await vi.waitFor(() =>
264+
expect(first).toBe('This connection attempt expired. Try connecting your account again.')
265+
)
266+
expect(second).toBeUndefined()
267+
expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled()
268+
success.postMessage('connected')
269+
await outcomes[1]
270+
expect(second).toBe('connected')
271+
} finally {
272+
controller.abort()
273+
observer.close()
274+
success.close()
275+
await Promise.all(outcomes)
276+
vi.useRealTimers()
277+
}
278+
}
279+
)
188280
})

‎apps/sim/app/api/mcp/oauth/callback/route.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,13 @@ async function completeManagedMcpCallback(params: {
100100
throw error
101101
}
102102
if (!attempt) {
103-
return htmlClose('Invalid or expired authorization state.', false, 'invalid_state')
103+
return htmlClose(
104+
'Invalid or expired authorization state.',
105+
false,
106+
'invalid_state',
107+
undefined,
108+
params.state
109+
)
104110
}
105111
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
106112
attempt.completionId
@@ -110,7 +116,30 @@ async function completeManagedMcpCallback(params: {
110116
attempt.returnTo === 'integrations' ? attempt.organizationId : undefined
111117
)
112118
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth })
113-
if (params.error) return failureRedirect('denied')
119+
if (params.error) {
120+
const errorCode = [
121+
'invalid_request',
122+
'unauthorized_client',
123+
'access_denied',
124+
'unsupported_response_type',
125+
'invalid_scope',
126+
'server_error',
127+
'temporarily_unavailable',
128+
].includes(params.error)
129+
? params.error
130+
: 'unknown'
131+
logger.warn('Managed MCP authorization returned a provider error', {
132+
phase: 'provider_authorization',
133+
errorCode,
134+
})
135+
return failureRedirect(
136+
errorCode === 'access_denied'
137+
? 'denied'
138+
: errorCode === 'server_error' || errorCode === 'temporarily_unavailable'
139+
? 'provider_unavailable'
140+
: 'failed'
141+
)
142+
}
114143
if (!params.code) {
115144
return failureRedirect('failed')
116145
}

‎apps/sim/lib/credential-groups/oauth-popup.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { toast } from '@sim/emcn'
22
import { toError } from '@sim/utils/errors'
3+
import { isRecordLike } from '@sim/utils/object'
34
import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts'
45
import {
56
CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES,
@@ -28,13 +29,16 @@ export async function connectCredentialGroupInPopup(
2829
return new Promise((resolve, reject) => {
2930
const controller = new AbortController()
3031
const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId))
32+
const mcpChannel = new BroadcastChannel('mcp-oauth')
33+
let mcpState: string | undefined
3134
let settled = false
3235
const finish = (error?: Error) => {
3336
if (settled) return
3437
settled = true
3538
controller.abort()
3639
clearTimeout(timer)
3740
channel.close()
41+
mcpChannel.close()
3842
signal.removeEventListener('abort', abort)
3943
toast.dismiss(notice)
4044
if (error) reject(error)
@@ -59,6 +63,19 @@ export async function connectCredentialGroupInPopup(
5963
else if (isCredentialGroupOAuthFailure(data))
6064
finish(new Error(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data]))
6165
}
66+
/** Invalid state has no stored completion ID; only accept a failure for this attempt's nonce. */
67+
mcpChannel.onmessage = ({ data }: MessageEvent<unknown>) => {
68+
if (
69+
mcpState &&
70+
isRecordLike(data) &&
71+
data.type === 'mcp-oauth' &&
72+
data.state === mcpState &&
73+
data.ok === false &&
74+
data.reason === 'invalid_state'
75+
) {
76+
finish(new Error(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.expired))
77+
}
78+
}
6279
signal.addEventListener('abort', abort, { once: true })
6380
if (signal.aborted) {
6481
abort()
@@ -71,6 +88,10 @@ export async function connectCredentialGroupInPopup(
7188
abort()
7289
return
7390
}
91+
const state = result.authorizationUrl
92+
? new URL(result.authorizationUrl).searchParams.get('state')
93+
: null
94+
if (state?.startsWith('mcp_cg_')) mcpState = state
7495
popup.location.replace(result.authorizationUrl ?? result.invitationLink)
7596
})
7697
.catch((error) => {

0 commit comments

Comments
 (0)