Skip to content

fix(auth): SSO auto-redirect + honest error page instead of the silent signup fallback - #55

Merged
JOY (JOY) merged 2 commits into
devfrom
fix/auth-error-page
Sep 22, 2026
Merged

JOY (JOY) merged 2 commits into
devfrom
fix/auth-error-page

Conversation

@JOY

Copy link
Copy Markdown

What kind of change does this PR introduce?

Bug fix / UX (DOS ID single-provider auth flow). Two commissioned fixes:

  1. SSO auto-redirect: when SSO is the only auth method (isGeneral && genericOauth), the /auth and /auth/login pages start the DOS ID flow immediately (a Redirecting-to-DOS-ID loader) instead of rendering a page whose only content is one button. OauthProvider gains an autoStart prop: fires gotoLogin on 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.
  2. Honest error state: a failed /oauth/:provider/exists exchange (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. AuthErrorState now 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

  • apps/frontend/src/components/auth/providers/oauth.provider.tsx: autoStart prop, gotoLogin now returns success (and resets the retry budget), a redirecting loader state, manual-button fallback on auto-start failure.
  • apps/frontend/src/components/auth/register.tsx: Register tracks exchange errors and renders AuthErrorState; the DOS ID button branch passes autoStart.
  • apps/frontend/src/components/auth/login.tsx: same autoStart pass-through.
  • i18n strings use the fork's t(key, english-default) pattern consistent with the existing SSO strings on these pages (sso_description etc.).

Verification & Testing

  • pnpm run build:frontend compiles (next build green, exit 0).
  • Flow logic: with SSO enforced, /auth now redirects to id.dos.me automatically; after login, new users see the Company onboarding, existing users land in the app; if the exchange fails, the user sees the error page (HTTP status shown) with Try again - two automatic restarts then a manual link.
  • CI on this PR runs the full build + branding guard.

QA

  1. Open the PR Files changed - confirm only oauth.provider.tsx, register.tsx, login.tsx changed
  2. On beta (after deploy): visit /auth while logged OUT of id.dos.me - should auto-redirect to id.dos.me without clicking anything
  3. Log in - existing users land in the app; new users see the Company onboarding
  4. To see the error state: block the network to api.dos.me after entering credentials, or reproduce the outage - the page shows Sign-in failed with the HTTP status and a Try again button instead of the signup form
  5. Press Try again twice - the third attempt should be a manual link (loop guard), and starting a fresh SSO flow resets the budget
  6. CI: build.yml and branding-guard.yml green on this PR

Checklist:

  • My code follows the project's code style and architectural conventions.
  • Local verification done: frontend build green; flow logic reviewed against the live chain captured during the outage.
  • Branding guard - CI gate on this PR.
  • Tests - no frontend unit test harness for these components yet; QA steps above + CI build are the gates.
  • Documentation has been updated (if applicable) - rationale embedded in code comments.
  • No secrets or sensitive credentials are included in this PR.
  • I have filled in the QA / Verification section above with real steps to verify this change.

… 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.

@dos dos Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏱️ 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: Stale attempts closure in AuthErrorState bypasses RETRY_LIMIT
    • Failure Trace:
      1. User encounters OAuth error; AuthErrorState mounts.
      2. attempts is read from sessionStorage as 0 during render.
      3. retry callback is created with attempts=0 in its closure.
      4. User clicks "Try again". retry executes: sessionStorage.setItem(..., '1').
      5. fetch('/auth/oauth/GENERIC') throws (network error) or returns non-ok.
      6. Code falls through to window.location.href = '/auth/login'.
      7. 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/login is blocked/failed and the user remains on the page, the component does not re-render.
      8. However, the most concrete failure is: If the fetch succeeds 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), the attempts variable in the retry closure remains 0.
      9. User clicks "Try again" again. retry executes: sessionStorage.setItem(..., '1') (overwrites 1 with 1 because attempts is still 0 in the closure).
      10. The RETRY_LIMIT of 2 is never reached because the local attempts variable is never updated to reflect the incremented storage value. The user can click "Try again" indefinitely, bypassing the loop guard.
    • Actionable Fix: Use useState to track the retry count locally, or re-read sessionStorage inside the retry callback to ensure the latest value is used.
  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.

Comment on lines +122 to +134
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]);

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +40 to +50
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]);

Comment on lines +121 to +134
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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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]);

Comment on lines 62 to 67
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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.
@JOY
JOY (JOY) merged commit 36d984d into dev Sep 22, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants