From 875f8f1b92f93b905a39dc4185a0ebadbf823c4b Mon Sep 17 00:00:00 2001 From: Keegan Vaz Date: Tue, 8 Sep 2026 02:00:21 +0530 Subject: [PATCH] Fix OAuth popup sign-in flow Better Auth's popup flow drops the token in top-level popups because the session cookie never arrives cross-site. This adds a custom OAuth popup helper that opens the sign-in flow, waits for the returned token, stores it under the key Better Auth's bearer helpers already read, and reloads the app so the session hook rehydrates. SignInPage now uses this helper instead of authClient.signIn.popup and performs a full navigation to avoid stale signed-out state. --- web/src/lib/popup-sign-in.ts | 82 ++++++++++++++++++++++++++++++++++++ web/src/pages/SignInPage.tsx | 14 +++--- 2 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/popup-sign-in.ts diff --git a/web/src/lib/popup-sign-in.ts b/web/src/lib/popup-sign-in.ts new file mode 100644 index 0000000..9f34284 --- /dev/null +++ b/web/src/lib/popup-sign-in.ts @@ -0,0 +1,82 @@ +import { POPUP_TOKEN_STORAGE_KEY } from 'better-auth/client/plugins' +import { apiOrigin } from './config' + +const POPUP_NAME = 'better-auth-oauth' +const TIMEOUT_MS = 5 * 60_000 +const CLOSED_POLL_MS = 500 + +type Provider = { provider: string } | { providerId: string } +type Outcome = + | { status: 'success'; token: string } + | { status: 'error'; message: string } + +/** + * Better Auth's own `signIn.popup` stores the returned token only when the page + * is in an iframe; a top-level page has its token cleared and falls back to the + * session cookie. Cross-site that cookie never arrives, so the flow fails with + * POPUP_SIGN_IN_FAILED despite a perfectly good sign-in. This drives the same + * endpoint and keeps the token, under the key the bearer helpers already read. + */ +export async function signInWithPopup( + provider: Provider, + callbackURL?: string, +): Promise { + const nonce = crypto.randomUUID() + const url = new URL(`${apiOrigin}/api/auth/oauth-popup/start`) + // The endpoint takes both kinds of provider in the same query parameter. + url.searchParams.set('provider', 'provider' in provider ? provider.provider : provider.providerId) + url.searchParams.set('popupOrigin', window.location.origin) + url.searchParams.set('popupNonce', nonce) + if (callbackURL) url.searchParams.set('callbackURL', callbackURL) + + const popup = window.open(url.toString(), POPUP_NAME, 'width=500,height=600') + if (!popup) return 'Your browser blocked the sign-in window. Allow pop-ups and try again.' + + const outcome = await waitForToken(popup, new URL(apiOrigin).origin, nonce) + if (outcome.status === 'error') return outcome.message + + try { + window.localStorage.setItem(POPUP_TOKEN_STORAGE_KEY, outcome.token) + } catch { + return 'Could not store the sign-in token. Check that site data is allowed.' + } + return null +} + +function waitForToken(popup: Window, authOrigin: string, nonce: string): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (outcome: Outcome) => { + if (settled) return + settled = true + window.removeEventListener('message', onMessage) + clearInterval(closedPoll) + clearTimeout(timeout) + try { + if (!popup.closed) popup.close() + } catch { + // Cross-origin popups can refuse close(); the outcome still stands. + } + resolve(outcome) + } + + const onMessage = (event: MessageEvent) => { + if (event.origin !== authOrigin) return + const data = event.data as { type?: string; nonce?: string; token?: string; error?: { code?: string; description?: string } } + if (data?.type !== 'better-auth:oauth-popup' || data.nonce !== nonce) return + if (data.error) { + settle({ status: 'error', message: data.error.description || data.error.code || 'Sign-in failed.' }) + return + } + if (typeof data.token === 'string' && data.token) { + settle({ status: 'success', token: data.token }) + } + } + + const closedPoll = setInterval(() => { + if (popup.closed) settle({ status: 'error', message: 'Sign-in window was closed.' }) + }, CLOSED_POLL_MS) + const timeout = setTimeout(() => settle({ status: 'error', message: 'Sign-in timed out.' }), TIMEOUT_MS) + window.addEventListener('message', onMessage) + }) +} diff --git a/web/src/pages/SignInPage.tsx b/web/src/pages/SignInPage.tsx index 8c4dc17..339b742 100644 --- a/web/src/pages/SignInPage.tsx +++ b/web/src/pages/SignInPage.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { signIn, authClient, usePopupSignIn } from '@/lib/auth-client' +import { signInWithPopup } from '@/lib/popup-sign-in' import { api } from '@/lib/api' import { withBase } from '@/lib/config' @@ -18,7 +19,6 @@ export function SignInPage() { // strips the base path from the redirect, so put it back. const callbackURL = `${window.location.origin}${withBase(params.get('redirect') ?? '/dashboard')}` - const navigate = useNavigate() const [error, setError] = useState(null) const target = params.get('redirect') ?? '/dashboard' @@ -36,12 +36,14 @@ export function SignInPage() { return } setError(null) - const { error: popupError } = await authClient.signIn.popup({ ...provider, callbackURL }) - if (popupError) { - setError(popupError.message || 'Sign-in was cancelled.') + const failure = await signInWithPopup(provider, callbackURL) + if (failure) { + setError(failure) return } - navigate(target, { replace: true }) + // Full navigation rather than a client-side one: the session hook has + // already cached "signed out", and a reload refetches it with the token. + window.location.assign(`${window.location.origin}${withBase(target)}`) } const { data: providers, isLoading } = useQuery({