Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/frontend/src/components/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function Login() {
<div className="flex flex-col">
{isGeneral && genericOauth ? (
<div className="flex flex-col gap-4 mt-2">
<OauthProvider />
<OauthProvider autoStart />
<p className="text-xs text-zinc-400 text-center mt-2 leading-relaxed">
{t(
'sso_description',
Expand Down
72 changes: 69 additions & 3 deletions apps/frontend/src/components/auth/providers/oauth.provider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
'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';

// 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();
const t = useT();
const gotoLogin = useCallback(async () => {
const [autoFailed, setAutoFailed] = useState(false);
const startedRef = useRef(false);

const gotoLogin = useCallback(async (): Promise<boolean> => {
try {
const response = await fetch('/auth/oauth/GENERIC');
if (!response.ok) {
Expand All @@ -18,11 +32,63 @@ 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;
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);
}
});
}, [autoStart, gotoLogin]);
Comment on lines +46 to +68

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


if (autoStart && !autoFailed) {
return (
<div
className={`flex w-full items-center justify-center gap-[10px] rounded-[10px] bg-white text-zinc-900 h-[50px] font-semibold text-[15px] shadow-md`}
>
<div className="w-[24px] h-[24px] flex items-center justify-center shrink-0">
<SafeImage
src={oauthLogoUrl || '/icons/generic-oauth.svg'}
alt="DOS ID"
width={24}
height={24}
className="w-[24px] h-[24px] object-contain"
/>
</div>
<div>
{t('redirecting_to', 'Redirecting to')}&nbsp;
{oauthDisplayName || 'DOS ID'}...
</div>
</div>
);
}

return (
<div
onClick={gotoLogin}
Expand Down
88 changes: 84 additions & 4 deletions apps/frontend/src/components/auth/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import clsx from 'clsx';
import { GoogleProvider } from '@gitroom/frontend/components/auth/providers/google.provider';
import { AppleProvider } from '@gitroom/frontend/components/auth/providers/apple.provider';
import { OauthProvider } from '@gitroom/frontend/components/auth/providers/oauth.provider';
import { OauthProvider, DOS_OAUTH_RETRY_KEY } from '@gitroom/frontend/components/auth/providers/oauth.provider';
import { useFireEvents } from '@gitroom/helpers/utils/use.fire.events';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { useTrack } from '@gitroom/react/helpers/use.track';
Expand All @@ -36,12 +36,17 @@
const [code, setCode] = useState(getQuery?.get('code') || '');
const [state] = useState(getQuery?.get('state') || '');
const [show, setShow] = useState(false);
const [error, setError] = useState<{
status?: number;
message: string;
} | null>(null);
useEffect(() => {
if (code) {
load();
}
}, []);
const load = useCallback(async () => {
setError(null);
try {
const response = await fetch(
`/auth/oauth/${provider?.toUpperCase() || 'GENERIC'}/exists`,
Expand All @@ -54,21 +59,28 @@
}
);
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;
}
Comment on lines 61 to 66

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;
}

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 <AuthErrorState status={error.status} message={error.message} />;
}
if (!code && !getQuery?.get('provider')) {
return <RegisterAfter token="" provider="LOCAL" />;
}
Expand All @@ -79,6 +91,74 @@
<RegisterAfter token={code} provider={provider?.toUpperCase() || 'LOCAL'} />
);
}

// 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,
}: {
status?: number;
message: string;
}) {
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]);
Comment on lines +113 to +125
Comment on lines +112 to +125

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

return (
<div className="flex flex-1 flex-col justify-center gap-[16px]">
<h1 className="text-[40px] font-[500] -tracking-[0.8px] text-start">
{t('sign_in_failed', 'Sign-in failed')}
</h1>
<p className="text-[14px] leading-relaxed text-zinc-400">
{t(
'sign_in_failed_body',
'We could not complete your sign-in. This is usually temporary - try again below.'
)}
{status ? ` (HTTP ${status})` : ''}
</p>
{!!message && (
<p className="text-[12px] break-all text-red-400">{message}</p>
)}
{attempts < RETRY_LIMIT ? (
<Button type="button" onClick={retry} className="!h-[52px]">
{t('try_again', 'Try again')}
</Button>
) : (
<Link
href="/auth/login"
className="flex h-[52px] cursor-pointer items-center justify-center rounded-[10px] border border-fifth text-[15px] font-semibold"
>
{t('try_again', 'Try again')}
</Link>
)}
<p className="text-center text-sm">
{t('already_have_an_account', 'Already Have An Account?')}&nbsp;
<Link href="/auth/login" className="underline cursor-pointer">
{t('sign_in', 'Sign In')}
</Link>
</p>
</div>
);
}
function getHelpfulReasonForRegistrationFailure(httpCode: number) {
switch (httpCode) {
case 400:
Expand Down Expand Up @@ -172,7 +252,7 @@
<div className="flex flex-col text-[14px]">
{!isAfterProvider && isGeneral && genericOauth ? (
<div className="flex flex-col gap-4 mt-2">
<OauthProvider />
<OauthProvider autoStart />
<p className="text-xs text-zinc-400 text-center mt-2 leading-relaxed">
{t(
'sso_description',
Expand Down
Loading