From 43442baa12d9d43f21d5b7851fc7bffa687446dd Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 20 Mar 2026 08:57:57 -0700 Subject: [PATCH 1/2] fix(expo): migrate useSSO to core-3 and fix browser dismissal for OAuth/SSO flows - Replace legacy useSignIn/useSignUp imports with core-3 useClerk/useSignIn - Use clerk.client.signIn.reload() with nonce + clerk.setActive() instead of legacy setActive - Add dismissBrowser() in finally block for both useSSO and useOAuth to prevent browser lingering - Handle session_exists errors by clearing stale JWT from SecureStore and retrying - Simplify return type: hook handles session activation internally, callers no longer need setActive --- packages/expo/src/hooks/useOAuth.ts | 21 ++++-- packages/expo/src/hooks/useSSO.ts | 107 +++++++++++++++------------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/expo/src/hooks/useOAuth.ts b/packages/expo/src/hooks/useOAuth.ts index 80487ecf8ad..cb77f03d66a 100644 --- a/packages/expo/src/hooks/useOAuth.ts +++ b/packages/expo/src/hooks/useOAuth.ts @@ -77,13 +77,22 @@ export function useOAuth(useOAuthParams: UseOAuthFlowParams) { const { externalVerificationRedirectURL } = signIn.firstFactorVerification; - const authSessionResult = await WebBrowserModule.openAuthSessionAsync( - // @ts-ignore - externalVerificationRedirectURL.toString(), - oauthRedirectUrl, - ); + let authSessionResult: WebBrowser.WebBrowserAuthSessionResult; + try { + authSessionResult = await WebBrowserModule.openAuthSessionAsync( + // @ts-ignore + externalVerificationRedirectURL.toString(), + oauthRedirectUrl, + ); + } finally { + try { + await WebBrowserModule.dismissBrowser(); + } catch { + // Already dismissed + } + } - // @ts-expect-error + // @ts-expect-error - url exists on success result type const { type, url } = authSessionResult || {}; // TODO: Check all the possible AuthSession results diff --git a/packages/expo/src/hooks/useSSO.ts b/packages/expo/src/hooks/useSSO.ts index 7dff8a208a2..b7ecfec1818 100644 --- a/packages/expo/src/hooks/useSSO.ts +++ b/packages/expo/src/hooks/useSSO.ts @@ -1,13 +1,10 @@ -import { useSignIn, useSignUp } from '@clerk/react/legacy'; -import type { - EnterpriseSSOStrategy, - OAuthStrategy, - SetActive, - SignInResource, - SignUpResource, -} from '@clerk/shared/types'; +import { useClerk, useSignIn } from '@clerk/react'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { OAuthStrategy, EnterpriseSSOStrategy } from '@clerk/shared/types'; +import * as SecureStore from 'expo-secure-store'; import type * as WebBrowser from 'expo-web-browser'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; import { errorThrower } from '../utils/errors'; export type StartSSOFlowParams = { @@ -27,27 +24,20 @@ export type StartSSOFlowParams = { export type StartSSOFlowReturnType = { createdSessionId: string | null; authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; - setActive?: SetActive; - signIn?: SignInResource; - signUp?: SignUpResource; }; export function useSSO() { - const { signIn, setActive, isLoaded: isSignInLoaded } = useSignIn(); - const { signUp, isLoaded: isSignUpLoaded } = useSignUp(); + const clerk = useClerk(); + const { signIn } = useSignIn(); async function startSSOFlow(startSSOFlowParams: StartSSOFlowParams): Promise { - if (!isSignInLoaded || !isSignUpLoaded) { + if (!signIn || !clerk.client) { return { createdSessionId: null, authSessionResult: null, - signIn, - signUp, - setActive, }; } - // Dynamically import expo-auth-session and expo-web-browser only when needed // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- dynamic import of optional dependency let AuthSession: typeof import('expo-auth-session'); // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- dynamic import of optional dependency @@ -61,63 +51,84 @@ export function useSSO() { ); } - const { strategy, unsafeMetadata, authSessionOptions } = startSSOFlowParams ?? {}; + const { strategy, authSessionOptions } = startSSOFlowParams ?? {}; - /** - * Creates a redirect URL based on the application platform - * It must be whitelisted, either via Clerk Dashboard, or BAPI, in order - * to include the `rotating_token_nonce` on SSO callback - * @ref https://clerk.com/docs/reference/backend-api/tag/Redirect-URLs#operation/CreateRedirectURL - */ const redirectUrl = startSSOFlowParams.redirectUrl ?? AuthSession.makeRedirectUri({ path: 'sso-callback', }); - await signIn.create({ + const createParams = { strategy, redirectUrl, ...(startSSOFlowParams.strategy === 'enterprise_sso' ? { identifier: startSSOFlowParams.identifier } : {}), - }); + }; + + // Create the sign-in attempt. If a stale session exists (e.g. JWT persisted + // in SecureStore after an incomplete sign-out), clear the token and retry. + let createResult = await signIn.create(createParams); + if (createResult.error) { + const isSessionExists = + isClerkAPIResponseError(createResult.error) && createResult.error.errors.some(e => e.code === 'session_exists'); + const isAlreadySignedIn = createResult.error.message?.includes('already signed in'); + + if (isSessionExists || isAlreadySignedIn) { + await SecureStore.deleteItemAsync(CLERK_CLIENT_JWT_KEY); + createResult = await signIn.create(createParams); + if (createResult.error) { + throw createResult.error; + } + } else { + throw createResult.error; + } + } const { externalVerificationRedirectURL } = signIn.firstFactorVerification; if (!externalVerificationRedirectURL) { return errorThrower.throw('Missing external verification redirect URL for SSO flow'); } - const authSessionResult = await WebBrowserModule.openAuthSessionAsync( - externalVerificationRedirectURL.toString(), - redirectUrl, - authSessionOptions, - ); + // Open the in-app browser for the OAuth/SSO provider. + let authSessionResult: WebBrowser.WebBrowserAuthSessionResult; + try { + authSessionResult = await WebBrowserModule.openAuthSessionAsync( + externalVerificationRedirectURL.toString(), + redirectUrl, + authSessionOptions, + ); + } finally { + // Dismiss the browser to prevent it from lingering in the background, + // which can cause subsequent SSO attempts to fail or appear frozen. + try { + await WebBrowserModule.dismissBrowser(); + } catch { + // Already dismissed (e.g. iOS ASWebAuthenticationSession auto-dismisses on success) + } + } + if (authSessionResult.type !== 'success' || !authSessionResult.url) { return { createdSessionId: null, - setActive, - signIn, - signUp, authSessionResult, }; } - const params = new URL(authSessionResult.url).searchParams; - const rotatingTokenNonce = params.get('rotating_token_nonce') ?? ''; - await signIn.reload({ rotatingTokenNonce }); + const callbackParams = new URL(authSessionResult.url).searchParams; + const createdSessionId = callbackParams.get('created_session_id'); + const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; - const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; - if (userNeedsToBeCreated) { - await signUp.create({ - transfer: true, - unsafeMetadata, - }); + // Pass the nonce to FAPI to verify the OAuth callback and update the client + // with the newly created session. The FAPI response populates the client's + // session list as a side effect, which is required for setActive to work. + await clerk.client.signIn.reload({ rotatingTokenNonce }); + + if (createdSessionId) { + await clerk.setActive({ session: createdSessionId }); } return { - createdSessionId: signUp.createdSessionId ?? signIn.createdSessionId, - setActive, - signIn, - signUp, + createdSessionId, authSessionResult, }; } From a1c71d35d7ffb528278f9f5734dbcb155496e4ea Mon Sep 17 00:00:00 2001 From: ChrisCanin Date: Fri, 20 Mar 2026 09:09:44 -0700 Subject: [PATCH 2/2] fix(expo): handle both thrown and returned errors from signIn.create in useSSO --- packages/expo/src/hooks/useSSO.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/expo/src/hooks/useSSO.ts b/packages/expo/src/hooks/useSSO.ts index b7ecfec1818..fbe0c15e35f 100644 --- a/packages/expo/src/hooks/useSSO.ts +++ b/packages/expo/src/hooks/useSSO.ts @@ -67,20 +67,25 @@ export function useSSO() { // Create the sign-in attempt. If a stale session exists (e.g. JWT persisted // in SecureStore after an incomplete sign-out), clear the token and retry. - let createResult = await signIn.create(createParams); - if (createResult.error) { - const isSessionExists = - isClerkAPIResponseError(createResult.error) && createResult.error.errors.some(e => e.code === 'session_exists'); - const isAlreadySignedIn = createResult.error.message?.includes('already signed in'); + // The error can surface as either a thrown exception (client-side "already signed in" + // guard) or a returned { error } (FAPI "session_exists" response). + try { + const createResult = await signIn.create(createParams); + if (createResult.error) { + throw createResult.error; + } + } catch (err) { + const isSessionExists = isClerkAPIResponseError(err) && err.errors.some(e => e.code === 'session_exists'); + const isAlreadySignedIn = err instanceof Error && err.message?.includes('already signed in'); if (isSessionExists || isAlreadySignedIn) { await SecureStore.deleteItemAsync(CLERK_CLIENT_JWT_KEY); - createResult = await signIn.create(createParams); - if (createResult.error) { - throw createResult.error; + const retryResult = await signIn.create(createParams); + if (retryResult.error) { + throw retryResult.error; } } else { - throw createResult.error; + throw err; } }