diff --git a/.changeset/silent-sign-in-state.md b/.changeset/silent-sign-in-state.md new file mode 100644 index 000000000..f5f39fba6 --- /dev/null +++ b/.changeset/silent-sign-in-state.md @@ -0,0 +1,10 @@ +--- +'@asgardeo/javascript': patch +'@asgardeo/react': patch +--- + +Fix `signInSilently()` always resolving `false`. + +The authorize request replaced the caller's `state` with the `instance_` prefix, so the `sign-in-silently` marker never reached the hidden iframe and the identity provider's response was never handed back to the parent window. The state now keeps both parts (for example `instance_0_sign-in-silently_request_0`). + +`AsgardeoProvider` also hands that response back to the parent before resuming a session. The iframe shares the parent's session storage, so it previously saw an active session and returned early, leaving the parent waiting until its silent sign-in timed out. diff --git a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts index 2e57e1288..e1d22af6d 100644 --- a/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts +++ b/packages/javascript/src/utils/__tests__/getAuthorizeRequestUrlParams.test.ts @@ -147,6 +147,35 @@ describe('getAuthorizeRequestUrlParams', (): void => { expect(params.get(OIDCRequestConstants.Params.STATE)).toBe('customState_request_1'); }); + it('should prefix the state with the instance ID', (): void => { + const params: Map = getAuthorizeRequestUrlParams( + { + clientId: 'client123', + instanceId: '0', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {}, + ); + + expect(params.get(OIDCRequestConstants.Params.STATE)).toBe('instance_0_request_1'); + }); + + it('should keep the custom state when an instance ID is also provided', (): void => { + const params: Map = getAuthorizeRequestUrlParams( + { + clientId: 'client123', + instanceId: '0', + prompt: 'none', + redirectUri: 'https://app/callback', + }, + {key: pkceKey}, + {[OIDCRequestConstants.Params.STATE]: 'sign-in-silently'}, + ); + + expect(params.get(OIDCRequestConstants.Params.STATE)).toBe('instance_0_sign-in-silently_request_1'); + }); + it('should set scope to undefined if none provided', (): void => { const params: Map = getAuthorizeRequestUrlParams( { diff --git a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts index cf846655a..f026a54b7 100644 --- a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts +++ b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts @@ -107,14 +107,22 @@ const getAuthorizeRequestUrlParams = ( } const AUTH_INSTANCE_PREFIX: string = 'instance_'; - let customStateValue: string = ''; + // Keep the caller's state alongside the instance prefix rather than replacing it: silent sign-in and + // check-session recognise their own responses by a marker in the state (e.g. `sign-in-silently`). + const stateParts: string[] = []; if (options.instanceId) { - customStateValue = AUTH_INSTANCE_PREFIX + options.instanceId; - } else if (customParams) { - customStateValue = customParams[OIDCRequestConstants.Params.STATE]?.toString() ?? ''; + stateParts.push(AUTH_INSTANCE_PREFIX + options.instanceId); } + const callerState: string = customParams?.[OIDCRequestConstants.Params.STATE]?.toString() ?? ''; + + if (callerState) { + stateParts.push(callerState); + } + + const customStateValue: string = stateParts.join('_'); + authorizeRequestParams.set( OIDCRequestConstants.Params.STATE, generateStateParamForRequestCorrelation(pkceKey, customStateValue), diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index 761b99ac2..b8f6b4839 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -35,6 +35,7 @@ import { EmbeddedSignInFlowResponseV2, TokenResponse, createPackageComponentLogger, + SPAUtils, } from '@asgardeo/browser'; import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, useCallback} from 'react'; import AsgardeoContext from './AsgardeoContext'; @@ -275,6 +276,19 @@ const AsgardeoProvider: FC> = ({ reRenderCheckRef.current = true; (async (): Promise => { + // Inside the hidden iframe opened by `signInSilently()`, hand the authorization response back to the + // parent window before anything else. This iframe shares the parent's session storage, so the + // `isSignedIn()` short-circuit below would otherwise resume the session and leave the parent waiting + // until its silent sign-in times out. + if ( + SPAUtils.isInitializedSilentSignIn() && + hasCalledForThisInstance(new URL(window.location.href), instanceId ?? 0) + ) { + await asgardeo.signInSilently(); + + return; + } + // User is already authenticated. Skip... const isAlreadySignedIn: boolean = await asgardeo.isSignedIn();