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
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ Replace `<NEXT_PUBLIC_APP_URL>` with your configured public origin, such as `htt
| Expire user authorization tokens | Enabled |
| Request user authorization (OAuth) during installation | Disabled |
| Enable Device Flow | Disabled |
| Post installation → Setup URL | Empty |
| Post installation → Setup URL | `<NEXT_PUBLIC_APP_URL>/api/knowledge/github/setup/callback` |
| Post installation → Redirect on update | Enabled |
| Webhook → Active | Disabled |

Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook.
Authorization starts from Sim so the callback can finish the pending connection. The **Setup URL** returns installation approval to that same setup attempt. Keep **Request user authorization (OAuth) during installation** disabled: Sim starts account authorization when it is needed, before installation. See GitHub's [Setup URL guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-setup-url).

<Image className="mx-auto h-auto w-full max-w-md" src="/static/search/github-app-callback.jpg" alt="GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled" width={768} height={929} />
For an existing deployment, deploy the application with the setup callback before changing the App registration. Use a separate GitHub App for each deployment origin, such as staging and production, so installation approval returns to the instance that started it. Keep the existing user authorization callback above unchanged.

*Example registration. Replace `sim.example.com` with your Sim domain.*
The connector polls GitHub's API and does not need a webhook.

</Step>
<Step>
Expand Down
70 changes: 43 additions & 27 deletions apps/docs/content/docs/search/github.mdx

Large diffs are not rendered by default.

Binary file not shown.
Binary file added apps/docs/public/static/search/github-connect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
191 changes: 191 additions & 0 deletions apps/sim/app/api/credential-groups/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/** @vitest-environment node */
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import { OAuthIdentityVerificationError } from '@/lib/oauth/identity-error'

const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
completeOAuth: vi.fn(),
consumeAttempt: vi.fn(),
logError: vi.fn(),
completeSetupOAuth: vi.fn(),
}))

vi.mock('@sim/logger', () => ({
createLogger: () => ({ error: mocks.logError }),
}))
vi.mock('@/lib/knowledge/application/github-setup', () => ({
completeGitHubSetupReaderOAuth: { execute: mocks.completeSetupOAuth },
}))
vi.mock('@/lib/api/server/routes', () => ({
internalSessionAuth: {
authenticate: async () => ({ kind: 'session', userId: 'admin', sessionId: 'browser' }),
},
}))
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
credentialGroupOAuthAttemptPrincipal: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
completePublicCredentialGroupOAuth: { execute: mocks.completeOAuth },
}))
vi.mock('@/lib/credential-groups/oauth-state', () => ({
consumeCredentialGroupOAuthAttempt: mocks.consumeAttempt,
}))

import { handleCredentialGroupOAuthCallback } from '@/app/api/credential-groups/oauth-callback'

const completionId = '550e8400-e29b-41d4-a716-446655440000'
const attempt = {
provider: 'github-repositories',
invitationToken: 'invitation-token',
optionId: 'option-1',
returnTo: 'search',
} as const

function completeCallback() {
return handleCredentialGroupOAuthCallback({
request: new NextRequest(
'https://sim.test/api/auth/oauth2/callback/github-repositories?state=cg_state&code=code-1'
),
provider: 'github-repositories',
query: { state: 'cg_state', code: 'code-1' },
limited: null,
})
}

describe('GitHub managed OAuth failure presentation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.authenticate.mockResolvedValue({ kind: 'credential_group_enrollment' })
})

describe.each([false, true])('completion redirect: %s', (completionRedirect) => {
it.each([
{
failure: new OAuthIdentityVerificationError('email_mismatch', 'emails'),
status: 'github_email_mismatch',
},
{
failure: new OAuthIdentityVerificationError('email_access_denied', 'emails', 403),
status: 'github_email_access_denied',
},
{
failure: new OAuthIdentityVerificationError('provider_unavailable', 'profile', 503),
status: 'provider_unavailable',
},
{
failure: new OAuthIdentityVerificationError('rate_limited', 'emails', 403),
status: 'rate_limited',
},
{
failure: new OAuthIdentityVerificationError('invalid_response', 'emails'),
status: 'provider_unavailable',
},
{
failure: new OAuthIdentityVerificationError('provider_rejected', 'profile', 401),
status: 'failed',
},
])('presents $status and logs only safe diagnostics', async ({ failure, status }) => {
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect, completionId })
mocks.completeOAuth.mockRejectedValueOnce(
new CredentialGroupOAuthError('Private details: member@example.com ghu_token', 502, failure)
)
const response = await completeCallback()
const location = new URL(response.headers.get('location')!, 'https://sim.test')
expect(location.searchParams.get('oauth')).toBe(status)
if (completionRedirect) {
expect(location.pathname).toBe('/credential-groups/complete')
expect(location.searchParams.get('completionId')).toBe(completionId)
} else {
expect(location.pathname).toBe('/credential-groups/enroll/invitation-token')
expect(location.searchParams.get('optionId')).toBe(attempt.optionId)
expect(location.searchParams.get('returnTo')).toBe('search')
}
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
provider: 'github-repositories',
failure: status,
errorClass: 'credential_group_oauth',
statusCode: 502,
identityReason: failure.reason,
identityStage: failure.stage,
providerStatus: failure.httpStatus,
})
expect(response.headers.get('location')).not.toContain('member@example.com')
expect(response.headers.get('location')).not.toContain('ghu_token')
})
})

it('does not infer an identity failure from an untyped provider exception', async () => {
mocks.consumeAttempt.mockResolvedValue(attempt)
mocks.completeOAuth.mockRejectedValueOnce(new Error('member@example.com ghu_token'))
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',
})
})

it('retains an application error classification without its private details', async () => {
mocks.consumeAttempt.mockResolvedValue(attempt)
mocks.completeOAuth.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'Private details: member@example.com')
)
await completeCallback()
expect(mocks.logError).toHaveBeenCalledExactlyOnceWith('Managed OAuth authorization failed', {
provider: 'github-repositories',
failure: 'failed',
errorClass: 'application',
applicationCode: 'forbidden',
statusCode: 403,
})
})
})

describe('GitHub installation setup OAuth return target', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resumes only the server-owned setup after the guarded OAuth completion', async () => {
mocks.consumeAttempt.mockResolvedValue({
...attempt,
returnTo: 'github-installation',
organizationId: 'organization',
completionId,
completionRedirect: true,
})
mocks.completeSetupOAuth.mockResolvedValue({ credentialId: 'reader' })
const response = await completeCallback()
const url = new URL(response.headers.get('location')!, 'https://sim.test')
expect(url.pathname).toBe('/api/knowledge/github/setup/continue')
expect(url.searchParams.get('organizationId')).toBe('organization')
expect(url.searchParams.get('setupId')).toBe(completionId)
expect(response.headers.get('referrer-policy')).toBe('no-referrer')
expect(mocks.completeSetupOAuth).toHaveBeenCalled()
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
it('returns classified OAuth failure to setup status without accepting a client redirect URL', async () => {
mocks.consumeAttempt.mockResolvedValue({
...attempt,
returnTo: 'github-installation',
organizationId: 'organization',
completionId,
completionRedirect: true,
})
const response = await handleCredentialGroupOAuthCallback({
request: new NextRequest('https://sim.test/api/auth/oauth2/callback/github-repositories'),
provider: 'github-repositories',
query: { state: 'cg_state', error: 'access_denied' },
limited: null,
})
const url = new URL(response.headers.get('location')!, 'https://sim.test')
expect(url.origin).toBe('https://sim.test')
expect(url.pathname).toBe('/api/knowledge/github/setup/continue')
expect(url.searchParams.get('oauth')).toBe('denied')
expect(url.searchParams.get('setupId')).toBe(completionId)
})
})
88 changes: 79 additions & 9 deletions apps/sim/app/api/credential-groups/oauth-callback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups'
import { internalSessionAuth } from '@/lib/api/server/routes'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
Expand All @@ -10,8 +12,11 @@ import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oaut
import {
CredentialGroupInvitationUnavailableError,
CredentialGroupOAuthError,
CredentialGroupProviderConfigurationError,
} from '@/lib/credential-groups/provider-adapter'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
import { completeGitHubSetupReaderOAuth } from '@/lib/knowledge/application/github-setup'
import { githubSetupContinueUrl } from '@/lib/knowledge/github-setup-urls'
import {
createCredentialGroupCompletionRedirect,
createCredentialGroupEnrollmentRedirect,
Expand Down Expand Up @@ -52,10 +57,26 @@ export async function handleCredentialGroupOAuthCallback({
const focus: Record<string, string> = attempt.returnTo
? { optionId: attempt.optionId, returnTo: attempt.returnTo }
: {}
const setupRedirect = (oauth?: CredentialGroupOAuthFailure) =>
new NextResponse(null, {
status: 303,
headers: {
Location: githubSetupContinueUrl(
{ organizationId: attempt.organizationId!, setupId: attempt.completionId! },
oauth
),
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
},
})
const installationSetup =
attempt.returnTo === 'github-installation' && attempt.organizationId && attempt.completionId
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
attempt.completionRedirect
? createCredentialGroupCompletionRedirect(oauth, attempt.completionId)
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
installationSetup
? setupRedirect(oauth)
: attempt.completionRedirect
? createCredentialGroupCompletionRedirect(oauth, attempt.completionId)
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
if (limited) {
return failureRedirect('rate_limited')
}
Expand All @@ -67,6 +88,11 @@ export async function handleCredentialGroupOAuthCallback({
}

try {
if (installationSetup) {
const principal = await internalSessionAuth.authenticate()
await completeGitHubSetupReaderOAuth.execute({ principal, input: { attempt, code }, request })
return setupRedirect()
}
const principal = await credentialGroupOAuthAttemptPrincipal(attempt)
await completePublicCredentialGroupOAuth.execute({
principal,
Expand All @@ -80,11 +106,9 @@ export async function handleCredentialGroupOAuthCallback({
connected: attempt.optionId,
})
} catch (error) {
logger.error('Managed OAuth authorization failed', {
provider,
error: getErrorMessage(error),
})
const status =
const identityFailure =
error instanceof CredentialGroupOAuthError ? error.identityFailure : undefined
let status: CredentialGroupOAuthFailure =
error instanceof CredentialGroupInvitationUnavailableError
? 'unavailable'
: error instanceof CredentialGroupOAuthError && error.statusCode === 403
Expand All @@ -94,6 +118,52 @@ export async function handleCredentialGroupOAuthCallback({
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
: 'failed'
if (identityFailure) {
switch (identityFailure.reason) {
case 'email_mismatch':
status = provider === 'github-repositories' ? 'github_email_mismatch' : 'account_mismatch'
break
case 'email_access_denied':
status =
provider === 'github-repositories'
? 'github_email_access_denied'
: 'permissions_required'
break
case 'rate_limited':
status = 'rate_limited'
break
case 'provider_unavailable':
case 'invalid_response':
status = 'provider_unavailable'
break
}
}
const applicationError = asOrchestrationError(error)
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',
...(error instanceof CredentialGroupOAuthError && { statusCode: error.statusCode }),
...(error instanceof CredentialGroupProviderConfigurationError && { statusCode: 503 }),
...(applicationError && {
applicationCode: applicationError.code,
statusCode: statusForOrchestrationError(applicationError.code),
}),
...(identityFailure && {
identityReason: identityFailure.reason,
identityStage: identityFailure.stage,
providerStatus: identityFailure.httpStatus,
}),
})
return failureRedirect(status)
}
}
51 changes: 51 additions & 0 deletions apps/sim/app/api/knowledge/github/setup/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { SessionPrincipal } from '@sim/auth/principal'
import { NextResponse } from 'next/server'
import { completeGitHubSearchSetupContract } from '@/lib/api/contracts/knowledge/github-setup'
import { parseRequest } from '@/lib/api/server'
import {
InternalUnauthenticatedError,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeGitHubSearchSetup } from '@/lib/knowledge/application/github-setup'
import { createCredentialGroupCompletionRedirect } from '@/app/api/credential-groups/enrollment-redirect'

/** GitHub's Setup URL carries untrusted installation IDs; the use case verifies ownership. */
export const GET = withRouteHandler(async (request) => {
let principal: SessionPrincipal
try {
principal = await internalSessionAuth.authenticate()
} catch (error) {
if (error instanceof InternalUnauthenticatedError)
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } }
)
throw error
}
const limited = await internalRateLimits
.user({ bucketName: 'github-search-setup' })
.enforce(request, principal)
if (limited) return limited
const parsed = await parseRequest(completeGitHubSearchSetupContract, request, {})
if (!parsed.success) return parsed.response
try {
const { state, installation_id, setup_action } = parsed.data.query
const result = await completeGitHubSearchSetup.execute({
principal,
input: { state, installationId: installation_id, setupAction: setup_action },
request,
})
return new NextResponse(null, {
status: 303,
headers: {
Location: result.url,
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
},
})
} catch {
return createCredentialGroupCompletionRedirect('unavailable')
}
})
Loading
Loading