From 04d21ba1d9ad6fdc624b672e15a0370832c6a7dd 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/21] 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 b857636e45615836f58db5d9962f9f103337286e 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/21] 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 9341d6bc437edc0c62876523e3a19dbb403f8675 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/21] 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 1f633341e01aaeaa6a7f19a4bb7bbc9b76f3f6d7 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/21] 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 c0595d20852..1a4534ee170 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 3fa1dacd6d4..0b38b511208 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -63,8 +63,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 { @@ -207,6 +207,7 @@ export abstract class BaseResource { { method: 'GET', path: this.path(), + codeVerifier: opts.codeVerifier, rotatingTokenNonce: opts.rotatingTokenNonce, signal: opts.abortSignal, }, 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 674196ff063c1ce8c441842146b6a09da5d4da9d 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/21] 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 1c9afea2e9ca6165e12156ba01055b49e7abccd8 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/21] 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 390aa6845981f6a7372cdb045aaae9fe8b5d829a 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/21] 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 b1b6e2d0926a961d22705cd95fdd66320d7f5d9c 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/21] 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 8ccb80ac0e3fe01b19135bef28acc826173d69d6 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/21] 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 1a4534ee170..c0595d20852 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 0b38b511208..3fa1dacd6d4 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -63,8 +63,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 { @@ -207,7 +207,6 @@ export abstract class BaseResource { { method: 'GET', path: this.path(), - codeVerifier: opts.codeVerifier, rotatingTokenNonce: opts.rotatingTokenNonce, signal: opts.abortSignal, }, diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 7697b936a66..ec6154b9c38 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, abortSignal }); } + 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 23e998aa5853c4dfd3d41c683d3841df9d20f2db 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/21] 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 ec6154b9c38..70a9ef7f132 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 1c1a4e019a8b59f3cc05fbb3e6bcf8a58b24cf69 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/21] 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 70a9ef7f132..66bea442bf3 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, abortSignal }); } + /** + * 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 8870565e472512479d9b1f99abd829a919b420f8 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/21] 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 66bea442bf3..85d2dfdce61 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 0794a8bd86bd75ad5187f1937d5cd7bd508fb50b 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/21] 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 85d2dfdce61..09d5b91754f 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, abortSignal }); } - /** - * 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 a12a6e6512a..764f5016d7e 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1067,6 +1067,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/21] 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 09d5b91754f..7697b936a66 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, abortSignal }); } - 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 84bde48a6c767a92152e4e9db54de65273da954f 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/21] 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 b93ac216c55..c0e6f02a4d6 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -61,6 +61,10 @@ "types": "./dist/apple/index.d.ts", "default": "./dist/apple/index.js" }, + "./hosted-auth": { + "types": "./dist/hosted-auth/index.d.ts", + "default": "./dist/hosted-auth/index.js" + }, "./resource-cache": { "types": "./dist/resource-cache/index.d.ts", "default": "./dist/resource-cache/index.js" @@ -92,6 +96,7 @@ "token-cache", "google", "apple", + "hosted-auth", "experimental", "legacy", "src/specs", diff --git a/packages/expo/src/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 { From e64412ddd7d5b342517ee965405510c7de41ee23 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:38:57 -0400 Subject: [PATCH 16/21] fix(expo): align hosted auth callbacks with native apps --- .../src/hooks/__tests__/useHostedAuth.test.ts | 64 ++++++++++++++++++- packages/expo/src/hooks/useHostedAuth.ts | 64 ++++++++++++------- packages/expo/src/utils/hostedAuth.ts | 2 +- 3 files changed, 105 insertions(+), 25 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 28dcbada89d..98eb9994f4b 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -20,6 +20,13 @@ const mocks = vi.hoisted(() => { randomUUID: vi.fn(), useClerk: vi.fn(), getClerkInstance: vi.fn(), + platformOS: 'ios', + expoConfig: undefined as + | { + ios?: { bundleIdentifier?: string }; + android?: { package?: string }; + } + | undefined, }; }); @@ -38,7 +45,9 @@ vi.mock('../../provider/singleton', () => { vi.mock('react-native', () => { return { Platform: { - OS: 'ios', + get OS() { + return mocks.platformOS; + }, }, }; }); @@ -107,8 +116,17 @@ describe('useHostedAuth', () => { randomUUID: mocks.randomUUID, }; } + if (request === 'expo-constants') { + return { + default: { + expoConfig: mocks.expoConfig, + }, + }; + } return originalModuleLoad.call(Module, request, parent, isMain); }); + mocks.platformOS = 'ios'; + mocks.expoConfig = undefined; mockClient.lastActiveSessionId = null; mockClient.fromJSON.mockImplementation((clientJSON: ClientJSON) => { mockClient.lastActiveSessionId = clientJSON.last_active_session_id; @@ -193,6 +211,48 @@ describe('useHostedAuth', () => { 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=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`, + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl, + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'state-123', + }, + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + redirectUrl, + undefined, + ); + expect(mocks.makeRedirectUri).not.toHaveBeenCalled(); + }); + test('falls back to the reloaded client session when callback session id is absent', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ @@ -305,7 +365,7 @@ describe('useHostedAuth', () => { body: { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, - mode: '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 c998766b1cb..fefc305611c 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -3,9 +3,10 @@ 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 { Platform } from 'react-native'; import { errorThrower } from '../utils/errors'; -import { createHostedAuth, type FapiHostedAuthMode, redeemHostedAuth } from '../utils/hostedAuth'; +import { createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; /** * Controls which Account Portal auth screen opens for hosted auth. @@ -18,8 +19,9 @@ export type HostedAuthMode = 'sign-in' | 'sign-up'; 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. + * 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`. */ redirectUrl?: string; /** @@ -105,19 +107,14 @@ export function useHostedAuth(): { ); } - const redirectUrl = - params.redirectUrl ?? - AuthSessionModule.makeRedirectUri({ - path: 'hosted-auth-callback', - isTripleSlashed: true, - }); + const redirectUrl = params.redirectUrl ?? getDefaultRedirectUrl(AuthSessionModule); const state = params.state ?? createState(); const pkce = await createPKCE(); const hostedAuth = await createHostedAuth( { redirectUrl, codeChallenge: pkce.codeChallenge, - mode: toFapiMode(params.mode), + mode: params.mode, state, }, clerk, @@ -188,24 +185,47 @@ export function useHostedAuth(): { }; } -function getClientUpdater(clerk: ReturnType): ((client: ClientResource) => void) | undefined { - const maybeClerkWithClientUpdater = clerk as typeof clerk & { - updateClient?: (client: ClientResource) => void; - }; +function getDefaultRedirectUrl(AuthSessionModule: typeof AuthSession): string { + const appIdentifier = getExpoAppIdentifier(); + if (appIdentifier && Platform.OS === 'ios') { + return `${appIdentifier}://callback`; + } + if (appIdentifier && Platform.OS === 'android') { + return `clerk://${appIdentifier}.callback`; + } - return maybeClerkWithClientUpdater.updateClient; + return AuthSessionModule.makeRedirectUri({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); } -function toFapiMode(mode: HostedAuthMode | undefined): FapiHostedAuthMode | undefined { - if (mode === 'sign-in') { - return 'sign_in'; +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?: { + expoConfig?: { + ios?: { bundleIdentifier?: string }; + android?: { package?: string }; + }; + }; + }; + const expoConfig = constantsModule.default?.expoConfig; + return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; + } catch { + return undefined; } +} - if (mode === 'sign-up') { - return 'sign_up'; - } +function getClientUpdater(clerk: ReturnType): ((client: ClientResource) => void) | undefined { + const maybeClerkWithClientUpdater = clerk as typeof clerk & { + updateClient?: (client: ClientResource) => void; + }; - return undefined; + return maybeClerkWithClientUpdater.updateClient; } function normalizeSessionId(sessionId: string | null | undefined): string | null { diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index f43b9f84615..db6e338156a 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -4,7 +4,7 @@ import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/share import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from './errors'; -export type FapiHostedAuthMode = 'sign_in' | 'sign_up'; +export type FapiHostedAuthMode = 'sign-in' | 'sign-up'; export type CreateHostedAuthParams = { redirectUrl: string; From 8dfae532df9645a0d05f19edb27d04d8f790ec3e Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:08:15 -0400 Subject: [PATCH 17/21] fix(expo): harden hosted auth completion --- .changeset/hosted-auth-expo.md | 2 - .../src/core/__tests__/fapiClient.test.ts | 9 --- .../src/hooks/__tests__/useHostedAuth.test.ts | 67 ++++++++++++++++--- packages/expo/src/hooks/useHostedAuth.ts | 65 ++++++++++-------- .../ClerkProvider.nativeClientSync.test.tsx | 1 - packages/expo/src/utils/hostedAuth.ts | 4 +- 6 files changed, 99 insertions(+), 49 deletions(-) diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md index 3e093a96edd..62ae070ebd9 100644 --- a/.changeset/hosted-auth-expo.md +++ b/.changeset/hosted-auth-expo.md @@ -1,7 +1,5 @@ --- '@clerk/expo': patch -'@clerk/clerk-js': patch -'@clerk/shared': patch --- Add `@clerk/expo/hosted-auth` for signing in or signing up through Account Portal from native Expo apps. diff --git a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts index f729489d67b..5de3432bdd5 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,15 +151,6 @@ describe('buildUrl(options)', () => { ); }); - it('adds rotating token nonce', () => { - const url = fapiClient.buildUrl({ - path: '/client', - rotatingTokenNonce: 'nonce_123', - }); - - expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_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/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 98eb9994f4b..4258403a171 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -85,6 +85,7 @@ 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(), }; @@ -128,8 +129,10 @@ describe('useHostedAuth', () => { mocks.platformOS = 'ios'; 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'); @@ -253,6 +256,28 @@ describe('useHostedAuth', () => { expect(mocks.makeRedirectUri).not.toHaveBeenCalled(); }); + test('uses Expo universal-link callbacks for HTTPS redirect URLs', async () => { + const redirectUrl = 'https://mobile.example.com/hosted-auth-callback'; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: `${redirectUrl}?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`, + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ + redirectUrl, + state: 'state-123', + authSessionOptions: { showInRecents: true }, + }); + + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith('https://example.accounts.dev/sign-in', redirectUrl, { + preferUniversalLinks: true, + showInRecents: true, + }); + }); + test('falls back to the reloaded client session when callback session id is absent', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ @@ -270,7 +295,7 @@ describe('useHostedAuth', () => { expect(response.client).toBe(mockClient); }); - test('does not activate a session when the callback does not return one', async () => { + test('rejects a redeemed client response without a created session', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', @@ -279,15 +304,17 @@ describe('useHostedAuth', () => { mockHostedAuthRedeemResponse(); const { result } = renderHook(() => useHostedAuth()); - const response = await result.current.startHostedAuth({ state: 'state-123' }); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth completion did not include the created session.', + ); expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' })); expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).not.toHaveBeenCalled(); - expect(response.createdSessionId).toBeNull(); }); - test('does not fall back to an existing active session when callback session params are missing', async () => { + test('rejects a successful callback without a rotating token nonce', async () => { mockClient.lastActiveSessionId = 'sess_existing'; mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ @@ -296,11 +323,29 @@ describe('useHostedAuth', () => { }); const { result } = renderHook(() => useHostedAuth()); - const response = await result.current.startHostedAuth({ state: 'state-123' }); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth callback did not include a rotating token nonce.', + ); expect(mockFapiRequest).toHaveBeenCalledTimes(1); expect(mockSetActive).not.toHaveBeenCalled(); - expect(response.createdSessionId).toBeNull(); + }); + + test('rejects a callback session that is absent from the redeemed client', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=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({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth completion did not include the created session.', + ); + expect(mockSetActive).not.toHaveBeenCalled(); }); test('surfaces browser session open failures', async () => { @@ -486,7 +531,13 @@ function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { }); } -function mockHostedAuthRedeemResponse({ lastActiveSessionId = null }: { lastActiveSessionId?: string | null } = {}) { +function mockHostedAuthRedeemResponse({ + lastActiveSessionId = null, + sessionIds = lastActiveSessionId ? [lastActiveSessionId] : [], +}: { + lastActiveSessionId?: string | null; + sessionIds?: string[]; +} = {}) { mockFapiRequest.mockResolvedValueOnce({ ok: true, status: 200, @@ -495,7 +546,7 @@ function mockHostedAuthRedeemResponse({ lastActiveSessionId = null }: { lastActi response: { object: 'client', id: 'client_123', - sessions: [], + sessions: sessionIds.map(id => ({ id })), sign_in: null, sign_up: null, last_active_session_id: lastActiveSessionId, diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index fefc305611c..fb3c035f397 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -18,10 +18,11 @@ export type HostedAuthMode = 'sign-in' | 'sign-up'; */ export type StartHostedAuthParams = { /** - * Native deep-link URL that Account Portal redirects to after auth completes. + * 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`. + * HTTPS callbacks use Expo's universal-link auth session support on iOS. */ redirectUrl?: string; /** @@ -36,9 +37,7 @@ export type StartHostedAuthParams = { /** * Options forwarded to `expo-web-browser` when opening the hosted auth session. */ - authSessionOptions?: { - showInRecents?: boolean; - }; + authSessionOptions?: WebBrowser.AuthSessionOpenOptions; }; /** @@ -57,7 +56,7 @@ export type StartHostedAuthReturnType = { url?: string | null; } | null; /** - * The current Clerk client after hosted auth completes. + * The current Clerk client after the hosted auth attempt. */ client?: { id?: string; @@ -120,10 +119,14 @@ export function useHostedAuth(): { clerk, ); + const authSessionOptions = + Platform.OS === 'ios' && isHTTPSRedirectUrl(redirectUrl) + ? { preferUniversalLinks: true, ...params.authSessionOptions } + : params.authSessionOptions; const authSessionResult = await WebBrowserModule.openAuthSessionAsync( hostedAuth.url, redirectUrl, - params.authSessionOptions, + authSessionOptions, ); if (authSessionResult.type !== 'success' || !authSessionResult.url) { return { @@ -148,30 +151,30 @@ export function useHostedAuth(): { return errorThrower.throw('Hosted auth callback state did not match the initiated state.'); } - let updatedClient: ClientResource | undefined; - let createdSessionId: string | null = null; const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? ''; - if (rotatingTokenNonce) { - updatedClient = await redeemHostedAuth( - { - rotatingTokenNonce, - codeVerifier: pkce.codeVerifier, - }, - clerk.client, - clerk, - ); - if (updatedClient) { - getClientUpdater(clerk)?.(updatedClient); - createdSessionId = normalizeSessionId( - callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId, - ); - } + if (!rotatingTokenNonce) { + return errorThrower.throw('Hosted auth callback did not include a rotating token nonce.'); } - if (createdSessionId) { - await clerk.setActive({ - session: createdSessionId, - }); + + const updatedClient = await redeemHostedAuth( + { + rotatingTokenNonce, + codeVerifier: pkce.codeVerifier, + }, + clerk.client, + clerk, + ); + getClientUpdater(clerk)?.(updatedClient); + + const createdSessionId = normalizeSessionId( + callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId, + ); + if (!createdSessionId || !updatedClient.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, @@ -288,3 +291,11 @@ function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): b return callbackUrl.pathname === expectedUrl.pathname; } + +function isHTTPSRedirectUrl(redirectUrl: string): boolean { + try { + return new URL(redirectUrl).protocol === 'https:'; + } catch { + return false; + } +} diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 764f5016d7e..a12a6e6512a 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1067,7 +1067,6 @@ 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( ClientResource; From ed2f301f7159206e9337cf4a21e27ce5eca51cc0 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:44 -0400 Subject: [PATCH 18/21] fix(expo): tighten hosted auth flow --- .../src/hooks/__tests__/useHostedAuth.test.ts | 172 +++++++++++------- packages/expo/src/hooks/useHostedAuth.ts | 83 ++++----- .../ClerkProvider.nativeClientSync.test.tsx | 51 ++++++ .../expo/src/provider/nativeClientSync.tsx | 5 +- packages/expo/src/utils/hostedAuth.ts | 87 ++++----- 5 files changed, 234 insertions(+), 164 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 4258403a171..d73868435f0 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -19,8 +19,9 @@ const mocks = vi.hoisted(() => { 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 }; @@ -36,12 +37,6 @@ vi.mock('@clerk/react', () => { }; }); -vi.mock('../../provider/singleton', () => { - return { - getClerkInstance: mocks.getClerkInstance, - }; -}); - vi.mock('react-native', () => { return { Platform: { @@ -89,8 +84,8 @@ describe('useHostedAuth', () => { reload: vi.fn(), fromJSON: vi.fn(), }; - const mockUpdateClient = vi.fn(); const mockSetActive = vi.fn(); + const mockHandleUnauthenticated = vi.fn(); beforeEach(() => { vi.clearAllMocks(); @@ -120,6 +115,8 @@ describe('useHostedAuth', () => { if (request === 'expo-constants') { return { default: { + appOwnership: mocks.appOwnership, + executionEnvironment: mocks.executionEnvironment, expoConfig: mocks.expoConfig, }, }; @@ -127,6 +124,8 @@ describe('useHostedAuth', () => { return originalModuleLoad.call(Module, request, parent, isMain); }); mocks.platformOS = 'ios'; + mocks.appOwnership = null; + mocks.executionEnvironment = 'standalone'; mocks.expoConfig = undefined; mockClient.lastActiveSessionId = null; mockClient.sessions = []; @@ -143,12 +142,7 @@ describe('useHostedAuth', () => { loaded: true, client: mockClient, setActive: mockSetActive, - updateClient: mockUpdateClient, - getFapiClient: () => ({ - request: mockFapiRequest, - }), - }); - mocks.getClerkInstance.mockReturnValue({ + handleUnauthenticated: mockHandleUnauthenticated, getFapiClient: () => ({ request: mockFapiRequest, }), @@ -169,12 +163,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123', + 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({ state: 'state-123' }); + const response = await result.current.startHostedAuth(); expect(mockFapiRequest).toHaveBeenCalledWith({ method: 'POST', @@ -183,10 +177,9 @@ describe('useHostedAuth', () => { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, mode: undefined, - state: 'state-123', + state: 'generated-state-123', }, }); - expect(mocks.getClerkInstance).not.toHaveBeenCalled(); expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ path: 'hosted-auth-callback', isTripleSlashed: true, @@ -209,7 +202,7 @@ describe('useHostedAuth', () => { }, }); expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); - expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); + expect(mockSetActive).toHaveBeenCalledTimes(1); expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_123' }); expect(response.createdSessionId).toBe('sess_123'); }); @@ -231,12 +224,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: `${redirectUrl}?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`, + 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({ state: 'state-123' }); + await result.current.startHostedAuth(); expect(mockFapiRequest).toHaveBeenCalledWith({ method: 'POST', @@ -245,7 +238,7 @@ describe('useHostedAuth', () => { redirectUrl, codeChallenge: mockCodeChallenge, mode: undefined, - state: 'state-123', + state: 'generated-state-123', }, }); expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( @@ -256,61 +249,99 @@ describe('useHostedAuth', () => { expect(mocks.makeRedirectUri).not.toHaveBeenCalled(); }); - test('uses Expo universal-link callbacks for HTTPS redirect URLs', async () => { - const redirectUrl = 'https://mobile.example.com/hosted-auth-callback'; + 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: 'success', - url: `${redirectUrl}?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`, - }); - mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); const { result } = renderHook(() => useHostedAuth()); - await result.current.startHostedAuth({ - redirectUrl, - state: 'state-123', - authSessionOptions: { showInRecents: true }, - }); + await result.current.startHostedAuth(); - expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith('https://example.accounts.dev/sign-in', redirectUrl, { - preferUniversalLinks: true, - showInRecents: true, + expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ + path: 'hosted-auth-callback', + isTripleSlashed: true, }); + expect(mockFapiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ redirectUrl }), + }), + ); + }); + + test('uses the Expo callback with older Expo Go ownership metadata', async () => { + mocks.executionEnvironment = ''; + mocks.appOwnership = 'expo'; + mocks.expoConfig = { android: { package: 'com.example.android' } }; + mocks.platformOS = 'android'; + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth(); + + expect(mocks.makeRedirectUri).toHaveBeenCalled(); + }); + + test('passes supported browser options to the auth session', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ authSessionOptions: { showInRecents: true } }); + + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + { showInRecents: true }, + ); + }); + + test('rejects HTTPS redirect URLs before creating hosted auth', async () => { + const redirectUrl = 'https://mobile.example.com/hosted-auth-callback'; + + const { result } = renderHook(() => useHostedAuth()); + await expect(result.current.startHostedAuth({ redirectUrl })).rejects.toThrow( + 'Hosted auth requires a custom-scheme redirect URL in Expo.', + ); + + expect(mockFapiRequest).not.toHaveBeenCalled(); + expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); }); test('falls back to the reloaded client session when callback session id is absent', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123', }); mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_reloaded' }); const { result } = renderHook(() => useHostedAuth()); - const response = await result.current.startHostedAuth({ state: 'state-123' }); + const response = await result.current.startHostedAuth(); - expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_reloaded' }); expect(response.createdSessionId).toBe('sess_reloaded'); - expect(response.client).toBe(mockClient); }); test('rejects a redeemed client response without a created session', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', + url: 'myapp:///hosted-auth-callback?state=generated-state-123&rotating_token_nonce=nonce-123', }); mockHostedAuthRedeemResponse(); const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth completion did not include the created session.', ); expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' })); - expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); + expect(mockClient.fromJSON).not.toHaveBeenCalled(); expect(mockSetActive).not.toHaveBeenCalled(); }); @@ -319,12 +350,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp:///hosted-auth-callback?state=state-123', + url: 'myapp:///hosted-auth-callback?state=generated-state-123', }); const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth callback did not include a rotating token nonce.', ); @@ -336,15 +367,16 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_other', + 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({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth completion did not include the created session.', ); + expect(mockClient.fromJSON).not.toHaveBeenCalled(); expect(mockSetActive).not.toHaveBeenCalled(); }); @@ -356,7 +388,7 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow('Unable to open browser'); + await expect(result.current.startHostedAuth()).rejects.toThrow('Unable to open browser'); }); test('generates a secure state with expo-crypto when one is not provided', async () => { @@ -390,7 +422,7 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth callback state did not match the initiated state.', ); }); @@ -402,7 +434,7 @@ describe('useHostedAuth', () => { }); const { result } = renderHook(() => useHostedAuth()); - await result.current.startHostedAuth({ mode: 'sign-up', state: 'state-123' }); + await result.current.startHostedAuth({ mode: 'sign-up' }); expect(mockFapiRequest).toHaveBeenCalledWith({ method: 'POST', @@ -411,7 +443,7 @@ describe('useHostedAuth', () => { redirectUrl: 'myapp:///hosted-auth-callback', codeChallenge: mockCodeChallenge, mode: 'sign-up', - state: 'state-123', + state: 'generated-state-123', }, }); }); @@ -420,12 +452,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'otherapp://hosted-auth-callback?state=state-123', + url: 'otherapp://hosted-auth-callback?state=generated-state-123', }); const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth callback URL did not match the initiated redirect URL.', ); }); @@ -439,9 +471,7 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( - 'Hosted auth callback URL was invalid.', - ); + await expect(result.current.startHostedAuth()).rejects.toThrow('Hosted auth callback URL was invalid.'); }); test('rejects callback path mismatches', async () => { @@ -449,12 +479,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse('https://example.accounts.dev/sign-in'); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'exp://127.0.0.1:8081/--/other-callback?state=state-123', + url: 'exp://127.0.0.1:8081/--/other-callback?state=generated-state-123', }); const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth callback URL did not match the initiated redirect URL.', ); }); @@ -463,12 +493,12 @@ describe('useHostedAuth', () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', - url: 'myapp://attacker/hosted-auth-callback?state=state-123', + url: 'myapp://attacker/hosted-auth-callback?state=generated-state-123', }); const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth callback URL did not match the initiated redirect URL.', ); }); @@ -487,7 +517,7 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + await expect(result.current.startHostedAuth()).rejects.toThrow( 'Hosted auth creation returned an invalid response.', ); expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); @@ -512,9 +542,25 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); - await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow('Redirect URL is invalid'); + await expect(result.current.startHostedAuth()).rejects.toThrow('Redirect URL is invalid'); expect(mocks.openAuthSessionAsync).not.toHaveBeenCalled(); }); + + test('reconciles Clerk state when hosted auth returns an unauthenticated response', async () => { + mockFapiRequest.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + payload: { + errors: [], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Unauthorized'); + expect(mockHandleUnauthenticated).toHaveBeenCalledTimes(1); + }); }); function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index fb3c035f397..454b64ef650 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -1,12 +1,12 @@ 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 { Platform } from 'react-native'; import { errorThrower } from '../utils/errors'; -import { createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; +import type { HostedAuthClerkInstance } from '../utils/hostedAuth'; +import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; /** * Controls which Account Portal auth screen opens for hosted auth. @@ -22,22 +22,17 @@ export type StartHostedAuthParams = { * 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`. - * HTTPS callbacks use Expo's universal-link auth session support on iOS. + * Custom values must use a non-HTTP URL scheme. */ 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; /** * Options forwarded to `expo-web-browser` when opening the hosted auth session. */ - authSessionOptions?: WebBrowser.AuthSessionOpenOptions; + authSessionOptions?: Pick; }; /** @@ -51,17 +46,7 @@ export type StartHostedAuthReturnType = { /** * 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 the hosted auth attempt. - */ - client?: { - id?: string; - lastActiveSessionId: string | null; - }; + authSessionResult: WebBrowser.WebBrowserAuthSessionResult | null; }; type HostedAuthPKCE = { @@ -82,12 +67,12 @@ export function useHostedAuth(): { return { createdSessionId: null, authSessionResult: null, - client: clerk.client, }; } if (!clerk.client) { return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); } + const hostedAuthClerk = getHostedAuthClerk(clerk); let AuthSessionModule: typeof AuthSession; let WebBrowserModule: typeof WebBrowser; @@ -107,7 +92,8 @@ export function useHostedAuth(): { } const redirectUrl = params.redirectUrl ?? getDefaultRedirectUrl(AuthSessionModule); - const state = params.state ?? createState(); + assertSupportedRedirectUrl(redirectUrl); + const state = createState(); const pkce = await createPKCE(); const hostedAuth = await createHostedAuth( { @@ -116,23 +102,18 @@ export function useHostedAuth(): { mode: params.mode, state, }, - clerk, + hostedAuthClerk, ); - const authSessionOptions = - Platform.OS === 'ios' && isHTTPSRedirectUrl(redirectUrl) - ? { preferUniversalLinks: true, ...params.authSessionOptions } - : params.authSessionOptions; const authSessionResult = await WebBrowserModule.openAuthSessionAsync( hostedAuth.url, redirectUrl, - authSessionOptions, + params.authSessionOptions, ); if (authSessionResult.type !== 'success' || !authSessionResult.url) { return { createdSessionId: null, authSessionResult, - client: clerk.client, }; } @@ -156,22 +137,22 @@ export function useHostedAuth(): { return errorThrower.throw('Hosted auth callback did not include a rotating token nonce.'); } - const updatedClient = await redeemHostedAuth( + const clientJSON = await redeemHostedAuth( { rotatingTokenNonce, codeVerifier: pkce.codeVerifier, }, - clerk.client, - clerk, + hostedAuthClerk, ); - getClientUpdater(clerk)?.(updatedClient); const createdSessionId = normalizeSessionId( - callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId, + callbackParams.get('created_session_id') || clientJSON.last_active_session_id, ); - if (!createdSessionId || !updatedClient.sessions.some(session => session.id === createdSessionId)) { + if (!createdSessionId || !clientJSON.sessions.some(session => session.id === createdSessionId)) { return errorThrower.throw('Hosted auth completion did not include the created session.'); } + + applyHostedAuthClientJSON(clerk.client, clientJSON); await clerk.setActive({ session: createdSessionId, }); @@ -179,7 +160,6 @@ export function useHostedAuth(): { return { createdSessionId, authSessionResult, - client: updatedClient ?? clerk.client, }; } @@ -210,25 +190,33 @@ function getExpoAppIdentifier(): string | undefined { // 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 expoConfig = constantsModule.default?.expoConfig; + 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 getClientUpdater(clerk: ReturnType): ((client: ClientResource) => void) | undefined { - const maybeClerkWithClientUpdater = clerk as typeof clerk & { - updateClient?: (client: ClientResource) => void; - }; +function getHostedAuthClerk(clerk: ReturnType): HostedAuthClerkInstance { + const hostedAuthClerk = clerk as ReturnType & Partial; + if (typeof hostedAuthClerk.getFapiClient !== 'function') { + return errorThrower.throw('Hosted auth requires a Clerk instance that can make FAPI requests.'); + } - return maybeClerkWithClientUpdater.updateClient; + return hostedAuthClerk as HostedAuthClerkInstance; } function normalizeSessionId(sessionId: string | null | undefined): string | null { @@ -292,10 +280,15 @@ function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): b return callbackUrl.pathname === expectedUrl.pathname; } -function isHTTPSRedirectUrl(redirectUrl: string): boolean { +function assertSupportedRedirectUrl(redirectUrl: string): void { + let protocol: string; try { - return new URL(redirectUrl).protocol === 'https:'; + protocol = new URL(redirectUrl).protocol; } catch { - return false; + 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/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index a12a6e6512a..f6456e1549d 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => { } | undefined, clerkInstance: { + __internal_setActiveInProgress: false, __internal_reloadInitialResources: vi.fn(), addListener: vi.fn(), addOnLoaded: vi.fn(), @@ -131,6 +132,7 @@ describe('ClerkProvider native client sync', () => { mocks.tokenCache.saveToken.mockResolvedValue(undefined); mocks.tokenCache.clearToken.mockResolvedValue(undefined); mocks.clerkOptions = undefined; + mocks.clerkInstance.__internal_setActiveInProgress = false; mocks.clerkInstance.__internal_reloadInitialResources.mockResolvedValue(undefined); mocks.clerkInstance.addOnLoaded = vi.fn(); mocks.clerkInstance.client = undefined; @@ -762,6 +764,55 @@ describe('ClerkProvider native client sync', () => { }); }); + test('does not start fallback activation during an explicit session transition', async () => { + const removedSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + }; + const replacementSession = { + id: 'session_2', + status: 'active', + user: { id: 'user_2' }, + }; + const replacementClient = { + signedInSessions: [replacementSession], + lastActiveSessionId: 'session_2', + }; + const originalUpdateClient = mocks.clerkInstance.updateClient; + + mocks.clerkInstance.client = { + signedInSessions: [removedSession], + lastActiveSessionId: 'session_1', + }; + mocks.clerkInstance.session = removedSession; + + render( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); + }); + + originalUpdateClient.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + mocks.clerkInstance.__internal_setActiveInProgress = true; + + act(() => { + mocks.clerkInstance.updateClient(replacementClient); + }); + + expect(originalUpdateClient).toHaveBeenCalledOnce(); + expect(originalUpdateClient).toHaveBeenCalledWith(replacementClient, { + __internal_dangerouslySkipEmit: true, + }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + }); + test('keeps follow-up client updates suppressed while reconciling a removed active session', async () => { const removedSession = { id: 'session_1', diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 1a24d566f07..9660c698f1c 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -22,6 +22,7 @@ export type SyncableClerkInstance = { session?: SignedInSessionResource | null; setActive?: (params: { session: SignedInSessionResource | string | null }) => Promise; updateClient?: (client: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }) => void; + __internal_setActiveInProgress?: boolean; __internal_reloadInitialResources?: () => void | Promise; }; @@ -494,13 +495,13 @@ export function NativeClientSync({ // even if the refreshed client still has another signed-in session. // Keep that transient state internal so native session switching does // not dismiss mounted native UI before setActive settles on JS. - isReconcilingRemovedActiveSession = true; originalUpdateClient(newClient, { __internal_dangerouslySkipEmit: true }); - if (alreadyReconcilingRemovedActiveSession) { + if (clerkInstance.__internal_setActiveInProgress || alreadyReconcilingRemovedActiveSession) { return; } + isReconcilingRemovedActiveSession = true; void runWithSuppressedJsClientChanges(suppressJsClientChangedRef, async () => { try { await clerkInstance.setActive?.({ session: fallbackSession }); diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index cc4ab3fd422..3ec1b77cfdf 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -1,7 +1,6 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/shared/types'; -import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from './errors'; export type FapiHostedAuthMode = 'sign-in' | 'sign-up'; @@ -10,7 +9,7 @@ export type CreateHostedAuthParams = { redirectUrl: string; codeChallenge: string; mode?: FapiHostedAuthMode; - state?: string; + state: string; }; export type RedeemHostedAuthParams = { @@ -28,16 +27,7 @@ type HostedAuthJSON = { }; type HostedAuthPayload = { - response?: HostedAuthJSON; - errors?: ClerkAPIErrorJSON[]; -}; - -type ClientPayload = { - response?: ClientJSON; - client?: ClientJSON; - meta?: { - client?: ClientJSON; - }; + response?: HostedAuthJSON | ClientJSON; errors?: ClerkAPIErrorJSON[]; }; @@ -46,7 +36,7 @@ type HostedAuthResponse = { status: number; statusText: string; headers?: Headers; - payload: HostedAuthPayload | HostedAuthJSON | ClientPayload | ClientJSON | null; + payload: HostedAuthPayload | null; }; type FapiClient = { @@ -57,24 +47,23 @@ type FapiClient = { }) => Promise; }; -type ClerkWithFapiClient = { - getFapiClient?: () => FapiClient | undefined; +export type HostedAuthClerkInstance = { + getFapiClient: () => FapiClient; + handleUnauthenticated?: () => Promise; }; -export async function createHostedAuth(params: CreateHostedAuthParams, 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({ +export async function createHostedAuth( + params: CreateHostedAuthParams, + clerk: HostedAuthClerkInstance, +): Promise { + const response = await clerk.getFapiClient().request({ method: 'POST', path: '/client/hosted_auth', body: params, }); if (!response.ok) { - throw buildHostedAuthAPIResponseError(response); + await throwHostedAuthAPIResponseError(response, clerk); } const hostedAuthJSON = getHostedAuthJSON(response.payload); @@ -89,15 +78,9 @@ 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({ + clerk: HostedAuthClerkInstance, +): Promise { + const response = await clerk.getFapiClient().request({ method: 'POST', path: '/client', body: { @@ -108,7 +91,7 @@ export async function redeemHostedAuth( }); if (!response.ok) { - throw buildHostedAuthAPIResponseError(response); + await throwHostedAuthAPIResponseError(response, clerk); } const clientJSON = getClientJSON(response.payload); @@ -116,14 +99,7 @@ export async function redeemHostedAuth( 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?.() - ); + return clientJSON; } function getHostedAuthJSON(payload: HostedAuthResponse['payload']): HostedAuthJSON | null { @@ -131,11 +107,11 @@ function getHostedAuthJSON(payload: HostedAuthResponse['payload']): HostedAuthJS return null; } - if ('response' in payload && isHostedAuthJSON(payload.response)) { + if (isHostedAuthJSON(payload.response)) { return payload.response; } - return isHostedAuthJSON(payload) ? payload : null; + return null; } function isHostedAuthJSON(payload: unknown): payload is HostedAuthJSON { @@ -147,19 +123,11 @@ function getClientJSON(payload: HostedAuthResponse['payload']): ClientJSON | nul return null; } - if ('response' in payload && isClientJSON(payload.response)) { + if (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; + return null; } function isClientJSON(payload: unknown): payload is ClientJSON { @@ -170,7 +138,7 @@ 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 { +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. @@ -184,6 +152,17 @@ function applyClientJSON(client: ClientResource, clientJSON: ClientJSON): Client return mutableClient.fromJSON(clientJSON); } +async function throwHostedAuthAPIResponseError( + response: HostedAuthResponse, + clerk: HostedAuthClerkInstance, +): Promise { + if (response.status === 401) { + await clerk.handleUnauthenticated?.(); + } + + throw buildHostedAuthAPIResponseError(response); +} + function buildHostedAuthAPIResponseError(response: HostedAuthResponse) { const errors = getHostedAuthErrors(response.payload); return new ClerkAPIResponseError(errors[0]?.long_message || response.statusText || 'Hosted auth request failed.', { From c83f85f8f2a74c51ce243a010e241bf479e23e00 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:30:33 -0400 Subject: [PATCH 19/21] fix(expo): complete hosted auth platform wiring --- packages/expo/app.plugin.js | 36 +++++++++++++++++++ .../__tests__/appPlugin.hostedAuth.test.js | 30 ++++++++++++++++ .../src/hooks/__tests__/useHostedAuth.test.ts | 28 ++++++++++++--- packages/expo/src/hooks/useHostedAuth.ts | 9 ++--- 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 packages/expo/src/__tests__/appPlugin.hostedAuth.test.js diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 3759c010c43..ef9106cba88 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -9,10 +9,12 @@ * Native modules and views are registered via Expo Modules autolinking. */ const { + AndroidConfig, withXcodeProject, withDangerousMod, withInfoPlist, withAppBuildGradle, + withAndroidManifest, withEntitlementsPlist, } = require('@expo/config-plugins'); const path = require('path'); @@ -21,6 +23,30 @@ const packageJson = require('./package.json'); const CLERK_MIN_IOS_VERSION = '17.0'; +const addHostedAuthIntentFilter = (mainActivity, packageName) => { + const callbackHost = `${packageName}.callback`; + const intentFilters = mainActivity['intent-filter'] || []; + const callbackIsRegistered = intentFilters.some(intentFilter => + intentFilter.data?.some( + data => data.$?.['android:scheme'] === 'clerk' && data.$?.['android:host'] === callbackHost, + ), + ); + + if (callbackIsRegistered) { + return; + } + + intentFilters.push({ + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + category: [ + { $: { 'android:name': 'android.intent.category.DEFAULT' } }, + { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, + ], + data: [{ $: { 'android:scheme': 'clerk', 'android:host': callbackHost } }], + }); + mainActivity['intent-filter'] = intentFilters; +}; + const withClerkIOS = config => { console.log('✅ Clerk iOS plugin loaded'); @@ -94,6 +120,15 @@ const withClerkIOS = config => { const withClerkAndroid = config => { console.log('✅ Clerk Android plugin loaded'); + config = withAndroidManifest(config, modConfig => { + const packageName = config.android?.package; + if (packageName) { + const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults); + addHostedAuthIntentFilter(mainActivity, packageName); + } + return modConfig; + }); + return withAppBuildGradle(config, modConfig => { let buildGradle = modConfig.modResults.contents; @@ -354,6 +389,7 @@ const withClerkExpo = (config, props = {}) => { module.exports = withClerkExpo; module.exports._testing = { + addHostedAuthIntentFilter, validateThemeJson, isPlainObject, VALID_COLOR_KEYS, diff --git a/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js b/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js new file mode 100644 index 00000000000..3d29e7d81f7 --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.hostedAuth.test.js @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { addHostedAuthIntentFilter } = require('../../app.plugin.js')._testing; + +describe('addHostedAuthIntentFilter', () => { + test('registers the canonical Android hosted auth callback', () => { + const mainActivity = {}; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toContainEqual({ + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + category: [ + { $: { 'android:name': 'android.intent.category.DEFAULT' } }, + { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, + ], + data: [{ $: { 'android:scheme': 'clerk', 'android:host': 'com.example.app.callback' } }], + }); + }); + + test('does not duplicate an existing hosted auth callback', () => { + const mainActivity = {}; + + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + addHostedAuthIntentFilter(mainActivity, 'com.example.app'); + + expect(mainActivity['intent-filter']).toHaveLength(1); + }); +}); diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index d73868435f0..4edb636ebcb 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => { getRandomBytes: vi.fn(), randomUUID: vi.fn(), useClerk: vi.fn(), + getClerkInstance: vi.fn(), platformOS: 'ios', appOwnership: null as string | null, executionEnvironment: 'standalone', @@ -37,6 +38,12 @@ vi.mock('@clerk/react', () => { }; }); +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + vi.mock('react-native', () => { return { Platform: { @@ -138,15 +145,17 @@ describe('useHostedAuth', () => { 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, - client: mockClient, - setActive: mockSetActive, + mocks.getClerkInstance.mockReturnValue({ handleUnauthenticated: mockHandleUnauthenticated, getFapiClient: () => ({ request: mockFapiRequest, }), }); + mocks.useClerk.mockReturnValue({ + loaded: true, + client: mockClient, + setActive: mockSetActive, + }); }); afterEach(() => { @@ -159,6 +168,17 @@ describe('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({ diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 454b64ef650..1837b0052fd 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -4,6 +4,7 @@ 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 } from '../utils/hostedAuth'; import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; @@ -72,7 +73,7 @@ export function useHostedAuth(): { if (!clerk.client) { return errorThrower.throw('Hosted auth requires a loaded Clerk client.'); } - const hostedAuthClerk = getHostedAuthClerk(clerk); + const hostedAuthClerk = getHostedAuthClerk(); let AuthSessionModule: typeof AuthSession; let WebBrowserModule: typeof WebBrowser; @@ -210,9 +211,9 @@ function getExpoAppIdentifier(): string | undefined { } } -function getHostedAuthClerk(clerk: ReturnType): HostedAuthClerkInstance { - const hostedAuthClerk = clerk as ReturnType & Partial; - if (typeof hostedAuthClerk.getFapiClient !== 'function') { +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.'); } From 322ca1ab50341ff02d55c1d5bcadf094003570bf Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:05:55 -0400 Subject: [PATCH 20/21] fix(expo): harden hosted auth flow - Reject concurrent startHostedAuth calls with an in-flight guard so overlapping invocations cannot create duplicate attempts or browser sessions; the guard is released when the flow settles. - Apply the redeemed client JSON before validating the created session, since the rotated client token is already persisted locally after a successful redemption. - Use distinct error messages for a missing created session id versus a session absent from the redeemed client. - Document that the default Android redirect URL requires the Clerk Expo config plugin's intent filter and a native rebuild, and warn once in dev when the default is used on Android. - Deduplicate the hosted auth mode type into utils/hostedAuth. - Pin the S256 code challenge derivation to the RFC 7636 appendix B vector with a real SHA-256 digest. --- .../src/hooks/__tests__/useHostedAuth.test.ts | 74 ++++++++++++++-- packages/expo/src/hooks/useHostedAuth.ts | 87 ++++++++++++++++--- packages/expo/src/utils/hostedAuth.ts | 7 +- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts index 4edb636ebcb..4109d8daab8 100644 --- a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -1,10 +1,11 @@ +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'; +import { createCodeChallenge, useHostedAuth } from '../useHostedAuth'; const moduleWithLoad = Module as unknown as { _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; @@ -346,7 +347,7 @@ describe('useHostedAuth', () => { expect(response.createdSessionId).toBe('sess_reloaded'); }); - test('rejects a redeemed client response without a created session', async () => { + test('rejects a redeemed client response without a created session id but still applies the client', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', @@ -357,11 +358,12 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); await expect(result.current.startHostedAuth()).rejects.toThrow( - 'Hosted auth completion did not include the created session.', + 'Hosted auth completion did not include a created session id.', ); expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' })); - expect(mockClient.fromJSON).not.toHaveBeenCalled(); + // 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(); }); @@ -383,7 +385,7 @@ describe('useHostedAuth', () => { expect(mockSetActive).not.toHaveBeenCalled(); }); - test('rejects a callback session that is absent from the redeemed client', async () => { + test('rejects a callback session that is absent from the redeemed client but still applies the client', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockResolvedValue({ type: 'success', @@ -394,12 +396,59 @@ describe('useHostedAuth', () => { const { result } = renderHook(() => useHostedAuth()); await expect(result.current.startHostedAuth()).rejects.toThrow( - 'Hosted auth completion did not include the created session.', + 'Hosted auth created session was not found on the redeemed client.', ); - expect(mockClient.fromJSON).not.toHaveBeenCalled(); + expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); expect(mockSetActive).not.toHaveBeenCalled(); }); + test('rejects concurrent invocations while hosted auth is in progress', async () => { + mockHostedAuthResponse(); + let resolveBrowser: (result: { type: string }) => void = () => {}; + mocks.openAuthSessionAsync.mockReturnValue( + new Promise(resolve => { + resolveBrowser = resolve; + }), + ); + + const { result } = renderHook(() => useHostedAuth()); + const firstAttempt = result.current.startHostedAuth(); + + await expect(result.current.startHostedAuth()).rejects.toThrow('Hosted auth is already in progress.'); + + resolveBrowser({ type: 'dismiss' }); + await expect(firstAttempt).resolves.toEqual( + expect.objectContaining({ + createdSessionId: null, + }), + ); + + // The rejected concurrent call never created an attempt or opened a browser session. + expect(mockFapiRequest).toHaveBeenCalledTimes(1); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + + test('releases the in-progress guard after a failed attempt', async () => { + mockFapiRequest.mockResolvedValueOnce({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + payload: { + errors: [], + }, + }); + + const { result } = renderHook(() => useHostedAuth()); + await expect(result.current.startHostedAuth()).rejects.toThrow('Unprocessable Entity'); + + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ type: 'dismiss' }); + const response = await result.current.startHostedAuth(); + + expect(response.createdSessionId).toBeNull(); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + test('surfaces browser session open failures', async () => { mockHostedAuthResponse(); mocks.openAuthSessionAsync.mockImplementation(() => { @@ -583,6 +632,17 @@ describe('useHostedAuth', () => { }); }); +describe('createCodeChallenge', () => { + test('derives the S256 code challenge from the RFC 7636 appendix B vector', async () => { + const sha256Base64 = (value: string) => + Promise.resolve(createHash('sha256').update(value, 'ascii').digest('base64')); + + await expect(createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', sha256Base64)).resolves.toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); +}); + function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') { mockFapiRequest.mockResolvedValueOnce({ ok: true, diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 1837b0052fd..692f70d1005 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -6,13 +6,17 @@ import { Platform } from 'react-native'; import { getClerkInstance } from '../provider/singleton'; import { errorThrower } from '../utils/errors'; -import type { HostedAuthClerkInstance } from '../utils/hostedAuth'; +import type { HostedAuthClerkInstance, HostedAuthMode } from '../utils/hostedAuth'; import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; -/** - * Controls which Account Portal auth screen opens for hosted auth. - */ -export type HostedAuthMode = 'sign-in' | 'sign-up'; +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. @@ -24,6 +28,13 @@ export type StartHostedAuthParams = { * 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; /** @@ -57,13 +68,35 @@ type HostedAuthPKCE = { /** * Returns helpers for authenticating native Expo users through Clerk's hosted Account Portal. + * + * On Android, the default redirect URL depends on an intent filter that only the Clerk Expo + * config plugin registers: the plugin must be enabled in `app.json` and the native project + * rebuilt (for example with `npx expo prebuild`), or the browser cannot return to the app + * after auth completes. See {@link StartHostedAuthParams.redirectUrl}. */ export function useHostedAuth(): { startHostedAuth: (params?: StartHostedAuthParams) => Promise; } { const clerk = useClerk(); + /** + * Opens Account Portal in an auth session and activates the session it creates. + * Only one hosted auth flow can run at a time; concurrent calls throw. + */ async function startHostedAuth(params: StartHostedAuthParams = {}): Promise { + if (hostedAuthInProgress) { + return errorThrower.throw('Hosted auth is already in progress.'); + } + + hostedAuthInProgress = true; + try { + return await runHostedAuth(params); + } finally { + hostedAuthInProgress = false; + } + } + + async function runHostedAuth(params: StartHostedAuthParams): Promise { if (!clerk.loaded) { return { createdSessionId: null, @@ -146,14 +179,22 @@ export function useHostedAuth(): { hostedAuthClerk, ); + // A successful redemption means the server session exists and the rotated client + // token has already been persisted locally by the response middleware. Sync the + // local client state before validating the created session, so a validation + // failure below does not leave the local client stale against the rotated token. + applyHostedAuthClientJSON(clerk.client, clientJSON); + const createdSessionId = normalizeSessionId( callbackParams.get('created_session_id') || clientJSON.last_active_session_id, ); - if (!createdSessionId || !clientJSON.sessions.some(session => session.id === createdSessionId)) { - return errorThrower.throw('Hosted auth completion did not include the created session.'); + if (!createdSessionId) { + return errorThrower.throw('Hosted auth completion did not include a created session id.'); + } + if (!clientJSON.sessions.some(session => session.id === createdSessionId)) { + return errorThrower.throw('Hosted auth created session was not found on the redeemed client.'); } - applyHostedAuthClientJSON(clerk.client, clientJSON); await clerk.setActive({ session: createdSessionId, }); @@ -175,6 +216,7 @@ function getDefaultRedirectUrl(AuthSessionModule: typeof AuthSession): string { return `${appIdentifier}://callback`; } if (appIdentifier && Platform.OS === 'android') { + warnAndroidDefaultRedirectOnce(); return `clerk://${appIdentifier}.callback`; } @@ -184,6 +226,18 @@ function getDefaultRedirectUrl(AuthSessionModule: typeof AuthSession): string { }); } +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 @@ -231,8 +285,8 @@ function createState(): string { async function createPKCE(): Promise { const Crypto = loadExpoCrypto(); const codeVerifier = bytesToHex(Crypto.getRandomBytes(32)); - const codeChallenge = base64ToBase64Url( - await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, codeVerifier, { + const codeChallenge = await createCodeChallenge(codeVerifier, verifier => + Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.BASE64, }), ); @@ -243,6 +297,19 @@ async function createPKCE(): Promise { }; } +/** + * Derives the S256 PKCE code challenge (RFC 7636) from a code verifier, + * given a function that returns the standard-base64 SHA-256 digest of a string. + * + * @internal Exported for testing. + */ +export async function createCodeChallenge( + codeVerifier: string, + sha256Base64: (value: string) => Promise, +): Promise { + return base64ToBase64Url(await sha256Base64(codeVerifier)); +} + function loadExpoCrypto(): typeof ExpoCrypto { try { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index 3ec1b77cfdf..83c94853e20 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -3,12 +3,15 @@ import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/share import { errorThrower } from './errors'; -export type FapiHostedAuthMode = 'sign-in' | 'sign-up'; +/** + * 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?: FapiHostedAuthMode; + mode?: HostedAuthMode; state: string; }; From 6040c7b5fdcf80e00b7c08dfa464bab51ce524ef Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:06:03 -0400 Subject: [PATCH 21/21] chore(expo): release hosted auth as a minor version useHostedAuth and the @clerk/expo/hosted-auth subpath export are new public API, so the changeset should be minor rather than patch. --- .changeset/hosted-auth-expo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md index 62ae070ebd9..99d539d4447 100644 --- a/.changeset/hosted-auth-expo.md +++ b/.changeset/hosted-auth-expo.md @@ -1,5 +1,5 @@ --- -'@clerk/expo': patch +'@clerk/expo': minor --- Add `@clerk/expo/hosted-auth` for signing in or signing up through Account Portal from native Expo apps.