diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 9edf95449d5..4f178548c87 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -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 diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts index 5340ef5adb7..56667492d07 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.test.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -6,7 +6,6 @@ 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(), })) @@ -14,13 +13,8 @@ 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') @@ -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 () => { @@ -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() }) }) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 58c78a2d3f5..2badb62a6ea 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -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' @@ -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 }, @@ -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(',') @@ -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', diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 791740d9412..bd6c5c44124 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -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(), @@ -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, })) @@ -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', @@ -442,31 +425,32 @@ 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' })) @@ -474,58 +458,18 @@ describe('OAuth2 authorize route', () => { 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) + } + ) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index abd36a647b9..f241cf4cb10 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -18,16 +18,12 @@ import { type OAuthResource, parseOAuthResource, } from '@/lib/auth/oauth-resource' -import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { isAuthDisabled } from '@/lib/core/config/env-flags' import { OrchestrationError } 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 { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' -import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchScopedCredentialConnection } from '@/lib/credentials/application/launch-scoped-credential-connection' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' @@ -210,37 +206,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { draftId } = parsed.data.query - let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query + const { draftId, callbackURL: requestedCallback } = parsed.data.query + // A connect link names only a workspace and provider, so anyone can craft one, + // and a same-origin hop (a sign-in, a client-side redirect) makes it look like + // the user's own navigation. It never starts a connection itself: the + // confirmation page does, on the user's click, and returns here with its draft. + if (!draftId) { + const confirmUrl = new URL('/oauth/connect', baseUrl) + for (const [name, value] of Object.entries(parsed.data.query)) { + if (value) confirmUrl.searchParams.set(name, value) + } + return NextResponse.redirect(confirmUrl) + } + + let providerId: string | undefined try { - let organizationId: string | undefined - let fromConnectionDraft = false - let connectionDraftId: string | undefined - let encryptedQuickBooksClientConfig: string | null | undefined - if (draftId) { - try { - const { draft } = await launchScopedCredentialConnection({ - principal, - input: { draftId }, - request, - }) - providerId = draft.providerId - workspaceId = draft.workspaceId ?? undefined - organizationId = draft.organizationId ?? undefined - credentialId = draft.credentialId ?? undefined - connectionDraftId = draft.id - encryptedQuickBooksClientConfig = draft.oauthConfig - fromConnectionDraft = true - } catch (error) { - if (!(error instanceof OrchestrationError)) throw error - logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) - } + const launched = await launchScopedCredentialConnection({ + principal, + input: { draftId }, + request, + }).catch((error: unknown) => { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return null + }) + if (!launched) { + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) } + const { draft } = launched + providerId = draft.providerId + const connectionDraftId = draft.id - if (!providerId || (!workspaceId && !organizationId)) { - throw new Error('Validated OAuth authorization request is missing its target') + if (!draft.workspaceId && !draft.organizationId) { + throw new Error('OAuth connection draft is missing its owner') } if (providerId !== 'quickbooks') { requireConfiguredOAuthClient(providerId) @@ -248,76 +247,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) connectionCompleteUrl.searchParams.set('result', 'connected') - const callbackURL = fromConnectionDraft - ? requestedCallback && isSameOrigin(requestedCallback) - ? requestedCallback - : connectionCompleteUrl.toString() - : requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}${APP_ENTRY_PATH}` - - if (!fromConnectionDraft) { - if (!workspaceId) throw new Error('Workspace OAuth launch is missing its owner') - // The query alone picks the workspace and credential, 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 OAuth workspace launch', { userId, providerId }) - return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) - } - try { - const connection = await createCredentialConnection.execute({ - principal, - input: credentialId - ? { workspaceId, credentialId, assertedProviderId: providerId } - : { workspaceId, providerId }, - request, - }) - providerId = connection.providerId - workspaceId = connection.workspaceId - credentialId = connection.credentialId - connectionDraftId = connection.draftId - } catch (error) { - if (error instanceof CredentialConnectionProviderMismatchError) { - return NextResponse.redirect( - `${baseUrl}${APP_ENTRY_PATH}?error=credential_provider_mismatch` - ) - } - if ( - credentialId && - error instanceof ForbiddenOperationError && - error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' - ) { - return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=credential_access_denied`) - } - if (error instanceof OrchestrationError && error.code === 'not_found') { - return NextResponse.redirect( - `${baseUrl}${APP_ENTRY_PATH}?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` - ) - } - if (error instanceof OrchestrationError && error.code === 'forbidden') { - return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=workspace_access_denied`) - } - throw error - } - } - - if (!connectionDraftId) { - throw new Error('OAuth authorization is missing its credential draft id') - } + const callerReturnUrl = + requestedCallback && isSameOrigin(requestedCallback) ? requestedCallback : null + const callbackURL = callerReturnUrl ?? connectionCompleteUrl.toString() if (providerId === 'quickbooks') { - if (!encryptedQuickBooksClientConfig) { - const { draft } = await launchScopedCredentialConnection({ - principal, - input: { draftId: connectionDraftId }, - request, - }) - encryptedQuickBooksClientConfig = draft.oauthConfig - } - if (!encryptedQuickBooksClientConfig) { + if (!draft.oauthConfig) { throw new Error('QuickBooks OAuth client configuration is missing') } - const clientConfig = await decryptQuickBooksOAuthClientConfig(encryptedQuickBooksClientConfig) + const clientConfig = await decryptQuickBooksOAuthClientConfig(draft.oauthConfig) const redirectUri = `${baseUrl}/api/auth/oauth2/callback/quickbooks` const state = createQuickBooksOAuthState({ userId, @@ -349,9 +287,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId, callbackURL: stateCallbackUrl.toString(), ...(scopes && { scopes }), - ...(fromConnectionDraft - ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } - : {}), + // A caller that named its return page reads the outcome itself (the chat + // chips watch for the provider error page); otherwise failures land on + // the status page beside the success one. + ...(callerReturnUrl + ? {} + : { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` }), }, headers: request.headers, asResponse: true, diff --git a/apps/sim/app/api/credentials/connections/route.ts b/apps/sim/app/api/credentials/connections/route.ts new file mode 100644 index 00000000000..4eb6ca98c6c --- /dev/null +++ b/apps/sim/app/api/credentials/connections/route.ts @@ -0,0 +1,23 @@ +import { startCredentialConnectionContract } from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: startCredentialConnectionContract, + auth: internalSessionAuth, + operation: credentialOperations.createConnection, + rateLimit: internalRateLimits.user({ bucketName: 'credentials.connections.start' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body: { workspaceId, providerId, credentialId } }) => + credentialId + ? { workspaceId, credentialId, assertedProviderId: providerId } + : { workspaceId, providerId }, + useCase: createCredentialConnection, + present: ({ authorizationUrl }) => ({ authorizationUrl }), +}) diff --git a/apps/sim/app/oauth/connect/connect-confirmation.tsx b/apps/sim/app/oauth/connect/connect-confirmation.tsx new file mode 100644 index 00000000000..6a639e8b355 --- /dev/null +++ b/apps/sim/app/oauth/connect/connect-confirmation.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useState } from 'react' +import { AuthIdentity, AuthButton as Chip } from '@sim/auth-ui' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { startCredentialConnectionContract } from '@/lib/api/contracts/credentials' +import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' + +interface ConnectConfirmationProps { + email: string + serviceName: string + workspaceId: string + providerId: string + credentialId?: string + callbackURL?: string +} + +/** + * The user-gesture gate for a workspace connection: Continue creates the + * user-bound draft and hands it to the authorize route, which takes the user to + * the provider. + */ +export function ConnectConfirmation({ + email, + serviceName, + workspaceId, + providerId, + credentialId, + callbackURL, +}: ConnectConfirmationProps) { + const [status, setStatus] = useState<'idle' | 'working' | 'error'>('idle') + const [errorMessage, setErrorMessage] = useState(null) + + const connect = async () => { + if (status === 'working') return + setStatus('working') + setErrorMessage(null) + try { + const { authorizationUrl } = await requestJson(startCredentialConnectionContract, { + body: { workspaceId, providerId, credentialId }, + }) + const launch = new URL(authorizationUrl) + if (callbackURL) launch.searchParams.set('callbackURL', callbackURL) + window.location.assign(launch.toString()) + } catch (error) { + if (isApiClientError(error) && error.status === 401) { + // The session ended after this page rendered: sign in again, then return here. + const here = `${window.location.pathname}${window.location.search}` + window.location.assign(`/login?callbackUrl=${encodeURIComponent(here)}`) + return + } + setErrorMessage(error instanceof Error ? error.message : null) + setStatus('error') + } + } + + const action = credentialId ? 'Reconnect' : 'Connect' + return ( + } + title={`${action} ${serviceName}`} + description={ + status === 'error' + ? (errorMessage ?? `Something went wrong starting the connection. Try again.`) + : `Continue to ${serviceName} to approve access for this workspace connection.` + } + > + void connect()} + > + {status === 'error' ? 'Try again' : 'Continue'} + + + ) +} diff --git a/apps/sim/app/oauth/connect/page.tsx b/apps/sim/app/oauth/connect/page.tsx new file mode 100644 index 00000000000..830a4b19e57 --- /dev/null +++ b/apps/sim/app/oauth/connect/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' +import { ConnectConfirmation } from '@/app/oauth/connect/connect-confirmation' + +export const metadata: Metadata = { + title: 'Connect an account', + robots: { index: false }, +} + +export const dynamic = 'force-dynamic' + +interface ConnectPageProps { + searchParams: Promise> +} + +function readParam(params: Record, name: string) { + const value = params[name] + return typeof value === 'string' && value ? value : undefined +} + +/** + * Where a workspace connect link lands. The link only names a workspace and a + * provider, so anyone can craft one, and a same-origin hop (a sign-in, a + * client-side redirect) makes it look like the user's own navigation. Opening + * it therefore changes nothing: the connection draft is created only when the + * signed-in user clicks Continue here. + */ +export default async function ConnectPage({ searchParams }: ConnectPageProps) { + const params = await searchParams + const workspaceId = readParam(params, 'workspaceId') + const providerId = readParam(params, 'providerId') + const service = providerId ? getServiceConfigByProviderId(providerId) : null + if (!workspaceId || !providerId || !service) { + return ( + + ) + } + + const session = await getSession() + if (!session?.user) { + const query = new URLSearchParams() + for (const [name, value] of Object.entries(params)) { + if (typeof value === 'string') query.set(name, value) + } + redirect(`/login?callbackUrl=${encodeURIComponent(`/oauth/connect?${query.toString()}`)}`) + } + + return ( + + ) +} diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 611008a5d41..9c62e9198fb 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -666,3 +666,22 @@ export const startPersonalCredentialConnectionContract = defineRouteContract({ schema: z.object({ url: z.string().url(), providerId: z.string().min(1).max(100) }), }, }) + +/** + * Starts a workspace connection from the confirmation page. A connect link only + * opens that page; this POST is the explicit step that creates the user-bound + * draft the authorize route launches from. + */ +export const startCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/credentials/connections', + body: z.object({ + workspaceId: workspaceIdSchema, + providerId: z.string().trim().min(1, 'Provider is required').max(100), + credentialId: z.string().min(1, 'Credential ID must not be empty').max(255).optional(), + }), + response: { + mode: 'json', + schema: z.object({ authorizationUrl: z.string().url() }), + }, +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 324583008a8..9b5a4e78365 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -21,7 +21,7 @@ export interface ResolvedCredentialConnectionTarget { displayName?: string } -export class CredentialConnectionProviderMismatchError extends OrchestrationError { +class CredentialConnectionProviderMismatchError extends OrchestrationError { constructor() { super('validation', 'Credential provider does not match the requested OAuth provider') this.name = 'CredentialConnectionProviderMismatchError' diff --git a/scripts/check-test-types.baseline.json b/scripts/check-test-types.baseline.json index 9188621c97b..9ae010b7853 100644 --- a/scripts/check-test-types.baseline.json +++ b/scripts/check-test-types.baseline.json @@ -26,8 +26,7 @@ "TS2739": 2 }, "apps/sim/app/api/auth/oauth2/authorize/route.test.ts": { - "TS2345": 2, - "TS2554": 1 + "TS2345": 2 }, "apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts": { "TS2554": 3