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
4 changes: 2 additions & 2 deletions apps/sim/app/(auth)/auth-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthR

/**
* OAuth reauthentication must reach the form even when an older session cookie exists.
* Only an OAuth client's request qualifies: a workspace account launch on the same path
* would otherwise turn a cross-site link into a same-origin request after sign-in.
* Only an OAuth client's request qualifies: a workspace connect link on the same path
* needs no fresh sign-in, so a signed-in user continues to the app instead.
*/
export function isOAuthAuthorizationCallback(callbackUrl: string, origin: string): boolean {
if (!callbackUrl) return false
Expand Down
40 changes: 6 additions & 34 deletions apps/sim/app/api/auth/instagram/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,15 @@ import { urlsMockFns } from '@sim/testing/mocks/urls.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
createCredentialConnection: vi.fn(),
requireConfiguredOAuthClient: vi.fn(),
}))

vi.mock('@/lib/core/config/env-capabilities.server', () => ({
requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient,
}))

vi.mock('@/lib/credentials/application/create-credential-connection', () => ({
createCredentialConnection: { execute: mocks.createCredentialConnection },
}))

vi.mock('@/lib/oauth/utils', () => oauthUtilsMock)

import { OrchestrationError } from '@/lib/core/orchestration/types'
import { GET } from '@/app/api/auth/instagram/authorize/route'

urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test')
Expand All @@ -35,7 +29,6 @@ describe('Instagram authorize route', () => {
mocks.requireConfiguredOAuthClient.mockReturnValue({
values: { INSTAGRAM_CLIENT_ID: 'instagram-client' },
})
mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' })
})

it('preserves an exact credential draft when workspaceId is also supplied', async () => {
Expand All @@ -52,42 +45,21 @@ describe('Instagram authorize route', () => {
expect(response.headers.get('set-cookie')).toContain(
'instagram_credential_draft_id=draft-exact'
)
expect(mocks.createCredentialConnection).not.toHaveBeenCalled()
})

it('refuses a cross-site workspace launch before creating a connection draft', async () => {
it('sends a workspace connect link to the confirmation page without starting it', async () => {
const request = createMockRequest(
'GET',
undefined,
{ 'sec-fetch-site': 'cross-site' },
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1'
{ 'sec-fetch-site': 'same-origin' },
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&returnUrl=https%3A%2F%2Fsim.test%2Fhome'
)

const response = await GET(request, createRouteContext({}))

expect(response.status).toBe(403)
expect(mocks.createCredentialConnection).not.toHaveBeenCalled()
})

it('returns a conflict when a different connection intent is already active', async () => {
mocks.createCredentialConnection.mockRejectedValue(
new OrchestrationError(
'conflict',
'A different OAuth connection flow is already active for this provider'
)
expect(response.headers.get('location')).toBe(
'https://sim.test/oauth/connect?providerId=instagram&workspaceId=workspace-1&callbackURL=https%3A%2F%2Fsim.test%2Fhome'
)
const request = createMockRequest(
'GET',
undefined,
{},
'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1'
)

const response = await GET(request, createRouteContext({}))

expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({
error: 'A different OAuth connection flow is already active for this provider',
})
expect(response.headers.get('set-cookie')).toBeNull()
})
})
46 changes: 10 additions & 36 deletions apps/sim/app/api/auth/instagram/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'

Expand All @@ -29,8 +26,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const sessionId = session.session?.id
if (!sessionId) throw new Error('Authenticated session is missing its session ID')

const {
values: { INSTAGRAM_CLIENT_ID: clientId },
Expand All @@ -39,39 +34,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(authorizeInstagramContract, request, {})
if (!parsed.success) return parsed.response
const { returnUrl, workspaceId, draftId } = parsed.data.query
let credentialDraftId = draftId
const baseUrl = getBaseUrl()

// Same gesture gate as the shared authorize route: a workspace link opens the
// confirmation page, which creates the draft on the user's click.
if (workspaceId && !draftId) {
// The query alone picks the workspace, so a cross-site page must not be
// able to start this launch with the viewer's session cookie.
if (isCrossSiteSessionRequest(request)) {
logger.warn('Rejected cross-site Instagram workspace launch', { userId: session.user.id })
return NextResponse.json({ error: 'Cross-site launch refused' }, { status: 403 })
}
try {
const connection = await createCredentialConnection.execute({
principal: { kind: 'session', userId: session.user.id, sessionId },
input: { workspaceId, providerId: 'instagram' },
request,
})
credentialDraftId = connection.draftId
} catch (error) {
const classified = asOrchestrationError(error)
if (classified?.code === 'conflict') {
logger.warn('Rejected conflicting Instagram OAuth connection intent', {
userId: session.user.id,
workspaceId,
})
return NextResponse.json({ error: classified.message }, { status: 409 })
}
if (classified?.code === 'forbidden' || classified?.code === 'not_found') {
return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 })
}
throw error
}
const confirmUrl = new URL('/oauth/connect', baseUrl)
confirmUrl.searchParams.set('providerId', 'instagram')
confirmUrl.searchParams.set('workspaceId', workspaceId)
if (returnUrl) confirmUrl.searchParams.set('callbackURL', returnUrl)
return NextResponse.redirect(confirmUrl)
}

const baseUrl = getBaseUrl()
const state = generateShortId(32)
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram`
const scope = getCanonicalScopesForProvider('instagram').join(',')
Expand All @@ -91,8 +65,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
maxAge: CREDENTIAL_DRAFT_TTL_SECONDS,
path: INSTAGRAM_STATE_COOKIE_PATH,
})
if (credentialDraftId) {
response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, {
if (draftId) {
response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, draftId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
Expand Down
120 changes: 32 additions & 88 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,12 @@ import { oauthUtilsMock, oauthUtilsMockFns } from '@sim/testing/mocks/oauth-util
import { urlsMockFns } from '@sim/testing/mocks/urls.mock'
import type { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target'

const mocks = vi.hoisted(() => ({
betterAuthGET: vi.fn(),
getSession: vi.fn(),
linkAccount: vi.fn(),
requireClient: vi.fn(),
createConnection: vi.fn(),
launchConnection: vi.fn(),
decryptQuickBooksClientConfig: vi.fn(),
createQuickBooksState: vi.fn(),
Expand All @@ -46,12 +42,6 @@ vi.mock('@/lib/core/config/env-capabilities.server', () => ({
execute: vi.fn(),
}),
}))
vi.mock('@/lib/credentials/application/create-credential-connection', () => ({
createCredentialConnection: {
operation: { id: 'credentials.connections.create' },
execute: mocks.createConnection,
},
}))
vi.mock('@/lib/credentials/application/launch-scoped-credential-connection', () => ({
launchScopedCredentialConnection: mocks.launchConnection,
}))
Expand Down Expand Up @@ -97,13 +87,6 @@ describe('OAuth2 authorize route', () => {
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.createConnection.mockResolvedValue({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
draftId: 'draft-1',
expiresAt: new Date('2026-08-14T12:00:00.000Z'),
authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`,
})
mocks.launchConnection.mockResolvedValue({
draft: {
id: 'draft-1',
Expand Down Expand Up @@ -442,90 +425,51 @@ describe('OAuth2 authorize route', () => {
expect(mocks.betterAuthGET).not.toHaveBeenCalled()
})

it('refuses a cross-site workspace launch before creating a connection draft', async () => {
it('sends a workspace connect link to the confirmation page without starting it', async () => {
const callbackURL = `${BASE_URL}/workspace/${WORKSPACE_ID}`
const response = await GET(
request(
{ providerId: 'google-email', workspaceId: WORKSPACE_ID, credentialId: 'credential-1' },
{ 'sec-fetch-site': 'cross-site' }
{
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: 'credential-1',
callbackURL,
},
{ 'sec-fetch-site': 'same-origin' }
)
)

expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_invalid`)
expect(mocks.createConnection).not.toHaveBeenCalled()
const location = new URL(response.headers.get('location') ?? '')
expect(`${location.origin}${location.pathname}`).toBe(`${BASE_URL}/oauth/connect`)
expect(Object.fromEntries(location.searchParams)).toEqual({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: 'credential-1',
callbackURL,
})
expect(mocks.launchConnection).not.toHaveBeenCalled()
expect(mocks.linkAccount).not.toHaveBeenCalled()
})

it.each([{ 'sec-fetch-site': 'none' }, { 'sec-fetch-site': 'same-site' }])(
'starts a workspace launch that is not cross-site: %o',
async (headers) => {
const response = await GET(
request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }, headers)
)

expect(mocks.createConnection).toHaveBeenCalledOnce()
expect(response.headers.get('location')).toBe('https://provider.example/authorize')
}
)

it('starts a cross-site launch of the signed-in user’s own connection draft', async () => {
const response = await GET(request({ draftId: 'draft-1' }, { 'sec-fetch-site': 'cross-site' }))

expect(mocks.launchConnection).toHaveBeenCalledOnce()
expect(response.headers.get('location')).toBe('https://provider.example/authorize')
})

it('requires a configured OAuth client before creating a legacy draft', async () => {
mocks.requireClient.mockImplementationOnce(() => {
throw new Error('OAuth client is not configured')
})

const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))

expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`)
expect(mocks.requireClient).toHaveBeenCalledWith('google-email')
expect(mocks.createConnection).not.toHaveBeenCalled()
})

it('maps a provider mismatch without exposing the credential', async () => {
mocks.createConnection.mockRejectedValue(
new CredentialConnectionProviderMismatchError('google-email', 'slack')
)

const response = await GET(
request({
providerId: 'slack',
workspaceId: WORKSPACE_ID,
credentialId: 'credential-1',
})
)

expect(response.headers.get('location')).toBe(
`${BASE_URL}/home?error=credential_provider_mismatch`
)
})

it('maps credential and workspace authorization failures separately', async () => {
mocks.createConnection.mockRejectedValueOnce(
new ForbiddenOperationError(
'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
'Credential admin permission required'
)
)
const credentialResponse = await GET(
request({
providerId: 'google-email',
workspaceId: WORKSPACE_ID,
credentialId: 'credential-1',
})
)
mocks.createConnection.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'Write permission required')
)
const workspaceResponse = await GET(
request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
)

expect(credentialResponse.headers.get('location')).toContain('credential_access_denied')
expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied')
})
it.each([
{
callbackURL: undefined,
errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`,
},
{ callbackURL: `${BASE_URL}/oauth/chat-complete`, errorCallbackURL: undefined },
])(
'sends a failed draft launch to the status page only when no return page was named: %o',
async ({ callbackURL, errorCallbackURL }) => {
await GET(request({ draftId: 'draft-1', ...(callbackURL && { callbackURL }) }))

expect(mocks.linkAccount.mock.calls[0][0].body.errorCallbackURL).toBe(errorCallbackURL)
}
)
})
Loading
Loading