15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
15 changes: 15 additions & 0 deletions .changeset/bright-taxis-sing.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
---
'@clerk/expo': minor
---

Add an experimental `useSSO()` hook at `@clerk/expo/experimental` that uses future auth resources and activates completed SSO sessions automatically.

```tsx
import { useSSO } from '@clerk/expo/experimental';

const { startSSOFlow } = useSSO();

await startSSOFlow({
strategy: 'oauth_google',
});
```
2 changes: 1 addition & 1 deletion packages/expo/src/experimental.ts
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
export {};
export * from './hooks/useSSO.experimental';
33 changes: 33 additions & 0 deletions packages/expo/src/hooks/__tests__/ssoDependencies.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Always restore the module-loader spy.

If Line 30 fails, Line 31 is skipped and _load remains mocked for subsequent tests. Use try/finally around the assertion.

Proposed fix
- expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);- loadSpy.mockRestore();+ try {+ expect(() => loadSSODependencies()).toThrow(/npx expo install expo-auth-session expo-web-browser/);+ } finally {+ loadSpy.mockRestore();+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
loadSpy.mockRestore();
test('throws install guidance when an optional dependency cannot be loaded',()=>{
constloadSpy=vi.spyOn(moduleWithLoad,'_load').mockImplementation((request,parent,isMain)=>{
if(request==='expo-auth-session'){
thrownewError('Cannot find module expo-auth-session');
}
returnoriginalModuleLoad(request,parent,isMain);
});
try{
expect(()=>loadSSODependencies()).toThrow(/npxexpoinstallexpo-auth-sessionexpo-web-browser/);
}finally{
loadSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/ssoDependencies.test.ts` around lines 21 -
31, Ensure the module-loader spy created in the loadSSODependencies test is
always restored, including when the assertion fails. Wrap the expect assertion
in a try/finally block and call loadSpy.mockRestore() in the finally clause.

});
});
335 changes: 335 additions & 0 deletions packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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(),
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,
},
};
const mockSignUp = {
create: vi.fn(),
finalize: vi.fn(),
createdSessionId: null as string | null,
existingSession: null as { sessionId: 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.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';
mockSignIn.createdSessionId = 'sess_123';
}

return Promise.resolve({ __internal_future: mockSignIn });
});

mockSignUp.createdSessionId = null;
mockSignUp.existingSession = null;
mockSignUp.create.mockResolvedValue({ error: null });
mockSignUp.finalize.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.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 () => {
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(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(response).toMatchObject({
createdSessionId: 'sess_123',
authSessionResult: {
type: 'success',
url: 'myapp://sso-callback?rotating_token_nonce=nonce_123',
},
signIn: mockSignIn,
signUp: mockSignUp,
});
expect(response).not.toHaveProperty('setActive');
});

test('uses the reloaded sign-in future for callback state and finalization', async () => {
const reloadedSignIn = {
...mockSignIn,
createdSessionId: 'sess_reloaded',
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'verified',
},
finalize: vi.fn().mockResolvedValue({ error: null }),
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(reloadedSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignIn.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_reloaded');
expect(response.signIn).toBe(reloadedSignIn);
});
Comment on lines +184 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reloaded-resource finalization failures.

Add a test where reloadedSignIn.finalize() returns a Clerk error and assert startSSOFlow rejects with that exact object. The new success-only coverage would not catch broken structured-error propagation.

As per coding guidelines, “Unit tests are required for all new functionality” and tests must “Verify proper error handling and edge cases.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/expo/src/hooks/__tests__/useSSO.experimental.test.ts` around lines
184 - 203, Add a test alongside the reloaded-resource success case that
configures reloadedSignIn.finalize to resolve with a Clerk error, then assert
startSSOFlow rejects with that exact error object. Reuse the existing reload
setup and verify the failure propagates through useSSO without being replaced or
transformed.

Source: Coding guidelines


test('ignores a session retained by an unrelated sign-up resource', async () => {
mockSignUp.createdSessionId = 'sess_stale_signup';
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSignIn.finalize).toHaveBeenCalledOnce();
expect(mockSignUp.finalize).not.toHaveBeenCalled();
expect(response.createdSessionId).toBe('sess_123');
});

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 () => {
const reloadedSignIn = {
...mockSignIn,
firstFactorVerification: {
...mockSignIn.firstFactorVerification,
status: 'transferable',
},
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });
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(mockSignUp.finalize).toHaveBeenCalledOnce();
expect(response.createdSessionId).toBe('sess_signup');
expect(response.signIn).toBe(reloadedSignIn);
});

test('activates an existing session without finalizing a future resource', async () => {
const reloadedSignIn = {
...mockSignIn,
existingSession: { sessionId: 'sess_existing' },
};
mockClientSignIn.reload.mockResolvedValue({ __internal_future: reloadedSignIn });

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);
expect(response.signIn).toBe(reloadedSignIn);
});

test('does not activate an existing session retained by an unrelated sign-up resource', async () => {
mockClientSignIn.reload.mockResolvedValue({ __internal_future: mockSignIn });
mockSignUp.existingSession = { sessionId: 'sess_stale_signup' };
const { result } = renderHook(() => useSSO());

const response = await result.current.startSSOFlow({ strategy: 'oauth_google' });

expect(mockSetActive).not.toHaveBeenCalled();
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',
});

const { result } = renderHook(() => 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('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.toBe(clerkError);
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,
);
});
});
Loading
Loading