fix(auth): SSO auto-redirect + honest error page instead of the silent signup fallback - #55
Conversation
… 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.
There was a problem hiding this comment.
⏱️ Code Review completed (3 files · 9,921 chars · 1 PR unit(s))
⏱️ Adversarial Review completed (Model: qwen3.8-27b)
🔍 Verified Adversarial Review Findings
🟡 IMPORTANT
apps/frontend/src/components/auth/register.tsx:118-120: Staleattemptsclosure inAuthErrorStatebypassesRETRY_LIMIT- Failure Trace:
- User encounters OAuth error;
AuthErrorStatemounts. attemptsis read fromsessionStorageas0during render.retrycallback is created withattempts=0in its closure.- User clicks "Try again".
retryexecutes:sessionStorage.setItem(..., '1'). fetch('/auth/oauth/GENERIC')throws (network error) or returns non-ok.- Code falls through to
window.location.href = '/auth/login'. - User navigates back to the register page (e.g., via browser back button or manual URL entry) without a full page reload that clears the React component tree state if the SPA router preserves it, OR more simply: if the redirect to
/auth/loginis blocked/failed and the user remains on the page, the component does not re-render. - However, the most concrete failure is: If the
fetchsucceeds but the redirect to the SSO provider fails or the user cancels and returns to the app, the component might re-mount. But if the component does not re-mount (e.g., if the error state is triggered by a client-side state change that doesn't unmount the component, or if the user is stuck on the page due to a JS error preventing the redirect), theattemptsvariable in theretryclosure remains0. - User clicks "Try again" again.
retryexecutes:sessionStorage.setItem(..., '1')(overwrites1with1becauseattemptsis still0in the closure). - The
RETRY_LIMITof 2 is never reached because the localattemptsvariable is never updated to reflect the incremented storage value. The user can click "Try again" indefinitely, bypassing the loop guard.
- User encounters OAuth error;
- Actionable Fix: Use
useStateto track the retry count locally, or re-readsessionStorageinside theretrycallback to ensure the latest value is used.
- Failure Trace:
const [attempts, setAttempts] = useState(() => Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0'));
const retry = useCallback(async () => {
const currentAttempts = Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0');
try {
window.sessionStorage.setItem(DOS_OAUTH_RETRY_KEY, String(currentAttempts + 1));
setAttempts(currentAttempts + 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';
}, []);
🛡️ Dismissed Claims
- None: The single candidate claim is valid and retained as an IMPORTANT issue.
| 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]); |
| console.error('Failed to restart the SSO flow:', e); | ||
| } | ||
| window.location.href = '/auth/login'; | ||
| }, [attempts]); |
There was a problem hiding this comment.
Code Review
This pull request introduces an autoStart mechanism for the OauthProvider and implements a robust error handling state for OAuth failures using a new AuthErrorState component. The changes include a retry budget stored in sessionStorage to manage SSO flow restarts. The reviewer provided critical feedback regarding a potential infinite redirect loop if the retry limit is exceeded, a risk of SSR crashes due to direct window access in the render body, and a suggestion to improve error reporting by capturing response text during failed OAuth exchanges.
| 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]); |
There was a problem hiding this comment.
If the user exceeds the retry limit, they are redirected to /auth/login to try again manually. However, because /auth/login has autoStart enabled, it immediately triggers another redirect to the SSO provider, bypassing the loop guard and causing an infinite redirect loop. Checking the retry count in useEffect before auto-starting prevents this loop.
useEffect(() => {
if (!autoStart || startedRef.current) return;
const attempts = Number(window.sessionStorage.getItem(DOS_OAUTH_RETRY_KEY) || '0');
if (attempts >= 2) {
setAutoFailed(true);
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]);
| 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]); |
There was a problem hiding this comment.
Accessing window.sessionStorage directly in the render body of AuthErrorState will cause a ReferenceError: window is not defined during Next.js server-side rendering (SSR) or static generation if the component is evaluated or rendered. Using useState and useEffect to safely read from sessionStorage on the client side prevents this crash.
const [attempts, setAttempts] = useState(0);
useEffect(() => {
setAttempts(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, fetch]);
| 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; | ||
| } |
There was a problem hiding this comment.
When the OAuth exchange fails server-side, the error message is set to an empty string (message: ''), which prevents the user from seeing the actual error details/reasons for the failure. Reading the response text from the failed request and passing it as the error message provides a more helpful and honest error state.
| 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; | |
| } | |
| if (!response.ok) { | |
| // The exchange failed server-side. Never masquerade this failure as | |
| // a fresh signup: surface it with a loop-guarded retry instead. | |
| const errorText = await response.text().catch(() => ''); | |
| setError({ status: response.status, message: errorText }); | |
| return; | |
| } |
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.
What kind of change does this PR introduce?
Bug fix / UX (DOS ID single-provider auth flow). Two commissioned fixes:
isGeneral && genericOauth), the/authand/auth/loginpages start the DOS ID flow immediately (a Redirecting-to-DOS-ID loader) instead of rendering a page whose only content is one button.OauthProvidergains anautoStartprop: firesgotoLoginon mount (ref-guarded), falls back to the manual button if the link fetch fails, and clears the error-page retry budget on every deliberately initiated flow./oauth/:provider/existsexchange (state cookie mismatch, upstream token error - as during the 2026-09-21/22 prod login outage) previously fell through to the signup form, so a broken sign-in looked like a fresh registration.AuthErrorStatenow shows what happened (title, explanation, HTTP status, error detail) with a loop-guarded retry: up to 2 automatic SSO restarts via a sessionStorage budget (dos_oauth_retry_count, cleared on each deliberately initiated flow), then a manual link so a persistent failure cannot ping-pong forever.No backend changes, no schema changes.
Why was this change needed?
Commissioned auth UX work (reminded by the DOS-Me agent after the prod login outage): (1) JOY commissioned the auto-redirect to id.dos.me earlier; (2) during the outage every affected user was dumped on a Sign Up/Company page that looked like forced re-registration - the error was invisible.
Technical Details & Scope
Verification & Testing
QA
Checklist: