From 5411fe23d7880b30a45c39ab123397d310dcc3a6 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:49:03 +0700 Subject: [PATCH 1/2] fix(auth): SSO auto-redirect plus an honest error page instead of the silent signup fallback Two commissioned auth UX fixes for the DOS ID single-provider flow: 1. Auto-redirect: when SSO is the only auth method (isGeneral + genericOauth), the /auth and /auth/login pages now start the DOS ID flow immediately (Redirecting to DOS ID... loader) instead of showing a page whose only content is one button. OauthProvider gains an autoStart prop: it fires gotoLogin on mount, falls back to the manual button if the link fetch fails, and clearing the error-page retry budget on a deliberately initiated flow. 2. Honest error state: a failed /oauth/:provider/exists exchange (state cookie mismatch, upstream token error) previously fell through to the signup form, so a broken sign-in looked like a fresh registration - the exact confusion reported on 2026-09-21 during the prod login outage. AuthErrorState now shows what happened (HTTP status, error message) with a loop-guarded retry: up to 2 automatic SSO restarts via sessionStorage budget, then a manual link so a persistent failure cannot ping-pong. The i18n strings use the fork's t(key, english-default) pattern consistent with the existing SSO strings on these pages. --- apps/frontend/src/components/auth/login.tsx | 2 +- .../auth/providers/oauth.provider.tsx | 54 ++++++++++- .../frontend/src/components/auth/register.tsx | 97 ++++++++++++++++++- 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index 2ac62effbc..5d92d8691d 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -80,7 +80,7 @@ export function Login() {
{isGeneral && genericOauth ? (
- +

{t( 'sso_description', diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx index cf0a560dcd..e378f590e6 100644 --- a/apps/frontend/src/components/auth/providers/oauth.provider.tsx +++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx @@ -1,15 +1,23 @@ 'use client'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import SafeImage from '@gitroom/react/helpers/safe.image'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; -export const OauthProvider = () => { + +// Session key shared with the auth error state (register.tsx): a freshly +// initiated SSO flow resets the error-page retry budget. +export const DOS_OAUTH_RETRY_KEY = 'dos_oauth_retry_count'; + +export const OauthProvider = ({ autoStart = false }: { autoStart?: boolean }) => { const fetch = useFetch(); const { oauthLogoUrl, oauthDisplayName } = useVariables(); const t = useT(); - const gotoLogin = useCallback(async () => { + const [autoFailed, setAutoFailed] = useState(false); + const startedRef = useRef(false); + + const gotoLogin = useCallback(async (): Promise => { try { const response = await fetch('/auth/oauth/GENERIC'); if (!response.ok) { @@ -18,11 +26,51 @@ export const OauthProvider = () => { ); } const link = await response.text(); + // A deliberately initiated SSO flow starts fresh: clear the error-page + // retry budget so the user gets their full retry allowance. + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); window.location.href = link; + return true; } catch (error) { console.error('Failed to get generic oauth login link:', error); + return false; } }, []); + + useEffect(() => { + if (!autoStart || startedRef.current) return; + startedRef.current = true; + gotoLogin().then((ok) => { + if (!ok) { + // Auto-start could not even fetch the link - fall back to the + // manual button instead of a dead redirecting state. + setAutoFailed(true); + } + }); + }, [autoStart, gotoLogin]); + + if (autoStart && !autoFailed) { + return ( +

+
+ +
+
+ {t('redirecting_to', 'Redirecting to')}  + {oauthDisplayName || 'DOS ID'}... +
+
+ ); + } + return (
(null); useEffect(() => { if (code) { load(); } }, []); const load = useCallback(async () => { + setError(null); try { const response = await fetch( `/auth/oauth/${provider?.toUpperCase() || 'GENERIC'}/exists`, @@ -54,21 +60,34 @@ export function Register() { } ); if (!response.ok) { - setShow(true); + // The exchange failed server-side. Never masquerade this failure as + // a fresh signup: surface it with a loop-guarded retry instead. + setError({ status: response.status, message: '' }); return; } const data = await response.json(); if (data?.token) { + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); setCode(data.token); setShow(true); } else { + window.sessionStorage.removeItem(DOS_OAUTH_RETRY_KEY); window.location.href = '/'; } } catch (e) { console.error('Failed to verify oauth code:', e); - setShow(true); + setError({ message: (e as Error)?.message || '' }); } }, [provider, code, state]); + if (error) { + return ( + + ); + } if (!code && !getQuery?.get('provider')) { return ; } @@ -79,6 +98,76 @@ export function Register() { ); } + +// A failed OAuth exchange (state cookie mismatch, upstream token error, ...) +// used to fall through to the signup form, so a broken sign-in looked like a +// fresh registration - the exact confusion reported on 2026-09-21. This state +// shows what happened and offers a loop-guarded retry: up to RETRY_LIMIT +// automatic SSO restarts (a live id.dos.me session makes that one click), +// then a manual link so a persistent failure cannot ping-pong forever. +const RETRY_LIMIT = 2; + +function AuthErrorState({ + status, + message, + onRetry, +}: { + status?: number; + message: string; + onRetry: () => void; +}) { + const t = useT(); + const fetch = useFetch(); + const attempts = Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0'); + const retry = useCallback(async () => { + try { + window.sessionStorage.setItem(DOS_OAUTH_RETRY_KEY, String(attempts + 1)); + const response = await fetch('/auth/oauth/GENERIC'); + if (response.ok) { + window.location.href = await response.text(); + return; + } + } catch (e) { + console.error('Failed to restart the SSO flow:', e); + } + window.location.href = '/auth/login'; + }, [attempts]); + return ( +
+

+ {t('sign_in_failed', 'Sign-in failed')} +

+

+ {t( + 'sign_in_failed_body', + 'We could not complete your sign-in. This is usually temporary - try again below.' + )} + {status ? ` (HTTP ${status})` : ''} +

+ {!!message && ( +

{message}

+ )} + {attempts < RETRY_LIMIT ? ( + + ) : ( + + {t('try_again', 'Try again')} + + )} +

+ {t('already_have_an_account', 'Already Have An Account?')}  + + {t('sign_in', 'Sign In')} + +

+
+ ); +} function getHelpfulReasonForRegistrationFailure(httpCode: number) { switch (httpCode) { case 400: @@ -172,7 +261,7 @@ export function RegisterAfter({
{!isAfterProvider && isGeneral && genericOauth ? (
- +

{t( 'sso_description', From f46220d799793b8eed2d5d3cab3035ece97f4ac1 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:06:55 +0700 Subject: [PATCH 2/2] fix(auth): cap the automatic SSO redirect against bounce loops Review round: an IdP error bounce can return to /auth WITHOUT a code, which skips the exists-exchange error path entirely and re-fires the auto-start - an uncontrolled /auth <-> IdP redirect storm is possible if the IdP ever auto-returns. Cap it: OauthProvider's autoStart writes a sessionStorage timestamp before navigating and refuses to auto-redirect again within 10s (falls back to the manual button). Manual clicks are exempt. Also per review: drop the dead useT in Register and the never-called onRetry prop of AuthErrorState. --- .../auth/providers/oauth.provider.tsx | 18 ++++++++++++++++++ apps/frontend/src/components/auth/register.tsx | 11 +---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/components/auth/providers/oauth.provider.tsx b/apps/frontend/src/components/auth/providers/oauth.provider.tsx index e378f590e6..48e9b84eeb 100644 --- a/apps/frontend/src/components/auth/providers/oauth.provider.tsx +++ b/apps/frontend/src/components/auth/providers/oauth.provider.tsx @@ -10,6 +10,12 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; // initiated SSO flow resets the error-page retry budget. export const DOS_OAUTH_RETRY_KEY = 'dos_oauth_retry_count'; +// Cap for the automatic redirect only: if we auto-started a flow and are +// back on an auth page without completing it within 10s (IdP error bounce, +// remembered-deny, callback without code), show the manual button instead of +// contributing to a browser/IdP redirect storm. Manual clicks are exempt. +const DOS_OAUTH_AUTOSTART_TS_KEY = 'dos_oauth_autostart_ts'; + export const OauthProvider = ({ autoStart = false }: { autoStart?: boolean }) => { const fetch = useFetch(); const { oauthLogoUrl, oauthDisplayName } = useVariables(); @@ -40,10 +46,22 @@ export const OauthProvider = ({ autoStart = false }: { autoStart?: boolean }) => useEffect(() => { if (!autoStart || startedRef.current) return; startedRef.current = true; + const lastStart = Number( + window.sessionStorage.getItem(DOS_OAUTH_AUTOSTART_TS_KEY) || '0' + ); + if (Date.now() - lastStart < 10_000) { + setAutoFailed(true); + return; + } + window.sessionStorage.setItem( + DOS_OAUTH_AUTOSTART_TS_KEY, + String(Date.now()) + ); gotoLogin().then((ok) => { if (!ok) { // Auto-start could not even fetch the link - fall back to the // manual button instead of a dead redirecting state. + window.sessionStorage.removeItem(DOS_OAUTH_AUTOSTART_TS_KEY); setAutoFailed(true); } }); diff --git a/apps/frontend/src/components/auth/register.tsx b/apps/frontend/src/components/auth/register.tsx index 8b1dfa26f5..e9498edf6e 100644 --- a/apps/frontend/src/components/auth/register.tsx +++ b/apps/frontend/src/components/auth/register.tsx @@ -32,7 +32,6 @@ type Inputs = { export function Register() { const getQuery = useSearchParams(); const fetch = useFetch(); - const t = useT(); const [provider] = useState(getQuery?.get('provider')?.toUpperCase() || 'GENERIC'); const [code, setCode] = useState(getQuery?.get('code') || ''); const [state] = useState(getQuery?.get('state') || ''); @@ -80,13 +79,7 @@ export function Register() { } }, [provider, code, state]); if (error) { - return ( - - ); + return ; } if (!code && !getQuery?.get('provider')) { return ; @@ -110,11 +103,9 @@ const RETRY_LIMIT = 2; function AuthErrorState({ status, message, - onRetry, }: { status?: number; message: string; - onRetry: () => void; }) { const t = useT(); const fetch = useFetch();