Skip to content

Commit 294de5f

Browse files
chore(auth): diagnose unexpected managed OAuth callback failures (#7896)
1 parent 2243890 commit 294de5f

2 files changed

Lines changed: 126 additions & 12 deletions

File tree

‎apps/sim/app/api/credential-groups/oauth-callback.test.ts‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** @vitest-environment node */
2+
import { sha256Hex } from '@sim/security/hash'
23
import { NextRequest } from 'next/server'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
45
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1112
consumeAttempt: vi.fn(),
1213
logError: vi.fn(),
1314
completeSetupOAuth: vi.fn(),
15+
authenticateSession: vi.fn(),
1416
}))
1517

1618
vi.mock('@sim/logger', () => ({
@@ -21,7 +23,7 @@ vi.mock('@/lib/knowledge/application/github-setup', () => ({
2123
}))
2224
vi.mock('@/lib/api/server/routes', () => ({
2325
internalSessionAuth: {
24-
authenticate: async () => ({ kind: 'session', userId: 'admin', sessionId: 'browser' }),
26+
authenticate: mocks.authenticateSession,
2527
},
2628
}))
2729
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
@@ -127,6 +129,55 @@ describe('GitHub managed OAuth failure presentation', () => {
127129
provider: 'github-repositories',
128130
failure: 'failed',
129131
errorClass: 'unexpected',
132+
stage: 'enrollment_completion',
133+
errorType: 'Error',
134+
fingerprint: sha256Hex('member@example.com ghu_token').slice(0, 12),
135+
})
136+
})
137+
138+
it('identifies a wrapped database failure without logging SQL, parameters, or provider data', async () => {
139+
const cause = Object.assign(new Error('duplicate key for member@example.com'), {
140+
name: 'PostgresError',
141+
code: '23505',
142+
detail: 'ghu_private_token',
143+
})
144+
mocks.consumeAttempt.mockResolvedValue(attempt)
145+
mocks.completeOAuth.mockRejectedValueOnce(
146+
new Error('Failed query: INSERT INTO credential\nparams: ghu_private_token', { cause })
147+
)
148+
const response = await completeCallback()
149+
expect(response.headers.get('location')).toContain('oauth=failed')
150+
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
151+
provider: 'github-repositories',
152+
failure: 'failed',
153+
errorClass: 'unexpected',
154+
stage: 'enrollment_completion',
155+
errorType: 'PostgresError',
156+
databaseCode: '23505',
157+
fingerprint: sha256Hex(cause.message).slice(0, 12),
158+
})
159+
const logged = JSON.stringify(mocks.logError.mock.calls)
160+
expect(logged).not.toContain('member@example.com')
161+
expect(logged).not.toContain('ghu_private_token')
162+
expect(logged).not.toContain('INSERT')
163+
})
164+
165+
it('does not log arbitrary error names or codes as diagnostic metadata', async () => {
166+
mocks.consumeAttempt.mockResolvedValue(attempt)
167+
mocks.completeOAuth.mockRejectedValueOnce(
168+
Object.assign(new Error('private provider response'), {
169+
name: 'ghu_private_token',
170+
code: 'client_secret=private',
171+
})
172+
)
173+
await completeCallback()
174+
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
175+
provider: 'github-repositories',
176+
failure: 'failed',
177+
errorClass: 'unexpected',
178+
stage: 'enrollment_completion',
179+
errorType: 'UnknownError',
180+
fingerprint: sha256Hex('private provider response').slice(0, 12),
130181
})
131182
})
132183

