diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md new file mode 100644 index 00000000000..99d539d4447 --- /dev/null +++ b/.changeset/hosted-auth-expo.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add `@clerk/expo/hosted-auth` for signing in or signing up through Account Portal from native Expo apps. diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 3759c010c43..ef9106cba88 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -9,10 +9,12 @@ * Native modules and views are registered via Expo Modules autolinking. */ const { + AndroidConfig, withXcodeProject, withDangerousMod, withInfoPlist, withAppBuildGradle, + withAndroidManifest, withEntitlementsPlist, } = require('@expo/config-plugins'); const path = require('path'); @@ -21,6 +23,30 @@ const packageJson = require('./package.json'); const CLERK_MIN_IOS_VERSION = '17.0'; +const addHostedAuthIntentFilter = (mainActivity, packageName) => { + const callbackHost = `${packageName}.callback`; + const intentFilters = mainActivity['intent-filter'] || []; + const callbackIsRegistered = intentFilters.some(intentFilter => + intentFilter.data?.some( + data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost, + ), + ); + + if (callbackIsRegistered) { + return; + } + + intentFilters.push({ + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + category: [ + { $: { 'android:name': 'android.intent.category.DEFAULT' } }, + { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, + ], + data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }], + }); + mainActivity['intent-filter'] = intentFilters; +}; + const withClerkIOS = config => { console.log('✅ Clerk iOS plugin loaded'); @@ -94,6 +120,15 @@ const withClerkIOS = config => { const withClerkAndroid = config => { console.log('✅ Clerk Android plugin loaded'); + config = withAndroidManifest(config, modConfig => { + const packageName = config.android?.package; + if (packageName) { + const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults); + addHostedAuthIntentFilter(mainActivity, packageName); + } + return modConfig; + }); + return withAppBuildGradle(config, modConfig => { let buildGradle = modConfig.modResults.contents; @@ -354,6 +389,7 @@ const withClerkExpo = (config, props = {}) => { module.exports = withClerkExpo; module.exports._testing = { + addHostedAuthIntentFilter, validateThemeJson, isPlainObject, VALID_COLOR_KEYS, diff --git a/packages/expo/hosted-auth/package.json b/packages/expo/hosted-auth/package.json new file mode 100644 index 00000000000..fee5e036ad2 --- /dev/null +++ b/packages/expo/hosted-auth/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/hosted-auth/index.js", + "types": "../dist/hosted-auth/index.d.ts" +} diff --git a/packages/expo/package.json b/packages/expo/package.json index b93ac216c55..c0e6f02a4d6 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -61,6 +61,10 @@ "types": "./dist/apple/index.d.ts", "default": "./dist/apple/index.js" }, + "./hosted-auth": { + "types": "./dist/hosted-auth/index.d.ts", + "default": "./dist/hosted-auth/index.js" + }, "./resource-cache": { "types": "./dist/resource-cache/index.d.ts", "default": "./dist/resource-cache/index.js" @@ -92,6 +96,7 @@ "token-cache", "google", "apple", + "hosted-auth", "experimental", "legacy", "src/specs", diff --git a/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js b/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js new file mode 100644 index 00000000000..3d29e7d81f7 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing; + +describe('addHostedAuthIntentFilter', () => { + test('registers the canonical Android hosted auth callback', () => { + const mainActivity = {}; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toContainEqual({ + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + category: [ + { $: { 'android:name': 'android.intent.category.DEFAULT' } }, + { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, + ], + data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.callback' } }], + }); + }); + + test('does not duplicate an existing hosted auth callback', () => { + const mainActivity = {}; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toHaveLength(1); + }); +}); diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts new file mode 100644 index 00000000000..21c87100098 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -0,0 +1,740 @@ +import { createHash } from 'node:crypto'; +import Module from 'node:module'; + +import type { ClientJSON } from '@clerk/shared/types'; +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createCodeChallenge, useHostedAuth } from '../useHostedAuth'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + makeRedirectUri: vi.fn(), + openAuthSessionAsync: vi.fn(), + digestStringAsync: vi.fn(), + getRandomBytes: vi.fn(), + randomUUID: vi.fn(), + useClerk: vi.fn(), + getClerkInstance: vi.fn(), + platformOS: 'ios', + appOwnership: null as string | null, + executionEnvironment: 'standalone', + expoConfig: undefined as + | { + ios?: { bundleIdentifier?: string }; + android?: { package?: string }; + } + | undefined, + }; +}); + +vi.mock('@clerk/react', () => { + return { + useClerk: mocks.useClerk, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + }; +}); + +vi.mock('expo-auth-session', () => { + return { + makeRedirectUri: mocks.makeRedirectUri, + }; +}); + +vi.mock('expo-web-browser', () => { + return { + openAuthSessionAsync: mocks.openAuthSessionAsync, + }; +}); + +vi.mock('expo-crypto', () => { + return { + CryptoDigestAlgorithm: { + SHA256: 'SHA256', + }, + CryptoEncoding: { + BASE64: 'BASE64', + }, + digestStringAsync: mocks.digestStringAsync, + getRandomBytes: mocks.getRandomBytes, + randomUUID: mocks.randomUUID, + }; +}); + +const mockFapiRequest = vi.fn(); +const mockCodeVerifier = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; +const mockCodeChallenge = 'mock-code-challenge-_'; + +describe('useHostedAuth', () => { + const mockClient = { + lastActiveSessionId: null as string | null, + sessions: [] as Array<{ id: string }>, + reload: vi.fn(), + fromJSON: vi.fn(), + }; + const mockSetActive = vi.fn(); + const mockHandleUnauthenticated = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockFapiRequest.mockReset(); + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-auth-session') { + return { makeRedirectUri: mocks.makeRedirectUri }; + } + if (request === 'expo-web-browser') { + return { + openAuthSessionAsync: mocks.openAuthSessionAsync, + }; + } + if (request === 'expo-crypto') { + return { + CryptoDigestAlgorithm: { + SHA256: 'SHA256', + }, + CryptoEncoding: { + BASE64: 'BASE64', + }, + digestStringAsync: mocks.digestStringAsync, + getRandomBytes: mocks.getRandomBytes, + randomUUID: mocks.randomUUID, + }; + } + if (request === 'expo-constants') { + return { + default: { + appOwnership: mocks.appOwnership, + executionEnvironment: mocks.executionEnvironment, + expoConfig: mocks.expoConfig, + }, + }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + mocks.platformOS = 'ios'; + mocks.appOwnership = null; + mocks.executionEnvironment = 'standalone'; + mocks.expoConfig = undefined; + mockClient.lastActiveSessionId = null; + mockClient.sessions = []; + mockClient.fromJSON.mockImplementation((clientJSON: ClientJSON) => { + mockClient.lastActiveSessionId = clientJSON.last_active_session_id; + mockClient.sessions = clientJSON.sessions.map(session => ({ id: session.id })); + return mockClient; + }); + mocks.makeRedirectUri.mockReturnValue('myapp:///hosted-auth-callback'); + mocks.getRandomBytes.mockReturnValue(Uint8Array.from(Array.from({ length: 32 }, (_, index) => index))); + mocks.digestStringAsync.mockResolvedValue('mock-code-challenge+/='); + mocks.randomUUID.mockReturnValue('generated-state-123'); + mocks.getClerkInstance.mockReturnValue({ + handleUnauthenticated: mockHandleUnauthenticated, + getFapiClient: () => ({ + request: mockFapiRequest, + }), + }); + mocks.useClerk.mockReturnValue({ + loaded: true, + client: mockClient, + setActive: mockSetActive, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('returns the startHostedAuth function', () => { + const { result } = renderHook(() => useHostedAuth()); + + expect(typeof result.current.startHostedAuth).toBe('function'); + }); + + test('uses the native Clerk instance for FAPI requests', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mocks.getClerkInstance).toHaveBeenCalledTimes(1); + expect(mockFapiRequest).toHaveBeenCalledWith(expect.objectContaining({ path: '/client/hosted_auth' })); + }); + + test('opens the hosted URL and verifies callback state', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123', + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth(); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'generated-state-123', + }, + }); + expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); + expect(mocks.digestStringAsync).toHaveBeenCalledWith('SHA256', mockCodeVerifier, { + encoding: 'BASE64', + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + undefined, + ); + expect(mockFapiRequest).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: 'nonce-123', + codeVerifier: mockCodeVerifier, + }, + }); + expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); + expect(mockSetActive).toHaveBeenCalledTimes(1); + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_123' }); + expect(response.createdSessionId).toBe('sess_123'); + }); + + test.each([ + { + platform: 'ios', + expoConfig: { ios: { bundleIdentifier: 'com.example.ios' } }, + redirectUrl: 'com.example.ios://callback', + }, + { + platform: 'android', + expoConfig: { android: { package: 'com.example.android' } }, + redirectUrl: 'clerk://com.example.android.callback', + }, + ])('uses the canonical $platform callback registered by Clerk', async ({ platform, expoConfig, redirectUrl }) => { + mocks.platformOS = platform; + mocks.expoConfig = expoConfig; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: `${redirectUrl}?state=generated-state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`, + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl, + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'generated-state-123', + }, + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + redirectUrl, + undefined, + ); + expect(mocks.makeRedirectUri).not.toHaveBeenCalled(); + }); + + test('uses the Expo callback in Expo Go even when the project config has a bundle identifier', async () => { + const redirectUrl = 'exp://127.0.0.1:8081/--/hosted-auth-callback'; + mocks.executionEnvironment = 'storeClient'; + mocks.expoConfig = { ios: { bundleIdentifier: 'com.example.ios' } }; + mocks.makeRedirectUri.mockReturnValue(redirectUrl); + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); + expect(mockFapiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ redirectUrl }), + }), + ); + }); + + test('uses the Expo callback with older Expo Go ownership metadata', async () => { + mocks.executionEnvironment = ''; + mocks.appOwnership = 'expo'; + mocks.expoConfig = { android: { package: 'com.example.android' } }; + mocks.platformOS = 'android'; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mocks.makeRedirectUri).toHaveBeenCalled(); + }); + + test('passes supported browser options to the auth session', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ authSessionOptions: { showInRecents: true } }); + + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + { showInRecents: true }, + ); + }); + + test('rejects HTTPS redirect URLs before creating hosted auth', async () => { + const redirectUrl = 'https://mobile.example.com/hosted-auth-callback'; + + const { result } = renderHook(() => useHostedAuth()); + await expect(result.current.startHostedAuth({ redirectUrl })).rejects.toThrow( + 'Hosted auth requires a custom-scheme redirect URL in Expo.', + ); + + expect(mockFapiRequest).not.toHaveBeenCalled(); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); + + test('falls back to the reloaded client session when callback session id is absent', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123', + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_reloaded' }); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth(); + + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_reloaded' }); + expect(response.createdSessionId).toBe('sess_reloaded'); + }); + + test('rejects a redeemed client response without a created session id but still applies the client', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123', + }); + mockHostedAuthRedeemResponse(); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth completion did not include a created session id.', + ); + + expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' })); + // The client token is already rotated after redemption, so the client JSON must be applied before the throw. + expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); + expect(mockSetActive).not.toHaveBeenCalled(); + }); + + test('rejects a successful callback without a rotating token nonce', async () => { + mockClient.lastActiveSessionId = 'sess_existing'; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback did not include a rotating token nonce.', + ); + + expect(mockFapiRequest).toHaveBeenCalledTimes(1); + expect(mockSetActive).not.toHaveBeenCalled(); + }); + + test('rejects a callback session that is absent from the redeemed client but still applies the client', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123&created_session_id=sess_other', + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth created session was not found on the redeemed client.', + ); + expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); + expect(mockSetActive).not.toHaveBeenCalled(); + }); + + test('rejects concurrent invocations while hosted auth is in progress', async () => { + mockHostedAuthResponse(); + let resolveBrowser: (result: { type: string }) => void = () => {}; + mocks.openAuthSessionAsync.mockReturnValue( + new Promise(resolve => { + resolveBrowser = resolve; + }), + ); + + const { result } = renderHook(() => useHostedAuth()); + const firstAttempt = result.current.startHostedAuth(); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Hosted auth is already in progress.'); + + resolveBrowser({ type: 'dismiss' }); + await expect(firstAttempt).resolves.toEqual( + expect.objectContaining({ + createdSessionId: null, + }), + ); + + // The rejected concurrent call never created an attempt or opened a browser session. + expect(mockFapiRequest).toHaveBeenCalledTimes(1); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + + test('releases the in-progress guard after a failed attempt', async () => { + mockFapiRequest.mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + payload: { + errors: [], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + await expect(result.current.startHostedAuth()).rejects.toThrow('Unprocessable Entity'); + + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + const response = await result.current.startHostedAuth(); + + expect(response.createdSessionId).toBeNull(); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + + test('surfaces browser session open failures', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockImplementation(() => { + throw new Error('Unable to open browser'); + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Unable to open browser'); + }); + + test('generates a secure state with expo-crypto when one is not provided', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'dismiss', + }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mocks.randomUUID).toHaveBeenCalled(); + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'generated-state-123', + }, + }); + }); + + test('rejects callback state mismatches', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=other-state', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback state did not match the initiated state.', + ); + }); + + test('passes through the requested mode', async () => { + mockHostedAuthResponse('https://example.accounts.dev/sign-up'); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'dismiss', + }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ mode: 'sign-up' }); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, + mode: 'sign-up', + state: 'generated-state-123', + }, + }); + }); + + test('rejects callback URL mismatches', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'otherapp://hosted-auth-callback?state=generated-state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback URL did not match the initiated redirect URL.', + ); + }); + + test('rejects malformed callback URLs with a hosted auth error', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: '://not-a-url', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Hosted auth callback URL was invalid.'); + }); + + test('rejects callback path mismatches', async () => { + mocks.makeRedirectUri.mockReturnValue('exp://127.0.0.1:8081/--/hosted-auth-callback'); + mockHostedAuthResponse('https://example.accounts.dev/sign-in'); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'exp://127.0.0.1:8081/--/other-callback?state=generated-state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback URL did not match the initiated redirect URL.', + ); + }); + + test('rejects callback authority mismatches for triple-slashed redirect URLs', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp://attacker/hosted-auth-callback?state=generated-state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback URL did not match the initiated redirect URL.', + ); + }); + + test('rejects invalid hosted auth responses', async () => { + mockFapiRequest.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'hosted_auth', + }, + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth creation returned an invalid response.', + ); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); + + test('surfaces hosted auth FAPI errors', async () => { + mockFapiRequest.mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + payload: { + errors: [ + { + code: 'form_param_format_invalid', + message: 'Redirect URL is invalid', + long_message: 'Redirect URL is invalid', + meta: {}, + }, + ], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Redirect URL is invalid'); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); + + test('retries hosted auth creation once after a signed-out response', async () => { + mockFapiRequest.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + payload: { + errors: [{ code: 'signed_out', message: 'Signed out', long_message: 'You are signed out' }], + }, + }); + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth(); + + expect(response.createdSessionId).toBeNull(); + expect(mockHandleUnauthenticated).not.toHaveBeenCalled(); + expect(mockFapiRequest).toHaveBeenCalledTimes(2); + const expectedCreateRequest = { + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'generated-state-123', + }, + }; + expect(mockFapiRequest).toHaveBeenNthCalledWith(1, expectedCreateRequest); + expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expectedCreateRequest); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + + test('surfaces a second unauthenticated hosted auth response without retrying again', async () => { + mockFapiRequest.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + payload: { + errors: [{ code: 'signed_out', message: 'Signed out', long_message: 'You are signed out' }], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('You are signed out'); + expect(mockHandleUnauthenticated).not.toHaveBeenCalled(); + expect(mockFapiRequest).toHaveBeenCalledTimes(2); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); + + test('does not retry an unrelated unauthorized hosted auth response', async () => { + mockFapiRequest.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + payload: { + errors: [{ code: 'authentication_invalid', message: 'Invalid', long_message: 'Authentication is invalid' }], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Authentication is invalid'); + expect(mockHandleUnauthenticated).not.toHaveBeenCalled(); + expect(mockFapiRequest).toHaveBeenCalledTimes(1); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); +}); + +describe('createCodeChallenge', () => { + test('derives the S256 code challenge from the RFC 7636 appendix B vector', async () => { + const sha256Base64 = (value: string) => + Promise.resolve(createHash('sha256').update(value, 'ascii').digest('base64')); + + await expect(createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', sha256Base64)).resolves.toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); +}); + +function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { + mockFapiRequest.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'hosted_auth', + url, + }, + }, + }); +} + +function mockHostedAuthRedeemResponse({ + lastActiveSessionId = null, + sessionIds = lastActiveSessionId ? [lastActiveSessionId] : [], +}: { + lastActiveSessionId?: string | null; + sessionIds?: string[]; +} = {}) { + mockFapiRequest.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'client', + id: 'client_123', + sessions: sessionIds.map(id => ({ id })), + sign_in: null, + sign_up: null, + last_active_session_id: lastActiveSessionId, + captcha_bypass: false, + cookie_expires_at: null, + last_authentication_strategy: null, + created_at: Date.now() - 1000, + updated_at: Date.now(), + } as unknown as ClientJSON, + }, + }); +} diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts new file mode 100644 index 00000000000..692f70d1005 --- /dev/null +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -0,0 +1,362 @@ +import { useClerk } from '@clerk/react'; +import type * as AuthSession from 'expo-auth-session'; +import type * as ExpoCrypto from 'expo-crypto'; +import type * as WebBrowser from 'expo-web-browser'; +import { Platform } from 'react-native'; + +import { getClerkInstance } from '../provider/singleton'; +import { errorThrower } from '../utils/errors'; +import type { HostedAuthClerkInstance, HostedAuthMode } from '../utils/hostedAuth'; +import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; + +export type { HostedAuthMode }; + +// Hosted auth keeps its `state` and PKCE verifier in memory for the duration of a single +// `startHostedAuth` call. If Android kills the app process while the Custom Tab is in the +// foreground, the flow cannot be resumed and must be restarted. This is an accepted +// limitation, consistent with `useSSO`. +let hostedAuthInProgress = false; +let hasWarnedAndroidDefaultRedirect = false; + +/** + * Options for starting hosted auth from a native Expo application. + */ +export type StartHostedAuthParams = { + /** + * Native callback URL that Account Portal redirects to after auth completes. + * Defaults to the canonical callback Clerk registers for the configured iOS + * bundle identifier or Android package name. Expo Go and projects without a + * configured application identifier fall back to `AuthSession.makeRedirectUri`. + * Custom values must use a non-HTTP URL scheme. + * + * On Android, the default `clerk://.callback` URL only reaches the app + * when the Clerk Expo config plugin is enabled in `app.json` and the native + * project has been rebuilt (for example with `npx expo prebuild`), because the + * plugin registers the matching intent filter. Without it, the browser cannot + * redirect back to the app and the flow hangs in the Custom Tab. Pass a custom + * `redirectUrl` handled by the app to opt out of this requirement. + */ + redirectUrl?: string; + /** + * Initial hosted auth screen to open. + */ + mode?: HostedAuthMode; + /** + * Options forwarded to `expo-web-browser` when opening the hosted auth session. + */ + authSessionOptions?: Pick; +}; + +/** + * Result returned after a hosted auth attempt finishes. + */ +export type StartHostedAuthReturnType = { + /** + * The session activated in the native SDK, or `null` when auth did not complete. + */ + createdSessionId: string | null; + /** + * Result returned by the hosted browser session, or `null` when Clerk was not loaded. + */ + authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; +}; + +type HostedAuthPKCE = { + codeVerifier: string; + codeChallenge: string; +}; + +/** + * Returns helpers for authenticating native Expo users through Clerk's hosted Account Portal. + * + * On Android, the default redirect URL depends on an intent filter that only the Clerk Expo + * config plugin registers: the plugin must be enabled in `app.json` and the native project + * rebuilt (for example with `npx expo prebuild`), or the browser cannot return to the app + * after auth completes. See {@link StartHostedAuthParams.redirectUrl}. + */ +export function useHostedAuth(): { + startHostedAuth: (params?: StartHostedAuthParams) => Promise; +} { + const clerk = useClerk(); + + /** + * Opens Account Portal in an auth session and activates the session it creates. + * Only one hosted auth flow can run at a time; concurrent calls throw. + */ + async function startHostedAuth(params: StartHostedAuthParams = {}): Promise { + if (hostedAuthInProgress) { + return errorThrower.throw('Hosted auth is already in progress.'); + } + + hostedAuthInProgress = true; + try { + return await runHostedAuth(params); + } finally { + hostedAuthInProgress = false; + } + } + + async function runHostedAuth(params: StartHostedAuthParams): Promise { + if (!clerk.loaded) { + return { + createdSessionId: null, + authSessionResult: null, + }; + } + if (!clerk.client) { + return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); + } + const hostedAuthClerk = getHostedAuthClerk(); + + let AuthSessionModule: typeof AuthSession; + let WebBrowserModule: typeof WebBrowser; + try { + // Load via synchronous require() instead of import(): Metro inlines require() into the main + // bundle, while import() emits an async chunk that fails to resolve without @expo/metro-runtime. + // eslint-disable-next-line @typescript-eslint/no-require-imports + AuthSessionModule = require('expo-auth-session'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + WebBrowserModule = require('expo-web-browser'); + } catch (err) { + return errorThrower.throw( + `Unable to load expo-auth-session and expo-web-browser, which are required for hosted auth: ${ + err instanceof Error ? err.message : 'Unknown error' + }. If they are not installed, run: npx expo install expo-auth-session expo-web-browser`, + ); + } + + const redirectUrl = params.redirectUrl ?? getDefaultRedirectUrl(AuthSessionModule); + assertSupportedRedirectUrl(redirectUrl); + const state = createState(); + const pkce = await createPKCE(); + const hostedAuth = await createHostedAuth( + { + redirectUrl, + codeChallenge: pkce.codeChallenge, + mode: params.mode, + state, + }, + hostedAuthClerk, + ); + + const authSessionResult = await WebBrowserModule.openAuthSessionAsync( + hostedAuth.url, + redirectUrl, + params.authSessionOptions, + ); + if (authSessionResult.type !== 'success' || !authSessionResult.url) { + return { + createdSessionId: null, + authSessionResult, + }; + } + + let callbackUrl: URL; + try { + callbackUrl = new URL(authSessionResult.url); + } catch { + return errorThrower.throw('Hosted auth callback URL was invalid.'); + } + if (!callbackUrlMatchesRedirectUrl(callbackUrl, redirectUrl)) { + return errorThrower.throw('Hosted auth callback URL did not match the initiated redirect URL.'); + } + + const callbackParams = callbackUrl.searchParams; + if (callbackParams.get('state') !== state) { + return errorThrower.throw('Hosted auth callback state did not match the initiated state.'); + } + + const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; + if (!rotatingTokenNonce) { + return errorThrower.throw('Hosted auth callback did not include a rotating token nonce.'); + } + + const clientJSON = await redeemHostedAuth( + { + rotatingTokenNonce, + codeVerifier: pkce.codeVerifier, + }, + hostedAuthClerk, + ); + + // A successful redemption means the server session exists and the rotated client + // token has already been persisted locally by the response middleware. Sync the + // local client state before validating the created session, so a validation + // failure below does not leave the local client stale against the rotated token. + applyHostedAuthClientJSON(clerk.client, clientJSON); + + const createdSessionId = normalizeSessionId( + callbackParams.get('created_session_id') || clientJSON.last_active_session_id, + ); + if (!createdSessionId) { + return errorThrower.throw('Hosted auth completion did not include a created session id.'); + } + if (!clientJSON.sessions.some(session => session.id === createdSessionId)) { + return errorThrower.throw('Hosted auth created session was not found on the redeemed client.'); + } + + await clerk.setActive({ + session: createdSessionId, + }); + + return { + createdSessionId, + authSessionResult, + }; + } + + return { + startHostedAuth, + }; +} + +function getDefaultRedirectUrl(AuthSessionModule: typeof AuthSession): string { + const appIdentifier = getExpoAppIdentifier(); + if (appIdentifier && Platform.OS === 'ios') { + return `${appIdentifier}://callback`; + } + if (appIdentifier && Platform.OS === 'android') { + warnAndroidDefaultRedirectOnce(); + return `clerk://${appIdentifier}.callback`; + } + + return AuthSessionModule.makeRedirectUri({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); +} + +function warnAndroidDefaultRedirectOnce(): void { + if (__DEV__ && !hasWarnedAndroidDefaultRedirect) { + hasWarnedAndroidDefaultRedirect = true; + console.warn( + '[useHostedAuth] The default Android redirect URL relies on the `clerk://.callback` intent ' + + 'filter registered by the Clerk Expo config plugin. If the plugin is not enabled in app.json or the ' + + 'native project has not been rebuilt since (e.g. `npx expo prebuild`), the browser cannot redirect ' + + 'back to the app and hosted auth will hang. Alternatively, pass a custom `redirectUrl` handled by the app.', + ); + } +} + +function getExpoAppIdentifier(): string | undefined { + try { + // expo-constants is already an optional @clerk/expo peer and is present in + // standard Expo projects. Keep the fallback below for Expo Go and bare apps. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const constantsModule = require('expo-constants') as { + default?: { + appOwnership?: string | null; + executionEnvironment?: string; + expoConfig?: { + ios?: { bundleIdentifier?: string }; + android?: { package?: string }; + }; + }; + }; + const constants = constantsModule.default; + if (constants?.executionEnvironment === 'storeClient' || constants?.appOwnership === 'expo') { + return undefined; + } + + const expoConfig = constants?.expoConfig; + return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; + } catch { + return undefined; + } +} + +function getHostedAuthClerk(): HostedAuthClerkInstance { + const hostedAuthClerk = getClerkInstance() as Partial | undefined; + if (typeof hostedAuthClerk?.getFapiClient !== 'function') { + return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); + } + + return hostedAuthClerk as HostedAuthClerkInstance; +} + +function normalizeSessionId(sessionId: string | null | undefined): string | null { + return sessionId || null; +} + +function createState(): string { + return loadExpoCrypto().randomUUID(); +} + +async function createPKCE(): Promise { + const Crypto = loadExpoCrypto(); + const codeVerifier = bytesToHex(Crypto.getRandomBytes(32)); + const codeChallenge = await createCodeChallenge(codeVerifier, verifier => + Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, { + encoding: Crypto.CryptoEncoding.BASE64, + }), + ); + + return { + codeVerifier, + codeChallenge, + }; +} + +/** + * Derives the S256 PKCE code challenge (RFC 7636) from a code verifier, + * given a function that returns the standard-base64 SHA-256 digest of a string. + * + * @internal Exported for testing. + */ +export async function createCodeChallenge( + codeVerifier: string, + sha256Base64: (value: string) => Promise, +): Promise { + return base64ToBase64Url(await sha256Base64(codeVerifier)); +} + +function loadExpoCrypto(): typeof ExpoCrypto { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('expo-crypto') as typeof ExpoCrypto; + } catch { + return errorThrower.throw( + 'expo-crypto is required to start hosted auth. Please install it by running: npx expo install expo-crypto', + ); + } +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function base64ToBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): boolean { + let expectedUrl: URL; + try { + expectedUrl = new URL(redirectUrl); + } catch { + return false; + } + + if (callbackUrl.protocol !== expectedUrl.protocol) { + return false; + } + + if (callbackUrl.host !== expectedUrl.host) { + return false; + } + + return callbackUrl.pathname === expectedUrl.pathname; +} + +function assertSupportedRedirectUrl(redirectUrl: string): void { + let protocol: string; + try { + protocol = new URL(redirectUrl).protocol; + } catch { + return errorThrower.throw('Hosted auth redirect URL was invalid.'); + } + + if (protocol === 'http:' || protocol === 'https:') { + return errorThrower.throw('Hosted auth requires a custom-scheme redirect URL in Expo.'); + } +} diff --git a/packages/expo/src/hosted-auth/index.ts b/packages/expo/src/hosted-auth/index.ts new file mode 100644 index 00000000000..da2a6edd46f --- /dev/null +++ b/packages/expo/src/hosted-auth/index.ts @@ -0,0 +1,2 @@ +export { useHostedAuth } from '../hooks/useHostedAuth'; +export type { HostedAuthMode, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index a12a6e6512a..f6456e1549d 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => { } | undefined, clerkInstance: { + __internal_setActiveInProgress: false, __internal_reloadInitialResources: vi.fn(), addListener: vi.fn(), addOnLoaded: vi.fn(), @@ -131,6 +132,7 @@ describe('ClerkProvider native client sync', () => { mocks.tokenCache.saveToken.mockResolvedValue(undefined); mocks.tokenCache.clearToken.mockResolvedValue(undefined); mocks.clerkOptions = undefined; + mocks.clerkInstance.__internal_setActiveInProgress = false; mocks.clerkInstance.__internal_reloadInitialResources.mockResolvedValue(undefined); mocks.clerkInstance.addOnLoaded = vi.fn(); mocks.clerkInstance.client = undefined; @@ -762,6 +764,55 @@ describe('ClerkProvider native client sync', () => { }); }); + test('does not start fallback activation during an explicit session transition', async () => { + const removedSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const replacementSession = { + id: 'session_2', + status: 'active', + user: { id: 'user_2' }, + }; + const replacementClient = { + signedInSessions: [replacementSession], + lastActiveSessionId: 'session_2', + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.clerkInstance.client = { + signedInSessions: [removedSession], + lastActiveSessionId: 'session_1', + }; + mocks.clerkInstance.session = removedSession; + + render( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); + }); + + originalUpdateClient.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + mocks.clerkInstance.__internal_setActiveInProgress = true; + + act(() => { + mocks.clerkInstance.updateClient(replacementClient); + }); + + expect(originalUpdateClient).toHaveBeenCalledOnce(); + expect(originalUpdateClient).toHaveBeenCalledWith(replacementClient, { + __internal_dangerouslySkipEmit: true, + }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + }); + test('keeps follow-up client updates suppressed while reconciling a removed active session', async () => { const removedSession = { id: 'session_1', diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 1a24d566f07..9660c698f1c 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -22,6 +22,7 @@ export type SyncableClerkInstance = { session?: SignedInSessionResource | null; setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise; updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; + __internal_setActiveInProgress?: boolean; __internal_reloadInitialResources?: () => void | Promise; }; @@ -494,13 +495,13 @@ export function NativeClientSync({ // even if the refreshed client still has another signed-in session. // Keep that transient state internal so native session switching does // not dismiss mounted native UI before setActive settles on JS. - isReconcilingRemovedActiveSession = true; originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true }); - if (alreadyReconcilingRemovedActiveSession) { + if (clerkInstance.__internal_setActiveInProgress || alreadyReconcilingRemovedActiveSession) { return; } + isReconcilingRemovedActiveSession = true; void runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { await clerkInstance.setActive?.({ session: fallbackSession }); diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts new file mode 100644 index 00000000000..365ea6ae30d --- /dev/null +++ b/packages/expo/src/utils/hostedAuth.ts @@ -0,0 +1,205 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/shared/types'; + +import { errorThrower } from './errors'; + +/** + * Controls which Account Portal auth screen opens for hosted auth. + */ +export type HostedAuthMode = 'sign-in' | 'sign-up'; + +export type CreateHostedAuthParams = { + redirectUrl: string; + codeChallenge: string; + mode?: HostedAuthMode; + state: string; +}; + +export type RedeemHostedAuthParams = { + rotatingTokenNonce: string; + codeVerifier: string; +}; + +export type HostedAuthResource = { + url: string; +}; + +type HostedAuthJSON = { + object: 'hosted_auth'; + url: string; +}; + +type HostedAuthPayload = { + response?: HostedAuthJSON | ClientJSON; + errors?: ClerkAPIErrorJSON[]; +}; + +type HostedAuthResponse = { + ok: boolean; + status: number; + statusText: string; + headers?: Headers; + payload: HostedAuthPayload | null; +}; + +type FapiClient = { + request: (requestInit: { + method: 'POST'; + path: '/client/hosted_auth' | '/client'; + body: CreateHostedAuthParams | (RedeemHostedAuthParams & { _method: 'GET' }); + }) => Promise; +}; + +export type HostedAuthClerkInstance = { + getFapiClient: () => FapiClient; + handleUnauthenticated?: () => Promise; +}; + +export async function createHostedAuth( + params: CreateHostedAuthParams, + clerk: HostedAuthClerkInstance, +): Promise { + const request = () => + clerk.getFapiClient().request({ + method: 'POST', + path: '/client/hosted_auth', + body: params, + }); + + let response = await request(); + if ( + !response.ok && + response.status === 401 && + getHostedAuthErrors(response.payload).some(error => error.code === 'signed_out') + ) { + // The Expo response hook has already persisted the replacement device token. + response = await request(); + } + + if (!response.ok) { + throw buildHostedAuthAPIResponseError(response); + } + + const hostedAuthJSON = getHostedAuthJSON(response.payload); + if (!hostedAuthJSON?.url) { + return errorThrower.throw('Hosted auth creation returned an invalid response.'); + } + + return { + url: hostedAuthJSON.url, + }; +} + +export async function redeemHostedAuth( + params: RedeemHostedAuthParams, + clerk: HostedAuthClerkInstance, +): Promise { + const response = await clerk.getFapiClient().request({ + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: params.rotatingTokenNonce, + codeVerifier: params.codeVerifier, + }, + }); + + if (!response.ok) { + await throwHostedAuthAPIResponseError(response, clerk); + } + + const clientJSON = getClientJSON(response.payload); + if (!clientJSON) { + return errorThrower.throw('Hosted auth completion returned an invalid response.'); + } + + return clientJSON; +} + +function getHostedAuthJSON(payload: HostedAuthResponse['payload']): HostedAuthJSON | null { + if (!payload) { + return null; + } + + if (isHostedAuthJSON(payload.response)) { + return payload.response; + } + + return null; +} + +function isHostedAuthJSON(payload: unknown): payload is HostedAuthJSON { + return hasObjectType(payload, 'hosted_auth'); +} + +function getClientJSON(payload: HostedAuthResponse['payload']): ClientJSON | null { + if (!payload) { + return null; + } + + if (isClientJSON(payload.response)) { + return payload.response; + } + + return null; +} + +function isClientJSON(payload: unknown): payload is ClientJSON { + return hasObjectType(payload, 'client'); +} + +function hasObjectType(payload: unknown, object: string): boolean { + return !!payload && typeof payload === 'object' && (payload as { object?: unknown }).object === object; +} + +export function applyHostedAuthClientJSON(client: ClientResource, clientJSON: ClientJSON): ClientResource { + // Hosted auth gets the same /client payload as Client.reload(), but its verifier-bound + // exchange uses a request body. Apply it to the existing ClerkJS client instance here + // instead of adding a hosted-auth branch to every resource reload path. + const mutableClient = client as ClientResource & { + fromJSON?: (data: ClientJSON) => ClientResource; + }; + if (typeof mutableClient.fromJSON !== 'function') { + return errorThrower.throw('Hosted auth completion could not update the current client.'); + } + + return mutableClient.fromJSON(clientJSON); +} + +async function throwHostedAuthAPIResponseError( + response: HostedAuthResponse, + clerk: HostedAuthClerkInstance, +): Promise { + if (response.status === 401) { + await clerk.handleUnauthenticated?.(); + } + + throw buildHostedAuthAPIResponseError(response); +} + +function buildHostedAuthAPIResponseError(response: HostedAuthResponse) { + const errors = getHostedAuthErrors(response.payload); + return new ClerkAPIResponseError(errors[0]?.long_message || response.statusText || 'Hosted auth request failed.', { + data: errors, + status: response.status, + retryAfter: getRetryAfter(response.headers), + }); +} + +function getHostedAuthErrors(payload: HostedAuthResponse['payload']): ClerkAPIErrorJSON[] { + if (!payload || !('errors' in payload)) { + return []; + } + + return payload.errors ?? []; +} + +function getRetryAfter(headers: Headers | undefined): number | undefined { + const retryAfter = headers?.get('retry-after'); + if (!retryAfter) { + return undefined; + } + + const retryAfterSeconds = parseInt(retryAfter, 10); + return Number.isNaN(retryAfterSeconds) ? undefined : retryAfterSeconds; +}