From bbd3637351a2244e59abe3cf4cab49706a582ab7 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:24:28 +0700 Subject: [PATCH 1/2] fix(auth): fall back to the manual form when the OIDC provider is unreachable The automatic OIDC redirect fired authClient.oidc.signIn() without a .catch, so an unreachable IdP left users on the redirect spinner forever with no error, no retry, and no path to the break-glass form. Catch the rejection and re-render the manual form (retry button, ?direct=1 break-glass door) instead. Also port the synchronous embedded-hash guard from the signin route to the signup route so embedded signing widgets cannot bounce to the IdP from a crafted /signup URL. --- .../app/routes/_unauthenticated+/signin.tsx | 9 +++++++-- .../app/routes/_unauthenticated+/signup.tsx | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/remix/app/routes/_unauthenticated+/signin.tsx b/apps/remix/app/routes/_unauthenticated+/signin.tsx index 3b13832b4b..6f9b9bda09 100644 --- a/apps/remix/app/routes/_unauthenticated+/signin.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signin.tsx @@ -95,6 +95,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { const [searchParams] = useSearchParams(); const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false); + const [isRedirectFailed, setIsRedirectFailed] = useState(false); const errorParam = searchParams.get('error'); const signupError = errorParam ? SIGNUP_ERROR_MESSAGES[errorParam] : undefined; @@ -128,10 +129,14 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { return; } - void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' }); + authClient.oidc.signIn({ redirectPath: returnTo ?? '/' }).catch(() => { + // Fall back to the manual form (retry button, break-glass door) instead + // of leaving the user on the spinner forever when the IdP is unreachable. + setIsRedirectFailed(true); + }); }, [shouldRedirectToOIDC, returnTo]); - if (shouldRedirectToOIDC) { + if (shouldRedirectToOIDC && !isRedirectFailed) { return (
diff --git a/apps/remix/app/routes/_unauthenticated+/signup.tsx b/apps/remix/app/routes/_unauthenticated+/signup.tsx index 7a254457d2..da7088a60a 100644 --- a/apps/remix/app/routes/_unauthenticated+/signup.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signup.tsx @@ -12,7 +12,7 @@ import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-vali import { msg } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; import { Loader2Icon } from 'lucide-react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { redirect, useSearchParams } from 'react-router'; import { SignUpForm } from '~/components/forms/signup'; @@ -78,6 +78,7 @@ export default function SignUp({ loaderData }: Route.ComponentProps) { } = loaderData; const [searchParams] = useSearchParams(); + const [isRedirectFailed, setIsRedirectFailed] = useState(false); // Suppress the automatic redirect when the user asked for the manual form // via ?direct=1, or when a previous OIDC attempt bounced back with an error @@ -92,10 +93,20 @@ export default function SignUp({ loaderData }: Route.ComponentProps) { return; } - void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' }); + // Embedded signing widgets must not bounce to the IdP; read the hash + // synchronously to match the guard on the signin route. + if (new URLSearchParams(window.location.hash.slice(1)).get('embedded') === 'true') { + return; + } + + authClient.oidc.signIn({ redirectPath: returnTo ?? '/' }).catch(() => { + // Fall back to the manual form instead of leaving the user on the + // spinner forever when the IdP is unreachable. + setIsRedirectFailed(true); + }); }, [shouldRedirectToOIDC, returnTo]); - if (shouldRedirectToOIDC) { + if (shouldRedirectToOIDC && !isRedirectFailed) { return (
From a8a41d227f50e5eea876f5f9dec45c3c529dac34 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:32:06 +0700 Subject: [PATCH 2/2] fix(auth): mirror the embedded redirect state on the signup route Porting only the synchronous hash guard left the signup spinner branch reachable from a crafted /signup#embedded=true URL: signIn() was blocked but the spinner kept rendering with no way out. Track the embedded hash in state like the signin route so the manual form renders instead. --- apps/remix/app/routes/_unauthenticated+/signup.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/remix/app/routes/_unauthenticated+/signup.tsx b/apps/remix/app/routes/_unauthenticated+/signup.tsx index da7088a60a..313fa29de0 100644 --- a/apps/remix/app/routes/_unauthenticated+/signup.tsx +++ b/apps/remix/app/routes/_unauthenticated+/signup.tsx @@ -79,6 +79,7 @@ export default function SignUp({ loaderData }: Route.ComponentProps) { const [searchParams] = useSearchParams(); const [isRedirectFailed, setIsRedirectFailed] = useState(false); + const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false); // Suppress the automatic redirect when the user asked for the manual form // via ?direct=1, or when a previous OIDC attempt bounced back with an error @@ -86,15 +87,21 @@ export default function SignUp({ loaderData }: Route.ComponentProps) { const isDirectEntry = searchParams.get('direct') === '1'; const hasIdpError = searchParams.get('error') !== null; - const shouldRedirectToOIDC = shouldAutoRedirectToOIDC && !isDirectEntry && !hasIdpError; + useEffect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + + setIsEmbeddedRedirect(params.get('embedded') === 'true'); + }, []); + + const shouldRedirectToOIDC = shouldAutoRedirectToOIDC && !isDirectEntry && !hasIdpError && !isEmbeddedRedirect; useEffect(() => { if (!shouldRedirectToOIDC) { return; } - // Embedded signing widgets must not bounce to the IdP; read the hash - // synchronously to match the guard on the signin route. + // Guard against the initial render racing the embedded detection above: + // read the hash synchronously so embedded contexts never bounce to the IdP. if (new URLSearchParams(window.location.hash.slice(1)).get('embedded') === 'true') { return; }