diff --git a/.changeset/hosted-auth-expo.md b/.changeset/hosted-auth-expo.md new file mode 100644 index 00000000000..3e093a96edd --- /dev/null +++ b/.changeset/hosted-auth-expo.md @@ -0,0 +1,7 @@ +--- +'@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 5de3432bdd5..f729489d67b 100644 --- a/packages/clerk-js/src/core/__tests__/fapiClient.test.ts +++ b/packages/clerk-js/src/core/__tests__/fapiClient.test.ts @@ -151,6 +151,15 @@ 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/hosted-auth/package.json b/packages/expo/hosted-auth/package.json new file mode 100644 index 00000000000..fee5e036ad2 --- /dev/null +++ b/packages/expo/hosted-auth/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/hosted-auth/index.js", + "types": "../dist/hosted-auth/index.d.ts" +} diff --git a/packages/expo/package.json b/packages/expo/package.json index e67f3145960..940fa00652a 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -61,6 +61,10 @@ "types": "./dist/apple/index.d.ts", "default": "./dist/apple/index.js" }, + "./hosted-auth": { + "types": "./dist/hosted-auth/index.d.ts", + "default": "./dist/hosted-auth/index.js" + }, "./resource-cache": { "types": "./dist/resource-cache/index.d.ts", "default": "./dist/resource-cache/index.js" @@ -92,6 +96,7 @@ "token-cache", "google", "apple", + "hosted-auth", "experimental", "legacy", "src/specs", diff --git a/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts new file mode 100644 index 00000000000..28dcbada89d --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useHostedAuth.test.ts @@ -0,0 +1,450 @@ +import Module from 'node:module'; + +import type { ClientJSON } from '@clerk/shared/types'; +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useHostedAuth } from '../useHostedAuth'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + makeRedirectUri: vi.fn(), + openAuthSessionAsync: vi.fn(), + digestStringAsync: vi.fn(), + getRandomBytes: vi.fn(), + randomUUID: vi.fn(), + useClerk: vi.fn(), + getClerkInstance: vi.fn(), + }; +}); + +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 { + CryptoDigestAlgorithm: { + SHA256: 'SHA256', + }, + CryptoEncoding: { + BASE64: 'BASE64', + }, + digestStringAsync: mocks.digestStringAsync, + getRandomBytes: mocks.getRandomBytes, + randomUUID: mocks.randomUUID, + }; +}); + +const mockFapiRequest = vi.fn(); +const mockCodeVerifier = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; +const mockCodeChallenge = 'mock-code-challenge-_'; + +describe('useHostedAuth', () => { + const mockClient = { + lastActiveSessionId: null as string | null, + 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 }; + } + if (request === 'expo-web-browser') { + return { + openAuthSessionAsync: mocks.openAuthSessionAsync, + }; + } + if (request === 'expo-crypto') { + return { + CryptoDigestAlgorithm: { + SHA256: 'SHA256', + }, + CryptoEncoding: { + BASE64: 'BASE64', + }, + digestStringAsync: mocks.digestStringAsync, + getRandomBytes: mocks.getRandomBytes, + randomUUID: mocks.randomUUID, + }; + } + 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+/='); + mocks.randomUUID.mockReturnValue('generated-state-123'); + mocks.useClerk.mockReturnValue({ + loaded: true, + client: mockClient, + setActive: mockSetActive, + updateClient: mockUpdateClient, + getFapiClient: () => ({ + request: mockFapiRequest, + }), + }); + 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', + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' }); + + 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', + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'state-123', + }, + }); + expect(mocks.getClerkInstance).not.toHaveBeenCalled(); + expect(mocks.makeRedirectUri).toHaveBeenCalledWith({ + path: 'hosted-auth-callback', + isTripleSlashed: true, + }); + expect(mocks.digestStringAsync).toHaveBeenCalledWith('SHA256', mockCodeVerifier, { + encoding: 'BASE64', + }); + expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.accounts.dev/sign-in', + 'myapp:///hosted-auth-callback', + undefined, + ); + expect(mockFapiRequest).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: '/client', + body: { + _method: 'GET', + rotatingTokenNonce: 'nonce-123', + codeVerifier: mockCodeVerifier, + }, + }); + expect(mockClient.fromJSON).toHaveBeenCalledWith(expect.objectContaining({ object: 'client' })); + expect(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 () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123', + }); + mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_reloaded' }); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + expect(mockUpdateClient).toHaveBeenCalledWith(mockClient); + expect(mockSetActive).toHaveBeenCalledWith({ session: 'sess_reloaded' }); + expect(response.createdSessionId).toBe('sess_reloaded'); + expect(response.client).toBe(mockClient); + }); + + 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', + }); + mockHostedAuthRedeemResponse(); + + const { result } = renderHook(() => useHostedAuth()); + const response = await result.current.startHostedAuth({ state: 'state-123' }); + + 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 () => { + 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(mockFapiRequest).toHaveBeenCalledTimes(1); + expect(mockSetActive).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBeNull(); + }); + + 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', + codeChallenge: mockCodeChallenge, + mode: undefined, + state: 'generated-state-123', + }, + }); + }); + + test('rejects callback state mismatches', async () => { + mockHostedAuthResponse(); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'success', + url: 'myapp:///hosted-auth-callback?state=other-state', + }); + + const { result } = renderHook(() => useHostedAuth()); + + await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow( + 'Hosted auth callback state did not match the initiated state.', + ); + }); + + test('passes through the requested mode', async () => { + mockHostedAuthResponse('https://example.accounts.dev/sign-up'); + mocks.openAuthSessionAsync.mockResolvedValue({ + type: 'dismiss', + }); + + const { result } = renderHook(() => useHostedAuth()); + await result.current.startHostedAuth({ mode: 'sign-up', state: 'state-123' }); + + expect(mockFapiRequest).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/hosted_auth', + body: { + redirectUrl: 'myapp:///hosted-auth-callback', + codeChallenge: mockCodeChallenge, + mode: '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 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'); + 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 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, + 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.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + payload: { + response: { + object: 'hosted_auth', + url, + }, + }, + }); +} + +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 new file mode 100644 index 00000000000..c998766b1cb --- /dev/null +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -0,0 +1,270 @@ +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'; +import { createHostedAuth, type FapiHostedAuthMode, redeemHostedAuth } 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 })`. + * Production instances must allowlist this URL in the Clerk Dashboard. + */ + 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?: { + 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; + /** + * 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 = { + codeVerifier: string; + codeChallenge: string; +}; + +/** + * 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 { + if (!clerk.loaded) { + return { + createdSessionId: null, + authSessionResult: null, + client: clerk.client, + }; + } + if (!clerk.client) { + return errorThrower.throw('Hosted auth requires a loaded 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 ?? createState(); + const pkce = await createPKCE(); + const hostedAuth = await createHostedAuth( + { + redirectUrl, + codeChallenge: pkce.codeChallenge, + mode: toFapiMode(params.mode), + state, + }, + clerk, + ); + + const authSessionResult = await WebBrowserModule.openAuthSessionAsync( + hostedAuth.url, + redirectUrl, + params.authSessionOptions, + ); + if (authSessionResult.type !== 'success' || !authSessionResult.url) { + return { + createdSessionId: null, + authSessionResult, + client: clerk.client, + }; + } + + let callbackUrl: URL; + try { + callbackUrl = new URL(authSessionResult.url); + } catch { + return errorThrower.throw('Hosted auth callback URL was invalid.'); + } + if (!callbackUrlMatchesRedirectUrl(callbackUrl, redirectUrl)) { + return errorThrower.throw('Hosted auth callback URL did not match the initiated redirect URL.'); + } + + const callbackParams = callbackUrl.searchParams; + if (callbackParams.get('state') !== state) { + return errorThrower.throw('Hosted auth callback state did not match the initiated state.'); + } + + 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 (createdSessionId) { + await clerk.setActive({ + session: createdSessionId, + }); + } + + return { + createdSessionId, + authSessionResult, + client: updatedClient ?? clerk.client, + }; + } + + return { + startHostedAuth, + }; +} + +function getClientUpdater(clerk: ReturnType): ((client: ClientResource) => void) | undefined { + const maybeClerkWithClientUpdater = clerk as typeof clerk & { + updateClient?: (client: ClientResource) => void; + }; + + return maybeClerkWithClientUpdater.updateClient; +} + +function toFapiMode(mode: HostedAuthMode | undefined): FapiHostedAuthMode | undefined { + if (mode === 'sign-in') { + return 'sign_in'; + } + + if (mode === 'sign-up') { + return 'sign_up'; + } + + return undefined; +} + +function normalizeSessionId(sessionId: string | null | undefined): string | null { + return sessionId || null; +} + +function createState(): string { + return loadExpoCrypto().randomUUID(); +} + +async function createPKCE(): Promise { + const Crypto = loadExpoCrypto(); + const codeVerifier = bytesToHex(Crypto.getRandomBytes(32)); + const codeChallenge = 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 + return require('expo-crypto') as typeof ExpoCrypto; + } catch { + return errorThrower.throw( + 'expo-crypto is required to start hosted auth. Please install it by running: npx expo install expo-crypto', + ); + } +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function base64ToBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): boolean { + let expectedUrl: URL; + try { + expectedUrl = new URL(redirectUrl); + } catch { + return false; + } + + if (callbackUrl.protocol !== expectedUrl.protocol) { + return false; + } + + if (callbackUrl.host !== expectedUrl.host) { + return false; + } + + return callbackUrl.pathname === expectedUrl.pathname; +} diff --git a/packages/expo/src/hosted-auth/index.ts b/packages/expo/src/hosted-auth/index.ts new file mode 100644 index 00000000000..da2a6edd46f --- /dev/null +++ b/packages/expo/src/hosted-auth/index.ts @@ -0,0 +1,2 @@ +export { useHostedAuth } from '../hooks/useHostedAuth'; +export type { HostedAuthMode, StartHostedAuthParams, StartHostedAuthReturnType } from '../hooks/useHostedAuth'; diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index cb7c9af64fa..d1b32916dcc 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1040,6 +1040,7 @@ describe('ClerkProvider native client sync', () => { test('ignores native client events that echo a JS-originated sync', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); const { rerender } = render( Promise; +}; + +type ClerkWithFapiClient = { + getFapiClient?: () => FapiClient | undefined; +}; + +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({ + 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, + }; +} + +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 && isHostedAuthJSON(payload.response)) { + return payload.response; + } + + return isHostedAuthJSON(payload) ? payload : null; +} + +function isHostedAuthJSON(payload: unknown): payload is HostedAuthJSON { + return hasObjectType(payload, 'hosted_auth'); +} + +function getClientJSON(payload: HostedAuthResponse['payload']): ClientJSON | null { + if (!payload) { + return null; + } + + if ('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) { + 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; +}