diff --git a/apps/ui-server-auth/src/modules/auth-client.ts b/apps/ui-server-auth/src/modules/auth-client.ts index 9edf5bf8a..943a471b2 100644 --- a/apps/ui-server-auth/src/modules/auth-client.ts +++ b/apps/ui-server-auth/src/modules/auth-client.ts @@ -6,12 +6,12 @@ * makes no sense on the page the session cookie was just set on. This client * uses better-auth's cookie defaults (`credentials: 'include'`) instead. * - * Test seam: pass `fetchImpl` to substitute `globalThis.fetch` (wired as + * Transport seam: pass `fetchImpl` to substitute `globalThis.fetch` (wired as * `customFetchImpl`; see node_modules/better-auth/dist/client/config.mjs L+ * — the `restOfFetchOptions` spread happens after the default, so a - * user-supplied value wins). With `fetchImpl` we don't memoise, so tests - * can't leak state between cases; production callers memoise per - * `apiServerUrl`. + * user-supplied value wins). Test fetches and request-scoped abort signals + * both bypass memoisation so state cannot leak into the next attempt; + * ordinary production callers still memoise per `apiServerUrl`. * * Removal condition: better-auth ships a hosted typed client for OIDC IdP * setups where one process is both IdP and resource server. Until then, @@ -28,6 +28,11 @@ export interface AuthClientArgs { * every test case can install its own mock without bleed-through. */ fetchImpl?: typeof fetch + /** + * Optional signal for one request-scoped client. Supplying it disables + * memoisation so a later sign-in attempt receives a fresh signal. + */ + requestSignal?: AbortSignal } type AuthClient = ReturnType() * stage-ui singleton, this client carries the session cookie. */ export function getAuthClient(args: AuthClientArgs): AuthClient { - if (args.fetchImpl) { + if (args.fetchImpl || args.requestSignal) { return createAuthClient({ baseURL: args.apiServerUrl, plugins: [steamClient()], - fetchOptions: { customFetchImpl: args.fetchImpl }, + fetchOptions: { + ...(args.fetchImpl ? { customFetchImpl: args.fetchImpl } : {}), + ...(args.requestSignal ? { signal: args.requestSignal } : {}), + }, }) } diff --git a/apps/ui-server-auth/src/modules/sign-in.test.ts b/apps/ui-server-auth/src/modules/sign-in.test.ts index 2f17616e6..4eed5272e 100644 --- a/apps/ui-server-auth/src/modules/sign-in.test.ts +++ b/apps/ui-server-auth/src/modules/sign-in.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { createServerSignInContext, requestSocialSignInRedirect } from './sign-in' +import { + createServerSignInContext, + requestSocialSignInRedirect, + SocialSignInTimeoutError, +} from './sign-in' describe('ui-server-auth sign-in flow helpers', () => { it('rebuilds the OIDC callback URL without provider and prompt query params', () => { @@ -169,4 +173,40 @@ describe('ui-server-auth sign-in flow helpers', () => { fetchImpl, })).rejects.toThrow('Provider is temporarily unavailable') }) + + it.each(['google', 'steam'] as const)('aborts a stalled %s request when the provider timeout wins', async (provider) => { + vi.useFakeTimers() + let requestSignal: AbortSignal | null | undefined + let didAbort = false + const fetchImpl = vi.fn((_, init) => { + const signal = init?.signal + requestSignal = signal + + return new Promise((_, reject) => { + signal?.addEventListener('abort', () => { + didAbort = true + reject(signal.reason) + }, { once: true }) + }) + }) + + try { + const request = requestSocialSignInRedirect({ + apiServerUrl: 'https://api.airi.test', + provider, + callbackURL: '/', + fetchImpl, + timeoutMs: 50, + }) + + const rejection = expect(request).rejects.toBeInstanceOf(SocialSignInTimeoutError) + await vi.advanceTimersByTimeAsync(50) + await rejection + expect(requestSignal?.aborted).toBe(true) + expect(didAbort).toBe(true) + } + finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts index 1f4ee9f6b..a45233ae0 100644 --- a/apps/ui-server-auth/src/modules/sign-in.ts +++ b/apps/ui-server-auth/src/modules/sign-in.ts @@ -4,6 +4,8 @@ import { getAuthClient } from './auth-client' import { extractAuthError } from './auth-fetch' import { buildAuthUiPath } from './auth-ui-base' +const SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS = 15_000 + const TRUSTED_ADMIN_REDIRECT_ORIGINS = [ 'https://admin.airi.build', 'https://server-dev.airi-server-admin.pages.dev', @@ -26,6 +28,20 @@ export interface SocialSignInRedirectParams { provider: OAuthProvider callbackURL: string fetchImpl?: typeof fetch + /** + * Maximum wait for provider discovery before the UI restores sign-in controls. + * @default 15_000 + */ + timeoutMs?: number +} + +/** Identifies a provider discovery timeout without exposing its internal message to the UI. */ +export class SocialSignInTimeoutError extends Error { + /** Creates the stable timeout error handled by the localized sign-in page. */ + constructor() { + super('Provider sign-in request timed out') + this.name = 'SocialSignInTimeoutError' + } } export function createServerSignInContext(currentUrl: string, apiServerUrl: string): ServerSignInContext { @@ -101,14 +117,24 @@ function normalizeTrustedAdminRedirect(redirect: string): string | null { } export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise { - const client = getAuthClient({ apiServerUrl: params.apiServerUrl, fetchImpl: params.fetchImpl }) + const requestController = new AbortController() + const client = getAuthClient({ + apiServerUrl: params.apiServerUrl, + fetchImpl: params.fetchImpl, + requestSignal: requestController.signal, + }) // Steam is OpenID 2.0, not OAuth2 — the server steam plugin exposes // `/sign-in/steam`, surfaced here as the typed `signIn.steam` action. // Other providers use the standard `/sign-in/social`. - const result = params.provider === 'steam' - ? await client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true }) - : await client.signIn.social({ provider: params.provider, callbackURL: params.callbackURL, disableRedirect: true }) + const request = params.provider === 'steam' + ? client.signIn.steam({ callbackURL: params.callbackURL, disableRedirect: true }) + : client.signIn.social({ provider: params.provider, callbackURL: params.callbackURL, disableRedirect: true }) + const result = await settleSocialSignInRequest( + request, + params.timeoutMs ?? SOCIAL_SIGN_IN_REQUEST_TIMEOUT_MS, + requestController, + ) const url = result.data?.url if (typeof url === 'string') @@ -116,3 +142,31 @@ export async function requestSocialSignInRedirect(params: SocialSignInRedirectPa throw new Error(extractAuthError(result.data ?? result.error) ?? 'Unexpected response') } + +/** + * Bounds and cancels provider discovery so a timed-out request cannot apply a + * stale OAuth state cookie after the user starts another sign-in attempt. + */ +async function settleSocialSignInRequest( + request: Promise, + timeoutMs: number, + requestController: AbortController, +): Promise { + let timeoutId: ReturnType | undefined + + try { + return await Promise.race([ + request, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new SocialSignInTimeoutError()) + requestController.abort() + }, timeoutMs) + }), + ]) + } + finally { + if (timeoutId) + clearTimeout(timeoutId) + } +} diff --git a/apps/ui-server-auth/src/pages/sign-in.vue b/apps/ui-server-auth/src/pages/sign-in.vue index 32749a62a..c76981fda 100644 --- a/apps/ui-server-auth/src/pages/sign-in.vue +++ b/apps/ui-server-auth/src/pages/sign-in.vue @@ -22,7 +22,11 @@ import { signUpWithEmail, } from '../modules/email-password' import { getServerAuthBootstrapContext } from '../modules/server-auth-context' -import { createServerSignInContext, requestSocialSignInRedirect } from '../modules/sign-in' +import { + createServerSignInContext, + requestSocialSignInRedirect, + SocialSignInTimeoutError, +} from '../modules/sign-in' type Step = 'identify' | 'password' | 'create' @@ -86,6 +90,17 @@ const requestedProvider = computed(() => { return provider as OAuthProvider }) +const requestedProviderName = computed(() => { + if (!requestedProvider.value) + return '' + + return defaultSignInProviders.find(provider => provider.id === requestedProvider.value)?.name ?? requestedProvider.value +}) + +const isProviderHandoffActive = computed(() => + requestedProvider.value !== null && pendingProvider.value === requestedProvider.value, +) + const stepHeading = computed(() => { if (step.value === 'password') return t('server.auth.signIn.step.password.heading') @@ -138,7 +153,9 @@ async function handleProviderSelect(provider: OAuthProvider) { } catch (error) { trackLoginFailed({ method: provider }) - errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback') + errorMessage.value = error instanceof SocialSignInTimeoutError + ? t('server.auth.signIn.error.providerTimeout') + : describeAuthError(error) || t('server.auth.signIn.error.fallback') pendingProvider.value = null } } @@ -274,6 +291,28 @@ async function handleEmailSignUp(event: Event) {