diff --git a/.changeset/fancy-states-speak.md b/.changeset/fancy-states-speak.md new file mode 100644 index 00000000..89b10152 --- /dev/null +++ b/.changeset/fancy-states-speak.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/react': patch +--- + +Add redirect handling for external IdP flows diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index cc5a6d13..30eca37e 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -204,8 +204,7 @@ class AsgardeoReactClient e return getMeOrganizations({baseUrl}); } catch (error) { throw new AsgardeoRuntimeError( - `Failed to fetch the user's associated organizations: ${ - error instanceof Error ? error.message : String(error) + `Failed to fetch the user's associated organizations: ${error instanceof Error ? error.message : String(error) }`, 'AsgardeoReactClient-getMyOrganizations-RuntimeError-001', 'react', @@ -335,20 +334,39 @@ class AsgardeoReactClient e const arg1 = args[0]; const arg2 = args[1]; - const config: AsgardeoReactConfig = (await this.asgardeo.getConfigData()) as AsgardeoReactConfig; + const config: AsgardeoReactConfig | undefined = (await this.asgardeo.getConfigData()) as AsgardeoReactConfig | undefined; + + const platformFromStorage = sessionStorage.getItem('asgardeo_platform'); + const isV2Platform = + (config && config.platform === Platform.AsgardeoV2) || + platformFromStorage === 'AsgardeoV2'; + + if ( + isV2Platform && + typeof arg1 === 'object' && + arg1 !== null && + (arg1 as any).callOnlyOnRedirect === true + ) { + return undefined as any; + } if ( - config.platform === Platform.AsgardeoV2 && + isV2Platform && typeof arg1 === 'object' && + arg1 !== null && !isEmpty(arg1) && ('flowId' in arg1 || 'applicationId' in arg1) ) { - const sessionDataKey: string = new URL(window.location.href).searchParams.get('sessionDataKey'); + const sessionDataKeyFromUrl: string = new URL(window.location.href).searchParams.get('sessionDataKey'); + const sessionDataKeyFromStorage: string = sessionStorage.getItem('asgardeo_session_data_key'); + const sessionDataKey: string = sessionDataKeyFromUrl || sessionDataKeyFromStorage; + const baseUrlFromStorage: string = sessionStorage.getItem('asgardeo_base_url'); + const baseUrl: string = config?.baseUrl || baseUrlFromStorage; return executeEmbeddedSignInFlowV2({ payload: arg1 as EmbeddedSignInFlowHandleRequestPayload, url: arg2?.url, - baseUrl: config?.baseUrl, + baseUrl, sessionDataKey, }); } 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 1c9ccd70..4eb2a6d8 100644 --- a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx @@ -26,6 +26,7 @@ import { EmbeddedSignInFlowResponseV2, EmbeddedSignInFlowRequestV2, EmbeddedSignInFlowStatusV2, + EmbeddedSignInFlowTypeV2, } from '@asgardeo/browser'; import {normalizeFlowResponse} from './transformer'; import useTranslation from '../../../../hooks/useTranslation'; @@ -175,14 +176,68 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError const [flowError, setFlowError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const initializationAttemptedRef = useRef(false); + const oauthCodeProcessedRef = useRef(false); + + /** + * Handle REDIRECTION response by storing flow state and redirecting to OAuth provider. + */ + const handleRedirection = (response: EmbeddedSignInFlowResponseV2): boolean => { + if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { + const redirectURL = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + + if (redirectURL) { + if (response.flowId) { + sessionStorage.setItem('asgardeo_flow_id', response.flowId); + } + + const urlParams = new URL(window.location.href).searchParams; + const sessionDataKeyFromUrl = urlParams.get('sessionDataKey'); + if (sessionDataKeyFromUrl) { + sessionStorage.setItem('asgardeo_session_data_key', sessionDataKeyFromUrl); + } + + window.location.href = redirectURL; + return true; + } + } + return false; + }; - // Initialize the flow on component mount (always initialize) useEffect(() => { - if (isInitialized && !isLoading && !isFlowInitialized && !initializationAttemptedRef.current) { + const storedFlowId = sessionStorage.getItem('asgardeo_flow_id'); + + 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); + } + + if (code) { + const flowIdFromUrl = urlParams.get('flowId'); + const flowIdFromState = state || flowIdFromUrl || storedFlowId; + + if (flowIdFromState) { + setCurrentFlowId(flowIdFromState); + setIsFlowInitialized(true); + sessionStorage.setItem('asgardeo_flow_id', flowIdFromState); + initializationAttemptedRef.current = true; + } + return; + } + + if ( + isInitialized && + !isLoading && + !isFlowInitialized && + !initializationAttemptedRef.current && + !currentFlowId + ) { initializationAttemptedRef.current = true; initializeFlow(); } - }, [isInitialized, isLoading, isFlowInitialized]); + }, [isInitialized, isLoading, isFlowInitialized, currentFlowId]); /** * Initialize the authentication flow. @@ -190,13 +245,24 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError */ const initializeFlow = async (): Promise => { const urlParams = new URL(window.location.href).searchParams; + const code = urlParams.get('code'); + + if (code) { + return; + } + + const flowIdFromUrl: string = urlParams.get('flowId'); const applicationIdFromUrl: string = urlParams.get('applicationId'); + const sessionDataKeyFromUrl: string = urlParams.get('sessionDataKey'); + + // Preserve sessionDataKey in sessionStorage for OAuth callback + if (sessionDataKeyFromUrl) { + sessionStorage.setItem('asgardeo_session_data_key', sessionDataKeyFromUrl); + } - // Priority order: flowId from URL > applicationId from context > applicationId from URL const effectiveApplicationId = applicationId || applicationIdFromUrl; - // Validate that we have either flowId or applicationId if (!flowIdFromUrl && !effectiveApplicationId) { const error = new AsgardeoRuntimeError( 'Either flowId or applicationId is required for authentication', @@ -213,7 +279,6 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError let response: EmbeddedSignInFlowResponseV2; - // Use flowId if available (priority), otherwise use applicationId if (flowIdFromUrl) { response = await signIn({ flowId: flowIdFromUrl, @@ -225,7 +290,11 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError }) as EmbeddedSignInFlowResponseV2; } - const {flowId, components} = normalizeFlowResponse(response, t); + if (handleRedirection(response)) { + return; + } + + const { flowId, components } = normalizeFlowResponse(response, t); if (flowId && components) { setCurrentFlowId(flowId); @@ -250,29 +319,61 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError * Handle form submission from BaseSignIn or render props. */ const handleSubmit = async (payload: EmbeddedSignInFlowRequestV2): Promise => { - if (!currentFlowId) { + // Use flowId from payload if available, otherwise fall back to currentFlowId + const effectiveFlowId = payload.flowId || currentFlowId; + + if (!effectiveFlowId) { + console.error('[SignIn] handleSubmit - ERROR: No flowId available', { + payloadFlowId: payload.flowId, + currentFlowId, + }); 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: currentFlowId, + flowId: effectiveFlowId, ...payload, }) as EmbeddedSignInFlowResponseV2; - const {flowId, components} = normalizeFlowResponse(response, t); + if (handleRedirection(response)) { + return; + } + + const { flowId, components } = normalizeFlowResponse(response, t); 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; + + // Clear all OAuth-related storage on successful completion + 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()); + + const finalRedirectUrl = redirectUrl || afterSignInUrl; + onSuccess && onSuccess({ - redirectUrl: response.redirectUrl || afterSignInUrl, + redirectUrl: finalRedirectUrl, ...response.data, }); - window.location.href = response.redirectUrl || afterSignInUrl; + if (finalRedirectUrl) { + window.location.href = finalRedirectUrl; + } return; } @@ -281,6 +382,10 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError setCurrentFlowId(flowId); setComponents(components); } + + if (!currentFlowId && effectiveFlowId) { + setCurrentFlowId(effectiveFlowId); + } } catch (error) { const err = error as Error; setFlowError(err); @@ -306,7 +411,44 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError onError?.(error); }; - // If render props are provided, use them + 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 storedFlowId = sessionStorage.getItem('asgardeo_flow_id'); + + if (!code || oauthCodeProcessedRef.current || isSubmitting) { + return; + } + + const flowIdToUse = currentFlowId || state || flowIdFromUrl || storedFlowId; + + if (!flowIdToUse || !signIn) { + return; + } + + oauthCodeProcessedRef.current = true; + + if (!currentFlowId) { + setCurrentFlowId(flowIdToUse); + setIsFlowInitialized(true); + } + const submitPayload: EmbeddedSignInFlowRequestV2 = { + flowId: flowIdToUse, + inputs: { + code, + ...(nonce && { nonce }), + }, + }; + + handleSubmit(submitPayload).catch(() => { + oauthCodeProcessedRef.current = false; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFlowInitialized, currentFlowId, isInitialized, isLoading, isSubmitting, signIn]); + if (children) { const renderProps: SignInRenderProps = { initialize: initializeFlow, @@ -319,7 +461,6 @@ const SignIn: FC = ({className, size = 'medium', onSuccess, onError return <>{children(renderProps)}; } - // Otherwise, render the default BaseSignIn component return ( > = ({ useEffect(() => { (async (): Promise => { await asgardeo.initialize(config); - setConfig(await asgardeo.getConfiguration()); + const initializedConfig = await asgardeo.getConfiguration(); + setConfig(initializedConfig); + + if (initializedConfig?.platform) { + sessionStorage.setItem('asgardeo_platform', initializedConfig.platform); + } + if (initializedConfig?.baseUrl) { + sessionStorage.setItem('asgardeo_base_url', initializedConfig.baseUrl); + } })(); }, []); @@ -145,7 +154,9 @@ const AsgardeoProvider: FC> = ({ const currentUrl: URL = new URL(window.location.href); const hasAuthParamsResult: boolean = hasAuthParams(currentUrl, afterSignInUrl); - if (hasAuthParamsResult) { + const isV2Platform = config.platform === Platform.AsgardeoV2; + + if (hasAuthParamsResult && !isV2Platform) { try { await signIn( {callOnlyOnRedirect: true}, @@ -365,22 +376,40 @@ const AsgardeoProvider: FC> = ({ fetchBranding, ]); - const signIn = async (...args: any): Promise => { + const signIn = async (...args: any): Promise => { + // Check if this is a V2 embedded flow request BEFORE calling signIn + // This allows us to skip session checks entirely for V2 flows + const arg1 = args[0]; + const isV2FlowRequest = + config.platform === Platform.AsgardeoV2 && + typeof arg1 === 'object' && + arg1 !== null && + ('flowId' in arg1 || 'applicationId' in arg1); + try { - setIsUpdatingSession(true); - setIsLoadingSync(true); - const response: User = await asgardeo.signIn(...args); + if (!isV2FlowRequest) { + setIsUpdatingSession(true); + setIsLoadingSync(true); + } + + const response: User | EmbeddedSignInFlowResponseV2 = await asgardeo.signIn(...args); + + if (isV2FlowRequest || (response && typeof response === 'object' && 'flowStatus' in response)) { + return response; + } if (await asgardeo.isSignedIn()) { await updateSession(); } - return response; + return response as User; } catch (error) { - throw new Error(`Error while signing in: ${error}`); + throw new Error(`Error while signing in: ${error instanceof Error ? error.message : String(error)}`); } finally { - setIsUpdatingSession(false); - setIsLoadingSync(asgardeo.isLoading()); + if (!isV2FlowRequest) { + setIsUpdatingSession(false); + setIsLoadingSync(asgardeo.isLoading()); + } } };