Skip to content

Commit 45c6fb4

Browse files
os-zhuangclaude
andauthored
fix(auth): login-page config race + sign-in watchdog — never strand SSO-only users on a password wall (#2629)
Staging E2E (2026-07-17) on a freshly provisioned environment: the login page's FIRST load rendered the plain password form — no "Continue with ObjectStack", no ssoEnforced collapse — because the /auth/config fetch hung or failed while the kernel cold-started, and both LoginForm and SocialSignInButtons silently fell back to defaults. Platform-SSO JIT users have no password, so this dead-ends the "open your environment" moment (#2625). Clicking the SSO button inside the same cold-start window hung the POST forever with the button stuck spinning (#2626). - getConfig: single-flight + success cache (3 requests → 1) with retrying backoff (500ms/1.5s/3.5s, 8s per-attempt AbortController timeout) so a hung request converts into a retry; final failure clears the cache. - LoginForm: hold a spinner until config resolves; on resolve, honour ssoEnforced on first paint. On final failure keep the old safe default (password form) — break-glass beats lock-out. - signInWithProvider: 20s watchdog rejects a hung sign-in so the #2458 button contract (pending + inline error) can recover it; legacy oauth2 fallback failures no longer mask the social-route error. - Drop LoginForm's duplicate "or" divider (SocialSignInButtons already renders one) — the stacked dividers read as a glitch. Closes #2625. Closes #2626. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b4ef588 commit 45c6fb4

5 files changed

Lines changed: 247 additions & 22 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/auth': patch
3+
---
4+
5+
Login-page auth-config hardening (#2625, #2626):
6+
7+
- `createAuthClient.getConfig` now single-flights + caches the `/auth/config`
8+
fetch (the login page's three consumers used to fire three requests) and
9+
retries failures with backoff (500ms/1.5s/3.5s, 8s per-attempt abort) before
10+
rejecting. A cold-starting environment kernel no longer strands the page
11+
without its SSO buttons; a final failure clears the cache so later callers
12+
retry.
13+
- `LoginForm` holds a spinner instead of painting the password-form defaults
14+
while config resolves — an SSO-only deployment must never flash a password
15+
wall at JIT users who have no password. A failed config still falls back to
16+
the password form (break-glass beats lock-out).
17+
- `signInWithProvider` gains a 20s watchdog: a sign-in request that hangs now
18+
rejects with a clear timeout error so the provider button recovers instead
19+
of spinning forever.
20+
- Removed LoginForm's duplicate "or" divider — SocialSignInButtons already
21+
renders its own, and the stacked pair read as a rendering glitch.

packages/auth/src/LoginForm.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ export function LoginForm({
188188
// state because SSO routing is a raw fetch, not the shared `signIn` (whose
189189
// `isLoading` drives the password submit button).
190190
const [ssoSubmitting, setSsoSubmitting] = useState(false);
191+
// Config not resolved yet: render a spinner INSTEAD of the password-form
192+
// defaults. Painting the defaults first showed an SSO-only deployment a
193+
// password wall (no SSO button, no enforced collapse) whenever the config
194+
// fetch was slow/failed on first load — and platform-SSO JIT users have no
195+
// password at all (#2625). A FAILED fetch (after the client's retries)
196+
// still falls back to the password form: break-glass beats lock-out.
197+
const [configPending, setConfigPending] = useState(true);
191198

192199
useEffect(() => {
193200
let cancelled = false;
@@ -206,6 +213,9 @@ export function LoginForm({
206213
.catch(() => {
207214
// SSO is an enhancement, not required — leave the buttons/form hidden
208215
// or shown at their safe defaults.
216+
})
217+
.finally(() => {
218+
if (!cancelled) setConfigPending(false);
209219
});
210220
return () => {
211221
cancelled = true;
@@ -349,6 +359,14 @@ export function LoginForm({
349359
/>
350360

351361
<div className="space-y-5">
362+
{configPending ? (
363+
/* Auth config still resolving — hold the layout instead of painting
364+
the password-form defaults that may be wrong for this server. */
365+
<div className="flex justify-center py-10" role="status" aria-live="polite" data-testid="login-config-loading">
366+
<AuthSpinner />
367+
</div>
368+
) : (
369+
<>
352370
<SocialSignInButtons mode="sign-in" onProvidersResolved={(hasProviders) => setHasSocialProviders(hasProviders)} />
353371

354372
{passwordFormVisible ? (
@@ -357,8 +375,9 @@ export function LoginForm({
357375
Server-side the number is guarded by a per-number cooldown +
358376
hourly cap — the resend button mirrors it with a countdown. */
359377
<form onSubmit={handleSubmit} className="space-y-4">
360-
{hasSocialProviders && <AuthDivider label={l.orText} />}
361-
378+
{/* No divider here: SocialSignInButtons already renders its own
379+
"or continue with email" divider under the provider buttons —
380+
stacking a second "or" line read as a rendering glitch (#2625). */}
362381
{error && <AuthErrorBanner message={error} />}
363382

364383
<div className="space-y-2">
@@ -428,8 +447,9 @@ export function LoginForm({
428447
</form>
429448
) : (
430449
<form onSubmit={handleSubmit} className="space-y-4">
431-
{hasSocialProviders && <AuthDivider label={l.orText} />}
432-
450+
{/* No divider here: SocialSignInButtons already renders its own
451+
"or continue with email" divider under the provider buttons —
452+
stacking a second "or" line read as a rendering glitch (#2625). */}
433453
{error && <AuthErrorBanner message={error} />}
434454

435455
<div className="space-y-2">
@@ -539,6 +559,8 @@ export function LoginForm({
539559
</button>
540560
</div>
541561
)}
562+
</>
563+
)}
542564
</div>
543565

544566
{registerUrl && !ssoEnforced && (

packages/auth/src/__tests__/LoginForm.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,29 @@ describe('LoginForm — SSO button pending state (objectui#2458 item 1)', () =>
287287
}
288288
});
289289
});
290+
291+
describe('LoginForm — config-loading gate (#2625)', () => {
292+
it('holds a loading state instead of painting the password form while config resolves', async () => {
293+
let resolveConfig!: (c: AuthPublicConfig) => void;
294+
const pending = new Promise<AuthPublicConfig>((resolve) => { resolveConfig = resolve; });
295+
renderLogin(createMockClient({}, { getConfig: vi.fn().mockReturnValue(pending) }));
296+
297+
// While pending: spinner, NO password form (an SSO-only server must never
298+
// flash a password wall at users who have no password).
299+
expect(screen.getByTestId('login-config-loading')).toBeTruthy();
300+
expect(screen.queryByLabelText('Email')).toBeNull();
301+
302+
resolveConfig({ features: { ssoEnforced: true } });
303+
await waitFor(() => expect(screen.queryByTestId('login-config-loading')).toBeNull());
304+
// Enforced mode honoured on FIRST paint after resolve: password form
305+
// hidden, break-glass link offered.
306+
expect(screen.queryByLabelText('Email')).toBeNull();
307+
expect(screen.getByRole('button', { name: 'Use a password instead' })).toBeTruthy();
308+
});
309+
310+
it('falls back to the password form when config ultimately fails (break-glass beats lock-out)', async () => {
311+
renderLogin(createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('config down')) }));
312+
await screen.findByLabelText('Email');
313+
expect(screen.queryByTestId('login-config-loading')).toBeNull();
314+
});
315+
});

packages/auth/src/__tests__/createAuthClient.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,91 @@ describe('createAuthClient — signInWithProvider redirect contract (objectui#24
456456
).rejects.toThrow('Provider not found');
457457
});
458458
});
459+
460+
describe('createAuthClient — getConfig single-flight / cache / retry (#2625)', () => {
461+
it('single-flights concurrent callers and caches the success', async () => {
462+
const { mockFn, calls } = createMockFetch({
463+
'/config': { body: { data: { features: { sso: true } } } },
464+
});
465+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
466+
const [a, b] = await Promise.all([client.getConfig(), client.getConfig()]);
467+
const c = await client.getConfig();
468+
expect(a.features?.sso).toBe(true);
469+
expect(b).toBe(a);
470+
expect(c).toBe(a);
471+
// Three consumers, ONE request — the login page used to fire three.
472+
expect(calls.filter((x) => x.url.includes('/config'))).toHaveLength(1);
473+
});
474+
475+
it('retries a failing config fetch with backoff before resolving', async () => {
476+
vi.useFakeTimers();
477+
try {
478+
let attempts = 0;
479+
const mockFn = vi.fn(async () => {
480+
attempts += 1;
481+
if (attempts < 3) {
482+
return new Response(JSON.stringify({ message: 'cold start' }), { status: 503 });
483+
}
484+
return new Response(JSON.stringify({ data: { features: { ssoEnforced: true } } }), {
485+
status: 200,
486+
headers: { 'Content-Type': 'application/json' },
487+
});
488+
});
489+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
490+
const pending = client.getConfig();
491+
// Backoff schedule is 500ms → 1500ms; advance past both.
492+
await vi.advanceTimersByTimeAsync(500 + 1500 + 50);
493+
const config = await pending;
494+
expect(config.features?.ssoEnforced).toBe(true);
495+
expect(attempts).toBe(3);
496+
} finally {
497+
vi.useRealTimers();
498+
}
499+
});
500+
501+
it('a final failure rejects but does not poison the cache', async () => {
502+
vi.useFakeTimers();
503+
try {
504+
let attempts = 0;
505+
const mockFn = vi.fn(async () => {
506+
attempts += 1;
507+
if (attempts <= 4) {
508+
return new Response('down', { status: 503 });
509+
}
510+
return new Response(JSON.stringify({ data: { features: {} } }), {
511+
status: 200,
512+
headers: { 'Content-Type': 'application/json' },
513+
});
514+
});
515+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
516+
const first = client.getConfig();
517+
const firstAssertion = expect(first).rejects.toThrow(/Failed to load auth config/);
518+
await vi.advanceTimersByTimeAsync(500 + 1500 + 3500 + 50);
519+
await firstAssertion;
520+
// Next caller starts a FRESH cycle (attempt 5 succeeds immediately).
521+
const second = await client.getConfig();
522+
expect(second.features).toBeDefined();
523+
expect(attempts).toBe(5);
524+
} finally {
525+
vi.useRealTimers();
526+
}
527+
});
528+
});
529+
530+
describe('createAuthClient — signInWithProvider watchdog (#2626)', () => {
531+
it('rejects with a timeout error when the sign-in request never returns', async () => {
532+
vi.useFakeTimers();
533+
try {
534+
// A fetch that hangs forever — the cold-start failure mode where the
535+
// provider button used to spin until a page refresh.
536+
const mockFn = vi.fn(() => new Promise<Response>(() => { /* never settles */ }));
537+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
538+
const pending = client.signInWithProvider('objectstack-cloud', { type: 'oidc' });
539+
const assertion = expect(pending).rejects.toThrow(/timed out/);
540+
await vi.advanceTimersByTimeAsync(20_000 + 50);
541+
await assertion;
542+
} finally {
543+
vi.useRealTimers();
544+
}
545+
});
546+
});

packages/auth/src/createAuthClient.ts

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,64 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
229229
return payload ?? {};
230230
}
231231

232+
/**
233+
* Fetch `GET {authUrl}/config` once, with a per-attempt timeout so a
234+
* request that HANGS (a freshly provisioned environment whose kernel is
235+
* still cold-starting) converts into a retryable failure instead of
236+
* stalling the login page forever.
237+
*/
238+
async function fetchConfigAttempt(timeoutMs: number): Promise<AuthPublicConfig> {
239+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
240+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
241+
try {
242+
const url = `${origin}${basePath}/config`;
243+
const response = await bearerFetch(url, {
244+
method: 'GET',
245+
headers: { 'Content-Type': 'application/json' },
246+
...(controller ? { signal: controller.signal } : {}),
247+
});
248+
if (!response.ok) {
249+
throw new Error(`Failed to load auth config (status ${response.status})`);
250+
}
251+
const body = (await response.json()) as
252+
| { success?: boolean; data?: AuthPublicConfig; error?: { message?: string } }
253+
| AuthPublicConfig;
254+
// Server wraps the payload as `{ success, data }`; tolerate both shapes.
255+
if (body && typeof body === 'object' && 'data' in body && body.data) {
256+
return body.data as AuthPublicConfig;
257+
}
258+
return body as AuthPublicConfig;
259+
} finally {
260+
if (timer !== undefined) clearTimeout(timer);
261+
}
262+
}
263+
264+
// Auth config is static per server boot: single-flight + cache the success
265+
// so the login page's multiple consumers (LoginForm, SocialSignInButtons,
266+
// RegisterForm) share ONE request instead of three. Failures RETRY with a
267+
// short backoff before rejecting — a first-load failure used to render the
268+
// page without its SSO buttons, which strands SSO-only users on a password
269+
// wall they have no password for (#2625). A final failure clears the cache
270+
// so the next caller starts a fresh cycle.
271+
const CONFIG_RETRY_DELAYS_MS = [500, 1500, 3500];
272+
const CONFIG_ATTEMPT_TIMEOUT_MS = 8_000;
273+
let configPromise: Promise<AuthPublicConfig> | null = null;
274+
275+
async function loadConfigWithRetry(): Promise<AuthPublicConfig> {
276+
let lastError: unknown;
277+
for (let attempt = 0; attempt <= CONFIG_RETRY_DELAYS_MS.length; attempt++) {
278+
try {
279+
return await fetchConfigAttempt(CONFIG_ATTEMPT_TIMEOUT_MS);
280+
} catch (err) {
281+
lastError = err;
282+
if (attempt < CONFIG_RETRY_DELAYS_MS.length) {
283+
await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_DELAYS_MS[attempt]));
284+
}
285+
}
286+
}
287+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
288+
}
289+
232290
return {
233291
async signIn(credentials: SignInCredentials) {
234292
const { data, error } = await betterAuth.signIn.email({
@@ -549,36 +607,44 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
549607
},
550608

551609
async getConfig(): Promise<AuthPublicConfig> {
552-
const url = `${origin}${basePath}/config`;
553-
const response = await bearerFetch(url, {
554-
method: 'GET',
555-
headers: { 'Content-Type': 'application/json' },
556-
});
557-
if (!response.ok) {
558-
throw new Error(`Failed to load auth config (status ${response.status})`);
559-
}
560-
const body = (await response.json()) as
561-
| { success?: boolean; data?: AuthPublicConfig; error?: { message?: string } }
562-
| AuthPublicConfig;
563-
// Server wraps the payload as `{ success, data }`; tolerate both shapes.
564-
if (body && typeof body === 'object' && 'data' in body && body.data) {
565-
return body.data as AuthPublicConfig;
610+
if (!configPromise) {
611+
configPromise = loadConfigWithRetry().catch((err) => {
612+
configPromise = null;
613+
throw err;
614+
});
566615
}
567-
return body as AuthPublicConfig;
616+
return configPromise;
568617
},
569618

570619
async signInWithProvider(providerId: string, options: SignInWithProviderOptions = {}) {
571620
const { type = 'social', callbackURL, errorCallbackURL } = options;
621+
// Watchdog (#2626): a sign-in request that HANGS (e.g. the environment
622+
// kernel is cold-starting on first open) used to leave the provider
623+
// button spinning forever — no error, no way to retry. Convert a
624+
// no-response into a rejection so the button contract (#2458 pending +
625+
// inline error) can recover it. The abandoned request may still land
626+
// later; the user retrying just starts a fresh redirect, which is safe.
627+
const SIGN_IN_TIMEOUT_MS = 20_000;
628+
const withTimeout = <T,>(p: Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
629+
const timer = setTimeout(
630+
() => reject(new Error('Sign-in timed out — the server did not respond. Please try again.')),
631+
SIGN_IN_TIMEOUT_MS,
632+
);
633+
p.then(
634+
(v) => { clearTimeout(timer); resolve(v); },
635+
(e) => { clearTimeout(timer); reject(e); },
636+
);
637+
});
572638
// We pass `disableDefaultFetchPlugins: true` to better-auth above,
573639
// which also disables better-auth's `redirectPlugin` (the one that
574640
// navigates the browser to `data.url` when the server responds with
575641
// `{ url, redirect: true }`). The server still returns that payload
576642
// for OAuth/OIDC providers — we just have to honour it ourselves.
577-
const signInSocial = async () => await betterAuth.signIn.social({
643+
const signInSocial = async () => await withTimeout(betterAuth.signIn.social({
578644
provider: providerId as Parameters<typeof betterAuth.signIn.social>[0]['provider'],
579645
callbackURL,
580646
errorCallbackURL,
581-
}) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null };
647+
})) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null };
582648
let result: Awaited<ReturnType<typeof signInSocial>>;
583649
if (type === 'oidc') {
584650
// better-auth ≥ 1.7 registers generic OAuth/OIDC providers through
@@ -592,7 +658,9 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
592658
const oauth2 = (betterAuth as unknown as {
593659
signIn: { oauth2?: (args: Record<string, unknown>) => Promise<{ data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }> };
594660
}).signIn.oauth2;
595-
const legacy = oauth2 ? await oauth2({ providerId, callbackURL, errorCallbackURL }) : null;
661+
const legacy = oauth2
662+
? await withTimeout(oauth2({ providerId, callbackURL, errorCallbackURL })).catch(() => null)
663+
: null;
596664
if (legacy && !legacy.error) {
597665
result = legacy;
598666
}

0 commit comments

Comments
 (0)