From 6f167f942a7a3cd7f53c39adee2c7ec9a09f4e05 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:59:53 -0400 Subject: [PATCH 01/15] feat(expo): add hosted auth hook --- .../src/hooks/__tests__/useHostedAuth.test.ts | 298 ++++++++++++++++++ packages/expo/src/hooks/index.ts | 1 + packages/expo/src/hooks/useHostedAuth.ts | 168 ++++++++++ packages/expo/src/types/index.ts | 1 + packages/expo/src/utils/hostedAuth.ts | 116 +++++++ 5 files changed, 584 insertions(+) create mode 100644 packages/expo/src/hooks/__tests__/useHostedAuth.test.ts create mode 100644 packages/expo/src/hooks/useHostedAuth.ts create mode 100644 packages/expo/src/utils/hostedAuth.ts 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..26ebfb80c25 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -0,0 +1,298 @@ +import { renderHook } from '@testing-library/react'; +import Module from 'node:module'; +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(), + randomUUID: vi.fn(), + useClerk: vi.fn(), + getClerkInstance: vi.fn(), + }; +}); + +vi.mock('@clerk/react', () => { + return { + useClerk: mocks.useClerk, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +vi.mock('expo-auth-session', () => { + return { + makeRedirectUri: mocks.makeRedirectUri, + }; +}); + +vi.mock('expo-web-browser', () => { + return { + openAuthSessionAsync: mocks.openAuthSessionAsync, + }; +}); + +vi.mock('expo-crypto', () => { + return { + randomUUID: mocks.randomUUID, + }; +}); + +const mockFapiRequest = vi.fn(); + +describe('useHostedAuth', () => { + const mockClient = { + lastActiveSessionId: null, + reload: vi.fn(), + }; + const mockUpdateClient = vi.fn(); + const mockSetActive = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + 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 { randomUUID: mocks.randomUUID }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + mockClient.lastActiveSessionId = null; + mocks.makeRedirectUri.mockReturnValue('myapp:///hosted-auth-callback'); + mocks.randomUUID.mockReturnValue('generated-state-123'); + mocks.useClerk.mockReturnValue({ + loaded: true, + client: mockClient, + setActive: mockSetActive, + updateClient: mockUpdateClient, + }); + mocks.getClerkInstance.mockReturnValue({ + getFapiClient: () => ({ + request: mockFapiRequest, + }), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('returns the startHostedAuth function', () => { + const { result } = renderHook(() => useHostedAuth()); + + expect(typeof result.current.startHostedAuth).toBe('function'); + }); + + test('opens the hosted URL and verifies callback state', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123', + }); + mockClient.reload.mockResolvedValue(mockClient); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + initialPage: undefined, + state: 'state-123', + }, + }); + expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + undefined, + ); + expect(mockClient.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce-123' }); + expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_123' }); + expect(response.createdSessionId).toBe('sess_123'); + }); + + 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({ state: 'state-123' })).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', + initialPage: 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({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth callback state did not match the initiated state.', + ); + }); + + test('passes through the requested initial page', async () => { + mockHostedAuthResponse('https://example.accounts.dev/sign-up'); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'dismiss', + }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ initialPage: 'sign-up', state: 'state-123' }); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + initialPage: 'sign_up', + state: 'state-123', + }, + }); + }); + + test('rejects callback URL mismatches', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'otherapp://hosted-auth-callback?state=state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth callback URL did not match the initiated redirect URL.', + ); + }); + + 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=state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).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({ state: 'state-123' })).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({ state: 'state-123' })).rejects.toThrow('Redirect URL is invalid'); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); + }); +}); + +function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { + mockFapiRequest.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'hosted_auth', + url, + }, + }, + }); +} diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 92644a4eee3..8009d55582f 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -15,5 +15,6 @@ export { } from '@clerk/react'; export * from './useSSO'; +export * from './useHostedAuth'; export * from './useOAuth'; export * from './useAuth'; diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts new file mode 100644 index 00000000000..a6ef2d4e7d5 --- /dev/null +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -0,0 +1,168 @@ +import { useClerk } from '@clerk/react'; +import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; +import type * as AuthSession from 'expo-auth-session'; +import type * as WebBrowser from 'expo-web-browser'; + +import { errorThrower } from '../utils/errors'; +import { createHostedAuth, type FapiHostedAuthInitialPage } from '../utils/hostedAuth'; + +export type HostedAuthInitialPage = 'sign-in' | 'sign-up'; + +export type StartHostedAuthParams = { + redirectUrl?: string; + initialPage?: HostedAuthInitialPage; + state?: string; + authSessionOptions?: Pick; +}; + +export type StartHostedAuthReturnType = { + createdSessionId: string | null; + authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; + client?: ClientResource; +}; + +export function useHostedAuth() { + const clerk = useClerk(); + + async function startHostedAuth(params: StartHostedAuthParams = {}): Promise { + if (!clerk.loaded) { + return { + createdSessionId: null, + authSessionResult: null, + client: clerk.client, + }; + } + + 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 ?? + AuthSessionModule.makeRedirectUri({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); + const state = params.state ?? (await createState()); + if (!clerk.client) { + return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); + } + const hostedAuth = await createHostedAuth({ + redirectUrl, + initialPage: toFapiInitialPage(params.initialPage), + state, + }); + + const authSessionResult = await WebBrowserModule.openAuthSessionAsync( + hostedAuth.url, + redirectUrl, + params.authSessionOptions, + ); + if (authSessionResult.type !== 'success' || !authSessionResult.url) { + return { + createdSessionId: null, + authSessionResult, + client: clerk.client, + }; + } + + const callbackUrl = new URL(authSessionResult.url); + 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.'); + } + + let updatedClient: ClientResource | undefined; + const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; + if (rotatingTokenNonce) { + updatedClient = await clerk.client?.reload({ rotatingTokenNonce }); + if (updatedClient && 'updateClient' in clerk && typeof clerk.updateClient === 'function') { + clerk.updateClient(updatedClient); + } + } + const createdSessionId = + callbackParams.get('created_session_id') ?? + updatedClient?.lastActiveSessionId ?? + clerk.client?.lastActiveSessionId ?? + null; + if (createdSessionId) { + const createdSession = + updatedClient?.sessions?.find(session => session.id === createdSessionId) ?? + clerk.client?.sessions?.find(session => session.id === createdSessionId); + await clerk.setActive({ + session: (createdSession as SignedInSessionResource | undefined) ?? createdSessionId, + }); + } + + return { + createdSessionId, + authSessionResult, + client: updatedClient ?? clerk.client, + }; + } + + return { + startHostedAuth, + }; +} + +function toFapiInitialPage(initialPage: HostedAuthInitialPage | undefined): FapiHostedAuthInitialPage | undefined { + if (initialPage === 'sign-in') { + return 'sign_in'; + } + + if (initialPage === 'sign-up') { + return 'sign_up'; + } + + return undefined; +} + +async function createState(): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomUUID } = require('expo-crypto') as typeof import('expo-crypto'); + return randomUUID(); + } catch { + return errorThrower.throw( + 'expo-crypto is required to start hosted auth without an explicit state. ' + + 'Please install it by running: npx expo install expo-crypto', + ); + } +} + +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 (expectedUrl.host && callbackUrl.host !== expectedUrl.host) { + return false; + } + + return !expectedUrl.pathname || callbackUrl.pathname === expectedUrl.pathname; +} diff --git a/packages/expo/src/types/index.ts b/packages/expo/src/types/index.ts index 7c31837db0f..14c819d4e16 100644 --- a/packages/expo/src/types/index.ts +++ b/packages/expo/src/types/index.ts @@ -9,6 +9,7 @@ export type { IStorage, BuildClerkOptions } from '../provider/singleton/types'; // OAuth/SSO hook types export type { UseOAuthFlowParams, StartOAuthFlowParams, StartOAuthFlowReturnType } from '../hooks/useOAuth'; export type { StartSSOFlowParams, StartSSOFlowReturnType } from '../hooks/useSSO'; +export type { HostedAuthInitialPage, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; // Google Sign-In types export type { diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts new file mode 100644 index 00000000000..309869260ce --- /dev/null +++ b/packages/expo/src/utils/hostedAuth.ts @@ -0,0 +1,116 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIErrorJSON } from '@clerk/shared/types'; + +import { getClerkInstance } from '../provider/singleton'; +import { errorThrower } from './errors'; + +export type FapiHostedAuthInitialPage = 'sign_in' | 'sign_up'; + +export type CreateHostedAuthParams = { + redirectUrl: string; + initialPage?: FapiHostedAuthInitialPage; + state?: string; +}; + +export type HostedAuthResource = { + url: string; +}; + +type HostedAuthJSON = { + object: 'hosted_auth'; + url: string; +}; + +type HostedAuthPayload = { + response?: HostedAuthJSON; + errors?: ClerkAPIErrorJSON[]; +}; + +type HostedAuthResponse = { + ok: boolean; + status: number; + statusText: string; + headers?: Headers; + payload: HostedAuthPayload | HostedAuthJSON | null; +}; + +type FapiClient = { + request: (requestInit: { + method: 'POST'; + path: '/client/hosted_auth'; + body: CreateHostedAuthParams; + }) => Promise; +}; + +type ClerkWithFapiClient = { + getFapiClient?: () => FapiClient | undefined; +}; + +export async function createHostedAuth(params: CreateHostedAuthParams): Promise { + const fapiClient = (getClerkInstance() as ClerkWithFapiClient | undefined)?.getFapiClient?.(); + if (!fapiClient) { + return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); + } + + const response = await fapiClient.request({ + method: 'POST', + path: '/client/hosted_auth', + body: params, + }); + + 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, + }; +} + +function getHostedAuthJSON(payload: HostedAuthResponse['payload']): HostedAuthJSON | null { + if (!payload) { + return null; + } + + if ('response' in payload) { + return payload.response ?? null; + } + + return isHostedAuthJSON(payload) ? payload : null; +} + +function isHostedAuthJSON(payload: HostedAuthResponse['payload']): payload is HostedAuthJSON { + return !!payload && 'object' in payload && payload.object === 'hosted_auth'; +} + +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; +} From 9d5bdc61856565f2a167931bbf26ff4f6181fd1c Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:19:41 -0400 Subject: [PATCH 02/15] chore(expo): add hosted auth changeset --- .changeset/hosted-auth-expo.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hosted-auth-expo.md diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md new file mode 100644 index 00000000000..4ba4ec8bd7f --- /dev/null +++ b/.changeset/hosted-auth-expo.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Add a hosted auth hook for signing in or signing up through Account Portal from native Expo apps. From 37daec400bf28c77c10e3af62e495cb41db92cd6 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:49:18 -0400 Subject: [PATCH 03/15] fix(expo): activate hosted auth session --- .../src/hooks/__tests__/useHostedAuth.test.ts | 17 +++++++++++++++++ packages/expo/src/hooks/useHostedAuth.ts | 19 ++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 26ebfb80c25..73b39b9030a 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -144,6 +144,23 @@ describe('useHostedAuth', () => { expect(response.createdSessionId).toBe('sess_123'); }); + test('does not activate a session when the callback does not return one', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', + }); + mockClient.reload.mockResolvedValue(mockClient); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockClient.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce-123' }); + expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); + expect(mockSetActive).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBeNull(); + }); + test('surfaces browser session open failures', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockImplementation(() => { diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index a6ef2d4e7d5..32e6ad66318 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -1,5 +1,5 @@ import { useClerk } from '@clerk/react'; -import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; +import type { ClientResource } from '@clerk/shared/types'; import type * as AuthSession from 'expo-auth-session'; import type * as WebBrowser from 'expo-web-browser'; @@ -93,8 +93,8 @@ export function useHostedAuth() { const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; if (rotatingTokenNonce) { updatedClient = await clerk.client?.reload({ rotatingTokenNonce }); - if (updatedClient && 'updateClient' in clerk && typeof clerk.updateClient === 'function') { - clerk.updateClient(updatedClient); + if (updatedClient) { + getClientUpdater(clerk)?.(updatedClient); } } const createdSessionId = @@ -103,11 +103,8 @@ export function useHostedAuth() { clerk.client?.lastActiveSessionId ?? null; if (createdSessionId) { - const createdSession = - updatedClient?.sessions?.find(session => session.id === createdSessionId) ?? - clerk.client?.sessions?.find(session => session.id === createdSessionId); await clerk.setActive({ - session: (createdSession as SignedInSessionResource | undefined) ?? createdSessionId, + session: createdSessionId, }); } @@ -123,6 +120,14 @@ export function useHostedAuth() { }; } +function getClientUpdater(clerk: ReturnType): ((client: ClientResource) => void) | undefined { + const maybeClerkWithClientUpdater = clerk as typeof clerk & { + updateClient?: (client: ClientResource) => void; + }; + + return maybeClerkWithClientUpdater.updateClient; +} + function toFapiInitialPage(initialPage: HostedAuthInitialPage | undefined): FapiHostedAuthInitialPage | undefined { if (initialPage === 'sign-in') { return 'sign_in'; From a6dd2143e9116963284a969e08a927f0ad87b59c Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:34:30 -0400 Subject: [PATCH 04/15] fix(expo): redeem hosted auth with code verifier --- .../src/core/__tests__/fapiClient.test.ts | 11 +++++ packages/clerk-js/src/core/fapiClient.ts | 14 +++++- packages/clerk-js/src/core/resources/Base.ts | 5 ++- .../src/hooks/__tests__/useHostedAuth.test.ts | 42 ++++++++++++++++-- packages/expo/src/hooks/useHostedAuth.ts | 43 ++++++++++++++++--- packages/expo/src/utils/hostedAuth.ts | 1 + packages/shared/src/types/resource.ts | 4 ++ 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 5de3432bdd5..fded78995fa 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,6 +151,17 @@ describe('buildUrl(options)', () => { ); }); + it('adds rotating token nonce and code verifier when provided', () => { + const url = fapiClient.buildUrl({ + path: '/client', + rotatingTokenNonce: 'nonce_123', + codeVerifier: 'verifier_123', + }); + + expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_123'); + expect(url.searchParams.get('code_verifier')).toBe('verifier_123'); + }); + // The return value isn't as expected. // The buildUrl function converts an undefined value to the string 'undefined' // and includes it in the search parameters. diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index 412c708b871..dd4320466e2 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -18,6 +18,7 @@ export type FapiRequestInit = RequestInit & { search?: ConstructorParameters[0]; sessionId?: string; rotatingTokenNonce?: string; + codeVerifier?: string; pathPrefix?: string; url?: URL; }; @@ -27,6 +28,7 @@ type FapiQueryStringParameters = { _clerk_session_id?: string; _clerk_js_version?: string; rotating_token_nonce?: string; + code_verifier?: string; }; type FapiRequestOptions = { @@ -107,7 +109,14 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { } // TODO @userland-errors: - function buildQueryString({ method, path, sessionId, search, rotatingTokenNonce }: FapiRequestInit): string { + function buildQueryString({ + method, + path, + sessionId, + search, + rotatingTokenNonce, + codeVerifier, + }: FapiRequestInit): string { const searchParams = new URLSearchParams(search as any); // the above will parse {key: ['val1','val2']} as key: 'val1,val2' and we need to recreate the array bellow @@ -119,6 +128,9 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { if (rotatingTokenNonce) { searchParams.append('rotating_token_nonce', rotatingTokenNonce); } + if (codeVerifier) { + searchParams.append('code_verifier', codeVerifier); + } if (options.domain && options.instanceType === 'development' && options.isSatellite) { searchParams.append('__domain', options.domain); diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index df04fbad5fd..ad001160b27 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -62,8 +62,8 @@ export abstract class BaseResource { } public async reload(params?: ClerkResourceReloadParams): Promise { - const { rotatingTokenNonce } = params || {}; - return this._baseGet({ forceUpdateClient: true, rotatingTokenNonce }); + const { codeVerifier, rotatingTokenNonce } = params || {}; + return this._baseGet({ codeVerifier, forceUpdateClient: true, rotatingTokenNonce }); } public isNew(): boolean { @@ -206,6 +206,7 @@ export abstract class BaseResource { { method: 'GET', path: this.path(), + codeVerifier: opts.codeVerifier, rotatingTokenNonce: opts.rotatingTokenNonce, }, opts, diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 73b39b9030a..718169e8285 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -13,6 +13,8 @@ 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(), @@ -53,11 +55,21 @@ vi.mock('expo-web-browser', () => { 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 = { @@ -79,12 +91,24 @@ describe('useHostedAuth', () => { }; } if (request === 'expo-crypto') { - return { randomUUID: mocks.randomUUID }; + return { + CryptoDigestAlgorithm: { + SHA256: 'SHA256', + }, + CryptoEncoding: { + BASE64: 'BASE64', + }, + digestStringAsync: mocks.digestStringAsync, + getRandomBytes: mocks.getRandomBytes, + randomUUID: mocks.randomUUID, + }; } return originalModuleLoad.call(Module, request, parent, isMain); }); mockClient.lastActiveSessionId = null; 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.useClerk.mockReturnValue({ loaded: true, @@ -125,6 +149,7 @@ describe('useHostedAuth', () => { path: '/client/hosted_auth', body: { redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, initialPage: undefined, state: 'state-123', }, @@ -133,12 +158,18 @@ describe('useHostedAuth', () => { 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(mockClient.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce-123' }); + expect(mockClient.reload).toHaveBeenCalledWith({ + rotatingTokenNonce: 'nonce-123', + codeVerifier: mockCodeVerifier, + }); expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_123' }); expect(response.createdSessionId).toBe('sess_123'); @@ -155,7 +186,10 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); const response = await result.current.startHostedAuth({ state: 'state-123' }); - expect(mockClient.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'nonce-123' }); + expect(mockClient.reload).toHaveBeenCalledWith({ + rotatingTokenNonce: 'nonce-123', + codeVerifier: mockCodeVerifier, + }); expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).not.toHaveBeenCalled(); expect(response.createdSessionId).toBeNull(); @@ -187,6 +221,7 @@ describe('useHostedAuth', () => { path: '/client/hosted_auth', body: { redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, initialPage: undefined, state: 'generated-state-123', }, @@ -221,6 +256,7 @@ describe('useHostedAuth', () => { path: '/client/hosted_auth', body: { redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, initialPage: 'sign_up', state: 'state-123', }, diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 32e6ad66318..ac2c77e0547 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -1,6 +1,7 @@ import { useClerk } from '@clerk/react'; import type { ClientResource } from '@clerk/shared/types'; import type * as AuthSession from 'expo-auth-session'; +import type * as ExpoCrypto from 'expo-crypto'; import type * as WebBrowser from 'expo-web-browser'; import { errorThrower } from '../utils/errors'; @@ -21,6 +22,11 @@ export type StartHostedAuthReturnType = { client?: ClientResource; }; +type HostedAuthPKCE = { + codeVerifier: string; + codeChallenge: string; +}; + export function useHostedAuth() { const clerk = useClerk(); @@ -57,11 +63,13 @@ export function useHostedAuth() { isTripleSlashed: true, }); const state = params.state ?? (await createState()); + const pkce = await createPKCE(); if (!clerk.client) { return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); } const hostedAuth = await createHostedAuth({ redirectUrl, + codeChallenge: pkce.codeChallenge, initialPage: toFapiInitialPage(params.initialPage), state, }); @@ -92,7 +100,7 @@ export function useHostedAuth() { let updatedClient: ClientResource | undefined; const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; if (rotatingTokenNonce) { - updatedClient = await clerk.client?.reload({ rotatingTokenNonce }); + updatedClient = await clerk.client?.reload({ rotatingTokenNonce, codeVerifier: pkce.codeVerifier }); if (updatedClient) { getClientUpdater(clerk)?.(updatedClient); } @@ -141,18 +149,43 @@ function toFapiInitialPage(initialPage: HostedAuthInitialPage | undefined): Fapi } async function createState(): Promise { + return loadExpoCrypto().randomUUID(); +} + +async function createPKCE(): Promise { + const Crypto = loadExpoCrypto(); + const codeVerifier = bytesToHex(Crypto.getRandomBytes(32)); + const codeChallenge = base64ToBase64Url( + await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, codeVerifier, { + encoding: Crypto.CryptoEncoding.BASE64, + }), + ); + + return { + codeVerifier, + codeChallenge, + }; +} + +function loadExpoCrypto(): typeof ExpoCrypto { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - const { randomUUID } = require('expo-crypto') as typeof import('expo-crypto'); - return randomUUID(); + return require('expo-crypto') as typeof import('expo-crypto'); } catch { return errorThrower.throw( - 'expo-crypto is required to start hosted auth without an explicit state. ' + - 'Please install it by running: npx expo install expo-crypto', + '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 { diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index 309869260ce..0b858d2f2f1 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -8,6 +8,7 @@ export type FapiHostedAuthInitialPage = 'sign_in' | 'sign_up'; export type CreateHostedAuthParams = { redirectUrl: string; + codeChallenge: string; initialPage?: FapiHostedAuthInitialPage; state?: string; }; diff --git a/packages/shared/src/types/resource.ts b/packages/shared/src/types/resource.ts index 3a98d7c956a..32ad3ba19e8 100644 --- a/packages/shared/src/types/resource.ts +++ b/packages/shared/src/types/resource.ts @@ -4,6 +4,10 @@ export type ClerkResourceReloadParams = { * A nonce to use for rotating the user's token. Used in native application OAuth flows to allow the native client to update its JWT once despite changes in its rotating token. */ rotatingTokenNonce?: string; + /** + * A PKCE verifier used to redeem hosted auth rotating token nonces. + */ + codeVerifier?: string; }; /** From 03fe8941fc97d00edacbf1caff1fbc9b91cb78eb Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:44:49 -0400 Subject: [PATCH 05/15] fix(expo): rename hosted auth option to mode --- .../expo/src/hooks/__tests__/useHostedAuth.test.ts | 10 +++++----- packages/expo/src/hooks/useHostedAuth.ts | 14 +++++++------- packages/expo/src/types/index.ts | 2 +- packages/expo/src/utils/hostedAuth.ts | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 718169e8285..7513adec4df 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -150,7 +150,7 @@ describe('useHostedAuth', () => { body: { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, - initialPage: undefined, + mode: undefined, state: 'state-123', }, }); @@ -222,7 +222,7 @@ describe('useHostedAuth', () => { body: { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, - initialPage: undefined, + mode: undefined, state: 'generated-state-123', }, }); @@ -242,14 +242,14 @@ describe('useHostedAuth', () => { ); }); - test('passes through the requested initial page', async () => { + 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({ initialPage: 'sign-up', state: 'state-123' }); + await result.current.startHostedAuth({ mode: 'sign-up', state: 'state-123' }); expect(mockFapiRequest).toHaveBeenCalledWith({ method: 'POST', @@ -257,7 +257,7 @@ describe('useHostedAuth', () => { body: { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, - initialPage: 'sign_up', + mode: 'sign_up', state: 'state-123', }, }); diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index ac2c77e0547..c7b06392498 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -5,13 +5,13 @@ import type * as ExpoCrypto from 'expo-crypto'; import type * as WebBrowser from 'expo-web-browser'; import { errorThrower } from '../utils/errors'; -import { createHostedAuth, type FapiHostedAuthInitialPage } from '../utils/hostedAuth'; +import { createHostedAuth, type FapiHostedAuthMode } from '../utils/hostedAuth'; -export type HostedAuthInitialPage = 'sign-in' | 'sign-up'; +export type HostedAuthMode = 'sign-in' | 'sign-up'; export type StartHostedAuthParams = { redirectUrl?: string; - initialPage?: HostedAuthInitialPage; + mode?: HostedAuthMode; state?: string; authSessionOptions?: Pick; }; @@ -70,7 +70,7 @@ export function useHostedAuth() { const hostedAuth = await createHostedAuth({ redirectUrl, codeChallenge: pkce.codeChallenge, - initialPage: toFapiInitialPage(params.initialPage), + mode: toFapiMode(params.mode), state, }); @@ -136,12 +136,12 @@ function getClientUpdater(clerk: ReturnType): ((client: ClientR return maybeClerkWithClientUpdater.updateClient; } -function toFapiInitialPage(initialPage: HostedAuthInitialPage | undefined): FapiHostedAuthInitialPage | undefined { - if (initialPage === 'sign-in') { +function toFapiMode(mode: HostedAuthMode | undefined): FapiHostedAuthMode | undefined { + if (mode === 'sign-in') { return 'sign_in'; } - if (initialPage === 'sign-up') { + if (mode === 'sign-up') { return 'sign_up'; } diff --git a/packages/expo/src/types/index.ts b/packages/expo/src/types/index.ts index 14c819d4e16..dffa17a4a8b 100644 --- a/packages/expo/src/types/index.ts +++ b/packages/expo/src/types/index.ts @@ -9,7 +9,7 @@ export type { IStorage, BuildClerkOptions } from '../provider/singleton/types'; // OAuth/SSO hook types export type { UseOAuthFlowParams, StartOAuthFlowParams, StartOAuthFlowReturnType } from '../hooks/useOAuth'; export type { StartSSOFlowParams, StartSSOFlowReturnType } from '../hooks/useSSO'; -export type { HostedAuthInitialPage, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; +export type { HostedAuthMode, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; // Google Sign-In types export type { diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index 0b858d2f2f1..00b1023de91 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -4,12 +4,12 @@ import type { ClerkAPIErrorJSON } from '@clerk/shared/types'; import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from './errors'; -export type FapiHostedAuthInitialPage = 'sign_in' | 'sign_up'; +export type FapiHostedAuthMode = 'sign_in' | 'sign_up'; export type CreateHostedAuthParams = { redirectUrl: string; codeChallenge: string; - initialPage?: FapiHostedAuthInitialPage; + mode?: FapiHostedAuthMode; state?: string; }; From 2cd96bad986dc6f4136ccf604ff9a4f28d996680 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:19:33 -0400 Subject: [PATCH 06/15] fix(expo): satisfy hosted auth lint rules --- packages/expo/src/hooks/__tests__/useHostedAuth.test.ts | 3 ++- packages/expo/src/hooks/useHostedAuth.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 7513adec4df..b9ed05212e5 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -1,5 +1,6 @@ -import { renderHook } from '@testing-library/react'; import Module from 'node:module'; + +import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { useHostedAuth } from '../useHostedAuth'; diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index c7b06392498..1991fec7bf7 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -62,7 +62,7 @@ export function useHostedAuth() { path: 'hosted-auth-callback', isTripleSlashed: true, }); - const state = params.state ?? (await createState()); + const state = params.state ?? createState(); const pkce = await createPKCE(); if (!clerk.client) { return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); @@ -148,7 +148,7 @@ function toFapiMode(mode: HostedAuthMode | undefined): FapiHostedAuthMode | unde return undefined; } -async function createState(): Promise { +function createState(): string { return loadExpoCrypto().randomUUID(); } @@ -170,7 +170,7 @@ async function createPKCE(): Promise { function loadExpoCrypto(): typeof ExpoCrypto { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - return require('expo-crypto') as typeof import('expo-crypto'); + 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', From f17c26efb0d077ba03db543baadff22e6ed4a6b0 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:21:20 -0400 Subject: [PATCH 07/15] fix(expo): publish hosted auth verifier dependencies --- .changeset/hosted-auth-expo.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md index 4ba4ec8bd7f..63e568e84bd 100644 --- a/.changeset/hosted-auth-expo.md +++ b/.changeset/hosted-auth-expo.md @@ -1,5 +1,7 @@ --- '@clerk/expo': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch --- Add a hosted auth hook for signing in or signing up through Account Portal from native Expo apps. From bab2409def0be93576b2f09145bf7384debc47a4 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:45:43 -0400 Subject: [PATCH 08/15] fix(expo): address hosted auth review feedback --- .../src/hooks/__tests__/useHostedAuth.test.ts | 14 +++++ packages/expo/src/hooks/useHostedAuth.ts | 57 +++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index b9ed05212e5..0eada2700f6 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -293,6 +293,20 @@ describe('useHostedAuth', () => { ); }); + test('rejects callback authority mismatches for triple-slashed redirect URLs', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp://attacker/hosted-auth-callback?state=state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth callback URL did not match the initiated redirect URL.', + ); + }); + test('rejects invalid hosted auth responses', async () => { mockFapiRequest.mockResolvedValue({ ok: true, diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 1991fec7bf7..7114a0adada 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -7,19 +7,59 @@ import type * as WebBrowser from 'expo-web-browser'; import { errorThrower } from '../utils/errors'; import { createHostedAuth, type FapiHostedAuthMode } from '../utils/hostedAuth'; +/** + * Controls which Account Portal auth screen opens for hosted auth. + */ export type HostedAuthMode = 'sign-in' | 'sign-up'; +/** + * Options for starting hosted auth from a native Expo application. + */ export type StartHostedAuthParams = { + /** + * Native deep-link URL that Account Portal redirects to after auth completes. + * Defaults to `AuthSession.makeRedirectUri({ path: 'hosted-auth-callback', isTripleSlashed: true })`. + */ redirectUrl?: string; + /** + * Initial hosted auth screen to open. + */ mode?: HostedAuthMode; + /** + * Optional opaque state value used to bind the browser callback to this auth attempt. + * A cryptographically random value is generated when omitted. + */ state?: string; - authSessionOptions?: Pick; + /** + * Options forwarded to `expo-web-browser` when opening the hosted auth session. + */ + authSessionOptions?: { + showInRecents?: boolean; + }; }; +/** + * 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; - authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; - client?: ClientResource; + /** + * Result returned by the hosted browser session, or `null` when Clerk was not loaded. + */ + authSessionResult: { + type: string; + url?: string | null; + } | null; + /** + * The current Clerk client after hosted auth completes. + */ + client?: { + id?: string; + lastActiveSessionId: string | null; + }; }; type HostedAuthPKCE = { @@ -27,7 +67,12 @@ type HostedAuthPKCE = { codeChallenge: string; }; -export function useHostedAuth() { +/** + * Returns helpers for authenticating native Expo users through Clerk's hosted Account Portal. + */ +export function useHostedAuth(): { + startHostedAuth: (params?: StartHostedAuthParams) => Promise; +} { const clerk = useClerk(); async function startHostedAuth(params: StartHostedAuthParams = {}): Promise { @@ -198,9 +243,9 @@ function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): b return false; } - if (expectedUrl.host && callbackUrl.host !== expectedUrl.host) { + if (callbackUrl.host !== expectedUrl.host) { return false; } - return !expectedUrl.pathname || callbackUrl.pathname === expectedUrl.pathname; + return callbackUrl.pathname === expectedUrl.pathname; } From 5eda2e57f972da5c611cc74cae0ac2f25bf5a807 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:16:02 -0400 Subject: [PATCH 09/15] fix(expo): harden hosted auth session handoff --- .../src/core/__tests__/fapiClient.test.ts | 4 +- packages/clerk-js/src/core/fapiClient.ts | 14 +---- packages/clerk-js/src/core/resources/Base.ts | 5 +- .../clerk-js/src/core/resources/Client.ts | 22 +++++++ .../core/resources/__tests__/Client.test.ts | 46 +++++++++++++++ .../src/hooks/__tests__/useHostedAuth.test.ts | 57 ++++++++++++++++++- packages/expo/src/hooks/useHostedAuth.ts | 42 +++++++++----- packages/expo/src/utils/hostedAuth.ts | 6 +- 8 files changed, 159 insertions(+), 37 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index fded78995fa..ec5390e254a 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,15 +151,13 @@ describe('buildUrl(options)', () => { ); }); - it('adds rotating token nonce and code verifier when provided', () => { + it('adds rotating token nonce when provided', () => { const url = fapiClient.buildUrl({ path: '/client', rotatingTokenNonce: 'nonce_123', - codeVerifier: 'verifier_123', }); expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_123'); - expect(url.searchParams.get('code_verifier')).toBe('verifier_123'); }); // The return value isn't as expected. diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index dd4320466e2..412c708b871 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -18,7 +18,6 @@ export type FapiRequestInit = RequestInit & { search?: ConstructorParameters[0]; sessionId?: string; rotatingTokenNonce?: string; - codeVerifier?: string; pathPrefix?: string; url?: URL; }; @@ -28,7 +27,6 @@ type FapiQueryStringParameters = { _clerk_session_id?: string; _clerk_js_version?: string; rotating_token_nonce?: string; - code_verifier?: string; }; type FapiRequestOptions = { @@ -109,14 +107,7 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { } // TODO @userland-errors: - function buildQueryString({ - method, - path, - sessionId, - search, - rotatingTokenNonce, - codeVerifier, - }: FapiRequestInit): string { + function buildQueryString({ method, path, sessionId, search, rotatingTokenNonce }: FapiRequestInit): string { const searchParams = new URLSearchParams(search as any); // the above will parse {key: ['val1','val2']} as key: 'val1,val2' and we need to recreate the array bellow @@ -128,9 +119,6 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { if (rotatingTokenNonce) { searchParams.append('rotating_token_nonce', rotatingTokenNonce); } - if (codeVerifier) { - searchParams.append('code_verifier', codeVerifier); - } if (options.domain && options.instanceType === 'development' && options.isSatellite) { searchParams.append('__domain', options.domain); diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index ad001160b27..df04fbad5fd 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -62,8 +62,8 @@ export abstract class BaseResource { } public async reload(params?: ClerkResourceReloadParams): Promise { - const { codeVerifier, rotatingTokenNonce } = params || {}; - return this._baseGet({ codeVerifier, forceUpdateClient: true, rotatingTokenNonce }); + const { rotatingTokenNonce } = params || {}; + return this._baseGet({ forceUpdateClient: true, rotatingTokenNonce }); } public isNew(): boolean { @@ -206,7 +206,6 @@ export abstract class BaseResource { { method: 'GET', path: this.path(), - codeVerifier: opts.codeVerifier, rotatingTokenNonce: opts.rotatingTokenNonce, }, opts, diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 6b690c7261c..5622f710121 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -1,4 +1,5 @@ import type { + ClerkResourceReloadParams, ClientJSON, ClientJSONSnapshot, ClientResource, @@ -77,6 +78,27 @@ export class Client extends BaseResource implements ClientResource { return this._baseGet({ fetchMaxTries }); } + public async reload(params?: ClerkResourceReloadParams): Promise { + if (!params?.rotatingTokenNonce || !params.codeVerifier) { + return super.reload(params); + } + + const json = await Client._fetch( + { + method: 'POST', + path: this.path(), + body: { + _method: 'GET', + rotatingTokenNonce: params.rotatingTokenNonce, + codeVerifier: params.codeVerifier, + }, + }, + { forceUpdateClient: true }, + ); + + return this.fromJSON((json?.response || json) as ClientJSON | null); + } + async destroy(): Promise { // TODO: Make it restful by introducing a DELETE /client/:id endpoint return this._baseDelete({ path: '/client' }).then(() => { diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index 5514e1cc855..aeca706c78c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -193,6 +193,52 @@ describe('Client Singleton', () => { expect(client.signIn.status).toBe('needs_second_factor'); }); + it('redeems hosted auth rotating token nonce with verifier in the request body', async () => { + const user = createUser({ id: 'user_1' }); + const session = createSession({ id: 'session_1' }, user); + const clientObjectJSON: ClientJSON = { + object: 'client', + id: 'test_id', + status: 'active', + last_active_session_id: null, + sign_in: createSignIn({ id: 'test_sign_in_id' }, user), + sign_up: createSignUp({ id: 'test_sign_up_id' }), + sessions: [session], + created_at: Date.now() - 1000, + updated_at: Date.now(), + } as any; + + const reloadedClientJSON: ClientJSON = { + ...clientObjectJSON, + last_active_session_id: 'session_1', + }; + + const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValueOnce({ + response: reloadedClientJSON, + } as any); + + Client.clearInstance(); + const client = Client.getOrCreateInstance().fromJSON(clientObjectJSON); + await client.reload({ + rotatingTokenNonce: 'nonce_123', + codeVerifier: 'verifier_123', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + { + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: 'nonce_123', + codeVerifier: 'verifier_123', + }, + }, + { forceUpdateClient: true }, + ); + expect(client.lastActiveSessionId).toBe('session_1'); + }); + it('replaces sign up and sign in identity when fromJSON receives new ids', () => { const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' }); const session = createSession({ id: 'session_1' }, user); diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 0eada2700f6..394501d84d0 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -74,7 +74,7 @@ const mockCodeChallenge = 'mock-code-challenge-_'; describe('useHostedAuth', () => { const mockClient = { - lastActiveSessionId: null, + lastActiveSessionId: null as string | null, reload: vi.fn(), }; const mockUpdateClient = vi.fn(); @@ -116,6 +116,9 @@ describe('useHostedAuth', () => { client: mockClient, setActive: mockSetActive, updateClient: mockUpdateClient, + getFapiClient: () => ({ + request: mockFapiRequest, + }), }); mocks.getClerkInstance.mockReturnValue({ getFapiClient: () => ({ @@ -155,6 +158,7 @@ describe('useHostedAuth', () => { state: 'state-123', }, }); + expect(mocks.getClerkInstance).not.toHaveBeenCalled(); expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ path: 'hosted-auth-callback', isTripleSlashed: true, @@ -176,6 +180,27 @@ describe('useHostedAuth', () => { expect(response.createdSessionId).toBe('sess_123'); }); + test('falls back to the reloaded client session when callback session id is absent', async () => { + const updatedClient = { + ...mockClient, + lastActiveSessionId: 'sess_reloaded', + }; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', + }); + mockClient.reload.mockResolvedValue(updatedClient); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockUpdateClient).toHaveBeenCalledWith(updatedClient); + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_reloaded' }); + expect(response.createdSessionId).toBe('sess_reloaded'); + expect(response.client).toBe(updatedClient); + }); + test('does not activate a session when the callback does not return one', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ @@ -196,6 +221,22 @@ describe('useHostedAuth', () => { expect(response.createdSessionId).toBeNull(); }); + test('does not fall back to an existing active session when callback session params are missing', async () => { + mockClient.lastActiveSessionId = 'sess_existing'; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=state-123', + }); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockClient.reload).not.toHaveBeenCalled(); + expect(mockSetActive).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBeNull(); + }); + test('surfaces browser session open failures', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockImplementation(() => { @@ -278,6 +319,20 @@ describe('useHostedAuth', () => { ); }); + 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({ state: 'state-123' })).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'); diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 7114a0adada..241de39875c 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -19,6 +19,7 @@ export type StartHostedAuthParams = { /** * Native deep-link URL that Account Portal redirects to after auth completes. * Defaults to `AuthSession.makeRedirectUri({ path: 'hosted-auth-callback', isTripleSlashed: true })`. + * Production instances must allowlist this URL in the Clerk Dashboard. */ redirectUrl?: string; /** @@ -83,6 +84,9 @@ export function useHostedAuth(): { client: clerk.client, }; } + if (!clerk.client) { + return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); + } let AuthSessionModule: typeof AuthSession; let WebBrowserModule: typeof WebBrowser; @@ -109,15 +113,15 @@ export function useHostedAuth(): { }); const state = params.state ?? createState(); const pkce = await createPKCE(); - if (!clerk.client) { - return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); - } - const hostedAuth = await createHostedAuth({ - redirectUrl, - codeChallenge: pkce.codeChallenge, - mode: toFapiMode(params.mode), - state, - }); + const hostedAuth = await createHostedAuth( + { + redirectUrl, + codeChallenge: pkce.codeChallenge, + mode: toFapiMode(params.mode), + state, + }, + clerk, + ); const authSessionResult = await WebBrowserModule.openAuthSessionAsync( hostedAuth.url, @@ -132,7 +136,12 @@ export function useHostedAuth(): { }; } - const callbackUrl = new URL(authSessionResult.url); + 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.'); } @@ -143,18 +152,17 @@ export function useHostedAuth(): { } let updatedClient: ClientResource | undefined; + let createdSessionId: string | null = null; const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; if (rotatingTokenNonce) { updatedClient = await clerk.client?.reload({ rotatingTokenNonce, codeVerifier: pkce.codeVerifier }); if (updatedClient) { getClientUpdater(clerk)?.(updatedClient); + createdSessionId = normalizeSessionId( + callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId, + ); } } - const createdSessionId = - callbackParams.get('created_session_id') ?? - updatedClient?.lastActiveSessionId ?? - clerk.client?.lastActiveSessionId ?? - null; if (createdSessionId) { await clerk.setActive({ session: createdSessionId, @@ -193,6 +201,10 @@ function toFapiMode(mode: HostedAuthMode | undefined): FapiHostedAuthMode | unde return undefined; } +function normalizeSessionId(sessionId: string | null | undefined): string | null { + return sessionId || null; +} + function createState(): string { return loadExpoCrypto().randomUUID(); } diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index 00b1023de91..8483fe52587 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -47,8 +47,10 @@ type ClerkWithFapiClient = { getFapiClient?: () => FapiClient | undefined; }; -export async function createHostedAuth(params: CreateHostedAuthParams): Promise { - const fapiClient = (getClerkInstance() as ClerkWithFapiClient | undefined)?.getFapiClient?.(); +export async function createHostedAuth(params: CreateHostedAuthParams, clerk?: unknown): Promise { + const fapiClient = + (clerk as ClerkWithFapiClient | undefined)?.getFapiClient?.() ?? + (getClerkInstance() as ClerkWithFapiClient | undefined)?.getFapiClient?.(); if (!fapiClient) { return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); } From df68ca29dbd45c96c42074d000ff923c68321b5d Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:24:48 -0400 Subject: [PATCH 10/15] fix(clerk-js): type hosted auth verifier body --- packages/clerk-js/src/core/resources/Client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 5622f710121..8150d134b2d 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -91,7 +91,7 @@ export class Client extends BaseResource implements ClientResource { _method: 'GET', rotatingTokenNonce: params.rotatingTokenNonce, codeVerifier: params.codeVerifier, - }, + } as any, }, { forceUpdateClient: true }, ); From a8f2ce87519b4cdf2a693b6697819e0d96bb3d21 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:59:00 -0400 Subject: [PATCH 11/15] test(js): harden hosted auth verifier handling --- packages/clerk-js/src/core/__tests__/fapiClient.test.ts | 8 ++++++-- packages/clerk-js/src/core/resources/Client.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index ec5390e254a..8ca2f6b0b71 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,13 +151,17 @@ describe('buildUrl(options)', () => { ); }); - it('adds rotating token nonce when provided', () => { + it('adds rotating token nonce without serializing a code verifier', () => { const url = fapiClient.buildUrl({ path: '/client', rotatingTokenNonce: 'nonce_123', - }); + codeVerifier: 'secret_verifier', + code_verifier: 'secret_verifier', + } as any); expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_123'); + expect(url.searchParams.has('code_verifier')).toBe(false); + expect(url.searchParams.has('codeVerifier')).toBe(false); }); // The return value isn't as expected. diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 8150d134b2d..4d3f92fa7a6 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -78,6 +78,15 @@ export class Client extends BaseResource implements ClientResource { return this._baseGet({ fetchMaxTries }); } + /** + * Reloads the current client from FAPI. + * + * By default this uses the standard GET reload path. When redeeming a + * PKCE-bound rotating token nonce, callers must pass both + * `rotatingTokenNonce` and `codeVerifier`; that path sends the verifier in the + * request body via a POST override so the PKCE secret is not serialized into + * the URL. + */ public async reload(params?: ClerkResourceReloadParams): Promise { if (!params?.rotatingTokenNonce || !params.codeVerifier) { return super.reload(params); From 9910a31e008ca6b3cf35db8c3f651de904d328ca Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:22:13 -0400 Subject: [PATCH 12/15] fix(clerk-js): trim hosted auth reload path --- .../clerk-js/src/core/resources/Client.ts | 27 ++++++++----------- .../core/resources/__tests__/Client.test.ts | 19 ++++++------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 4d3f92fa7a6..2e56f528540 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -87,25 +87,20 @@ export class Client extends BaseResource implements ClientResource { * request body via a POST override so the PKCE secret is not serialized into * the URL. */ - public async reload(params?: ClerkResourceReloadParams): Promise { - if (!params?.rotatingTokenNonce || !params.codeVerifier) { + public reload(params?: ClerkResourceReloadParams): Promise { + const { rotatingTokenNonce, codeVerifier } = params || {}; + + if (!rotatingTokenNonce || !codeVerifier) { return super.reload(params); } - const json = await Client._fetch( - { - method: 'POST', - path: this.path(), - body: { - _method: 'GET', - rotatingTokenNonce: params.rotatingTokenNonce, - codeVerifier: params.codeVerifier, - } as any, - }, - { forceUpdateClient: true }, - ); - - return this.fromJSON((json?.response || json) as ClientJSON | null); + return this._basePost({ + body: { + _method: 'GET', + rotatingTokenNonce, + codeVerifier, + } as any, + }); } async destroy(): Promise { diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index aeca706c78c..692c9886d77 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -224,18 +224,15 @@ describe('Client Singleton', () => { codeVerifier: 'verifier_123', }); - expect(fetchSpy).toHaveBeenCalledWith( - { - method: 'POST', - path: '/client', - body: { - _method: 'GET', - rotatingTokenNonce: 'nonce_123', - codeVerifier: 'verifier_123', - }, + expect(fetchSpy).toHaveBeenCalledWith({ + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: 'nonce_123', + codeVerifier: 'verifier_123', }, - { forceUpdateClient: true }, - ); + }); expect(client.lastActiveSessionId).toBe('session_1'); }); From 4f1dddc8e2797281f204c97dcd4ac7365705be23 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:31:30 -0400 Subject: [PATCH 13/15] fix(expo): stabilize hosted auth CI --- .../clerk-js/src/core/resources/Client.ts | 24 ++++--------------- .../ClerkProvider.nativeClientSync.test.tsx | 1 + 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 2e56f528540..71984b7e2bc 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -78,29 +78,13 @@ export class Client extends BaseResource implements ClientResource { return this._baseGet({ fetchMaxTries }); } - /** - * Reloads the current client from FAPI. - * - * By default this uses the standard GET reload path. When redeeming a - * PKCE-bound rotating token nonce, callers must pass both - * `rotatingTokenNonce` and `codeVerifier`; that path sends the verifier in the - * request body via a POST override so the PKCE secret is not serialized into - * the URL. - */ public reload(params?: ClerkResourceReloadParams): Promise { - const { rotatingTokenNonce, codeVerifier } = params || {}; - - if (!rotatingTokenNonce || !codeVerifier) { - return super.reload(params); + if (params?.rotatingTokenNonce && params.codeVerifier) { + // POST override keeps verifier-bound reload secrets out of the URL. + return this._basePost({ body: { _method: 'GET', ...params } as any }); } - return this._basePost({ - body: { - _method: 'GET', - rotatingTokenNonce, - codeVerifier, - } as any, - }); + return super.reload(params); } async destroy(): Promise { diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index cb7c9af64fa..d1b32916dcc 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1040,6 +1040,7 @@ describe('ClerkProvider native client sync', () => { test('ignores native client events that echo a JS-originated sync', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); const { rerender } = render( Date: Thu, 25 Jun 2026 00:58:55 -0400 Subject: [PATCH 14/15] fix(expo): scope hosted auth completion to expo --- .../src/core/__tests__/fapiClient.test.ts | 8 +- .../clerk-js/src/core/resources/Client.ts | 10 -- .../core/resources/__tests__/Client.test.ts | 43 ------- .../src/hooks/__tests__/useHostedAuth.test.ts | 65 +++++++--- packages/expo/src/hooks/useHostedAuth.ts | 11 +- packages/expo/src/utils/hostedAuth.ts | 115 ++++++++++++++++-- packages/shared/src/types/resource.ts | 4 - 7 files changed, 162 insertions(+), 94 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index 8ca2f6b0b71..f729489d67b 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,17 +151,13 @@ describe('buildUrl(options)', () => { ); }); - it('adds rotating token nonce without serializing a code verifier', () => { + it('adds rotating token nonce', () => { const url = fapiClient.buildUrl({ path: '/client', rotatingTokenNonce: 'nonce_123', - codeVerifier: 'secret_verifier', - code_verifier: 'secret_verifier', - } as any); + }); expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_123'); - expect(url.searchParams.has('code_verifier')).toBe(false); - expect(url.searchParams.has('codeVerifier')).toBe(false); }); // The return value isn't as expected. diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 71984b7e2bc..6b690c7261c 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -1,5 +1,4 @@ import type { - ClerkResourceReloadParams, ClientJSON, ClientJSONSnapshot, ClientResource, @@ -78,15 +77,6 @@ export class Client extends BaseResource implements ClientResource { return this._baseGet({ fetchMaxTries }); } - public reload(params?: ClerkResourceReloadParams): Promise { - if (params?.rotatingTokenNonce && params.codeVerifier) { - // POST override keeps verifier-bound reload secrets out of the URL. - return this._basePost({ body: { _method: 'GET', ...params } as any }); - } - - return super.reload(params); - } - async destroy(): Promise { // TODO: Make it restful by introducing a DELETE /client/:id endpoint return this._baseDelete({ path: '/client' }).then(() => { diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index 692c9886d77..5514e1cc855 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -193,49 +193,6 @@ describe('Client Singleton', () => { expect(client.signIn.status).toBe('needs_second_factor'); }); - it('redeems hosted auth rotating token nonce with verifier in the request body', async () => { - const user = createUser({ id: 'user_1' }); - const session = createSession({ id: 'session_1' }, user); - const clientObjectJSON: ClientJSON = { - object: 'client', - id: 'test_id', - status: 'active', - last_active_session_id: null, - sign_in: createSignIn({ id: 'test_sign_in_id' }, user), - sign_up: createSignUp({ id: 'test_sign_up_id' }), - sessions: [session], - created_at: Date.now() - 1000, - updated_at: Date.now(), - } as any; - - const reloadedClientJSON: ClientJSON = { - ...clientObjectJSON, - last_active_session_id: 'session_1', - }; - - const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValueOnce({ - response: reloadedClientJSON, - } as any); - - Client.clearInstance(); - const client = Client.getOrCreateInstance().fromJSON(clientObjectJSON); - await client.reload({ - rotatingTokenNonce: 'nonce_123', - codeVerifier: 'verifier_123', - }); - - expect(fetchSpy).toHaveBeenCalledWith({ - method: 'POST', - path: '/client', - body: { - _method: 'GET', - rotatingTokenNonce: 'nonce_123', - codeVerifier: 'verifier_123', - }, - }); - expect(client.lastActiveSessionId).toBe('session_1'); - }); - it('replaces sign up and sign in identity when fromJSON receives new ids', () => { const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' }); const session = createSession({ id: 'session_1' }, user); diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 394501d84d0..28dcbada89d 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -1,5 +1,6 @@ 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'; @@ -76,12 +77,14 @@ describe('useHostedAuth', () => { const mockClient = { lastActiveSessionId: null as string | null, reload: vi.fn(), + fromJSON: vi.fn(), }; const mockUpdateClient = vi.fn(); const mockSetActive = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + mockFapiRequest.mockReset(); vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { if (request === 'expo-auth-session') { return { makeRedirectUri: mocks.makeRedirectUri }; @@ -107,6 +110,10 @@ describe('useHostedAuth', () => { return originalModuleLoad.call(Module, request, parent, isMain); }); mockClient.lastActiveSessionId = null; + mockClient.fromJSON.mockImplementation((clientJSON: ClientJSON) => { + mockClient.lastActiveSessionId = clientJSON.last_active_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+/='); @@ -143,7 +150,7 @@ describe('useHostedAuth', () => { type: 'success', url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123', }); - mockClient.reload.mockResolvedValue(mockClient); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); const { result } = renderHook(() => useHostedAuth()); const response = await result.current.startHostedAuth({ state: 'state-123' }); @@ -171,34 +178,36 @@ describe('useHostedAuth', () => { 'myapp:///hosted-auth-callback', undefined, ); - expect(mockClient.reload).toHaveBeenCalledWith({ - rotatingTokenNonce: 'nonce-123', - codeVerifier: mockCodeVerifier, + 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(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_123' }); expect(response.createdSessionId).toBe('sess_123'); }); test('falls back to the reloaded client session when callback session id is absent', async () => { - const updatedClient = { - ...mockClient, - lastActiveSessionId: 'sess_reloaded', - }; mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', }); - mockClient.reload.mockResolvedValue(updatedClient); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_reloaded' }); const { result } = renderHook(() => useHostedAuth()); const response = await result.current.startHostedAuth({ state: 'state-123' }); - expect(mockUpdateClient).toHaveBeenCalledWith(updatedClient); + expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_reloaded' }); expect(response.createdSessionId).toBe('sess_reloaded'); - expect(response.client).toBe(updatedClient); + expect(response.client).toBe(mockClient); }); test('does not activate a session when the callback does not return one', async () => { @@ -207,15 +216,12 @@ describe('useHostedAuth', () => { type: 'success', url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', }); - mockClient.reload.mockResolvedValue(mockClient); + mockHostedAuthRedeemResponse(); const { result } = renderHook(() => useHostedAuth()); const response = await result.current.startHostedAuth({ state: 'state-123' }); - expect(mockClient.reload).toHaveBeenCalledWith({ - rotatingTokenNonce: 'nonce-123', - codeVerifier: mockCodeVerifier, - }); + expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' })); expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).not.toHaveBeenCalled(); expect(response.createdSessionId).toBeNull(); @@ -232,7 +238,7 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); const response = await result.current.startHostedAuth({ state: 'state-123' }); - expect(mockClient.reload).not.toHaveBeenCalled(); + expect(mockFapiRequest).toHaveBeenCalledTimes(1); expect(mockSetActive).not.toHaveBeenCalled(); expect(response.createdSessionId).toBeNull(); }); @@ -407,7 +413,7 @@ describe('useHostedAuth', () => { }); function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { - mockFapiRequest.mockResolvedValue({ + mockFapiRequest.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK', @@ -419,3 +425,26 @@ function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { }, }); } + +function mockHostedAuthRedeemResponse({ lastActiveSessionId = null }: { lastActiveSessionId?: string | null } = {}) { + mockFapiRequest.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'client', + id: 'client_123', + sessions: [], + 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 index 241de39875c..c998766b1cb 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -5,7 +5,7 @@ import type * as ExpoCrypto from 'expo-crypto'; import type * as WebBrowser from 'expo-web-browser'; import { errorThrower } from '../utils/errors'; -import { createHostedAuth, type FapiHostedAuthMode } from '../utils/hostedAuth'; +import { createHostedAuth, type FapiHostedAuthMode, redeemHostedAuth } from '../utils/hostedAuth'; /** * Controls which Account Portal auth screen opens for hosted auth. @@ -155,7 +155,14 @@ export function useHostedAuth(): { let createdSessionId: string | null = null; const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; if (rotatingTokenNonce) { - updatedClient = await clerk.client?.reload({ rotatingTokenNonce, codeVerifier: pkce.codeVerifier }); + updatedClient = await redeemHostedAuth( + { + rotatingTokenNonce, + codeVerifier: pkce.codeVerifier, + }, + clerk.client, + clerk, + ); if (updatedClient) { getClientUpdater(clerk)?.(updatedClient); createdSessionId = normalizeSessionId( diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index 8483fe52587..f43b9f84615 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -1,5 +1,5 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type { ClerkAPIErrorJSON } from '@clerk/shared/types'; +import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/shared/types'; import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from './errors'; @@ -13,6 +13,11 @@ export type CreateHostedAuthParams = { state?: string; }; +export type RedeemHostedAuthParams = { + rotatingTokenNonce: string; + codeVerifier: string; +}; + export type HostedAuthResource = { url: string; }; @@ -27,19 +32,28 @@ type HostedAuthPayload = { errors?: ClerkAPIErrorJSON[]; }; +type ClientPayload = { + response?: ClientJSON; + client?: ClientJSON; + meta?: { + client?: ClientJSON; + }; + errors?: ClerkAPIErrorJSON[]; +}; + type HostedAuthResponse = { ok: boolean; status: number; statusText: string; headers?: Headers; - payload: HostedAuthPayload | HostedAuthJSON | null; + payload: HostedAuthPayload | HostedAuthJSON | ClientPayload | ClientJSON | null; }; type FapiClient = { request: (requestInit: { method: 'POST'; - path: '/client/hosted_auth'; - body: CreateHostedAuthParams; + path: '/client/hosted_auth' | '/client'; + body: CreateHostedAuthParams | (RedeemHostedAuthParams & { _method: 'GET' }); }) => Promise; }; @@ -48,9 +62,7 @@ type ClerkWithFapiClient = { }; export async function createHostedAuth(params: CreateHostedAuthParams, clerk?: unknown): Promise { - const fapiClient = - (clerk as ClerkWithFapiClient | undefined)?.getFapiClient?.() ?? - (getClerkInstance() as ClerkWithFapiClient | undefined)?.getFapiClient?.(); + const fapiClient = getHostedAuthFapiClient(clerk); if (!fapiClient) { return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); } @@ -75,20 +87,101 @@ export async function createHostedAuth(params: CreateHostedAuthParams, clerk?: u }; } +export async function redeemHostedAuth( + params: RedeemHostedAuthParams, + client: ClientResource, + clerk?: unknown, +): Promise { + const fapiClient = getHostedAuthFapiClient(clerk); + if (!fapiClient) { + return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); + } + + const response = await fapiClient.request({ + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: params.rotatingTokenNonce, + codeVerifier: params.codeVerifier, + }, + }); + + if (!response.ok) { + throw buildHostedAuthAPIResponseError(response); + } + + const clientJSON = getClientJSON(response.payload); + if (!clientJSON) { + return errorThrower.throw('Hosted auth completion returned an invalid response.'); + } + + return applyClientJSON(client, clientJSON); +} + +function getHostedAuthFapiClient(clerk?: unknown): FapiClient | undefined { + return ( + (clerk as ClerkWithFapiClient | undefined)?.getFapiClient?.() ?? + (getClerkInstance() as ClerkWithFapiClient | undefined)?.getFapiClient?.() + ); +} + function getHostedAuthJSON(payload: HostedAuthResponse['payload']): HostedAuthJSON | null { if (!payload) { return null; } - if ('response' in payload) { - return payload.response ?? null; + if ('response' in payload && isHostedAuthJSON(payload.response)) { + return payload.response; } return isHostedAuthJSON(payload) ? payload : null; } -function isHostedAuthJSON(payload: HostedAuthResponse['payload']): payload is HostedAuthJSON { - return !!payload && 'object' in payload && payload.object === 'hosted_auth'; +function isHostedAuthJSON(payload: unknown): payload is HostedAuthJSON { + return hasObjectType(payload, 'hosted_auth'); +} + +function getClientJSON(payload: HostedAuthResponse['payload']): ClientJSON | null { + if (!payload) { + return null; + } + + if ('response' in payload && isClientJSON(payload.response)) { + return payload.response; + } + + if ('client' in payload && isClientJSON(payload.client)) { + return payload.client; + } + + if ('meta' in payload && isClientJSON(payload.meta?.client)) { + return payload.meta.client; + } + + return isClientJSON(payload) ? payload : 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; +} + +function applyClientJSON(client: ClientResource, clientJSON: ClientJSON): ClientResource { + // Hosted auth gets the same /client payload as Client.reload(), but the verifier-bound + // exchange is Expo-specific. 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); } function buildHostedAuthAPIResponseError(response: HostedAuthResponse) { diff --git a/packages/shared/src/types/resource.ts b/packages/shared/src/types/resource.ts index 32ad3ba19e8..3a98d7c956a 100644 --- a/packages/shared/src/types/resource.ts +++ b/packages/shared/src/types/resource.ts @@ -4,10 +4,6 @@ export type ClerkResourceReloadParams = { * A nonce to use for rotating the user's token. Used in native application OAuth flows to allow the native client to update its JWT once despite changes in its rotating token. */ rotatingTokenNonce?: string; - /** - * A PKCE verifier used to redeem hosted auth rotating token nonces. - */ - codeVerifier?: string; }; /** From 63f5baa9908b3b8fee985ce7e4adfeffd578ab37 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:16:36 -0400 Subject: [PATCH 15/15] fix(expo): move hosted auth to subpath export --- .changeset/hosted-auth-expo.md | 2 +- packages/expo/hosted-auth/package.json | 4 ++++ packages/expo/package.json | 5 +++++ packages/expo/src/hooks/index.ts | 1 - packages/expo/src/hosted-auth/index.ts | 2 ++ packages/expo/src/types/index.ts | 1 - 6 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 packages/expo/hosted-auth/package.json create mode 100644 packages/expo/src/hosted-auth/index.ts diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md index 63e568e84bd..3e093a96edd 100644 --- a/.changeset/hosted-auth-expo.md +++ b/.changeset/hosted-auth-expo.md @@ -4,4 +4,4 @@ '@clerk/shared': patch --- -Add a hosted auth hook for signing in or signing up through Account Portal from native Expo apps. +Add `@clerk/expo/hosted-auth` for signing in or signing up through Account Portal from native Expo apps. 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 e67f3145960..940fa00652a 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/hooks/index.ts b/packages/expo/src/hooks/index.ts index 8009d55582f..92644a4eee3 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -15,6 +15,5 @@ export { } from '@clerk/react'; export * from './useSSO'; -export * from './useHostedAuth'; export * from './useOAuth'; export * from './useAuth'; 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/types/index.ts b/packages/expo/src/types/index.ts index dffa17a4a8b..7c31837db0f 100644 --- a/packages/expo/src/types/index.ts +++ b/packages/expo/src/types/index.ts @@ -9,7 +9,6 @@ export type { IStorage, BuildClerkOptions } from '../provider/singleton/types'; // OAuth/SSO hook types export type { UseOAuthFlowParams, StartOAuthFlowParams, StartOAuthFlowReturnType } from '../hooks/useOAuth'; export type { StartSSOFlowParams, StartSSOFlowReturnType } from '../hooks/useSSO'; -export type { HostedAuthMode, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; // Google Sign-In types export type {