From 31deeec31d264b5b63999efe25bad0638f216791 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Tue, 25 Nov 2025 17:55:14 +0530 Subject: [PATCH 1/4] Improve error handling --- .../SignIn/component-driven/SignIn.tsx | 340 +++++++++++++----- 1 file changed, 253 insertions(+), 87 deletions(-) diff --git a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx index 4eb2a6d8..50cd4d5d 100644 --- a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx @@ -178,6 +178,123 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const initializationAttemptedRef = useRef(false); const oauthCodeProcessedRef = useRef(false); + /** + * Sets flowId between sessionStorage and state. + * This ensures both are always in sync. + */ + const setFlowId = (flowId: string | null): void => { + setCurrentFlowId(flowId); + if (flowId) { + sessionStorage.setItem('asgardeo_flow_id', flowId); + } else { + sessionStorage.removeItem('asgardeo_flow_id'); + } + }; + + /** + * Clear all flow-related storage and state. + */ + const clearFlowState = (): void => { + setFlowId(null); + setIsFlowInitialized(false); + sessionStorage.removeItem('asgardeo_session_data_key'); + // Reset refs to allow new flows to start properly + oauthCodeProcessedRef.current = false; + }; + + /** + * Parse URL parameters used in flows. + */ + const getUrlParams = () => { + const urlParams = new URL(window?.location?.href ?? '').searchParams; + return { + code: urlParams.get('code'), + error: urlParams.get('error'), + errorDescription: urlParams.get('error_description'), + state: urlParams.get('state'), + nonce: urlParams.get('nonce'), + flowId: urlParams.get('flowId'), + applicationId: urlParams.get('applicationId'), + sessionDataKey: urlParams.get('sessionDataKey'), + }; + }; + + /** + * Handle sessionDataKey from URL and store it in sessionStorage. + */ + const handleSessionDataKey = (sessionDataKey: string | null): void => { + if (sessionDataKey) { + sessionStorage.setItem('asgardeo_session_data_key', sessionDataKey); + } + }; + + /** + * Resolve flowId from multiple sources with priority: currentFlowId > state > flowIdFromUrl > storedFlowId + */ + const resolveFlowId = ( + currentFlowId: string | null, + state: string | null, + flowIdFromUrl: string | null, + storedFlowId: string | null, + ): string | null => { + return currentFlowId || state || flowIdFromUrl || storedFlowId || null; + }; + + /** + * Clean up OAuth-related URL parameters from the browser URL. + */ + const cleanupOAuthUrlParams = (includeNonce = false): void => { + if (!window?.location?.href) return; + const url = new URL(window.location.href); + url.searchParams.delete('error'); + url.searchParams.delete('error_description'); + url.searchParams.delete('code'); + url.searchParams.delete('state'); + if (includeNonce) { + url.searchParams.delete('nonce'); + } + window?.history?.replaceState({}, '', url.toString()); + }; + + /** + * Clean up flow-related URL parameters (flowId, sessionDataKey) from the browser URL. + * Used after flowId is set in state to prevent using invalidated flowId from URL. + */ + const cleanupFlowUrlParams = (): void => { + if (!window?.location?.href) return; + const url = new URL(window.location.href); + url.searchParams.delete('flowId'); + url.searchParams.delete('sessionDataKey'); + window?.history?.replaceState({}, '', url.toString()); + }; + + /** + * Handle OAuth error from URL parameters. + * Clears flow state, creates error, and cleans up URL. + */ + const handleOAuthError = (error: string, errorDescription: string | null): void => { + console.warn('[SignIn] OAuth error detected:', error); + clearFlowState(); + const errorMessage = errorDescription || `OAuth error: ${error}`; + const err = new AsgardeoRuntimeError( + errorMessage, + 'SIGN_IN_ERROR', + 'react', + ); + setError(err); + cleanupOAuthUrlParams(true); + }; + + /** + * Set error state and call onError callback. + * Ensures isFlowInitialized is true so errors can be displayed in the UI. + */ + const setError = (error: Error): void => { + setFlowError(error); + setIsFlowInitialized(true); + onError?.(error); + }; + /** * Handle REDIRECTION response by storing flow state and redirecting to OAuth provider. */ @@ -185,16 +302,13 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { const redirectURL = (response.data as any)?.redirectURL || (response as any)?.redirectURL; - if (redirectURL) { + if (redirectURL && window?.location) { if (response.flowId) { - sessionStorage.setItem('asgardeo_flow_id', response.flowId); + setFlowId(response.flowId); } - const urlParams = new URL(window.location.href).searchParams; - const sessionDataKeyFromUrl = urlParams.get('sessionDataKey'); - if (sessionDataKeyFromUrl) { - sessionStorage.setItem('asgardeo_session_data_key', sessionDataKeyFromUrl); - } + const urlParams = getUrlParams(); + handleSessionDataKey(urlParams.sessionDataKey); window.location.href = redirectURL; return true; @@ -205,28 +319,49 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError useEffect(() => { const storedFlowId = sessionStorage.getItem('asgardeo_flow_id'); + const urlParams = getUrlParams(); - const urlParams = new URL(window.location.href).searchParams; - const code = urlParams.get('code'); - const state = urlParams.get('state'); - const sessionDataKeyFromUrl = urlParams.get('sessionDataKey'); - if (sessionDataKeyFromUrl) { - sessionStorage.setItem('asgardeo_session_data_key', sessionDataKeyFromUrl); + // Check for OAuth error in URL + if (urlParams.error) { + handleOAuthError(urlParams.error, urlParams.errorDescription); + return; } - if (code) { - const flowIdFromUrl = urlParams.get('flowId'); - const flowIdFromState = state || flowIdFromUrl || storedFlowId; + handleSessionDataKey(urlParams.sessionDataKey); + + if (urlParams.code) { + const flowIdFromState = resolveFlowId( + currentFlowId, + urlParams.state, + urlParams.flowId, + storedFlowId, + ); + // Only process code if we have a valid flowId to use if (flowIdFromState) { - setCurrentFlowId(flowIdFromState); + setFlowId(flowIdFromState); setIsFlowInitialized(true); - sessionStorage.setItem('asgardeo_flow_id', flowIdFromState); initializationAttemptedRef.current = true; + // Clean up flowId from URL after setting it in state + cleanupFlowUrlParams(); + } else { + console.warn('[SignIn] OAuth code in URL but no valid flowId found. Cleaning up stale OAuth parameters.'); + cleanupOAuthUrlParams(true); } return; } + // If flowId is in URL or sessionStorage but no code and no active flow state + if ((urlParams.flowId || storedFlowId) && !urlParams.code && !currentFlowId) { + console.warn( + '[SignIn] FlowId in URL/sessionStorage but no active flow state detected. ' + ); + setFlowId(null); + sessionStorage.removeItem('asgardeo_flow_id'); + cleanupFlowUrlParams(); + // Continue to initialize with applicationId instead + } + if ( isInitialized && !isLoading && @@ -234,6 +369,12 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError !initializationAttemptedRef.current && !currentFlowId ) { + // Clean up any stale OAuth parameters before starting a new flow + const urlParams = getUrlParams(); + if (urlParams.code || urlParams.state) { + console.debug('[SignIn] Cleaning up stale OAuth parameters before starting new flow'); + cleanupOAuthUrlParams(true); + } initializationAttemptedRef.current = true; initializeFlow(); } @@ -244,33 +385,22 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError * Priority: flowId > applicationId (from context) > applicationId (from URL) */ const initializeFlow = async (): Promise => { - const urlParams = new URL(window.location.href).searchParams; - const code = urlParams.get('code'); - - if (code) { - return; - } + const urlParams = getUrlParams(); + // Reset OAuth code processed ref when starting a new flow + oauthCodeProcessedRef.current = false; - const flowIdFromUrl: string = urlParams.get('flowId'); - const applicationIdFromUrl: string = urlParams.get('applicationId'); - const sessionDataKeyFromUrl: string = urlParams.get('sessionDataKey'); + handleSessionDataKey(urlParams.sessionDataKey); - // Preserve sessionDataKey in sessionStorage for OAuth callback - if (sessionDataKeyFromUrl) { - sessionStorage.setItem('asgardeo_session_data_key', sessionDataKeyFromUrl); - } - - const effectiveApplicationId = applicationId || applicationIdFromUrl; + const effectiveApplicationId = applicationId || urlParams.applicationId; - if (!flowIdFromUrl && !effectiveApplicationId) { + if (!urlParams.flowId && !effectiveApplicationId) { const error = new AsgardeoRuntimeError( 'Either flowId or applicationId is required for authentication', - 'SignIn-initializeFlow-RuntimeError-001', + 'SIGN_IN_ERROR', 'react', - 'Something went wrong while trying to sign in. Please try again later.', ); - setFlowError(error); + setError(error); throw error; } @@ -279,9 +409,9 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError let response: EmbeddedSignInFlowResponseV2; - if (flowIdFromUrl) { + if (urlParams.flowId) { response = await signIn({ - flowId: flowIdFromUrl, + flowId: urlParams.flowId, }) as EmbeddedSignInFlowResponseV2; } else { response = await signIn({ @@ -297,21 +427,28 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const { flowId, components } = normalizeFlowResponse(response, t); if (flowId && components) { - setCurrentFlowId(flowId); + setFlowId(flowId); setComponents(components); setIsFlowInitialized(true); + // Clean up flowId from URL after setting it in state + cleanupFlowUrlParams(); } } catch (error) { const err = error as Error; - setFlowError(err); - onError?.(err); + clearFlowState(); + + // Extract error message + const errorMessage = err instanceof Error ? err.message : String(err); - throw new AsgardeoRuntimeError( - `Failed to initialize authentication flow: ${error instanceof Error ? error.message : String(error)}`, - 'SignIn-initializeFlow-RuntimeError-002', + // Create error with backend message + const displayError = new AsgardeoRuntimeError( + errorMessage, + 'SIGN_IN_ERROR', 'react', - 'Something went wrong while trying to sign in. Please try again later.', ); + setError(displayError); + initializationAttemptedRef.current = false; + return; } }; @@ -330,12 +467,10 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError throw new Error('No active flow ID'); } - try { setIsSubmitting(true); setFlowError(null); - // Use effectiveFlowId - either from payload or currentFlowId const response: EmbeddedSignInFlowResponseV2 = await signIn({ flowId: effectiveFlowId, ...payload, @@ -347,56 +482,76 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const { flowId, components } = normalizeFlowResponse(response, t); + // Handle Error flow status - flow has failed and is invalidated + if (response.flowStatus === EmbeddedSignInFlowStatusV2.Error) { + console.error('[SignIn] Flow returned Error status, clearing flow state'); + clearFlowState(); + // Extract failureReason from response if available + const failureReason = (response as any)?.failureReason; + const errorMessage = failureReason || 'Authentication flow failed. Please try again.'; + const err = new AsgardeoRuntimeError( + errorMessage, + 'SIGN_IN_ERROR', + 'react', + ); + setError(err); + cleanupFlowUrlParams(); + return; + } + if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { - // Get redirectUrl from response (comes from /oauth2/authorize) or fall back to afterSignInUrl - const redirectUrl = (response as any).redirectUrl || (response as any).redirect_uri; + // Get redirectUrl from response (from /oauth2/authorize) or fall back to afterSignInUrl + const redirectUrl = (response as any)?.redirectUrl || (response as any)?.redirect_uri; + const finalRedirectUrl = redirectUrl || afterSignInUrl; + + // Clear submitting state before redirect + setIsSubmitting(false); // Clear all OAuth-related storage on successful completion + setFlowId(null); + setIsFlowInitialized(false); sessionStorage.removeItem('asgardeo_flow_id'); - if (redirectUrl) { - sessionStorage.removeItem('asgardeo_session_data_key'); - } - - const url = new URL(window.location.href); - url.searchParams.delete('code'); - url.searchParams.delete('state'); - url.searchParams.delete('nonce'); - window.history.replaceState({}, '', url.toString()); + sessionStorage.removeItem('asgardeo_session_data_key'); - const finalRedirectUrl = redirectUrl || afterSignInUrl; + // Clean up OAuth URL params before redirect + cleanupOAuthUrlParams(true); onSuccess && onSuccess({ redirectUrl: finalRedirectUrl, - ...response.data, + ...(response.data || {}), }); - if (finalRedirectUrl) { + if (finalRedirectUrl && window?.location) { window.location.href = finalRedirectUrl; + } else { + console.warn('[SignIn] Flow completed but no redirect URL available'); } return; } + // Update flowId if response contains a new one if (flowId && components) { - setCurrentFlowId(flowId); + setFlowId(flowId); setComponents(components); - } - - if (!currentFlowId && effectiveFlowId) { - setCurrentFlowId(effectiveFlowId); + // Clean up flowId from URL after setting it in state + cleanupFlowUrlParams(); } } catch (error) { const err = error as Error; - setFlowError(err); - onError?.(err); + clearFlowState(); - throw new AsgardeoRuntimeError( - `Failed to submit authentication flow: ${error instanceof Error ? error.message : String(error)}`, - 'SignIn-handleSubmit-RuntimeError-001', + // Extract error message + const errorMessage = err instanceof Error ? err.message : String(err); + + const displayError = new AsgardeoRuntimeError( + errorMessage, + 'SIGN_IN_ERROR', 'react', - 'Something went wrong while trying to sign in. Please try again later.', ); + setError(displayError); + return; } finally { setIsSubmitting(false); } @@ -407,23 +562,33 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError */ const handleError = (error: Error): void => { console.error('Authentication error:', error); - setFlowError(error); - onError?.(error); + setError(error); }; + /** + * Handle OAuth code processing from external OAuth providers. + */ useEffect(() => { - const urlParams = new URL(window.location.href).searchParams; - const code = urlParams.get('code'); - const nonce = urlParams.get('nonce'); - const state = urlParams.get('state'); - const flowIdFromUrl = urlParams.get('flowId'); + const urlParams = getUrlParams(); const storedFlowId = sessionStorage.getItem('asgardeo_flow_id'); - if (!code || oauthCodeProcessedRef.current || isSubmitting) { + // Check for OAuth error first - if present, don't process code + if (urlParams.error) { + handleOAuthError(urlParams.error, urlParams.errorDescription); + oauthCodeProcessedRef.current = true; // Mark as processed to prevent retry + return; + } + + if (!urlParams.code || oauthCodeProcessedRef.current || isSubmitting) { return; } - const flowIdToUse = currentFlowId || state || flowIdFromUrl || storedFlowId; + const flowIdToUse = resolveFlowId( + currentFlowId, + urlParams.state, + urlParams.flowId, + storedFlowId, + ); if (!flowIdToUse || !signIn) { return; @@ -432,19 +597,20 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError oauthCodeProcessedRef.current = true; if (!currentFlowId) { - setCurrentFlowId(flowIdToUse); + setFlowId(flowIdToUse); setIsFlowInitialized(true); } const submitPayload: EmbeddedSignInFlowRequestV2 = { flowId: flowIdToUse, inputs: { - code, - ...(nonce && { nonce }), + code: urlParams.code, + ...(urlParams.nonce && { nonce: urlParams.nonce }), }, }; - handleSubmit(submitPayload).catch(() => { - oauthCodeProcessedRef.current = false; + handleSubmit(submitPayload).catch((error) => { + console.error('[SignIn] OAuth callback submission failed:', error); + cleanupOAuthUrlParams(true); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFlowInitialized, currentFlowId, isInitialized, isLoading, isSubmitting, signIn]); From a1a2baead9e6ac7cd145bfd4eb97d5b1215da492 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Wed, 26 Nov 2025 15:12:40 +0530 Subject: [PATCH 2/4] Fix redirect state handling --- .../SignIn/component-driven/SignIn.tsx | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx index 50cd4d5d..548a37c5 100644 --- a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx @@ -317,6 +317,9 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError return false; }; + /** + * Initialize the flow and handle cleanup of stale flow state. + */ useEffect(() => { const storedFlowId = sessionStorage.getItem('asgardeo_flow_id'); const urlParams = getUrlParams(); @@ -329,32 +332,16 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError handleSessionDataKey(urlParams.sessionDataKey); - if (urlParams.code) { - const flowIdFromState = resolveFlowId( - currentFlowId, - urlParams.state, - urlParams.flowId, - storedFlowId, - ); - - // Only process code if we have a valid flowId to use - if (flowIdFromState) { - setFlowId(flowIdFromState); - setIsFlowInitialized(true); - initializationAttemptedRef.current = true; - // Clean up flowId from URL after setting it in state - cleanupFlowUrlParams(); - } else { - console.warn('[SignIn] OAuth code in URL but no valid flowId found. Cleaning up stale OAuth parameters.'); - cleanupOAuthUrlParams(true); - } + // Skip OAuth code processing - let the dedicated OAuth useEffect handle it + if (urlParams.code || urlParams.state) { return; } - // If flowId is in URL or sessionStorage but no code and no active flow state - if ((urlParams.flowId || storedFlowId) && !urlParams.code && !currentFlowId) { + // If flowId is in URL or sessionStorage but no active flow state, clean it up + // This handles stale flowIds from previous sessions or incomplete flows + if ((urlParams.flowId || storedFlowId) && !currentFlowId) { console.warn( - '[SignIn] FlowId in URL/sessionStorage but no active flow state detected. ' + '[SignIn] FlowId in URL/sessionStorage but no active flow state detected. Cleaning up stale flowId.' ); setFlowId(null); sessionStorage.removeItem('asgardeo_flow_id'); @@ -362,19 +349,19 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError // Continue to initialize with applicationId instead } + // Only initialize if we're not processing an OAuth callback or submission + const currentUrlParams = getUrlParams(); if ( isInitialized && !isLoading && !isFlowInitialized && !initializationAttemptedRef.current && - !currentFlowId + !currentFlowId && + !currentUrlParams.code && + !currentUrlParams.state && + !isSubmitting && + !oauthCodeProcessedRef.current ) { - // Clean up any stale OAuth parameters before starting a new flow - const urlParams = getUrlParams(); - if (urlParams.code || urlParams.state) { - console.debug('[SignIn] Cleaning up stale OAuth parameters before starting new flow'); - cleanupOAuthUrlParams(true); - } initializationAttemptedRef.current = true; initializeFlow(); } @@ -535,6 +522,8 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError if (flowId && components) { setFlowId(flowId); setComponents(components); + // Ensure flow is marked as initialized when we have components + setIsFlowInitialized(true); // Clean up flowId from URL after setting it in state cleanupFlowUrlParams(); } @@ -598,7 +587,6 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError if (!currentFlowId) { setFlowId(flowIdToUse); - setIsFlowInitialized(true); } const submitPayload: EmbeddedSignInFlowRequestV2 = { flowId: flowIdToUse, From ef9ae48d5450c400b16fa2b2b500b75833b8ed11 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Wed, 26 Nov 2025 15:36:15 +0530 Subject: [PATCH 3/4] Add changeset --- .changeset/four-cities-double.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/four-cities-double.md diff --git a/.changeset/four-cities-double.md b/.changeset/four-cities-double.md new file mode 100644 index 00000000..7ca6291a --- /dev/null +++ b/.changeset/four-cities-double.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/react': patch +--- + +Improve sign-in v2 error handling flows From 4ff39c86236e142fd2451762dde2ebd2b3642206 Mon Sep 17 00:00:00 2001 From: thiva-k Date: Thu, 27 Nov 2025 13:32:01 +0530 Subject: [PATCH 4/4] Fix redundant flow call and clear params --- .../SignIn/component-driven/SignIn.tsx | 14 ++----------- .../presentation/SignUp/BaseSignUp.tsx | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx index 548a37c5..4c3d8094 100644 --- a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx @@ -265,6 +265,7 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const url = new URL(window.location.href); url.searchParams.delete('flowId'); url.searchParams.delete('sessionDataKey'); + url.searchParams.delete('applicationId'); window?.history?.replaceState({}, '', url.toString()); }; @@ -337,18 +338,6 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError return; } - // If flowId is in URL or sessionStorage but no active flow state, clean it up - // This handles stale flowIds from previous sessions or incomplete flows - if ((urlParams.flowId || storedFlowId) && !currentFlowId) { - console.warn( - '[SignIn] FlowId in URL/sessionStorage but no active flow state detected. Cleaning up stale flowId.' - ); - setFlowId(null); - sessionStorage.removeItem('asgardeo_flow_id'); - cleanupFlowUrlParams(); - // Continue to initialize with applicationId instead - } - // Only initialize if we're not processing an OAuth callback or submission const currentUrlParams = getUrlParams(); if ( @@ -365,6 +354,7 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError initializationAttemptedRef.current = true; initializeFlow(); } + }, [isInitialized, isLoading, isFlowInitialized, currentFlowId]); /** diff --git a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx index cfb9dd59..488798c5 100644 --- a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx @@ -754,8 +754,29 @@ const BaseSignUpContent: FC = ({ ], ); + /** + * Parse URL parameters to check for OAuth redirect state. + */ + const getUrlParams = () => { + const urlParams = new URL(window?.location?.href ?? '').searchParams; + return { + code: urlParams.get('code'), + state: urlParams.get('state'), + error: urlParams.get('error'), + }; + }; + // Initialize the flow on component mount useEffect(() => { + // Skip initialization if we're in an OAuth redirect state + // Only apply this check for AsgardeoV2 platform + if (platform === Platform.AsgardeoV2) { + const urlParams = getUrlParams(); + if (urlParams.code || urlParams.state) { + return; + } + } + if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { initializationAttemptedRef.current = true;