From 205771d3aad6bbf0e3f6cc6c880df5e0ee7dc3b2 Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 7 Jul 2026 12:33:08 -0700 Subject: [PATCH 1/3] feat(expo): add experimental useSSO with new hooks --- packages/expo/src/experimental.ts | 2 +- .../__tests__/useSSO.experimental.test.ts | 251 ++++++++++++++++++ packages/expo/src/hooks/ssoDependencies.ts | 33 +++ .../expo/src/hooks/useSSO.experimental.ts | 151 +++++++++++ 4 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts create mode 100644 packages/expo/src/hooks/ssoDependencies.ts create mode 100644 packages/expo/src/hooks/useSSO.experimental.ts diff --git a/packages/expo/src/experimental.ts b/packages/expo/src/experimental.ts index cb0ff5c3b54..8aaff66da99 100644 --- a/packages/expo/src/experimental.ts +++ b/packages/expo/src/experimental.ts @@ -1 +1 @@ -export {}; +export * from './hooks/useSSO.experimental'; diff --git a/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts b/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts new file mode 100644 index 00000000000..f54645e449f --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts @@ -0,0 +1,251 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useSSO as experimentalUseSSO } from '../../experimental'; +import { useSSO } from '../useSSO.experimental'; + +const mocks = vi.hoisted(() => { + return { + useClerk: vi.fn(), + useSignIn: vi.fn(), + useSignUp: vi.fn(), + makeRedirectUri: vi.fn(), + openAuthSessionAsync: vi.fn(), + loadSSODependencies: vi.fn(), + }; +}); + +vi.mock('@clerk/react', () => { + return { + useClerk: mocks.useClerk, + useSignIn: mocks.useSignIn, + useSignUp: mocks.useSignUp, + }; +}); + +vi.mock('../ssoDependencies', () => { + return { + loadSSODependencies: mocks.loadSSODependencies, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +describe('experimental useSSO', () => { + const mockSetActive = vi.fn(); + const mockClientSignIn = { + reload: vi.fn(), + }; + const mockSignIn = { + create: vi.fn(), + createdSessionId: null as string | null, + firstFactorVerification: { + externalVerificationRedirectURL: new URL('https://accounts.example.com/sso'), + status: 'unverified' as string | null, + }, + }; + const mockSignUp = { + create: vi.fn(), + createdSessionId: null as string | null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mocks.makeRedirectUri.mockReturnValue('myapp://sso-callback'); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp://sso-callback?rotating_token_nonce=nonce_123', + }); + mocks.loadSSODependencies.mockReturnValue({ + AuthSession: { + makeRedirectUri: mocks.makeRedirectUri, + }, + WebBrowser: { + openAuthSessionAsync: mocks.openAuthSessionAsync, + }, + }); + + mockSignIn.createdSessionId = null; + mockSignIn.firstFactorVerification.externalVerificationRedirectURL = new URL('https://accounts.example.com/sso'); + mockSignIn.firstFactorVerification.status = 'unverified'; + mockSignIn.create.mockResolvedValue({ error: null }); + mockClientSignIn.reload.mockImplementation(({ rotatingTokenNonce }) => { + if (rotatingTokenNonce === 'nonce_123') { + mockSignIn.firstFactorVerification.status = 'verified'; + mockSignIn.createdSessionId = 'sess_123'; + } + + return Promise.resolve(); + }); + + mockSignUp.createdSessionId = null; + mockSignUp.create.mockResolvedValue({ error: null }); + + mocks.useClerk.mockReturnValue({ + loaded: true, + setActive: mockSetActive, + client: { + signIn: mockClientSignIn, + }, + }); + mocks.useSignIn.mockReturnValue({ + signIn: mockSignIn, + fetchStatus: 'idle', + errors: {}, + }); + mocks.useSignUp.mockReturnValue({ + signUp: mockSignUp, + fetchStatus: 'idle', + errors: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('exports useSSO from the experimental entrypoint', () => { + expect(experimentalUseSSO).toBe(useSSO); + }); + + test('returns the startSSOFlow function', () => { + const { result } = renderHook(() => useSSO()); + + expect(typeof result.current.startSSOFlow).toBe('function'); + expect(mocks.useSignIn).toHaveBeenCalled(); + expect(mocks.useSignUp).toHaveBeenCalled(); + }); + + test('returns early without starting the flow when Clerk is not loaded', async () => { + mocks.useClerk.mockReturnValue({ + loaded: false, + setActive: mockSetActive, + client: null, + }); + + const { result } = renderHook(() => useSSO()); + + const response = await result.current.startSSOFlow({ strategy: 'oauth_google' }); + + expect(mockSignIn.create).not.toHaveBeenCalled(); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe(null); + expect(response.setActive).toBe(mockSetActive); + expect(response.signIn).toBe(mockSignIn); + expect(response.signUp).toBe(mockSignUp); + }); + + test('starts OAuth SSO with future sign-in hooks and reloads the underlying client with the callback nonce', async () => { + const { result } = renderHook(() => useSSO()); + + const response = await result.current.startSSOFlow({ + strategy: 'oauth_google', + authSessionOptions: { showInRecents: true }, + }); + + expect(mockSignIn.create).toHaveBeenCalledWith({ + strategy: 'oauth_google', + redirectUrl: 'myapp://sso-callback', + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://accounts.example.com/sso', + 'myapp://sso-callback', + { showInRecents: true }, + ); + expect(mockClientSignIn.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce_123' }); + expect(response).toMatchObject({ + createdSessionId: 'sess_123', + authSessionResult: { + type: 'success', + url: 'myapp://sso-callback?rotating_token_nonce=nonce_123', + }, + setActive: mockSetActive, + signIn: mockSignIn, + signUp: mockSignUp, + }); + }); + + test('passes an enterprise SSO identifier to sign-in creation', async () => { + const { result } = renderHook(() => useSSO()); + + await result.current.startSSOFlow({ + strategy: 'enterprise_sso', + identifier: 'user@example.com', + }); + + expect(mockSignIn.create).toHaveBeenCalledWith({ + strategy: 'enterprise_sso', + redirectUrl: 'myapp://sso-callback', + identifier: 'user@example.com', + }); + }); + + test('creates a transfer sign-up with unsafe metadata when sign-in is transferable', async () => { + mockClientSignIn.reload.mockImplementation(() => { + mockSignIn.firstFactorVerification.status = 'transferable'; + return Promise.resolve(); + }); + mockSignUp.create.mockImplementation(() => { + mockSignUp.createdSessionId = 'sess_signup'; + return Promise.resolve({ error: null }); + }); + + const unsafeMetadata = { source: 'mobile' }; + const { result } = renderHook(() => useSSO()); + + const response = await result.current.startSSOFlow({ + strategy: 'oauth_google', + unsafeMetadata, + }); + + expect(mockSignUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata, + }); + expect(response.createdSessionId).toBe('sess_signup'); + }); + + test('returns without reloading when the browser auth session is dismissed', async () => { + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'dismiss', + }); + + const { result } = renderHook(() => useSSO()); + + const response = await result.current.startSSOFlow({ strategy: 'oauth_google' }); + + expect(mockClientSignIn.reload).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe(null); + expect(response.authSessionResult).toEqual({ type: 'dismiss' }); + }); + + test('surfaces future sign-in create errors', async () => { + mockSignIn.create.mockResolvedValue({ error: new Error('sign-in failed') }); + + const { result } = renderHook(() => useSSO()); + + await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toThrow('sign-in failed'); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); + + test('surfaces the underlying error when an auth-session dependency fails to load', async () => { + mocks.loadSSODependencies.mockImplementation(() => { + throw new Error( + '@clerk/expo: Unable to load expo-auth-session and expo-web-browser, which are required for SSO: missing auth session. If they are not installed, run: npx expo install expo-auth-session expo-web-browser', + ); + }); + + const { result } = renderHook(() => useSSO()); + + await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toThrow( + /required for SSO: missing auth session\. If they are not installed/s, + ); + }); +}); diff --git a/packages/expo/src/hooks/ssoDependencies.ts b/packages/expo/src/hooks/ssoDependencies.ts new file mode 100644 index 00000000000..625137d8f27 --- /dev/null +++ b/packages/expo/src/hooks/ssoDependencies.ts @@ -0,0 +1,33 @@ +import type * as AuthSession from 'expo-auth-session'; +import type * as WebBrowser from 'expo-web-browser'; + +import { errorThrower } from '../utils/errors'; + +export function loadSSODependencies(): { + AuthSession: typeof AuthSession; + WebBrowser: typeof WebBrowser; +} { + // Load via synchronous require() instead of import(): Metro inlines require() into the main + // bundle, while import() emits an async chunk that fails to resolve without @expo/metro-runtime. + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- type-only annotation for optional dependency + let AuthSessionModule: typeof import('expo-auth-session'); + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- type-only annotation for optional dependency + let WebBrowserModule: typeof import('expo-web-browser'); + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AuthSessionModule = require('expo-auth-session'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + WebBrowserModule = require('expo-web-browser'); + } catch (err) { + return errorThrower.throw( + `Unable to load expo-auth-session and expo-web-browser, which are required for SSO: ${ + err instanceof Error ? err.message : 'Unknown error' + }. If they are not installed, run: npx expo install expo-auth-session expo-web-browser`, + ); + } + + return { + AuthSession: AuthSessionModule, + WebBrowser: WebBrowserModule, + }; +} diff --git a/packages/expo/src/hooks/useSSO.experimental.ts b/packages/expo/src/hooks/useSSO.experimental.ts new file mode 100644 index 00000000000..e47685a9237 --- /dev/null +++ b/packages/expo/src/hooks/useSSO.experimental.ts @@ -0,0 +1,151 @@ +import { useClerk, useSignIn, useSignUp } from '@clerk/react'; +import type { + EnterpriseSSOStrategy, + OAuthStrategy, + SetActive, + SignInFutureResource, + SignUpFutureResource, +} from '@clerk/shared/types'; +import type * as WebBrowser from 'expo-web-browser'; + +import { errorThrower } from '../utils/errors'; +import { loadSSODependencies } from './ssoDependencies'; + +export type StartSSOFlowParams = { + redirectUrl?: string; + unsafeMetadata?: SignUpUnsafeMetadata; + authSessionOptions?: Pick; +} & ( + | { + strategy: OAuthStrategy; + } + | { + strategy: EnterpriseSSOStrategy; + identifier: string; + } +); + +export type StartSSOFlowReturnType = { + createdSessionId: string | null; + authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; + setActive?: SetActive; + signIn?: SignInFutureResource | null; + signUp?: SignUpFutureResource | null; +}; + +function getSSOErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { + return error.message; + } + + return 'An unknown SSO error occurred'; +} + +function assertNoSSOError(result: { error: unknown }): void { + if (result.error) { + return errorThrower.throw(getSSOErrorMessage(result.error)); + } +} + +export function useSSO() { + const { client, loaded, setActive } = useClerk(); + const { signIn, fetchStatus: signInFetchStatus } = useSignIn(); + const { signUp, fetchStatus: signUpFetchStatus } = useSignUp(); + + async function startSSOFlow(startSSOFlowParams: StartSSOFlowParams): Promise { + if ( + !loaded || + !client || + !signIn || + !signUp || + signInFetchStatus === 'fetching' || + signUpFetchStatus === 'fetching' + ) { + return { + createdSessionId: null, + authSessionResult: null, + signIn, + signUp, + setActive, + }; + } + + const { AuthSession, WebBrowser: WebBrowserModule } = loadSSODependencies(); + + const { strategy, unsafeMetadata, 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/POST/redirect_urls + */ + const redirectUrl = + startSSOFlowParams.redirectUrl ?? + AuthSession.makeRedirectUri({ + path: 'sso-callback', + }); + + assertNoSSOError( + await signIn.create({ + strategy, + redirectUrl, + ...(startSSOFlowParams.strategy === 'enterprise_sso' ? { identifier: startSSOFlowParams.identifier } : {}), + }), + ); + + 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, + ); + 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 client.signIn.reload({ rotatingTokenNonce }); + + const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; + if (userNeedsToBeCreated) { + assertNoSSOError( + await signUp.create({ + transfer: true, + unsafeMetadata, + }), + ); + } + + return { + createdSessionId: signUp.createdSessionId ?? signIn.createdSessionId, + setActive, + signIn, + signUp, + authSessionResult, + }; + } + + return { + startSSOFlow, + }; +} From a6ef32938c1863bb5e3eea047e225fe983678a0c Mon Sep 17 00:00:00 2001 From: Sam Wolfand Date: Wed, 29 Jul 2026 13:08:49 -0700 Subject: [PATCH 2/3] Update packages/expo/src/hooks/useSSO.experimental.ts Co-authored-by: Robert Soriano --- packages/expo/src/hooks/useSSO.experimental.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/expo/src/hooks/useSSO.experimental.ts b/packages/expo/src/hooks/useSSO.experimental.ts index e47685a9237..8203ff7572a 100644 --- a/packages/expo/src/hooks/useSSO.experimental.ts +++ b/packages/expo/src/hooks/useSSO.experimental.ts @@ -61,14 +61,7 @@ export function useSSO() { const { signUp, fetchStatus: signUpFetchStatus } = useSignUp(); async function startSSOFlow(startSSOFlowParams: StartSSOFlowParams): Promise { - if ( - !loaded || - !client || - !signIn || - !signUp || - signInFetchStatus === 'fetching' || - signUpFetchStatus === 'fetching' - ) { + if (!client || !signIn || !signUp) { return { createdSessionId: null, authSessionResult: null, From e7a9719be15e873be29bbd728231333c49a17bad Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 29 Jul 2026 13:24:55 -0700 Subject: [PATCH 3/3] fix(expo): finalize experimental SSO sessions --- .changeset/bright-taxis-sing.md | 5 + .../hooks/__tests__/ssoDependencies.test.ts | 33 ++++ .../__tests__/useSSO.experimental.test.ts | 42 ++++- .../expo/src/hooks/useSSO.experimental.ts | 149 ++++++++++++------ 4 files changed, 180 insertions(+), 49 deletions(-) create mode 100644 .changeset/bright-taxis-sing.md create mode 100644 packages/expo/src/hooks/__tests__/ssoDependencies.test.ts diff --git a/.changeset/bright-taxis-sing.md b/.changeset/bright-taxis-sing.md new file mode 100644 index 00000000000..ea058b22fc0 --- /dev/null +++ b/.changeset/bright-taxis-sing.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically. diff --git a/packages/expo/src/hooks/__tests__/ssoDependencies.test.ts b/packages/expo/src/hooks/__tests__/ssoDependencies.test.ts new file mode 100644 index 00000000000..3bd03b24852 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/ssoDependencies.test.ts @@ -0,0 +1,33 @@ +import Module from 'node:module'; + +import { describe, expect, test, vi } from 'vitest'; + +import { loadSSODependencies } from '../ssoDependencies'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +describe('loadSSODependencies', () => { + test('throws install guidance when an optional dependency cannot be loaded', () => { + const loadSpy = vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-auth-session') { + throw new Error('Cannot find module expo-auth-session'); + } + + return originalModuleLoad(request, parent, isMain); + }); + + expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/); + loadSpy.mockRestore(); + }); +}); diff --git a/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts b/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts index f54645e449f..de22aa3fec9 100644 --- a/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts +++ b/packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts @@ -44,7 +44,9 @@ describe('experimental useSSO', () => { }; const mockSignIn = { create: vi.fn(), + finalize: vi.fn(), createdSessionId: null as string | null, + existingSession: null as { sessionId: string } | null, firstFactorVerification: { externalVerificationRedirectURL: new URL('https://accounts.example.com/sso'), status: 'unverified' as string | null, @@ -52,7 +54,9 @@ describe('experimental useSSO', () => { }; const mockSignUp = { create: vi.fn(), + finalize: vi.fn(), createdSessionId: null as string | null, + existingSession: null as { sessionId: string } | null, }; beforeEach(() => { @@ -73,9 +77,11 @@ describe('experimental useSSO', () => { }); mockSignIn.createdSessionId = null; + mockSignIn.existingSession = null; mockSignIn.firstFactorVerification.externalVerificationRedirectURL = new URL('https://accounts.example.com/sso'); mockSignIn.firstFactorVerification.status = 'unverified'; mockSignIn.create.mockResolvedValue({ error: null }); + mockSignIn.finalize.mockResolvedValue({ error: null }); mockClientSignIn.reload.mockImplementation(({ rotatingTokenNonce }) => { if (rotatingTokenNonce === 'nonce_123') { mockSignIn.firstFactorVerification.status = 'verified'; @@ -86,7 +92,9 @@ describe('experimental useSSO', () => { }); mockSignUp.createdSessionId = null; + mockSignUp.existingSession = null; mockSignUp.create.mockResolvedValue({ error: null }); + mockSignUp.finalize.mockResolvedValue({ error: null }); mocks.useClerk.mockReturnValue({ loaded: true, @@ -137,9 +145,9 @@ describe('experimental useSSO', () => { expect(mockSignIn.create).not.toHaveBeenCalled(); expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); expect(response.createdSessionId).toBe(null); - expect(response.setActive).toBe(mockSetActive); expect(response.signIn).toBe(mockSignIn); expect(response.signUp).toBe(mockSignUp); + expect(response).not.toHaveProperty('setActive'); }); test('starts OAuth SSO with future sign-in hooks and reloads the underlying client with the callback nonce', async () => { @@ -160,16 +168,17 @@ describe('experimental useSSO', () => { { showInRecents: true }, ); expect(mockClientSignIn.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce_123' }); + expect(mockSignIn.finalize).toHaveBeenCalledOnce(); expect(response).toMatchObject({ createdSessionId: 'sess_123', authSessionResult: { type: 'success', url: 'myapp://sso-callback?rotating_token_nonce=nonce_123', }, - setActive: mockSetActive, signIn: mockSignIn, signUp: mockSignUp, }); + expect(response).not.toHaveProperty('setActive'); }); test('passes an enterprise SSO identifier to sign-in creation', async () => { @@ -209,9 +218,26 @@ describe('experimental useSSO', () => { transfer: true, unsafeMetadata, }); + expect(mockSignUp.finalize).toHaveBeenCalledOnce(); expect(response.createdSessionId).toBe('sess_signup'); }); + test('activates an existing session without finalizing a future resource', async () => { + mockClientSignIn.reload.mockImplementation(() => { + mockSignIn.existingSession = { sessionId: 'sess_existing' }; + return Promise.resolve(); + }); + + const { result } = renderHook(() => useSSO()); + + const response = await result.current.startSSOFlow({ strategy: 'oauth_google' }); + + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_existing' }); + expect(mockSignIn.finalize).not.toHaveBeenCalled(); + expect(mockSignUp.finalize).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe(null); + }); + test('returns without reloading when the browser auth session is dismissed', async () => { mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss', @@ -222,16 +248,22 @@ describe('experimental useSSO', () => { const response = await result.current.startSSOFlow({ strategy: 'oauth_google' }); expect(mockClientSignIn.reload).not.toHaveBeenCalled(); + expect(mockSignIn.finalize).not.toHaveBeenCalled(); + expect(mockSignUp.finalize).not.toHaveBeenCalled(); expect(response.createdSessionId).toBe(null); expect(response.authSessionResult).toEqual({ type: 'dismiss' }); }); - test('surfaces future sign-in create errors', async () => { - mockSignIn.create.mockResolvedValue({ error: new Error('sign-in failed') }); + test('preserves structured future sign-in create errors', async () => { + const clerkError = Object.assign(new Error('sign-in failed'), { + code: 'form_identifier_not_found', + errors: [{ code: 'form_identifier_not_found' }], + }); + mockSignIn.create.mockResolvedValue({ error: clerkError }); const { result } = renderHook(() => useSSO()); - await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toThrow('sign-in failed'); + await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toBe(clerkError); expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); }); diff --git a/packages/expo/src/hooks/useSSO.experimental.ts b/packages/expo/src/hooks/useSSO.experimental.ts index 8203ff7572a..053b16ec7d4 100644 --- a/packages/expo/src/hooks/useSSO.experimental.ts +++ b/packages/expo/src/hooks/useSSO.experimental.ts @@ -2,7 +2,6 @@ import { useClerk, useSignIn, useSignUp } from '@clerk/react'; import type { EnterpriseSSOStrategy, OAuthStrategy, - SetActive, SignInFutureResource, SignUpFutureResource, } from '@clerk/shared/types'; @@ -11,54 +10,99 @@ import type * as WebBrowser from 'expo-web-browser'; import { errorThrower } from '../utils/errors'; import { loadSSODependencies } from './ssoDependencies'; +/** + * Options for starting an OAuth or enterprise SSO flow. + */ export type StartSSOFlowParams = { + /** + * Native URL that Clerk redirects to after the browser authentication session completes. + * Defaults to an Expo AuthSession URL with the `sso-callback` path. + */ redirectUrl?: string; + /** + * Metadata to attach to the user when the SSO flow creates a new account. + */ unsafeMetadata?: SignUpUnsafeMetadata; + /** + * Options forwarded to `expo-web-browser` when opening the authentication session. + */ authSessionOptions?: Pick; } & ( | { + /** + * OAuth strategy to use for the SSO flow. + */ strategy: OAuthStrategy; } | { + /** + * Enterprise SSO strategy. + */ strategy: EnterpriseSSOStrategy; + /** + * Identifier used to discover the enterprise connection. + */ identifier: string; } ); +/** + * Result returned after an SSO attempt finishes. + */ export type StartSSOFlowReturnType = { + /** + * ID of the newly created session, or `null` when no new session was created. + */ createdSessionId: string | null; + /** + * Result returned by the browser authentication session, or `null` when the flow did not start. + */ authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; - setActive?: SetActive; + /** + * Current future sign-in resource. + */ signIn?: SignInFutureResource | null; + /** + * Current future sign-up resource. + */ signUp?: SignUpFutureResource | null; }; -function getSSOErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === 'string') { - return error; - } - - if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { - return error.message; - } - - return 'An unknown SSO error occurred'; -} - -function assertNoSSOError(result: { error: unknown }): void { - if (result.error) { - return errorThrower.throw(getSSOErrorMessage(result.error)); - } -} +/** + * Helpers returned by {@link useSSO}. + */ +export type UseSSOReturn = { + /** + * Starts an OAuth or enterprise SSO flow and activates the completed session. + * + * @param params - Options for the SSO flow. + * @returns The SSO result and current future resources. + * @throws A structured Clerk error when a future resource operation fails. + */ + startSSOFlow: (params: StartSSOFlowParams) => Promise; +}; -export function useSSO() { - const { client, loaded, setActive } = useClerk(); - const { signIn, fetchStatus: signInFetchStatus } = useSignIn(); - const { signUp, fetchStatus: signUpFetchStatus } = useSignUp(); +/** + * Returns a helper for authenticating users with OAuth or enterprise SSO in an Expo app. + * + * @returns An object containing `startSSOFlow`. + * @throws The returned `startSSOFlow` function throws when an SSO dependency cannot be loaded, the redirect response + * is invalid, or a Clerk future resource operation fails. + * + * @example + * ### Start a Google OAuth flow + * + * ```tsx + * const { startSSOFlow } = useSSO(); + * const { createdSessionId } = await startSSOFlow({ + * strategy: 'oauth_google', + * }); + * ``` + */ +export function useSSO(): UseSSOReturn { + const { client, setActive } = useClerk(); + const { signIn } = useSignIn(); + const { signUp } = useSignUp(); async function startSSOFlow(startSSOFlowParams: StartSSOFlowParams): Promise { if (!client || !signIn || !signUp) { @@ -67,7 +111,6 @@ export function useSSO() { authSessionResult: null, signIn, signUp, - setActive, }; } @@ -87,13 +130,14 @@ export function useSSO() { path: 'sso-callback', }); - assertNoSSOError( - await signIn.create({ - strategy, - redirectUrl, - ...(startSSOFlowParams.strategy === 'enterprise_sso' ? { identifier: startSSOFlowParams.identifier } : {}), - }), - ); + const { error: signInError } = await signIn.create({ + strategy, + redirectUrl, + ...(startSSOFlowParams.strategy === 'enterprise_sso' ? { identifier: startSSOFlowParams.identifier } : {}), + }); + if (signInError) { + throw signInError; + } const { externalVerificationRedirectURL } = signIn.firstFactorVerification; if (!externalVerificationRedirectURL) { @@ -108,7 +152,6 @@ export function useSSO() { if (authSessionResult.type !== 'success' || !authSessionResult.url) { return { createdSessionId: null, - setActive, signIn, signUp, authSessionResult, @@ -121,17 +164,35 @@ export function useSSO() { const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; if (userNeedsToBeCreated) { - assertNoSSOError( - await signUp.create({ - transfer: true, - unsafeMetadata, - }), - ); + const { error: signUpError } = await signUp.create({ + transfer: true, + unsafeMetadata, + }); + if (signUpError) { + throw signUpError; + } + } + + const createdSessionId = signUp.createdSessionId ?? signIn.createdSessionId; + if (signUp.createdSessionId) { + const { error } = await signUp.finalize(); + if (error) { + throw error; + } + } else if (signIn.createdSessionId) { + const { error } = await signIn.finalize(); + if (error) { + throw error; + } + } else { + const existingSessionId = signIn.existingSession?.sessionId ?? signUp.existingSession?.sessionId; + if (existingSessionId) { + await setActive({ session: existingSessionId }); + } } return { - createdSessionId: signUp.createdSessionId ?? signIn.createdSessionId, - setActive, + createdSessionId, signIn, signUp, authSessionResult,