@@ -149,7 +200,40 @@ describe('GitHub managed OAuth failure presentation', () => {
149200
describe('GitHub installation setup OAuth return target', () => {
150201
beforeEach(() => {
151202
vi.clearAllMocks()
203+
mocks.authenticateSession.mockResolvedValue({
204+
kind: 'session',
205+
userId: 'admin',
206+
sessionId: 'browser',
207+
})
152208
})
209+
210+
it.each(['session_authentication', 'setup_completion'])(
211+
'identifies an unexpected failure during %s without exposing its message',
212+
async (stage) => {
213+
mocks.consumeAttempt.mockResolvedValue({
214+
...attempt,
215+
returnTo: 'github-installation',
216+
organizationId: 'organization',
217+
completionId,
218+
})
219+
const error = new TypeError('private callback data')
220+
if (stage === 'session_authentication') {
221+
mocks.authenticateSession.mockRejectedValueOnce(error)
222+
} else {
223+
mocks.completeSetupOAuth.mockRejectedValueOnce(error)
224+
}
225+
const response = await completeCallback()
226+
expect(response.headers.get('location')).toContain('oauth=failed')
227+
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
228+
provider: 'github-repositories',
229+
failure: 'failed',
230+
errorClass: 'unexpected',
231+
stage,
232+
errorType: 'TypeError',
233+
fingerprint: sha256Hex(error.message).slice(0, 12),
234+
})
235+
}
236+
)
153237
it('resumes only the server-owned setup after the guarded OAuth completion', async () => {
154238
mocks.consumeAttempt.mockResolvedValue({
155239
...attempt,

‎apps/sim/app/api/credential-groups/oauth-callback.ts‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
2-
import { getErrorMessage } from '@sim/utils/errors'
2+
import { sha256Hex } from '@sim/security/hash'
3+
import { describeError, getErrorMessage } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups'
56
import { internalSessionAuth } from '@/lib/api/server/routes'
@@ -23,6 +24,18 @@ import {
2324
} from '@/app/api/credential-groups/enrollment-redirect'
2425

2526
const logger = createLogger('CredentialGroupOAuthCallbackAPI')
27+
const DIAGNOSTIC_ERROR_TYPES = new Set([
28+
'Error',
29+
'TypeError',
30+
'ReferenceError',
31+
'SyntaxError',
32+
'RangeError',
33+
'ZodError',
34+
'PostgresError',
35+
'DrizzleQueryError',
36+
'InternalUnauthenticatedError',
37+
'ManagedOAuthCredentialError',
38+
])
2639

2740
interface HandleCredentialGroupOAuthCallbackParams {
2841
request: NextRequest
@@ -87,13 +100,17 @@ export async function handleCredentialGroupOAuthCallback({
87100
return failureRedirect('failed')
88101
}
89102

103+
let stage = 'session_authentication'
90104
try {
91105
if (installationSetup) {
92106
const principal = await internalSessionAuth.authenticate()
107+
stage = 'setup_completion'
93108
await completeGitHubSetupReaderOAuth.execute({ principal, input: { attempt, code }, request })
94109
return setupRedirect()
95110
}
111+
stage = 'enrollment_authentication'
96112
const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
113+
stage = 'enrollment_completion'
97114
await completePublicCredentialGroupOAuth.execute({
98115
principal,
99116
input: { attempt, code },
@@ -137,19 +154,32 @@ export async function handleCredentialGroupOAuthCallback({
137154
}
138155
}
139156
const applicationError = asOrchestrationError(error)
157+
const errorClass =
158+
error instanceof CredentialGroupInvitationUnavailableError
159+
? 'invitation_unavailable'
160+
: error instanceof CredentialGroupOAuthError
161+
? 'credential_group_oauth'
162+
: error instanceof CredentialGroupProviderConfigurationError
163+
? 'provider_configuration'
164+
: applicationError
165+
? 'application'
166+
: 'unexpected'
167+
const unexpectedError = errorClass === 'unexpected' ? describeError(error) : undefined
140168
logger.error('Managed OAuth authorization failed', {
141169
provider,
142170
failure: status,
143-
errorClass:
144-
error instanceof CredentialGroupInvitationUnavailableError
145-
? 'invitation_unavailable'
146-
: error instanceof CredentialGroupOAuthError
147-
? 'credential_group_oauth'
148-
: error instanceof CredentialGroupProviderConfigurationError
149-
? 'provider_configuration'
150-
: applicationError
151-
? 'application'
152-
: 'unexpected',
171+
errorClass,
172+
/** Provider errors and SQL parameters may contain credentials; retain only bounded diagnostics. */
173+
...(unexpectedError && {
174+
stage,
175+
errorType: DIAGNOSTIC_ERROR_TYPES.has(unexpectedError.name)
176+
? unexpectedError.name
177+
: 'UnknownError',
178+
fingerprint: sha256Hex(unexpectedError.message).slice(0, 12),
179+
...(unexpectedError.code && /^[0-9A-Z]{5}$/.test(unexpectedError.code)
180+
? { databaseCode: unexpectedError.code }
181+
: {}),
182+
}),
153183
...(error instanceof CredentialGroupOAuthError && { statusCode: error.statusCode }),
154184
...(error instanceof CredentialGroupProviderConfigurationError && { statusCode: 503 }),
155185
...(applicationError && {

0 commit comments

Comments
 (0)