Skip to content

Commit 205771d

Browse files
committed
feat(expo): add experimental useSSO with new hooks
1 parent 409cdae commit 205771d

4 files changed

Lines changed: 436 additions & 1 deletion

File tree

packages/expo/src/experimental.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export {};
1+
export * from './hooks/useSSO.experimental';
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
4+
import { useSSO as experimentalUseSSO } from '../../experimental';
5+
import { useSSO } from '../useSSO.experimental';
6+
7+
const mocks = vi.hoisted(() => {
8+
return {
9+
useClerk: vi.fn(),
10+
useSignIn: vi.fn(),
11+
useSignUp: vi.fn(),
12+
makeRedirectUri: vi.fn(),
13+
openAuthSessionAsync: vi.fn(),
14+
loadSSODependencies: vi.fn(),
15+
};
16+
});
17+
18+
vi.mock('@clerk/react', () => {
19+
return {
20+
useClerk: mocks.useClerk,
21+
useSignIn: mocks.useSignIn,
22+
useSignUp: mocks.useSignUp,
23+
};
24+
});
25+
26+
vi.mock('../ssoDependencies', () => {
27+
return {
28+
loadSSODependencies: mocks.loadSSODependencies,
29+
};
30+
});
31+
32+
vi.mock('react-native', () => {
33+
return {
34+
Platform: {
35+
OS: 'ios',
36+
},
37+
};
38+
});
39+
40+
describe('experimental useSSO', () => {
41+
const mockSetActive = vi.fn();
42+
const mockClientSignIn = {
43+
reload: vi.fn(),
44+
};
45+
const mockSignIn = {
46+
create: vi.fn(),
47+
createdSessionId: null as string | null,
48+
firstFactorVerification: {
49+
externalVerificationRedirectURL: new URL('https://accounts.example.com/sso'),
50+
status: 'unverified' as string | null,
51+
},
52+
};
53+
const mockSignUp = {
54+
create: vi.fn(),
55+
createdSessionId: null as string | null,
56+
};
57+
58+
beforeEach(() => {
59+
vi.clearAllMocks();
60+
61+
mocks.makeRedirectUri.mockReturnValue('myapp://sso-callback');
62+
mocks.openAuthSessionAsync.mockResolvedValue({
63+
type: 'success',
64+
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
65+
});
66+
mocks.loadSSODependencies.mockReturnValue({
67+
AuthSession: {
68+
makeRedirectUri: mocks.makeRedirectUri,
69+
},
70+
WebBrowser: {
71+
openAuthSessionAsync: mocks.openAuthSessionAsync,
72+
},
73+
});
74+
75+
mockSignIn.createdSessionId = null;
76+
mockSignIn.firstFactorVerification.externalVerificationRedirectURL = new URL('https://accounts.example.com/sso');
77+
mockSignIn.firstFactorVerification.status = 'unverified';
78+
mockSignIn.create.mockResolvedValue({ error: null });
79+
mockClientSignIn.reload.mockImplementation(({ rotatingTokenNonce }) => {
80+
if (rotatingTokenNonce === 'nonce_123') {
81+
mockSignIn.firstFactorVerification.status = 'verified';
82+
mockSignIn.createdSessionId = 'sess_123';
83+
}
84+
85+
return Promise.resolve();
86+
});
87+
88+
mockSignUp.createdSessionId = null;
89+
mockSignUp.create.mockResolvedValue({ error: null });
90+
91+
mocks.useClerk.mockReturnValue({
92+
loaded: true,
93+
setActive: mockSetActive,
94+
client: {
95+
signIn: mockClientSignIn,
96+
},
97+
});
98+
mocks.useSignIn.mockReturnValue({
99+
signIn: mockSignIn,
100+
fetchStatus: 'idle',
101+
errors: {},
102+
});
103+
mocks.useSignUp.mockReturnValue({
104+
signUp: mockSignUp,
105+
fetchStatus: 'idle',
106+
errors: {},
107+
});
108+
});
109+
110+
afterEach(() => {
111+
vi.restoreAllMocks();
112+
});
113+
114+
test('exports useSSO from the experimental entrypoint', () => {
115+
expect(experimentalUseSSO).toBe(useSSO);
116+
});
117+
118+
test('returns the startSSOFlow function', () => {
119+
const { result } = renderHook(() => useSSO());
120+
121+
expect(typeof result.current.startSSOFlow).toBe('function');
122+
expect(mocks.useSignIn).toHaveBeenCalled();
123+
expect(mocks.useSignUp).toHaveBeenCalled();
124+
});
125+
126+
test('returns early without starting the flow when Clerk is not loaded', async () => {
127+
mocks.useClerk.mockReturnValue({
128+
loaded: false,
129+
setActive: mockSetActive,
130+
client: null,
131+
});
132+
133+
const { result } = renderHook(() => useSSO());
134+
135+
const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });
136+
137+
expect(mockSignIn.create).not.toHaveBeenCalled();
138+
expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled();
139+
expect(response.createdSessionId).toBe(null);
140+
expect(response.setActive).toBe(mockSetActive);
141+
expect(response.signIn).toBe(mockSignIn);
142+
expect(response.signUp).toBe(mockSignUp);
143+
});
144+
145+
test('starts OAuth SSO with future sign-in hooks and reloads the underlying client with the callback nonce', async () => {
146+
const { result } = renderHook(() => useSSO());
147+
148+
const response = await result.current.startSSOFlow({
149+
strategy: 'oauth_google',
150+
authSessionOptions: { showInRecents: true },
151+
});
152+
153+
expect(mockSignIn.create).toHaveBeenCalledWith({
154+
strategy: 'oauth_google',
155+
redirectUrl: 'myapp://sso-callback',
156+
});
157+
expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith(
158+
'https://accounts.example.com/sso',
159+
'myapp://sso-callback',
160+
{ showInRecents: true },
161+
);
162+
expect(mockClientSignIn.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce_123' });
163+
expect(response).toMatchObject({
164+
createdSessionId: 'sess_123',
165+
authSessionResult: {
166+
type: 'success',
167+
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
168+
},
169+
setActive: mockSetActive,
170+
signIn: mockSignIn,
171+
signUp: mockSignUp,
172+
});
173+
});
174+
175+
test('passes an enterprise SSO identifier to sign-in creation', async () => {
176+
const { result } = renderHook(() => useSSO());
177+
178+
await result.current.startSSOFlow({
179+
strategy: 'enterprise_sso',
180+
identifier: 'user@example.com',
181+
});
182+
183+
expect(mockSignIn.create).toHaveBeenCalledWith({
184+
strategy: 'enterprise_sso',
185+
redirectUrl: 'myapp://sso-callback',
186+
identifier: 'user@example.com',
187+
});
188+
});
189+
190+
test('creates a transfer sign-up with unsafe metadata when sign-in is transferable', async () => {
191+
mockClientSignIn.reload.mockImplementation(() => {
192+
mockSignIn.firstFactorVerification.status = 'transferable';
193+
return Promise.resolve();
194+
});
195+
mockSignUp.create.mockImplementation(() => {
196+
mockSignUp.createdSessionId = 'sess_signup';
197+
return Promise.resolve({ error: null });
198+
});
199+
200+
const unsafeMetadata = { source: 'mobile' };
201+
const { result } = renderHook(() => useSSO());
202+
203+
const response = await result.current.startSSOFlow({
204+
strategy: 'oauth_google',
205+
unsafeMetadata,
206+
});
207+
208+
expect(mockSignUp.create).toHaveBeenCalledWith({
209+
transfer: true,
210+
unsafeMetadata,
211+
});
212+
expect(response.createdSessionId).toBe('sess_signup');
213+
});
214+
215+
test('returns without reloading when the browser auth session is dismissed', async () => {
216+
mocks.openAuthSessionAsync.mockResolvedValue({
217+
type: 'dismiss',
218+
});
219+
220+
const { result } = renderHook(() => useSSO());
221+
222+
const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });
223+
224+
expect(mockClientSignIn.reload).not.toHaveBeenCalled();
225+
expect(response.createdSessionId).toBe(null);
226+
expect(response.authSessionResult).toEqual({ type: 'dismiss' });
227+
});
228+
229+
test('surfaces future sign-in create errors', async () => {
230+
mockSignIn.create.mockResolvedValue({ error: new Error('sign-in failed') });
231+
232+
const { result } = renderHook(() => useSSO());
233+
234+
await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toThrow('sign-in failed');
235+
expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled();
236+
});
237+
238+
test('surfaces the underlying error when an auth-session dependency fails to load', async () => {
239+
mocks.loadSSODependencies.mockImplementation(() => {
240+
throw new Error(
241+
'@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',
242+
);
243+
});
244+
245+
const { result } = renderHook(() => useSSO());
246+
247+
await expect(result.current.startSSOFlow({ strategy: 'oauth_google' })).rejects.toThrow(
248+
/required for SSO: missing auth session\. If they are not installed/s,
249+
);
250+
});
251+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type * as AuthSession from 'expo-auth-session';
2+
import type * as WebBrowser from 'expo-web-browser';
3+
4+
import { errorThrower } from '../utils/errors';
5+
6+
export function loadSSODependencies(): {
7+
AuthSession: typeof AuthSession;
8+
WebBrowser: typeof WebBrowser;
9+
} {
10+
// Load via synchronous require() instead of import(): Metro inlines require() into the main
11+
// bundle, while import() emits an async chunk that fails to resolve without @expo/metro-runtime.
12+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- type-only annotation for optional dependency
13+
let AuthSessionModule: typeof import('expo-auth-session');
14+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- type-only annotation for optional dependency
15+
let WebBrowserModule: typeof import('expo-web-browser');
16+
try {
17+
// eslint-disable-next-line @typescript-eslint/no-require-imports
18+
AuthSessionModule = require('expo-auth-session');
19+
// eslint-disable-next-line @typescript-eslint/no-require-imports
20+
WebBrowserModule = require('expo-web-browser');
21+
} catch (err) {
22+
return errorThrower.throw(
23+
`Unable to load expo-auth-session and expo-web-browser, which are required for SSO: ${
24+
err instanceof Error ? err.message : 'Unknown error'
25+
}. If they are not installed, run: npx expo install expo-auth-session expo-web-browser`,
26+
);
27+
}
28+
29+
return {
30+
AuthSession: AuthSessionModule,
31+
WebBrowser: WebBrowserModule,
32+
};
33+
}

0 commit comments

Comments
 (0)