diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md new file mode 100644 index 00000000000..fa64ee90786 --- /dev/null +++ b/.changeset/hosted-auth-expo.md @@ -0,0 +1,17 @@ +--- +'@clerk/expo': minor +--- + +Introduce the `useHostedAuth()` hook for signing users in or up through Clerk's hosted Account Portal from native Expo apps. + +```tsx +import { useHostedAuth } from '@clerk/expo/hosted-auth' + +const { startHostedAuth } = useHostedAuth() + +// Opens Account Portal on the sign-in page +await startHostedAuth() + +// Or open the sign-up page first +await startHostedAuth({ mode: 'sign-up' }) +``` diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index f42f84dc347..3490fd5d999 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,35 @@ 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 hasAndroidName = (entries, name) => entries?.some(entry => entry.$?.['android:name'] === name); + const callbackIsRegistered = intentFilters.some( + intentFilter => + hasAndroidName(intentFilter.action, 'android.intent.action.VIEW') && + hasAndroidName(intentFilter.category, 'android.intent.category.DEFAULT') && + hasAndroidName(intentFilter.category, 'android.intent.category.BROWSABLE') && + 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 +125,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; @@ -320,6 +360,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 d50b75e3b5d..662c7c9da00 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..82ae609cf23 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js @@ -0,0 +1,60 @@ +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; + +const hostedAuthIntentFilter = () => ({ + 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' } }], +}); + +describe('addHostedAuthIntentFilter', () => { + test('registers the canonical Android hosted auth callback', () => { + const mainActivity = {}; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter()); + }); + + 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); + }); + + test.each([ + ['VIEW action', filter => ({ ...filter, action: [] })], + [ + 'DEFAULT category', + filter => ({ + ...filter, + category: filter.category.filter(category => category.$['android:name'] !== 'android.intent.category.DEFAULT'), + }), + ], + [ + 'BROWSABLE category', + filter => ({ + ...filter, + category: filter.category.filter( + category => category.$['android:name'] !== 'android.intent.category.BROWSABLE', + ), + }), + ], + ])('registers a valid callback when a matching filter lacks the %s', (_requirement, omitRequirement) => { + const mainActivity = { + 'intent-filter': [omitRequirement(hostedAuthIntentFilter())], + }; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toContainEqual(hostedAuthIntentFilter()); + }); +}); 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..f7941043af9 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -0,0 +1,714 @@ +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 { 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; + }, + }, + }; +}); + +// The expo-* optional deps are loaded via require() at call time, which resolves through +// Module._load (spied in beforeEach) rather than vitest's ESM mock registry. +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, preferEphemeralSession: true } }); + + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + { showInRecents: true, preferEphemeralSession: 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('rejects a callback without a created session id before redeeming', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow( + 'Hosted auth callback did not include the created session.', + ); + + // The rotating token nonce must not be consumed on a malformed callback. + expect(mockFapiRequest).toHaveBeenCalledTimes(1); + 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 completion did not include the created session.', + ); + // 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 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( + 'A hosted authentication session 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('derives the S256 code challenge from the verifier per RFC 7636', async () => { + mocks.digestStringAsync.mockImplementation((_algorithm: string, value: string) => + Promise.resolve(createHash('sha256').update(value, 'ascii').digest('base64')), + ); + const expectedChallenge = createHash('sha256') + .update(mockCodeVerifier, 'ascii') + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mockFapiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ codeChallenge: expectedChallenge }), + }), + ); + }); + + 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(); + }); +}); + +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..107f9fb19f4 --- /dev/null +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -0,0 +1,342 @@ +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 requires the Clerk Expo config plugin; + * 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('A hosted authentication session 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 hostedAuthUrl = await createHostedAuth( + { + redirectUrl, + codeChallenge: pkce.codeChallenge, + mode: params.mode, + state, + }, + hostedAuthClerk, + ); + + const authSessionResult = await WebBrowserModule.openAuthSessionAsync( + hostedAuthUrl, + 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 createdSessionId = callbackParams.get('created_session_id'); + if (!createdSessionId) { + return errorThrower.throw('Hosted auth callback did not include the created session.'); + } + + 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); + + if (!clientJSON.sessions.some(session => session.id === createdSessionId)) { + return errorThrower.throw('Hosted auth completion did not include the created session.'); + } + + await clerk.setActive({ + session: createdSessionId, + }); + + return { + createdSessionId, + authSessionResult, + }; + } + + return { + startHostedAuth, + }; +} + +function getDefaultRedirectUrl(AuthSessionModule: typeof AuthSession): string { + const appIdentifier = getExpoAppIdentifier(); + if (appIdentifier && Platform.OS === 'ios') { + // Expo prebuild registers ios.bundleIdentifier as an iOS URL scheme. + 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 createState(): string { + return loadExpoCrypto().randomUUID(); +} + +// S256 code challenge per RFC 7636: base64url of the SHA-256 digest of the verifier. +async function createPKCE(): Promise { + const Crypto = loadExpoCrypto(); + const codeVerifier = bytesToHex(Crypto.getRandomBytes(32)); + const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, codeVerifier, { + encoding: Crypto.CryptoEncoding.BASE64, + }); + + return { + codeVerifier, + codeChallenge: base64ToBase64Url(digest), + }; +} + +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 b6ae6bb8eef..d3ea30e74e1 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(), @@ -135,6 +136,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; @@ -926,6 +928,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 1f50aa0699e..167c29595ba 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -25,6 +25,7 @@ export type SyncableClerkInstance = { status?: string; setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise; updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; + __internal_setActiveInProgress?: boolean; __internal_reloadInitialResources?: () => void | Promise; }; @@ -523,13 +524,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..d1c9d3bb436 --- /dev/null +++ b/packages/expo/src/utils/hostedAuth.ts @@ -0,0 +1,171 @@ +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; +}; + +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 = getResponseJSON(response.payload, 'hosted_auth'); + if (!hostedAuthJSON?.url) { + return errorThrower.throw('Hosted auth creation returned an invalid response.'); + } + + return 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 = getResponseJSON(response.payload, 'client'); + if (!clientJSON) { + return errorThrower.throw('Hosted auth completion returned an invalid response.'); + } + + return clientJSON; +} + +function getResponseJSON(payload: HostedAuthResponse['payload'], object: 'hosted_auth'): HostedAuthJSON | null; +function getResponseJSON(payload: HostedAuthResponse['payload'], object: 'client'): ClientJSON | null; +function getResponseJSON(payload: HostedAuthResponse['payload'], object: string): HostedAuthJSON | ClientJSON | null { + const response = payload?.response; + return !!response && typeof response === 'object' && response.object === object ? response : null; +} + +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); +} + +// Redemption only: creation handles its own 401s via the signed_out retry above. +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; +}