Skip to content
Merged
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
86 changes: 85 additions & 1 deletion apps/sim/app/api/credential-groups/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @vitest-environment node */
import { sha256Hex } from '@sim/security/hash'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
Expand All @@ -11,6 +12,7 @@ const mocks = vi.hoisted(() => ({
consumeAttempt: vi.fn(),
logError: vi.fn(),
completeSetupOAuth: vi.fn(),
authenticateSession: vi.fn(),
}))

vi.mock('@sim/logger', () => ({
Expand All @@ -21,7 +23,7 @@ vi.mock('@/lib/knowledge/application/github-setup', () => ({
}))
vi.mock('@/lib/api/server/routes', () => ({
internalSessionAuth: {
authenticate: async () => ({ kind: 'session', userId: 'admin', sessionId: 'browser' }),
authenticate: mocks.authenticateSession,
},
}))
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
Expand Down Expand Up @@ -127,6 +129,55 @@ describe('GitHub managed OAuth failure presentation', () => {
provider: 'github-repositories',
failure: 'failed',
errorClass: 'unexpected',
stage: 'enrollment_completion',
errorType: 'Error',
fingerprint: sha256Hex('member@example.com ghu_token').slice(0, 12),
})
})

it('identifies a wrapped database failure without logging SQL, parameters, or provider data', async () => {
const cause = Object.assign(new Error('duplicate key for member@example.com'), {
name: 'PostgresError',
code: '23505',
detail: 'ghu_private_token',
})
mocks.consumeAttempt.mockResolvedValue(attempt)
mocks.completeOAuth.mockRejectedValueOnce(
new Error('Failed query: INSERT INTO credential\nparams: ghu_private_token', { cause })
)
const response = await completeCallback()
expect(response.headers.get('location')).toContain('oauth=failed')
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
provider: 'github-repositories',
failure: 'failed',
errorClass: 'unexpected',
stage: 'enrollment_completion',
errorType: 'PostgresError',
databaseCode: '23505',
fingerprint: sha256Hex(cause.message).slice(0, 12),
})
const logged = JSON.stringify(mocks.logError.mock.calls)
expect(logged).not.toContain('member@example.com')
expect(logged).not.toContain('ghu_private_token')
expect(logged).not.toContain('INSERT')
})

it('does not log arbitrary error names or codes as diagnostic metadata', async () => {
mocks.consumeAttempt.mockResolvedValue(attempt)
mocks.completeOAuth.mockRejectedValueOnce(
Object.assign(new Error('private provider response'), {
name: 'ghu_private_token',
code: 'client_secret=private',
})
)
await completeCallback()
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
provider: 'github-repositories',
failure: 'failed',
errorClass: 'unexpected',
stage: 'enrollment_completion',
errorType: 'UnknownError',
fingerprint: sha256Hex('private provider response').slice(0, 12),
})
})

Expand All @@ -149,7 +200,40 @@ describe('GitHub managed OAuth failure presentation', () => {
describe('GitHub installation setup OAuth return target', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.authenticateSession.mockResolvedValue({
kind: 'session',
userId: 'admin',
sessionId: 'browser',
})
})

it.each(['session_authentication', 'setup_completion'])(
'identifies an unexpected failure during %s without exposing its message',
async (stage) => {
mocks.consumeAttempt.mockResolvedValue({
...attempt,
returnTo: 'github-installation',
organizationId: 'organization',
completionId,
})
const error = new TypeError('private callback data')
if (stage === 'session_authentication') {
mocks.authenticateSession.mockRejectedValueOnce(error)
} else {
mocks.completeSetupOAuth.mockRejectedValueOnce(error)
}
const response = await completeCallback()
expect(response.headers.get('location')).toContain('oauth=failed')
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
provider: 'github-repositories',
failure: 'failed',
errorClass: 'unexpected',
stage,
errorType: 'TypeError',
fingerprint: sha256Hex(error.message).slice(0, 12),
})
}
)
it('resumes only the server-owned setup after the guarded OAuth completion', async () => {
mocks.consumeAttempt.mockResolvedValue({
...attempt,
Expand Down
52 changes: 41 additions & 11 deletions apps/sim/app/api/credential-groups/oauth-callback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sha256Hex } from '@sim/security/hash'
import { describeError, getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups'
import { internalSessionAuth } from '@/lib/api/server/routes'
Expand All @@ -23,6 +24,18 @@ import {
} from '@/app/api/credential-groups/enrollment-redirect'

const logger = createLogger('CredentialGroupOAuthCallbackAPI')
const DIAGNOSTIC_ERROR_TYPES = new Set([
'Error',
'TypeError',
'ReferenceError',
'SyntaxError',
'RangeError',
'ZodError',
'PostgresError',
'DrizzleQueryError',
'InternalUnauthenticatedError',
'ManagedOAuthCredentialError',
])

interface HandleCredentialGroupOAuthCallbackParams {
request: NextRequest
Expand Down Expand Up @@ -87,13 +100,17 @@ export async function handleCredentialGroupOAuthCallback({
return failureRedirect('failed')
}

let stage = 'session_authentication'
try {
if (installationSetup) {
const principal = await internalSessionAuth.authenticate()
stage = 'setup_completion'
await completeGitHubSetupReaderOAuth.execute({ principal, input: { attempt, code }, request })
return setupRedirect()
}
stage = 'enrollment_authentication'
const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
stage = 'enrollment_completion'
await completePublicCredentialGroupOAuth.execute({
principal,
input: { attempt, code },
Expand Down Expand Up @@ -137,19 +154,32 @@ export async function handleCredentialGroupOAuthCallback({
}
}
const applicationError = asOrchestrationError(error)
const errorClass =
error instanceof CredentialGroupInvitationUnavailableError
? 'invitation_unavailable'
: error instanceof CredentialGroupOAuthError
? 'credential_group_oauth'
: error instanceof CredentialGroupProviderConfigurationError
? 'provider_configuration'
: applicationError
? 'application'
: 'unexpected'
const unexpectedError = errorClass === 'unexpected' ? describeError(error) : undefined
logger.error('Managed OAuth authorization failed', {
provider,
failure: status,
errorClass:
error instanceof CredentialGroupInvitationUnavailableError
? 'invitation_unavailable'
: error instanceof CredentialGroupOAuthError
? 'credential_group_oauth'
: error instanceof CredentialGroupProviderConfigurationError
? 'provider_configuration'
: applicationError
? 'application'
: 'unexpected',
errorClass,
/** Provider errors and SQL parameters may contain credentials; retain only bounded diagnostics. */
...(unexpectedError && {
stage,
errorType: DIAGNOSTIC_ERROR_TYPES.has(unexpectedError.name)
? unexpectedError.name
: 'UnknownError',
fingerprint: sha256Hex(unexpectedError.message).slice(0, 12),
...(unexpectedError.code && /^[0-9A-Z]{5}$/.test(unexpectedError.code)
? { databaseCode: unexpectedError.code }
: {}),
}),
...(error instanceof CredentialGroupOAuthError && { statusCode: error.statusCode }),
...(error instanceof CredentialGroupProviderConfigurationError && { statusCode: 503 }),
...(applicationError && {
Expand Down
Loading