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({