diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx new file mode 100644 index 00000000000..8b28bf1f473 --- /dev/null +++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createOAuthChatAttempt, + type OAuthChatAttempt, + readOAuthChatAttempt, +} from '@/lib/credentials/oauth-chat-attempt' +import { ChatCompleteHandoff } from '@/app/oauth/chat-complete/chat-complete-handoff' + +function renderAt(search: string): { root: Root } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + window.history.replaceState({}, '', `/oauth/chat-complete${search}`) + const root: Root = createRoot(document.createElement('div')) + act(() => root.render()) + return { root } +} + +describe('ChatCompleteHandoff', () => { + let attempt: OAuthChatAttempt + + beforeEach(() => { + vi.clearAllMocks() + window.localStorage.clear() + vi.spyOn(window, 'close').mockImplementation(() => {}) + attempt = createOAuthChatAttempt({ + workspaceId: 'workspace-1', + providerId: 'google-email', + baseProviderId: 'google', + displayName: 'Gmail', + controlId: 'message-1:0:0', + baselineCredentialIds: ['existing-gmail'], + }) + }) + + it('publishes success on arrival, without requiring a new credential to appear', () => { + // Re-authorizing an already-linked account updates the account row rather + // than creating one, so no new credential lands — reaching this page is + // still the server telling us the flow succeeded. + const { root } = renderAt(`?oauthAttempt=${attempt.id}`) + + expect(readOAuthChatAttempt(attempt.id)?.status).toBe('connected') + expect(window.close).toHaveBeenCalledOnce() + act(() => root.unmount()) + }) + + it('publishes failure when the provider returned an error', () => { + const { root } = renderAt(`?oauthAttempt=${attempt.id}&error=access_denied`) + + expect(readOAuthChatAttempt(attempt.id)?.status).toBe('failed') + act(() => root.unmount()) + }) + + it('leaves an unrelated attempt untouched when no attempt is named', () => { + const { root } = renderAt('') + + expect(readOAuthChatAttempt(attempt.id)?.status).toBe('pending') + expect(window.close).toHaveBeenCalledOnce() + act(() => root.unmount()) + }) + + it('still publishes the verdict when the attempt store rejects the write', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + // The verdict is lost, but the window must still be released — an + // unguarded throw would strand the popup open on this page. + expect(() => renderAt(`?oauthAttempt=${attempt.id}`)).not.toThrow() + expect(window.close).toHaveBeenCalledOnce() + setItem.mockRestore() + }) + + describe('close-refused fallback', () => { + const realLocation = window.location + + afterEach(() => { + vi.useRealTimers() + Object.defineProperty(window, 'location', { configurable: true, value: realLocation }) + }) + + /** + * jsdom performs no navigation and forbids redefining `location.replace`, + * so the whole location is swapped for a stub carrying only what the + * handoff reads: the current href, the origin, and the redirect sink. + */ + function renderWithStubbedLocation(search: string): { calls: string[]; root: Root } { + const calls: string[] = [] + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: `https://sim.test/oauth/chat-complete${search}`, + origin: 'https://sim.test', + replace: (url: string) => calls.push(url), + }, + }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root: Root = createRoot(document.createElement('div')) + act(() => root.render()) + return { calls, root } + } + + it('redirects to a same-origin returnTo once the close is refused', () => { + vi.useFakeTimers() + const returnTo = 'https://sim.test/workspace/workspace-1/chat/chat-1' + + const { calls, root } = renderWithStubbedLocation( + `?oauthAttempt=${attempt.id}&returnTo=${encodeURIComponent(returnTo)}` + ) + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(calls).toEqual([returnTo]) + act(() => root.unmount()) + }) + + it('refuses a cross-origin returnTo and falls back to the workspace', () => { + vi.useFakeTimers() + + const { calls, root } = renderWithStubbedLocation( + `?oauthAttempt=${attempt.id}&returnTo=${encodeURIComponent('https://evil.example/steal')}` + ) + act(() => { + vi.advanceTimersByTime(400) + }) + + expect(calls).toEqual(['/workspace']) + act(() => root.unmount()) + }) + }) +}) diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx new file mode 100644 index 00000000000..258bf17d1e8 --- /dev/null +++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { + OAUTH_CHAT_ATTEMPT_PARAM, + OAUTH_CHAT_RETURN_TO_PARAM, + setOAuthChatAttemptStatus, +} from '@/lib/credentials/oauth-chat-attempt' + +const CLOSE_FALLBACK_DELAY_MS = 400 + +/** + * The fallback redirect must never leave this origin — the target rides in a + * query param the user could have tampered with. + */ +function sanitizeReturnTo(raw: string | null): string | null { + if (!raw) return null + try { + const url = new URL(raw, window.location.origin) + return url.origin === window.location.origin ? url.toString() : null + } catch { + return null + } +} + +/** + * Behavior half of the chat OAuth return leg: publishes the verdict to the + * attempt record — which the chat tab's chip picks up over its storage + * listener — then closes the window. Renders nothing, so the page's frame + * paints as server markup before this hydrates. + * + * Reaching this page IS the verdict: Better Auth routes a flow here only as its + * success `callbackURL`, sending failures to `/oauth-error` or back here with + * an `error` code. That beats diffing the credential list, which calls a + * re-authorized account a failure — that path updates the existing account row + * and creates nothing for a diff to find. + * + * A window the browser refuses to close redirects to the chat instead (the + * popup-blocked path opens this leg in a tab, which no script may close). That + * URL carries no attempt id on purpose: the verdict is already published, and + * the destination would otherwise re-decide it by the very diff above. + */ +export function ChatCompleteHandoff() { + const ranRef = useRef(false) + + useEffect(() => { + if (ranRef.current) return + ranRef.current = true + + const params = new URL(window.location.href).searchParams + const attemptId = params.get(OAUTH_CHAT_ATTEMPT_PARAM) + const returnTo = sanitizeReturnTo(params.get(OAUTH_CHAT_RETURN_TO_PARAM)) + + if (attemptId) { + setOAuthChatAttemptStatus(attemptId, params.has('error') ? 'failed' : 'connected') + } + + window.close() + const timer = window.setTimeout(() => { + window.location.replace(returnTo ?? '/workspace') + }, CLOSE_FALLBACK_DELAY_MS) + return () => window.clearTimeout(timer) + }, []) + + return null +} diff --git a/apps/sim/app/oauth/chat-complete/page.tsx b/apps/sim/app/oauth/chat-complete/page.tsx new file mode 100644 index 00000000000..3567713cbe5 --- /dev/null +++ b/apps/sim/app/oauth/chat-complete/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next' +import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' +import { ChatCompleteHandoff } from '@/app/oauth/chat-complete/chat-complete-handoff' + +export const metadata: Metadata = { + title: 'Returning to Sim', + robots: { index: false }, +} + +/** + * Post-OAuth return leg for the chat credential chips. The chip rewrites the + * authorize URL's return param to land here, so the OAuth window finishes on + * this page — which signals the chat tab and closes — instead of loading a + * second copy of the app. + * + * Shown for a few hundred milliseconds in a popup, or briefly in the original + * tab when the popup was blocked, so it wears the same handoff frame as the + * other minimal-chrome gates (the 404, the desktop connect screens) rather + * than styling of its own. + */ +export default function ChatCompletePage() { + return ( + <> + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 69cb9c3bda9..f487ce2f2e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1,5 +1,6 @@ /** * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://sim.test/workspace/workspace-1/chat/chat-1" } */ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' @@ -51,6 +52,7 @@ vi.mock('@/hooks/queries/environment', () => ({ }), })) +import { toast } from '@sim/emcn' import { createOAuthChatAttempt, setOAuthChatAttemptStatus, @@ -319,6 +321,513 @@ describe('CredentialDisplay link tag', () => { act(() => root.unmount()) }) + it('runs the connect in a popup so the chat tab never navigates', () => { + const popup = { focus: vi.fn() } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + const link = container.querySelector('a') + const defaultPrevented = !link?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ) + + expect(defaultPrevented).toBe(true) + expect(popup.focus).toHaveBeenCalledOnce() + const openedUrl = new URL(openSpy.mock.calls[0][0] as string) + const callbackUrl = new URL(openedUrl.searchParams.get('callbackURL') ?? '') + expect(callbackUrl.pathname).toBe('/oauth/chat-complete') + // Named per attempt: a shared name would let a sibling row renavigate this + // popup and strand this attempt with no return leg. + const attemptId = callbackUrl.searchParams.get('oauthAttempt') + expect(openSpy.mock.calls[0][1]).toBe(`sim-oauth-connect-${attemptId}`) + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('keeps a cross-origin connect URL on the anchor instead of a popup', () => { + const openSpy = vi.spyOn(window, 'open') + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://evil.example/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fevil.example%2Fsink', + }) + + const link = container.querySelector('a') + link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + + // The anchor carries rel='noopener noreferrer'; window.open would not. + expect(openSpy).not.toHaveBeenCalled() + expect(link?.getAttribute('rel')).toBe('noopener noreferrer') + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('announces the connection once the popup publishes its verdict', async () => { + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + + expect(toastSuccess).not.toHaveBeenCalled() + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + expect(toastSuccess).toHaveBeenCalledWith('Gmail connected successfully.') + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) + + it('settles the row when the popup dies on a page that publishes no verdict', async () => { + // A denied consent lands on /oauth-error, which never writes a verdict and + // never self-closes. The row must not defer to it forever. + const popup = { + focus: vi.fn(), + closed: false, + location: { origin: 'https://sim.test', pathname: '/oauth-error' }, + } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + await act(async () => { + window.dispatchEvent(new Event('blur')) + window.dispatchEvent(new Event('focus')) + }) + + expect(container.textContent).toContain('Not connected — connect Gmail') + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('keeps the row retryable when the verdict lands but no credential appears', async () => { + // The credential-draft failure is swallowed server-side, so the flow can + // report success with nothing in the workspace. Label follows the verdict; + // the control must stay clickable so the user can try again. + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + expect(container.textContent).toContain('Connected Gmail') + expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('false') + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) + + it('keeps waiting when the tab is refocused while the connect popup is still open', async () => { + const popup = { + focus: vi.fn(), + closed: false, + // Still on the provider's pages: reading location across origins throws. + get location(): Location { + throw new DOMException('cross-origin', 'SecurityError') + }, + } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + await act(async () => { + window.dispatchEvent(new Event('blur')) + window.dispatchEvent(new Event('focus')) + }) + + expect(container.textContent).toContain('Waiting for Gmail connection') + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('settles the row from the popup watcher when the flow ends with no event in this tab', async () => { + // A provider interstitial (Trello parks on one for ~3s) bounces to the + // workspace root without ever reaching the completion page. Neither page + // publishes a verdict, and a user who never leaves this tab gets no focus + // event either — only the watcher can end the wait. + vi.useFakeTimers() + const popup = { + focus: vi.fn(), + closed: false, + location: { origin: 'https://sim.test', pathname: '/api/auth/trello/callback' }, + } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + + // The interstitial is not terminal, so the row is still deferring. + expect(container.textContent).toContain('Waiting for Gmail connection') + + popup.location.pathname = '/workspace' + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + + expect(container.textContent).toContain('Not connected — connect Gmail') + vi.useRealTimers() + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('does not fail the row when a disowned popup handle reports closed', async () => { + // A provider page with COOP `same-origin` disowns the window, and the + // disowned handle reports `closed` for a consent screen that is still + // running. The watcher must not read that as an ending: publishing 'failed' + // here would fight a live flow, and the row would invite a rival retry. + vi.useFakeTimers() + const popup = { focus: vi.fn(), closed: true } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(4000) + }) + + expect(container.textContent).toContain('Waiting for Gmail connection') + expect(container.textContent).not.toContain('Not connected') + + // Indistinguishable from a popup the user simply closed, so the wait is + // bounded rather than indefinite: past the safety timeout the row decides + // from the credential list instead of waiting on a verdict that never came. + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + }) + + expect(container.textContent).toContain('Not connected — connect Gmail') + vi.useRealTimers() + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('holds the deadline open while the popup is live, then settles once it is not', async () => { + // A consent screen can outlive the safety timeout. Expiring against a live + // window would spend the bound early and leave nothing to catch the popup + // dying unobservably later, so the deadline waits instead of firing. + vi.useFakeTimers() + const popup = { + focus: vi.fn(), + closed: false, + // Still on the provider's pages: reading location across origins throws. + get location(): Location { + throw new DOMException('cross-origin', 'SecurityError') + }, + } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(11 * 60 * 1000) + }) + + expect(container.textContent).toContain('Waiting for Gmail connection') + + // Dies unobservably well after the original deadline — the watcher releases + // the handle without a verdict, so only a still-armed deadline can settle. + popup.closed = true + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + + expect(container.textContent).toContain('Not connected — connect Gmail') + vi.useRealTimers() + openSpy.mockRestore() + act(() => root.unmount()) + }) + + it('keeps the unobservable deadline across a remount', async () => { + // The transcript virtualizes, so a row can scroll away mid-connect and come + // back with no window handle and no blur behind it. The deadline is derived + // from the attempt's requestedAt rather than held in the effect, so the + // remounted row inherits the remaining time instead of waiting forever. + vi.useFakeTimers() + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const data: CredentialItemData = { + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + } + const first = renderCredentialLink(data) + + await act(async () => { + first.container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + popup.closed = true + await act(async () => { + await vi.advanceTimersByTimeAsync(60 * 1000) + }) + act(() => first.root.unmount()) + + const second = renderCredentialLink(data) + expect(second.container.textContent).toContain('Waiting for Gmail connection') + + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + }) + + expect(second.container.textContent).toContain('Not connected — connect Gmail') + vi.useRealTimers() + openSpy.mockRestore() + act(() => second.root.unmount()) + }) + + it('still announces the connection when the watcher released the popup first', async () => { + // The watcher drops the window handle as soon as it stops being observable, + // which routinely happens before React applies the storage-driven verdict. + // The announcement has to survive that, so it cannot be gated on the handle. + vi.useFakeTimers() + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + + popup.closed = true + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + expect(toastSuccess).toHaveBeenCalledWith('Gmail connected successfully.') + vi.useRealTimers() + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) + + it('focuses the live popup instead of starting a rival attempt on a repeat click', async () => { + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { + focus: vi.fn(), + closed: false, + // Still on the provider's pages: reading location across origins throws. + get location(): Location { + throw new DOMException('cross-origin', 'SecurityError') + }, + } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + const link = container.querySelector('a') + + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + // No second window, and no second attempt to strand the first one's verdict. + expect(openSpy).toHaveBeenCalledOnce() + expect(popup.focus).toHaveBeenCalledTimes(2) + + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + expect(container.textContent).toContain('Connected Gmail') + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) + + it('locks the row once the popup verdict is corroborated by a refetched credential', async () => { + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + mockRefetchWorkspaceCredentials.mockResolvedValue({ + data: [{ id: 'new-gmail', providerId: 'google', updatedAt: '2026-08-07T10:05:00Z' }], + }) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + // The popup flow never blurs this tab, so this refetch is the only one that + // can corroborate the verdict and let the control lock. + expect(mockRefetchWorkspaceCredentials).toHaveBeenCalled() + expect(container.textContent).toContain('Connected Gmail') + expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('true') + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) + + it('navigates the tab through the same completion page when the popup is blocked', () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }) + + const link = container.querySelector('a') + const defaultPrevented = !link?.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ) + + expect(defaultPrevented).toBe(false) + const callbackUrl = new URL( + new URL(link?.getAttribute('href') ?? '').searchParams.get('callbackURL') ?? '' + ) + expect(callbackUrl.pathname).toBe('/oauth/chat-complete') + expect(callbackUrl.searchParams.get('oauthAttempt')).not.toBeNull() + openSpy.mockRestore() + act(() => root.unmount()) + }) + it('waits for the credential baseline before opening an OAuth link', () => { mockUseWorkspaceCredentials.mockReturnValue({ data: undefined, @@ -606,6 +1115,76 @@ describe('CredentialDisplay link tag', () => { expect(container.querySelector('input')).toBeNull() act(() => root.unmount()) }) + + it('keeps a typed secret while a sibling row runs its OAuth connect', async () => { + // The card's secret drafts live in component state until its Submit, so a + // connect that navigates this tab away discards whatever the user already + // typed into a sibling row. Running the flow in a popup is what makes the + // mixed card safe: this tab is never unloaded, so the draft outlives the + // connect — including the re-render its verdict and refetch trigger. + const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '') + const popup = { focus: vi.fn(), closed: false } + const openSpy = vi + .spyOn(window, 'open') + .mockReturnValue(popup as unknown as ReturnType) + const container = document.createElement('div') + const root: Root = createRoot(container) + const onOptionSelect = vi.fn() + const data: CredentialItemData[] = [ + { + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + }, + { type: 'secret_input', name: 'OPENAI_API_KEY' }, + ] + act(() => { + root.render( + + ) + }) + + const secretInput = container.querySelector('input') + act(() => { + if (!secretInput) return + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + valueSetter?.call(secretInput, 'sk-test-key') + secretInput.dispatchEvent(new Event('input', { bubbles: true })) + }) + + await act(async () => { + container + .querySelector('a') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) + const attemptId = new URL( + new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? '' + ).searchParams.get('oauthAttempt') as string + await act(async () => { + setOAuthChatAttemptStatus(attemptId, 'connected') + }) + + // The field masks while unfocused, so assert on what the draft is actually + // for: submitting it. A mask of the right length is not proof it survived. + const submitButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Submit' + ) + await act(async () => { + submitButton?.click() + }) + + expect(mockUpsertWorkspaceEnvironment).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + variables: { OPENAI_API_KEY: 'sk-test-key' }, + }) + openSpy.mockRestore() + toastSuccess.mockRestore() + act(() => root.unmount()) + }) }) describe('parseSpecialTags sim_key placeholder', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts index 74c4b95ebb4..f8933527f8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts @@ -1,9 +1,11 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from '@sim/emcn' import { useParams } from 'next/navigation' import { addOAuthChatAttemptToAuthorizeUrl, + buildOAuthChatCompleteAuthorizeUrl, clearActiveDesktopOAuthChatAttempt, createOAuthChatAttempt, getOAuthCredentialBaseline, @@ -23,6 +25,63 @@ import type { OAuthProvider } from '@/lib/oauth/types' import { parseProvider } from '@/lib/oauth/utils' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +const OAUTH_POPUP_WINDOW_NAME = 'sim-oauth-connect' +/** Matches the MCP OAuth popup (`hooks/mcp/use-mcp-oauth-popup.ts`) so the two consent windows open alike. */ +const OAUTH_POPUP_FEATURES = 'width=560,height=720,resizable=yes,scrollbars=yes' +/** + * How often to re-examine a live popup. Matches the MCP OAuth popup's own + * watcher: a closed or terminal window fires no event in the opener, so + * polling the handle is the only way to observe it. + */ +const OAUTH_POPUP_POLL_INTERVAL_MS = 400 +/** Matches the MCP OAuth popup's safety timeout: bounds a flow whose outcome never becomes observable. */ +const OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS = 10 * 60 * 1000 + +/** + * Same-origin pages an OAuth flow can die on without reaching the return leg — + * Better Auth sends pre-state failures (usually a denied consent) to its global + * error page, and the custom-provider callbacks exit to the workspace root. + * Neither publishes a verdict, so a popup sitting on one is finished. + */ +const OAUTH_POPUP_TERMINAL_PATHS = new Set(['/oauth-error', '/workspace']) + +/** + * What the opener can actually prove about a popup it launched. `ended` needs + * positive evidence: a same-origin page we can read that publishes no verdict. + * A handle reporting closed is only `unobservable` — COOP disowns the window + * and the disowned handle reports closed for a consent screen still running. + */ +type PopupObservation = 'live' | 'ended' | 'unobservable' + +function observePopup(popup: { window: Window } | null): PopupObservation { + if (!popup) return 'unobservable' + let closed: boolean + try { + closed = popup.window.closed + } catch { + return 'unobservable' + } + if (closed) return 'unobservable' + try { + const { origin, pathname } = popup.window.location + if (origin === window.location.origin && OAUTH_POPUP_TERMINAL_PATHS.has(pathname)) { + return 'ended' + } + } catch { + // Reading `location` across origins throws, which is the signal we want: + // the popup is still on the provider's pages, so the flow is in flight. + } + return 'live' +} + +/** + * Whether the popup still owns the flow — i.e. whether the row should keep + * deferring to it instead of deciding for itself. + */ +function isPopupStillOpen(popup: { window: Window } | null): boolean { + return observePopup(popup) === 'live' +} + interface UseOAuthChipConnectionParams { /** Authorize URL streamed by the agent; provider and reconnect scope are read from it. */ connectUrl?: string @@ -43,9 +102,12 @@ export interface OAuthChipConnection { /** True when the row should read as connected, from any signal. */ connected: boolean /** - * True only when *this row's* attempt completed. Workspace-wide observation - * cannot be attributed to one row, so this — not {@link connected} — is what - * may lock the control. + * True only when *this row's* attempt completed AND the credential it was + * supposed to produce is actually present (a reconnect, which produces no + * new credential, is exempt). Workspace-wide observation cannot be + * attributed to one row, so this — not {@link connected} — is what may lock + * the control, and it is deliberately stricter than the label so a connect + * that reported success without landing stays retryable. */ connectedFromAttempt: boolean /** The workspace already holds a credential this row would connect. */ @@ -117,6 +179,9 @@ export function useOAuthChipConnection({ const [connectedFromWorkspaceChange, setConnectedFromWorkspaceChange] = useState(false) const onConnectedRef = useRef(onConnected) const oauthWindowWasAwayRef = useRef(false) + const popupRef = useRef<{ window: Window; attemptId: string } | null>(null) + /** Attempt this row launched in a popup, outliving the window handle so the success toast survives the watcher clearing it. */ + const launchedAttemptIdRef = useRef(null) const workspaceCredentialBaselineRef = useRef<{ scope: string baseline: ReturnType @@ -131,23 +196,27 @@ export function useOAuthChipConnection({ credentialTarget, workspaceOAuthCredentials ) - const connectedFromAttempt = connectionStatus === 'connected' - const connected = connectedFromAttempt || connectedFromWorkspaceChange + const verdictConnected = connectionStatus === 'connected' + const connected = verdictConnected || connectedFromWorkspaceChange + // The label trusts the verdict; the lock does not. A swallowed credential-draft + // failure lets a flow report success with nothing in the workspace, and locking + // on that would strand the row saying "Connected" with no way to retry. A + // reconnect produces no new credential, so it has nothing to corroborate against. + const connectedFromAttempt = + verdictConnected && (reconnectCredentialId ? true : connectedFromWorkspaceChange) useEffect(() => { onConnectedRef.current = onConnected }, [onConnected]) /** - * A credential for this row can also appear without the row launching it — - * the integrations page in another tab, or a desktop flow that never comes - * back through the return URL. Diffing the workspace list against the - * baseline captured for this scope surfaces that. + * A credential can appear without this row launching it — the integrations + * page in another tab, or a desktop flow that never returns through the URL. + * Diffing the workspace list against this scope's baseline surfaces that. * - * This signal is workspace-wide, so it cannot be attributed to one row: - * sibling chips for the same provider all see the same change. It therefore - * only ever *shows* the row as satisfied — {@link connectedFromAttempt} is - * what locks it, so a second same-provider row stays clickable. + * Workspace-wide, so it cannot be attributed to one row: it only ever *shows* + * the row satisfied. {@link connectedFromAttempt} is what locks it, so a + * sibling chip for the same provider stays clickable. */ useEffect(() => { if (!isFetched) return @@ -185,12 +254,10 @@ export function useOAuthChipConnection({ ]) /** - * This row's attempt: the one named by the return URL when we came back from - * the provider, else the last one stored for this exact row. The stored - * lookup is what survives a reload — and what covers the transcript - * rendering only after the return hook has already stripped the URL param. - * Both are scoped to the row, so a sibling chip for the same provider can - * never claim this one's result. + * This row's attempt: the one named by the return URL, else the last one + * stored for this exact row. The stored lookup survives a reload, and covers + * a transcript rendered after the return hook stripped the URL param. Both + * are row-scoped, so a sibling chip can never claim this one's result. */ const readRowAttempt = useCallback((): OAuthChatAttempt | null => { const active = activeAttemptId ? readOAuthChatAttempt(activeAttemptId) : null @@ -211,6 +278,49 @@ export function useOAuthChipConnection({ }) }, [activeAttemptId, controlId, providerId, reconnectCredentialId, workspaceId]) + /** + * Refetches the workspace credentials and settles this row's pending attempt + * against them. Shared by the two endings the completion page cannot cover: a + * return to this tab, and a popup seen closed or parked on a terminal page. + * Idempotent — an attempt is only rewritten while still `pending`. + */ + const settleFromCredentials = useCallback(async () => { + // Identity is captured up front so the verdict below can only land on the + // attempt this settle was started for. A retry during the refetch installs + // a replacement, and resolving that one from a run it never triggered would + // fail a connect whose popup is still going. + const targetAttemptId = readRowAttempt()?.id + const result = await refetchWorkspaceOAuthCredentials() + const credentials = result.data ?? [] + + // Also covers a credential connected from another tab, which this row never + // launched and query state alone would surface only on its next fetch. + const storedBaseline = workspaceCredentialBaselineRef.current + if (!reconnectCredentialId && storedBaseline?.scope === credentialScope) { + setConnectedFromWorkspaceChange( + hasOAuthCredentialChanged({ ...credentialTarget, ...storedBaseline.baseline }, credentials) + ) + } + + // Status is re-read after the await: the snapshot above goes stale the + // moment the popup publishes its verdict mid-refetch, and that stale + // 'pending' would license the very overwrite this guard exists to prevent. + const attempt = readRowAttempt() + if (!attempt || attempt.id !== targetAttemptId || attempt.status !== 'pending') return + // Only the callback path can prove a reconnect returned — an unrelated edit + // to the credential is indistinguishable from one here. + const attemptConnected = reconnectCredentialId + ? false + : hasOAuthCredentialChanged(attempt, credentials) + setOAuthChatAttemptStatus(attempt.id, attemptConnected ? 'connected' : 'failed') + }, [ + credentialScope, + credentialTarget, + readRowAttempt, + reconnectCredentialId, + refetchWorkspaceOAuthCredentials, + ]) + useEffect(() => { const syncStatus = () => setConnectionStatus(readRowAttempt()?.status ?? null) window.addEventListener(OAUTH_CHAT_ATTEMPT_EVENT, syncStatus) @@ -226,38 +336,19 @@ export function useOAuthChipConnection({ const markAway = () => { oauthWindowWasAwayRef.current = true } - const verifyAfterReturn = async () => { + const verifyAfterReturn = () => { if (!oauthWindowWasAwayRef.current || document.visibilityState !== 'visible') return + // A live popup still owns the flow; settling now would flash "not + // connected" mid-connect. The away flag deliberately survives this bail — + // consuming it would spend the only signal a later return has to work + // with, and no second focus event is guaranteed to arrive. + if (isPopupStillOpen(popupRef.current)) return oauthWindowWasAwayRef.current = false - const attempt = readRowAttempt() - const result = await refetchWorkspaceOAuthCredentials() - const credentials = result.data ?? [] - - // Refetching on every return closes the other-tab gap even when this row - // did not launch the connection. Query state normally drives the effect - // above; updating here as well makes the result immediate and deterministic. - const storedBaseline = workspaceCredentialBaselineRef.current - if (!reconnectCredentialId && storedBaseline?.scope === credentialScope) { - setConnectedFromWorkspaceChange( - hasOAuthCredentialChanged( - { ...credentialTarget, ...storedBaseline.baseline }, - credentials - ) - ) - } - - if (!attempt || attempt.status !== 'pending') return - // A reconnect is verified by the callback path, which has proof that the - // OAuth flow returned. A plain focus event cannot distinguish it from an - // unrelated edit to the same credential. - const attemptConnected = reconnectCredentialId - ? false - : hasOAuthCredentialChanged(attempt, credentials) - setOAuthChatAttemptStatus(attempt.id, attemptConnected ? 'connected' : 'failed') + void settleFromCredentials() } const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') markAway() - else void verifyAfterReturn() + else verifyAfterReturn() } window.addEventListener('blur', markAway) @@ -268,18 +359,85 @@ export function useOAuthChipConnection({ window.removeEventListener('focus', verifyAfterReturn) document.removeEventListener('visibilitychange', handleVisibilityChange) } - }, [ - credentialScope, - credentialTarget, - readRowAttempt, - reconnectCredentialId, - refetchWorkspaceOAuthCredentials, - ]) + }, [settleFromCredentials]) + + /** + * Watches a live popup for the endings that publish no verdict. None fire an + * event here, and the user need never leave this tab for a focus event + * either, so polling is the only way the row learns the flow is over. + */ + useEffect(() => { + if (connectionStatus !== 'pending' || !popupRef.current) return + const poll = window.setInterval(() => { + const observation = observePopup(popupRef.current) + if (observation === 'live') return + // Stop before settling: the refetch leaves the status `pending` for its + // duration, so a later tick could resolve an attempt a retry replaced. + window.clearInterval(poll) + popupRef.current = null + // Only `ended` is proof the flow finished with nothing published. A + // closed-or-disowned handle is not — the deadline below bounds that. + if (observation === 'ended') void settleFromCredentials() + }, OAUTH_POPUP_POLL_INTERVAL_MS) + return () => window.clearInterval(poll) + }, [connectionStatus, settleFromCredentials]) + + /** + * Backstop for a pending attempt whose outcome never becomes observable. + * Bounds every pending attempt, not just one this mount launched — the + * transcript virtualizes, so a row can remount with no window handle at all. + * Dated from the attempt's `requestedAt`, so a remount inherits the time + * remaining rather than restarting the clock. + */ + useEffect(() => { + if (connectionStatus !== 'pending') return + const attempt = readRowAttempt() + if (!attempt) return + const remaining = Math.max( + 0, + OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS - (Date.now() - attempt.requestedAt) + ) + let deadline: number + const settleUnlessPopupOwnsIt = () => { + // A popup still demonstrably running owns the flow, so the deadline waits + // rather than expiring: giving up here would spend the bound on a live + // window that can still die unobservably long afterwards. + if (isPopupStillOpen(popupRef.current)) { + deadline = window.setTimeout(settleUnlessPopupOwnsIt, OAUTH_POPUP_POLL_INTERVAL_MS) + return + } + void settleFromCredentials() + } + deadline = window.setTimeout(settleUnlessPopupOwnsIt, remaining) + return () => window.clearTimeout(deadline) + }, [connectionStatus, readRowAttempt, settleFromCredentials]) useEffect(() => { if (connected) onConnectedRef.current?.() }, [connected]) + /** + * The popup publishes the verdict and closes, showing the user nothing they + * keep, so the launching tab surfaces the success toast. Failure + * deliberately stays a label (the retry text): a focus flip mid-flow can + * settle 'failed' prematurely while the popup is still open, and a toast on + * that would read as the flow failing behind the user's back. + */ + useEffect(() => { + const launchedAttemptId = launchedAttemptIdRef.current + if (connectionStatus !== 'connected' || !launchedAttemptId) return + const attempt = readOAuthChatAttempt(launchedAttemptId) + launchedAttemptIdRef.current = null + popupRef.current = null + // The verdict settles the label, but the lock also waits on the credential + // being observable. A popup flow never blurs this tab, so nothing else + // refetches here and the row would read "Connected" yet stay clickable. + void settleFromCredentials() + if (attempt?.status === 'connected') { + toast.success(`${attempt.displayName} connected successfully.`) + } + }, [connectionStatus, settleFromCredentials]) + /** * Desktop app: OAuth cannot run in an embedded window — not in the app * window (better-auth binds the flow's state to the initiating browser's @@ -295,6 +453,20 @@ export function useOAuthChipConnection({ event.preventDefault() return } + // A second click would replace both the active attempt and the window + // handle, orphaning the first flow — its verdict lands on an attempt id + // the row no longer reads, so a successful connect waits forever. The + // live window is the flow; surface it instead of starting a rival one. + const livePopup = popupRef.current + if (isPopupStillOpen(livePopup)) { + event.preventDefault() + try { + livePopup?.window.focus?.() + } catch { + // A COOP-severed handle refuses focus, but still owns the flow. + } + return + } const attempt = createOAuthChatAttempt({ workspaceId, providerId, @@ -327,7 +499,33 @@ export function useOAuthChipConnection({ return } - event.currentTarget.href = addOAuthChatAttemptToAuthorizeUrl(connectUrl, attempt.id) + // Web: run the whole flow in a popup so this tab never navigates — the + // return leg lands on the self-closing chat-complete page, whose verdict + // reaches this row over the storage listener. A blocked popup navigates + // to the same URL instead, so the flow keeps the completion page's + // server-backed verdict either way; only an authorize URL with no return + // param to rewrite falls all the way back to the plain tab return. + const completeUrl = buildOAuthChatCompleteAuthorizeUrl(connectUrl, attempt.id) + if (completeUrl) { + // Named per attempt: a card can render several credential rows, and a + // shared name would let the second click renavigate the first row's + // live popup, stranding that attempt with no return leg. + const popup = window.open( + completeUrl, + `${OAUTH_POPUP_WINDOW_NAME}-${attempt.id}`, + OAUTH_POPUP_FEATURES + ) + if (popup) { + event.preventDefault() + popup.focus?.() + popupRef.current = { window: popup, attemptId: attempt.id } + launchedAttemptIdRef.current = attempt.id + return + } + } + + event.currentTarget.href = + completeUrl ?? addOAuthChatAttemptToAuthorizeUrl(connectUrl, attempt.id) }, [ baseProviderId, diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts index 3a20b017316..99ae6e78bb1 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts @@ -1,15 +1,19 @@ /** * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://sim.test/workspace/workspace-1/chat/chat-1" } */ import { beforeEach, describe, expect, it } from 'vitest' import { addOAuthChatAttemptToAuthorizeUrl, + buildOAuthChatCompleteAuthorizeUrl, createOAuthChatAttempt, getOAuthCredentialBaseline, hasOAuthCredentialChanged, OAUTH_CHAT_ATTEMPT_EVENT, OAUTH_CHAT_ATTEMPT_PARAM, + OAUTH_CHAT_COMPLETE_PATH, + OAUTH_CHAT_RETURN_TO_PARAM, readLatestOAuthChatAttempt, readOAuthChatAttempt, resolveActiveDesktopOAuthChatAttempt, @@ -47,6 +51,64 @@ describe('OAuth chat attempts', () => { } ) + it('routes the return through the chat-complete page', () => { + const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl( + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + 'attempt-1' + ) + + const callbackUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('callbackURL') ?? '') + expect(callbackUrl.pathname).toBe(OAUTH_CHAT_COMPLETE_PATH) + expect(callbackUrl.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBe('attempt-1') + // Anchored on the server-generated return URL's origin, not this tab's — + // a proxied deployment's two origins need not agree. + expect(callbackUrl.origin).toBe('https://sim.test') + }) + + it('refuses a cross-origin authorize URL so the attempt id never leaves the app', () => { + expect( + buildOAuthChatCompleteAuthorizeUrl( + 'https://evil.example/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fevil.example%2Fsink', + 'attempt-1' + ) + ).toBeNull() + }) + + it('leaves the attempt off the close-fallback target so it is not re-decided', () => { + const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl( + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1', + 'attempt-1' + ) + + const callbackUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('callbackURL') ?? '') + const returnTo = new URL(callbackUrl.searchParams.get(OAUTH_CHAT_RETURN_TO_PARAM) ?? '') + expect(returnTo.pathname).toBe('/workspace/workspace-1/chat/chat-1') + expect(returnTo.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBeNull() + }) + + it.each(['instagram', 'shopify', 'trello'])( + 'routes the %s return through the chat-complete page', + (provider) => { + const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl( + `https://sim.test/api/auth/${provider}/authorize?returnUrl=${encodeURIComponent('https://sim.test/workspace/workspace-1/chat/chat-1')}`, + 'attempt-2' + ) + + const returnUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('returnUrl') ?? '') + expect(returnUrl.pathname).toBe(OAUTH_CHAT_COMPLETE_PATH) + expect(returnUrl.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBe('attempt-2') + } + ) + + it('declines a chat-complete URL when there is no return param to rewrite', () => { + expect( + buildOAuthChatCompleteAuthorizeUrl( + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', + 'attempt-3' + ) + ).toBeNull() + }) + it('publishes and persists server-verified completion', () => { let publishedStatus: string | undefined window.addEventListener( diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.ts b/apps/sim/lib/credentials/oauth-chat-attempt.ts index 65ad90a3a31..0a6fd6bed3e 100644 --- a/apps/sim/lib/credentials/oauth-chat-attempt.ts +++ b/apps/sim/lib/credentials/oauth-chat-attempt.ts @@ -4,6 +4,8 @@ import { generateShortId } from '@sim/utils/id' export const OAUTH_CHAT_ATTEMPT_PARAM = 'oauthAttempt' export const OAUTH_CHAT_ATTEMPT_EVENT = 'sim:oauth-chat-attempt' +export const OAUTH_CHAT_COMPLETE_PATH = '/oauth/chat-complete' +export const OAUTH_CHAT_RETURN_TO_PARAM = 'returnTo' const OAUTH_CHAT_ATTEMPT_KEY_PREFIX = 'sim.oauth-chat-attempt.' const OAUTH_CHAT_LATEST_KEY_PREFIX = 'sim.oauth-chat-latest.' @@ -143,8 +145,13 @@ function isOAuthChatAttempt(value: unknown): value is OAuthChatAttempt { function writeOAuthChatAttempt(attempt: OAuthChatAttempt): void { if (typeof window === 'undefined') return - window.localStorage.setItem(attemptStorageKey(attempt.id), JSON.stringify(attempt)) - window.localStorage.setItem(latestAttemptStorageKey(attempt), attempt.id) + // A blocked or full store must not throw into the caller: the chat-complete + // page writes the verdict before closing its window, so an unguarded throw + // would strand the popup open instead of losing only the verdict. + try { + window.localStorage.setItem(attemptStorageKey(attempt.id), JSON.stringify(attempt)) + window.localStorage.setItem(latestAttemptStorageKey(attempt), attempt.id) + } catch {} window.dispatchEvent( new CustomEvent(OAUTH_CHAT_ATTEMPT_EVENT, { detail: attempt }) ) @@ -244,11 +251,31 @@ function appendAttemptToReturnUrl(rawReturnUrl: string, attemptId: string): stri return returnUrl.toString() } -/** Adds the attempt id to the eventual same-origin OAuth return URL. */ -export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: string): string { +interface AuthorizeReturnTarget { + authorizeUrl: URL + /** Which param this provider's authorize route reads the return URL from. */ + returnParam: 'callbackURL' | 'returnUrl' + rawReturnUrl: string | null +} + +/** + * Locates the return URL an authorize link will come back through. Shared so + * both builders below agree on which param carries it — a provider added to + * one and missed in the other would break only the path its caller uses. + */ +function resolveAuthorizeReturnTarget(rawUrl: string): AuthorizeReturnTarget { const authorizeUrl = new URL(rawUrl, window.location.origin) const returnParam = authorizeUrl.searchParams.has('callbackURL') ? 'callbackURL' : 'returnUrl' - const rawReturnUrl = authorizeUrl.searchParams.get(returnParam) + return { + authorizeUrl, + returnParam, + rawReturnUrl: authorizeUrl.searchParams.get(returnParam), + } +} + +/** Adds the attempt id to the eventual same-origin OAuth return URL. */ +export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: string): string { + const { authorizeUrl, returnParam, rawReturnUrl } = resolveAuthorizeReturnTarget(rawUrl) if (rawReturnUrl) { authorizeUrl.searchParams.set(returnParam, appendAttemptToReturnUrl(rawReturnUrl, attemptId)) @@ -258,3 +285,43 @@ export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: str return authorizeUrl.toString() } + +/** + * Chat-flavored authorize URL: the return leg lands on the lightweight + * chat-complete page — which publishes the verdict and closes the window — + * instead of reloading the whole app in the OAuth window. + * + * The complete page's fallback redirect target is the plain return URL with no + * attempt id, so a window that cannot close lands on the chat surface without + * its return router re-deciding a verdict the completion page already + * published. + * + * Returns null when the authorize URL is not a same-origin Sim route, or + * carries no return param to rewrite; the caller falls back to + * {@link addOAuthChatAttemptToAuthorizeUrl} and its plain anchor navigation. + */ +export function buildOAuthChatCompleteAuthorizeUrl( + rawUrl: string, + attemptId: string +): string | null { + const { authorizeUrl, returnParam, rawReturnUrl } = resolveAuthorizeReturnTarget(rawUrl) + // The connect URL is streamed model output, checked only for a safe protocol. + // Refusing a foreign origin here keeps the attempt id out of a URL we do not + // control, and keeps the caller on its anchor — whose rel='noopener' the + // popup path would otherwise drop, handing a hostile page our window handle. + if (authorizeUrl.origin !== window.location.origin) return null + if (!rawReturnUrl) return null + + // Anchored on the return URL the server generated, not this tab's origin — + // both the authorize route and the custom-provider callbacks accept a return + // target only when it matches the deployment's configured base URL, which a + // proxied or aliased origin need not equal. + const completeUrl = new URL( + OAUTH_CHAT_COMPLETE_PATH, + new URL(rawReturnUrl, window.location.origin).origin + ) + completeUrl.searchParams.set(OAUTH_CHAT_ATTEMPT_PARAM, attemptId) + completeUrl.searchParams.set(OAUTH_CHAT_RETURN_TO_PARAM, rawReturnUrl) + authorizeUrl.searchParams.set(returnParam, completeUrl.toString()) + return authorizeUrl.toString() +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 172bf16d220..8fdc0915632 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -318,8 +318,12 @@ const nextConfig: NextConfig = { }, { // Exclude Vercel internal resources and static assets from strict COOP, Google Drive Picker - // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues - source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive|demo).*)', + // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues. + // The pages an OAuth popup can land on are excluded too: `same-origin` would disown the + // popup from its opener, leaving it not reliably script-closable and reporting `closed` + // for a live window. + source: + '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace|api/tools/drive|demo|oauth-error|oauth/chat-complete).*)', headers: [ { key: 'Cross-Origin-Opener-Policy', @@ -342,8 +346,11 @@ const nextConfig: NextConfig = { ], }, { - // For main app routes, Google Drive Picker, the /demo Cal.com embed, and Vercel resources - use permissive policies - source: '/(w/.*|workspace/.*|api/tools/drive|demo.*|_next/.*|_vercel/.*)', + // For main app routes, Google Drive Picker, the /demo Cal.com embed, the OAuth popup pages, + // and Vercel resources - use permissive policies. The popup pages match their opener's + // value so the two stay in one browsing-context group. + source: + '/(w/.*|workspace.*|api/tools/drive|demo.*|oauth-error|oauth/chat-complete|_next/.*|_vercel/.*)', headers: [ { key: 'Cross-Origin-Embedder-Policy',