diff --git a/.changeset/expo-native-to-web.md b/.changeset/expo-native-to-web.md new file mode 100644 index 00000000000..626fd612819 --- /dev/null +++ b/.changeset/expo-native-to-web.md @@ -0,0 +1,11 @@ +--- +'@clerk/expo': minor +--- + +Add `@clerk/expo/native-to-web` to open your web app with the native app's user already signed in, in a WebView or in the system browser. + +- `useAuthenticatedWebView({ destination })` prepares a one-time sign-in link and returns `webViewProps` to spread onto a `react-native-webview` ``, plus a `status` of `preparing`, `ready`, `signed-out`, or `error`. Keep your loading overlay up while `status` is `preparing`, so users never see Clerk's sign-in page between your app and the destination. Remounts and React Strict Mode never create a second link. +- `useNativeToWeb().openExternalBrowser({ destination })` opens the destination in the default browser. On Android it needs `expo-intent-launcher`, so install it with `npx expo install expo-intent-launcher expo-application`. +- `prepareSessionTransfer({ destination, presentation })` from `@clerk/expo/native-to-web/advanced` returns the link for apps that present it themselves. + +Failures reject or report a `SessionTransferError`, which you can detect with `isSessionTransferError()` and branch on through its `code`, such as `signed_out`, `feature_not_enabled`, or `expired`. Session transfers must be enabled for your instance, and `destination` must be on one of your instance's domains. diff --git a/packages/expo/native-to-web/advanced/package.json b/packages/expo/native-to-web/advanced/package.json new file mode 100644 index 00000000000..d2dfa3d9e01 --- /dev/null +++ b/packages/expo/native-to-web/advanced/package.json @@ -0,0 +1,4 @@ +{ + "main": "../../dist/native-to-web/advanced.js", + "types": "../../dist/native-to-web/advanced.d.ts" +} diff --git a/packages/expo/native-to-web/package.json b/packages/expo/native-to-web/package.json new file mode 100644 index 00000000000..f97f3ef96d8 --- /dev/null +++ b/packages/expo/native-to-web/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/native-to-web/index.js", + "types": "../dist/native-to-web/index.d.ts" +} diff --git a/packages/expo/package.json b/packages/expo/package.json index 8e26691c811..7bd3d105da2 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -73,6 +73,14 @@ "types": "./dist/hosted-auth/index.d.ts", "default": "./dist/hosted-auth/index.js" }, + "./native-to-web": { + "types": "./dist/native-to-web/index.d.ts", + "default": "./dist/native-to-web/index.js" + }, + "./native-to-web/advanced": { + "types": "./dist/native-to-web/advanced.d.ts", + "default": "./dist/native-to-web/advanced.js" + }, "./resource-cache": { "types": "./dist/resource-cache/index.d.ts", "default": "./dist/resource-cache/index.js" @@ -105,6 +113,7 @@ "apple", "biometrics", "hosted-auth", + "native-to-web", "experimental", "legacy", "app.plugin.js", @@ -141,10 +150,12 @@ "expo-auth-session": "^7.0.11", "expo-constants": "^18.0.13", "expo-crypto": "^15.0.9", + "expo-intent-launcher": "^13.0.8", "expo-local-authentication": "^17.0.8", "expo-secure-store": "^15.0.8", "expo-web-browser": "^15.0.11", "react-native": "^0.86.0", + "react-native-webview": "^14.0.1", "tslib": "catalog:repo" }, "peerDependencies": { @@ -158,6 +169,7 @@ "expo-auth-session": ">=5", "expo-constants": ">=12", "expo-crypto": ">=12", + "expo-intent-launcher": ">=12", "expo-local-authentication": ">=13.5.0", "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", @@ -193,6 +205,9 @@ "expo-crypto": { "optional": true }, + "expo-intent-launcher": { + "optional": true + }, "expo-local-authentication": { "optional": true }, diff --git a/packages/expo/src/app-version/nativeAppIdentity.ts b/packages/expo/src/app-version/nativeAppIdentity.ts index 578f3cd55c6..f9db0635dc8 100644 --- a/packages/expo/src/app-version/nativeAppIdentity.ts +++ b/packages/expo/src/app-version/nativeAppIdentity.ts @@ -53,7 +53,7 @@ export function isExpoApplicationInstalled(): boolean { return loadExpoApplication() !== null; } -function loadExpoApplication(): ExpoApplication | null { +export function loadExpoApplication(): ExpoApplication | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('expo-application') as ExpoApplication; diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index ab7019b2678..2e07d8d29f6 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -5,10 +5,10 @@ import type * as WebBrowser from 'expo-web-browser'; import { Platform } from 'react-native'; import { getClerkInstance } from '../provider/singleton'; +import { getExpoAppIdentifier } from '../utils/appIdentifier'; import { errorThrower } from '../utils/errors'; import type { HostedAuthClerkInstance, HostedAuthMode } from '../utils/hostedAuth'; import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; -import { isExpoGo } from '../utils/runtime'; export type { HostedAuthMode }; @@ -237,30 +237,6 @@ function warnAndroidDefaultRedirectOnce(): void { } } -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 }; - }; - }; - }; - if (isExpoGo()) { - return undefined; - } - - const expoConfig = constantsModule.default?.expoConfig; - return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; - } catch { - return undefined; - } -} - function getHostedAuthClerk(): HostedAuthClerkInstance { const hostedAuthClerk = getClerkInstance() as Partial | undefined; if (typeof hostedAuthClerk?.getFapiClient !== 'function') { diff --git a/packages/expo/src/native-to-web/__tests__/errors.test.ts b/packages/expo/src/native-to-web/__tests__/errors.test.ts new file mode 100644 index 00000000000..b9638b6e7f2 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/errors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import { isSessionTransferError, sessionTransferError, toSessionTransferError } from '../errors'; + +describe('session transfer errors', () => { + test('builds an Error with a name and code', () => { + const error = sessionTransferError('expired', 'The sign-in link expired.'); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: 'SessionTransferError', + code: 'expired', + message: 'The sign-in link expired.', + }); + expect(error).not.toHaveProperty('cause'); + }); + + test('keeps the cause when given one', () => { + const cause = new Error('underlying'); + + expect(sessionTransferError('network_error', 'Offline.', cause).cause).toBe(cause); + }); + + test.each([ + ['a session transfer error', sessionTransferError('signed_out', 'Signed out.'), true], + ['an error with a code but another name', Object.assign(new Error('x'), { code: 'signed_out' }), false], + ['a plain object', { name: 'SessionTransferError', code: 'signed_out' }, false], + ['null', null, false], + ])('recognizes %s: %s', (_, value, expected) => { + expect(isSessionTransferError(value)).toBe(expected); + }); + + test('wraps an unknown failure as unexpected_response', () => { + const cause = new Error('boom'); + const known = sessionTransferError('load_failed', 'Load failed.'); + + expect(toSessionTransferError(known)).toBe(known); + expect(toSessionTransferError(cause)).toMatchObject({ code: 'unexpected_response', cause }); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts b/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts new file mode 100644 index 00000000000..5034af8d58b --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts @@ -0,0 +1,158 @@ +import Module from 'node:module'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { resolveBrowserLauncher } from '../launchBrowser.android'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + appStateListeners: [] as Array<(state: string) => void>, + removeSubscription: vi.fn(), + startActivityAsync: vi.fn(), + applicationId: 'com.example.app' as string | null, + expoApplicationInstalled: true, + androidPackage: undefined as string | undefined, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { OS: 'android' }, + AppState: { + addEventListener: (_: string, listener: (state: string) => void) => { + mocks.appStateListeners.push(listener); + return { remove: mocks.removeSubscription }; + }, + }, + }; +}); + +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +function pending() { + let resolve: (value: unknown) => void = () => {}; + let reject: (reason: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function changeAppState(state: string) { + mocks.appStateListeners.forEach(listener => listener(state)); +} + +async function outcome(promise: Promise) { + const settled = await Promise.race([ + promise.then( + () => 'resolved', + (error: unknown) => error, + ), + new Promise(resolve => setTimeout(() => resolve('pending'), 0)), + ]); + return settled; +} + +describe('resolveBrowserLauncher on Android', () => { + beforeEach(() => { + mocks.appStateListeners = []; + mocks.removeSubscription.mockReset(); + mocks.startActivityAsync.mockReset(); + mocks.applicationId = 'com.example.app'; + mocks.expoApplicationInstalled = true; + mocks.androidPackage = undefined; + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-intent-launcher') { + return { startActivityAsync: mocks.startActivityAsync }; + } + if (request === 'expo-application') { + if (!mocks.expoApplicationInstalled) { + throw new Error("Cannot find module 'expo-application'"); + } + return { applicationId: mocks.applicationId }; + } + if (request === 'expo-constants') { + return { + default: { executionEnvironment: 'standalone', expoConfig: { android: { package: mocks.androidPackage } } }, + }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('sends REFERRER_NAME with the application id and leaves the browser choice to Android', async () => { + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await resolveBrowserLauncher()(TRANSFER_URL); + + expect(mocks.startActivityAsync).toHaveBeenCalledWith('android.intent.action.VIEW', { + data: TRANSFER_URL, + extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.app' }, + }); + expect(mocks.startActivityAsync.mock.calls[0][1]).not.toHaveProperty('packageName'); + }); + + test('falls back to the package name from the app config without expo-application', async () => { + mocks.expoApplicationInstalled = false; + mocks.androidPackage = 'com.example.config'; + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await resolveBrowserLauncher()(TRANSFER_URL); + + expect(mocks.startActivityAsync).toHaveBeenCalledWith( + 'android.intent.action.VIEW', + expect.objectContaining({ extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.config' } }), + ); + }); + + test('throws missing_dependency when no package name is available', () => { + mocks.expoApplicationInstalled = false; + + expect(() => resolveBrowserLauncher()).toThrow( + expect.objectContaining({ code: 'missing_dependency', message: expect.stringContaining('expo-application') }), + ); + }); + + test('resolves when the launch settles', async () => { + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await expect(outcome(resolveBrowserLauncher()(TRANSFER_URL))).resolves.toBe('resolved'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + }); + + test('resolves when the app leaves the foreground before the launch settles', async () => { + const launch = pending(); + mocks.startActivityAsync.mockReturnValue(launch.promise); + const opening = resolveBrowserLauncher()(TRANSFER_URL); + + changeAppState('active'); + await expect(outcome(opening)).resolves.toBe('pending'); + changeAppState('background'); + + await expect(outcome(opening)).resolves.toBe('resolved'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + launch.reject(new Error('activity finished')); + await expect(outcome(opening)).resolves.toBe('resolved'); + }); + + test('rejects with browser_unavailable when the launch fails first', async () => { + mocks.startActivityAsync.mockRejectedValue(new Error(`No Activity found to handle Intent { dat=${TRANSFER_URL} }`)); + + const result = await outcome(resolveBrowserLauncher()(TRANSFER_URL)); + + expect(result).toMatchObject({ name: 'SessionTransferError', code: 'browser_unavailable' }); + expect(result).not.toHaveProperty('cause'); + expect(String(result)).not.toContain('one-time'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts b/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts new file mode 100644 index 00000000000..b2096f21c3d --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts @@ -0,0 +1,356 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { SessionTransferError } from '../errors'; +import { isSessionTransferError } from '../errors'; +import type * as SessionTransferModule from '../sessionTransfer'; + +const mocks = vi.hoisted(() => { + return { + platformOS: 'ios', + getClerkInstance: vi.fn(), + request: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/billing'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; +const NOW = new Date('2026-11-10T12:00:00Z').getTime(); + +let sessionTransfer: typeof SessionTransferModule; + +function mintedResponse(url = TRANSFER_URL, expiresAt = NOW + 60_000) { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url, expires_at: expiresAt } }, + }; +} + +function errorResponse(status: number, code: string, longMessage: string, headers?: Headers) { + return { + ok: false, + status, + statusText: 'Error', + headers, + payload: { errors: [{ code, message: longMessage, long_message: longMessage, meta: {} }] }, + }; +} + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +async function rejectionOf(promise: Promise): Promise { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + if (!isSessionTransferError(error)) { + throw new Error('Expected a SessionTransferError rejection'); + } + return error; +} + +function webviewRequest(destination = DESTINATION) { + return sessionTransfer.resolveTransferRequest({ destination, presentation: 'webview' }); +} + +describe('sessionTransfer', () => { + beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + mocks.platformOS = 'ios'; + mocks.request.mockReset(); + mocks.getClerkInstance.mockReset(); + mocks.getClerkInstance.mockReturnValue({ + loaded: true, + session: { id: 'sess_1' }, + getFapiClient: () => ({ request: mocks.request }), + }); + sessionTransfer = await import('../sessionTransfer'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('prepareSessionTransfer', () => { + test('posts the destination and presentation for the active session', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'browser', + }); + + expect(mocks.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sessions/sess_1/session_transfers', + body: { redirectUrl: DESTINATION, presentationMode: 'browser' }, + }); + expect(transfer.url).toBe(TRANSFER_URL); + expect(transfer.expiresAt).toEqual(new Date(NOW + 60_000)); + }); + + test('keeps the url out of enumeration, serialization, and spread', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'webview', + }); + + expect(Object.keys(transfer)).toEqual(['expiresAt']); + expect(JSON.stringify(transfer)).not.toContain('one-time'); + expect({ ...transfer }).not.toHaveProperty('url'); + }); + + test('mints on every call', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + await sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }); + await sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('retries once after a signed_out response', async () => { + mocks.request + .mockResolvedValueOnce(errorResponse(401, 'signed_out', 'You are signed out')) + .mockResolvedValueOnce(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'webview', + }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + expect(transfer.url).toBe(TRANSFER_URL); + }); + + test('rejects with signed_out when the retry is also signed out', async () => { + mocks.request.mockResolvedValue(errorResponse(401, 'signed_out', 'You are signed out')); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ name: 'SessionTransferError', code: 'signed_out', message: 'You are signed out' }); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('maps a Clerk API refusal to its error code and keeps the API error as the cause', async () => { + mocks.request.mockResolvedValue( + errorResponse( + 403, + 'feature_not_enabled', + 'Session transfers are not enabled', + new Headers({ 'retry-after': '7' }), + ), + ); + + const error = await rejectionOf( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ); + + expect(error).toMatchObject({ + code: 'feature_not_enabled', + message: 'Session transfers are not enabled', + cause: { status: 403, retryAfter: 7 }, + }); + expect(isClerkAPIResponseError(error.cause)).toBe(true); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('uses unexpected_response when a refusal has no error code', async () => { + mocks.request.mockResolvedValue({ ok: false, status: 502, statusText: 'Bad Gateway', payload: null }); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ code: 'unexpected_response', message: 'Bad Gateway' }); + }); + + test('maps a thrown request to network_error', async () => { + const failure = new Error('Network request failed'); + mocks.request.mockRejectedValue(failure); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ code: 'network_error', cause: failure }); + }); + + test.each([ + ['a different object', { response: { object: 'client', url: TRANSFER_URL, expires_at: NOW } }], + ['a missing url', { response: { object: 'session_transfer', expires_at: NOW } }], + ['a non-numeric expiry', { response: { object: 'session_transfer', url: TRANSFER_URL, expires_at: 'soon' } }], + ['no response', {}], + ])('rejects an envelope with %s as unexpected_response', async (_, payload) => { + mocks.request.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', payload }); + + const error = await rejectionOf( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ); + + expect(error).toMatchObject({ code: 'unexpected_response' }); + expect(error.message).not.toContain('one-time'); + }); + + test.each([ + ['unsupported_platform', () => (mocks.platformOS = 'web'), DESTINATION], + ['invalid_destination', () => {}, '/billing'], + ['invalid_destination', () => {}, 'myapp://billing'], + [ + 'clerk_not_loaded', + () => mocks.getClerkInstance.mockReturnValue({ loaded: false, session: null, getFapiClient: vi.fn() }), + DESTINATION, + ], + [ + 'clerk_not_loaded', + () => + mocks.getClerkInstance.mockImplementation(() => { + throw new Error('Missing publishableKey'); + }), + DESTINATION, + ], + [ + 'signed_out', + () => mocks.getClerkInstance.mockReturnValue({ loaded: true, session: null, getFapiClient: vi.fn() }), + DESTINATION, + ], + ])('rejects with %s before any request', async (code, arrange, destination) => { + arrange(); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination, presentation: 'webview' }), + ).rejects.toMatchObject({ + code, + }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + }); + + describe('transfer ledger', () => { + test('keys a request by session, presentation, and destination', () => { + expect(webviewRequest().key).toBe(`sess_1\nwebview\n${DESTINATION}`); + }); + + test('hands an unclaimed lease to the next acquire for the same key', async () => { + const minted = deferred>(); + mocks.request.mockReturnValue(minted.promise); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + minted.resolve(mintedResponse()); + + expect(second).toBe(first); + await expect(second.transfer).resolves.toMatchObject({ url: TRANSFER_URL }); + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('mints separately for a different destination or presentation', () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const lease = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(sessionTransfer.acquireTransfer(webviewRequest('https://app.example.com/other'))).not.toBe(lease); + expect( + sessionTransfer.acquireTransfer( + sessionTransfer.resolveTransferRequest({ destination: DESTINATION, presentation: 'browser' }), + ), + ).not.toBe(lease); + expect(mocks.request).toHaveBeenCalledTimes(3); + }); + + test('mints again after the lease is claimed', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + sessionTransfer.claimTransfer(first); + sessionTransfer.claimTransfer(first); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(second).not.toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('leaves a newer lease alone when an older one is claimed', () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + sessionTransfer.claimTransfer(first); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + sessionTransfer.claimTransfer(first); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(second); + }); + + test('evicts a minted lease with less than 15 seconds left', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + vi.setSystemTime(NOW + 45_001); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).not.toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('keeps a minted lease with 15 seconds or more left', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + vi.setSystemTime(NOW + 45_000); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(first); + }); + + test('evicts expiring leases for other keys on every acquire', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const stale = sessionTransfer.acquireTransfer(webviewRequest()); + await stale.transfer; + vi.setSystemTime(NOW + 50_000); + sessionTransfer.acquireTransfer(webviewRequest('https://app.example.com/other')); + vi.setSystemTime(NOW); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).not.toBe(stale); + }); + + test('deletes a lease whose mint failed', async () => { + mocks.request + .mockResolvedValueOnce(errorResponse(403, 'feature_not_enabled', 'Session transfers are not enabled')) + .mockResolvedValueOnce(mintedResponse()); + + const failed = sessionTransfer.acquireTransfer(webviewRequest()); + await expect(failed.transfer).rejects.toMatchObject({ code: 'feature_not_enabled' }); + const next = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(next).not.toBe(failed); + await expect(next.transfer).resolves.toMatchObject({ url: TRANSFER_URL }); + }); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx b/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx new file mode 100644 index 00000000000..cf7f5b3d300 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx @@ -0,0 +1,309 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { StrictMode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useAuthenticatedWebView } from '../useAuthenticatedWebView'; + +const mocks = vi.hoisted(() => { + return { + auth: { isLoaded: true, isSignedIn: true as boolean, sessionId: 'sess_0' as string | null }, + getClerkInstance: vi.fn(), + request: vi.fn(), + }; +}); + +vi.mock('@clerk/react', () => { + return { + useAuth: () => mocks.auth, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { OS: 'ios' }, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/billing'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +type MintResponse = ReturnType; + +function mintedResponse(url = TRANSFER_URL) { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url, expires_at: Date.now() + 60_000 } }, + }; +} + +function refusal(status: number, code: string) { + return { + ok: false, + status, + statusText: 'Error', + payload: { errors: [{ code, message: code, long_message: `Refused with ${code}`, meta: {} }] }, + }; +} + +function deferredMint() { + let resolve: (response: MintResponse) => void = () => {}; + const response = new Promise(r => { + resolve = r; + }); + mocks.request.mockReturnValueOnce(response); + return { + deliver: async (url?: string) => { + resolve(mintedResponse(url)); + await response; + }, + }; +} + +function strictMode({ children }: { children: ReactNode }) { + return {children}; +} + +function renderWebView(destination = DESTINATION, wrapper?: (props: { children: ReactNode }) => ReactNode) { + return renderHook((props: { destination: string }) => useAuthenticatedWebView(props), { + initialProps: { destination }, + wrapper, + }); +} + +let sessionCount = 0; + +describe('useAuthenticatedWebView', () => { + beforeEach(() => { + sessionCount += 1; + const sessionId = `sess_${sessionCount}`; + mocks.auth = { isLoaded: true, isSignedIn: true, sessionId }; + mocks.request.mockReset(); + mocks.getClerkInstance.mockReset(); + mocks.getClerkInstance.mockImplementation(() => ({ + loaded: true, + session: mocks.auth.sessionId ? { id: mocks.auth.sessionId } : null, + getFapiClient: () => ({ request: mocks.request }), + })); + }); + + test('mints once under Strict Mode and hands the URL to the WebView', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const { result } = renderWebView(DESTINATION, strictMode); + + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null, error: null }); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + expect(result.current.status).toBe('preparing'); + expect(result.current.webViewProps?.source).toEqual({ uri: TRANSFER_URL }); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ body: { redirectUrl: DESTINATION, presentationMode: 'webview' } }), + ); + }); + + test('reuses the pending mint when the screen remounts before it arrives', async () => { + const mint = deferredMint(); + + const first = renderWebView(); + first.unmount(); + const second = renderWebView(); + await act(() => mint.deliver()); + + expect(second.result.current.webViewProps?.source).toEqual({ uri: TRANSFER_URL }); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('mints a new URL when the screen remounts after the WebView received one', async () => { + mocks.request + .mockResolvedValueOnce(mintedResponse()) + .mockResolvedValueOnce(mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=second')); + + const first = renderWebView(); + await waitFor(() => expect(first.result.current.webViewProps).not.toBeNull()); + first.unmount(); + const second = renderWebView(); + + await waitFor(() => + expect(second.result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=second', + ), + ); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('reports ready once the WebView finishes loading past the sign-in page', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + const props = result.current.webViewProps; + + act(() => props?.onLoadStart({ nativeEvent: { url: TRANSFER_URL } })); + act(() => props?.onLoadEnd({ nativeEvent: { url: TRANSFER_URL } })); + expect(result.current.status).toBe('preparing'); + + act(() => props?.onLoadEnd({ nativeEvent: { url: DESTINATION } })); + expect(result.current).toMatchObject({ status: 'ready', webViewProps: props, error: null }); + + act(() => props?.onLoadStart({ nativeEvent: { url: `${DESTINATION}?__clerk_error_code=expired` } })); + expect(result.current.status).toBe('ready'); + }); + + test('keeps the WebView props when redeeming fails', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + const props = result.current.webViewProps; + + act(() => + props?.onLoadStart({ + nativeEvent: { url: `${DESTINATION}?__clerk_status=failed&__clerk_error_code=already_used` }, + }), + ); + + expect(result.current).toMatchObject({ + status: 'error', + webViewProps: props, + error: { name: 'SessionTransferError', code: 'already_used' }, + }); + expect(result.current.error?.message).not.toContain('one-time'); + }); + + test('reports load_failed when the sign-in page returns an HTTP error', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + act(() => result.current.webViewProps?.onHttpError({ nativeEvent: { url: TRANSFER_URL } })); + + expect(result.current).toMatchObject({ status: 'error', error: { code: 'load_failed' } }); + expect(result.current.webViewProps).not.toBeNull(); + }); + + test('reports load_failed when the WebView fails to load', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + act(() => result.current.webViewProps?.onLoadEnd({ nativeEvent: { url: TRANSFER_URL, code: -1009 } })); + + expect(result.current).toMatchObject({ status: 'error', error: { code: 'load_failed' } }); + }); + + test('retry mints a new URL after a failed mint', async () => { + mocks.request.mockResolvedValueOnce(refusal(403, 'feature_not_enabled')).mockResolvedValueOnce(mintedResponse()); + const { result } = renderWebView(); + + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current).toMatchObject({ webViewProps: null, error: { code: 'feature_not_enabled' } }); + + act(() => result.current.retry()); + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('retry after a redeem failure replaces the WebView props', async () => { + mocks.request + .mockResolvedValueOnce(mintedResponse()) + .mockResolvedValueOnce(mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=second')); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + act(() => + result.current.webViewProps?.onLoadStart({ nativeEvent: { url: `${DESTINATION}?__clerk_error_code=expired` } }), + ); + const failedProps = result.current.webViewProps; + + act(() => result.current.retry()); + + await waitFor(() => + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=second', + ), + ); + expect(result.current.status).toBe('preparing'); + act(() => failedProps?.onLoadEnd({ nativeEvent: { url: DESTINATION } })); + expect(result.current.status).toBe('preparing'); + }); + + test('a destination change mints for the new destination and drops the old result', async () => { + const oldMint = deferredMint(); + mocks.request.mockResolvedValueOnce( + mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=new'), + ); + const { result, rerender } = renderWebView(); + + rerender({ destination: 'https://app.example.com/settings' }); + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + await waitFor(() => + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=new', + ), + ); + await act(() => oldMint.deliver()); + + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=new', + ); + expect(mocks.request).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: { redirectUrl: 'https://app.example.com/settings', presentationMode: 'webview' }, + }), + ); + }); + + test('reports signed-out without minting when there is no session', () => { + mocks.auth = { isLoaded: true, isSignedIn: false, sessionId: null }; + + const { result } = renderWebView(); + + expect(result.current).toMatchObject({ status: 'signed-out', webViewProps: null, error: null }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('reports signed-out when the user signs out after the URL was prepared', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result, rerender } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + mocks.auth = { isLoaded: true, isSignedIn: false, sessionId: null }; + rerender({ destination: DESTINATION }); + + expect(result.current).toMatchObject({ status: 'signed-out', webViewProps: null }); + }); + + test('reports signed-out when Clerk refuses the mint as signed out', async () => { + mocks.request.mockResolvedValue(refusal(401, 'signed_out')); + + const { result } = renderWebView(); + + await waitFor(() => expect(result.current.status).toBe('signed-out')); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('waits without minting until Clerk loads', () => { + mocks.auth = { isLoaded: false, isSignedIn: false, sessionId: null }; + + const { result } = renderWebView(); + + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('reports invalid_destination without minting', async () => { + const { result } = renderWebView('/billing'); + + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current.error?.code).toBe('invalid_destination'); + expect(mocks.request).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts b/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts new file mode 100644 index 00000000000..59cd0e0e00c --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts @@ -0,0 +1,247 @@ +import Module from 'node:module'; + +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useNativeToWeb } from '../useNativeToWeb'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + platformOS: 'ios', + launcher: 'linking' as 'linking' | 'android', + openURL: vi.fn(), + addEventListener: vi.fn(), + startActivityAsync: vi.fn(), + intentLauncherInstalled: true, + getClerkInstance: vi.fn(), + request: vi.fn(), + sessionId: 'sess_0' as string | null, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + Linking: { openURL: mocks.openURL }, + AppState: { addEventListener: mocks.addEventListener }, + }; +}); + +vi.mock('../launchBrowser', async () => { + const linking = await vi.importActual('../launchBrowser'); + const android = await vi.importActual('../launchBrowser.android'); + return { + resolveBrowserLauncher: () => + mocks.launcher === 'android' ? android.resolveBrowserLauncher() : linking.resolveBrowserLauncher(), + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/settings'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +function mintedResponse() { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url: TRANSFER_URL, expires_at: Date.now() + 60_000 } }, + }; +} + +function pending() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +let sessionCount = 0; + +describe('useNativeToWeb', () => { + beforeEach(() => { + sessionCount += 1; + mocks.sessionId = `sess_${sessionCount}`; + mocks.platformOS = 'ios'; + mocks.launcher = 'linking'; + mocks.intentLauncherInstalled = true; + mocks.openURL.mockReset().mockResolvedValue(undefined); + mocks.addEventListener.mockReset().mockReturnValue({ remove: vi.fn() }); + mocks.startActivityAsync.mockReset().mockResolvedValue({ resultCode: 0 }); + mocks.request.mockReset().mockResolvedValue(mintedResponse()); + mocks.getClerkInstance.mockReset().mockImplementation(() => ({ + loaded: true, + session: mocks.sessionId ? { id: mocks.sessionId } : null, + getFapiClient: () => ({ request: mocks.request }), + })); + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-intent-launcher') { + if (!mocks.intentLauncherInstalled) { + throw new Error("Cannot find module 'expo-intent-launcher'"); + } + return { startActivityAsync: mocks.startActivityAsync }; + } + if (request === 'expo-application') { + return { applicationId: 'com.example.app' }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('returns the same openExternalBrowser on every render', () => { + const { result, rerender } = renderHook(() => useNativeToWeb()); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + expect(result.current.openExternalBrowser).toBe(first.openExternalBrowser); + }); + + test('mints a browser transfer and opens it', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ body: { redirectUrl: DESTINATION, presentationMode: 'browser' } }), + ); + expect(mocks.openURL).toHaveBeenCalledWith(TRANSFER_URL); + }); + + test('shares one mint and one launch between overlapping calls', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + const first = openExternalBrowser({ destination: DESTINATION }); + const second = openExternalBrowser({ destination: DESTINATION }); + await Promise.all([first, second]); + + expect(second).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.openURL).toHaveBeenCalledTimes(1); + }); + + test('joins the open in flight after its URL was claimed', async () => { + const opened = pending(); + mocks.openURL.mockReturnValue(opened.promise); + const { openExternalBrowser } = useNativeToWeb(); + + const first = openExternalBrowser({ destination: DESTINATION }); + await vi.waitFor(() => expect(mocks.openURL).toHaveBeenCalledTimes(1)); + const second = openExternalBrowser({ destination: DESTINATION }); + opened.resolve(); + await Promise.all([first, second]); + + expect(second).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.openURL).toHaveBeenCalledTimes(1); + }); + + test('mints again once the previous open finished', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + expect(mocks.openURL).toHaveBeenCalledTimes(2); + }); + + test('rejects with browser_unavailable without exposing the URL', async () => { + mocks.openURL.mockRejectedValue(new Error(`Unable to open URL: ${TRANSFER_URL}`)); + const { openExternalBrowser } = useNativeToWeb(); + + const error = await openExternalBrowser({ destination: DESTINATION }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toMatchObject({ name: 'SessionTransferError', code: 'browser_unavailable' }); + expect(error).not.toHaveProperty('cause'); + expect(JSON.stringify(error)).not.toContain('one-time'); + expect(String(error)).not.toContain('one-time'); + }); + + test('rejects with signed_out without minting', async () => { + mocks.sessionId = null; + const { openExternalBrowser } = useNativeToWeb(); + + await expect(openExternalBrowser({ destination: DESTINATION })).rejects.toMatchObject({ code: 'signed_out' }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('rejects with unsupported_platform on web', async () => { + mocks.platformOS = 'web'; + const { openExternalBrowser } = useNativeToWeb(); + + await expect(openExternalBrowser({ destination: DESTINATION })).rejects.toMatchObject({ + code: 'unsupported_platform', + }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + describe('on Android', () => { + beforeEach(() => { + mocks.platformOS = 'android'; + mocks.launcher = 'android'; + }); + + test('opens the transfer with a VIEW intent that names the app as referrer', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.startActivityAsync).toHaveBeenCalledTimes(1); + expect(mocks.startActivityAsync).toHaveBeenCalledWith('android.intent.action.VIEW', { + data: TRANSFER_URL, + extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.app' }, + }); + expect(mocks.openURL).not.toHaveBeenCalled(); + }); + + test('fails with missing_dependency before minting when expo-intent-launcher is missing', async () => { + mocks.intentLauncherInstalled = false; + const { openExternalBrowser } = useNativeToWeb(); + + const error = await openExternalBrowser({ destination: DESTINATION }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toMatchObject({ code: 'missing_dependency' }); + expect(String(error)).toContain('npx expo install expo-intent-launcher'); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('shares one mint and one intent between overlapping calls', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await Promise.all([ + openExternalBrowser({ destination: DESTINATION }), + openExternalBrowser({ destination: DESTINATION }), + ]); + + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.startActivityAsync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts b/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts new file mode 100644 index 00000000000..775c79c187e --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; + +import type { NavigationEvent, NavigationVerdict } from '../webViewNavigation'; +import { judgeNavigation } from '../webViewNavigation'; + +const FAPI_TRANSFER = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; +const PROXIED_TRANSFER = 'https://app.example.com/__clerk/v1/session_transfers/redeem?token=one-time'; + +const pending: NavigationVerdict = { kind: 'pending' }; +const ready: NavigationVerdict = { kind: 'ready' }; +const failed = (code: string): NavigationVerdict => ({ kind: 'failed', code }); + +const start = (url: string): NavigationEvent => ({ kind: 'load-start', url }); +const end = (url: string, loadFailed = false): NavigationEvent => ({ kind: 'load-end', url, failed: loadFailed }); +const httpError = (url: string): NavigationEvent => ({ kind: 'http-error', url }); + +describe('judgeNavigation', () => { + test.each<[string, string, NavigationEvent, NavigationVerdict]>([ + ['redeem page starts loading', FAPI_TRANSFER, start(FAPI_TRANSFER), pending], + ['redeem page finishes loading', FAPI_TRANSFER, end(FAPI_TRANSFER), pending], + [ + 'redeem form post finishes without the token', + FAPI_TRANSFER, + end('https://clerk.example.com/v1/session_transfers/redeem'), + pending, + ], + ['redeem page returns an HTTP error', FAPI_TRANSFER, httpError(FAPI_TRANSFER), failed('load_failed')], + ['redeem page fails to load', FAPI_TRANSFER, end(FAPI_TRANSFER, true), failed('load_failed')], + ['a load fails with no URL', FAPI_TRANSFER, end('', true), failed('load_failed')], + ['destination fails to load', FAPI_TRANSFER, end('https://app.example.com/billing', true), failed('load_failed')], + ['destination starts loading', FAPI_TRANSFER, start('https://app.example.com/billing'), pending], + ['destination finishes loading', FAPI_TRANSFER, end('https://app.example.com/billing'), ready], + ['destination returns an HTTP error', FAPI_TRANSFER, httpError('https://app.example.com/billing'), pending], + [ + 'redirect carries a failed status', + FAPI_TRANSFER, + start('https://app.example.com/billing?__clerk_status=failed&__clerk_error_code=expired'), + failed('expired'), + ], + [ + 'redirect carries a session_transfer_failed status', + FAPI_TRANSFER, + start('https://app.example.com/billing?__clerk_status=session_transfer_failed&__clerk_error_code=already_used'), + failed('already_used'), + ], + [ + 'redirect carries an error code without a status', + FAPI_TRANSFER, + start('https://app.example.com/?__clerk_error_code=invalid_token'), + failed('invalid_token'), + ], + [ + 'finished page still carries the error code', + FAPI_TRANSFER, + end('https://app.example.com/billing?__clerk_status=failed&__clerk_error_code=cross_site_request'), + failed('cross_site_request'), + ], + [ + 'error code arrives with an unrelated status', + FAPI_TRANSFER, + end('https://app.example.com/billing?__clerk_status=verified&__clerk_error_code=expired'), + ready, + ], + ['about:blank finishes loading', FAPI_TRANSFER, end('about:blank'), pending], + [ + 'same path on another host finishes loading', + FAPI_TRANSFER, + end('https://app.example.com/v1/session_transfers/redeem'), + ready, + ], + ['proxied redeem page finishes loading', PROXIED_TRANSFER, end(PROXIED_TRANSFER), pending], + [ + 'proxied redeem form post finishes', + PROXIED_TRANSFER, + end('https://app.example.com/__clerk/v1/session_transfers/redeem'), + pending, + ], + ['proxied redeem page returns an HTTP error', PROXIED_TRANSFER, httpError(PROXIED_TRANSFER), failed('load_failed')], + ['destination on the proxy host finishes loading', PROXIED_TRANSFER, end('https://app.example.com/billing'), ready], + ])('%s', (_, transferUrl, event, verdict) => { + expect(judgeNavigation(transferUrl, event)).toEqual(verdict); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts b/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts new file mode 100644 index 00000000000..fe8f9740899 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts @@ -0,0 +1,21 @@ +import type { ComponentProps } from 'react'; +import type { WebView } from 'react-native-webview'; +import type { AndroidWebViewProps, IOSWebViewProps } from 'react-native-webview/lib/WebViewTypes'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { AuthenticatedWebViewProps } from '../useAuthenticatedWebView'; + +describe('AuthenticatedWebViewProps', () => { + test('spreads onto the react-native-webview WebView', () => { + expectTypeOf().toExtend>>(); + }); + + test('fits the iOS and Android WebView props', () => { + expectTypeOf().toExtend>(); + expectTypeOf().toExtend>(); + }); + + test('rejects props the WebView would not accept', () => { + expectTypeOf<{ source: { url: string } }>().not.toExtend>>(); + }); +}); diff --git a/packages/expo/src/native-to-web/advanced.ts b/packages/expo/src/native-to-web/advanced.ts new file mode 100644 index 00000000000..bbafe655eb5 --- /dev/null +++ b/packages/expo/src/native-to-web/advanced.ts @@ -0,0 +1,2 @@ +export { prepareSessionTransfer } from './sessionTransfer'; +export type { PrepareSessionTransferParams, SessionTransfer, SessionTransferPresentation } from './sessionTransfer'; diff --git a/packages/expo/src/native-to-web/errors.ts b/packages/expo/src/native-to-web/errors.ts new file mode 100644 index 00000000000..c48063f41fb --- /dev/null +++ b/packages/expo/src/native-to-web/errors.ts @@ -0,0 +1,77 @@ +/** + * Why a session transfer failed. + * + * The SDK raises `unsupported_platform`, `clerk_not_loaded`, `signed_out`, `invalid_destination`, + * `missing_dependency`, `browser_unavailable`, `load_failed`, `network_error`, and `unexpected_response`. + * When Clerk refuses to create the transfer, the code is the first Clerk API error code, + * such as `feature_not_enabled`. When the web page refuses to redeem it, the code is the + * `__clerk_error_code` the page redirected with, such as `expired` or `already_used`. + */ +export type SessionTransferErrorCode = + | 'unsupported_platform' + | 'clerk_not_loaded' + | 'signed_out' + | 'invalid_destination' + | 'missing_dependency' + | 'browser_unavailable' + | 'load_failed' + | 'network_error' + | 'unexpected_response' + | 'feature_not_enabled' + | 'resource_forbidden' + | 'invalid_action_for_session' + | 'user_locked' + | 'invalid_token' + | 'expired' + | 'already_used' + | 'source_session_invalid' + | 'impersonation_in_progress' + | 'other_user_signed_in' + | 'cross_site_request' + | (string & {}); + +/** + * The error every session transfer API rejects with. Clerk API refusals keep the original + * `ClerkAPIResponseError` on `cause`, so `status` and `retryAfter` stay available. + */ +export type SessionTransferError = Error & { + name: 'SessionTransferError'; + code: SessionTransferErrorCode; + cause?: unknown; +}; + +/** + * Returns `true` when `error` came from a session transfer API. + * + * @example + * ### Send a signed-out user to sign in + * ```ts + * try { + * await openExternalBrowser({ destination: 'https://app.example.com/settings' }); + * } catch (error) { + * if (isSessionTransferError(error) && error.code === 'signed_out') { + * router.replace('/sign-in'); + * } + * } + * ``` + */ +export function isSessionTransferError(error: unknown): error is SessionTransferError { + return ( + error instanceof Error && error.name === 'SessionTransferError' && 'code' in error && typeof error.code === 'string' + ); +} + +export function sessionTransferError( + code: SessionTransferErrorCode, + message: string, + cause?: unknown, +): SessionTransferError { + const error = Object.assign(new Error(message), { name: 'SessionTransferError' as const, code }); + return cause === undefined ? error : Object.assign(error, { cause }); +} + +export function toSessionTransferError(error: unknown): SessionTransferError { + return isSessionTransferError(error) + ? error + : sessionTransferError('unexpected_response', 'The session transfer failed unexpectedly.', error); +} diff --git a/packages/expo/src/native-to-web/index.ts b/packages/expo/src/native-to-web/index.ts new file mode 100644 index 00000000000..9e3439377e5 --- /dev/null +++ b/packages/expo/src/native-to-web/index.ts @@ -0,0 +1,10 @@ +export { isSessionTransferError } from './errors'; +export type { SessionTransferError, SessionTransferErrorCode } from './errors'; +export { useAuthenticatedWebView } from './useAuthenticatedWebView'; +export type { + AuthenticatedWebView, + AuthenticatedWebViewProps, + UseAuthenticatedWebViewParams, +} from './useAuthenticatedWebView'; +export { useNativeToWeb } from './useNativeToWeb'; +export type { OpenExternalBrowserParams, UseNativeToWebReturn } from './useNativeToWeb'; diff --git a/packages/expo/src/native-to-web/launchBrowser.android.ts b/packages/expo/src/native-to-web/launchBrowser.android.ts new file mode 100644 index 00000000000..509fb2c76e6 --- /dev/null +++ b/packages/expo/src/native-to-web/launchBrowser.android.ts @@ -0,0 +1,53 @@ +import type * as IntentLauncher from 'expo-intent-launcher'; +import { AppState } from 'react-native'; + +import { loadExpoApplication } from '../app-version/nativeAppIdentity'; +import { getExpoAppIdentifier } from '../utils/appIdentifier'; +import { sessionTransferError } from './errors'; + +export function resolveBrowserLauncher(): (url: string) => Promise { + const { startActivityAsync } = loadIntentLauncher(); + const packageName = loadExpoApplication()?.applicationId ?? getExpoAppIdentifier(); + if (!packageName) { + throw sessionTransferError( + 'missing_dependency', + 'Opening a signed-in browser on Android needs the app package name. Install expo-application by running: npx expo install expo-application', + ); + } + + return url => + new Promise((resolve, reject) => { + const subscription = AppState.addEventListener('change', state => { + if (state !== 'active') { + subscription.remove(); + resolve(); + } + }); + + startActivityAsync('android.intent.action.VIEW', { + data: url, + extra: { 'android.intent.extra.REFERRER_NAME': `android-app://${packageName}` }, + }).then( + () => { + subscription.remove(); + resolve(); + }, + () => { + subscription.remove(); + reject(sessionTransferError('browser_unavailable', 'No browser could open the sign-in link.')); + }, + ); + }); +} + +function loadIntentLauncher(): typeof IntentLauncher { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('expo-intent-launcher') as typeof IntentLauncher; + } catch { + throw sessionTransferError( + 'missing_dependency', + 'Opening a signed-in browser on Android needs expo-intent-launcher. Install it by running: npx expo install expo-intent-launcher', + ); + } +} diff --git a/packages/expo/src/native-to-web/launchBrowser.ts b/packages/expo/src/native-to-web/launchBrowser.ts new file mode 100644 index 00000000000..8361312c3b0 --- /dev/null +++ b/packages/expo/src/native-to-web/launchBrowser.ts @@ -0,0 +1,17 @@ +import { Linking, Platform } from 'react-native'; + +import { sessionTransferError } from './errors'; + +export function resolveBrowserLauncher(): (url: string) => Promise { + if (Platform.OS === 'web') { + throw sessionTransferError('unsupported_platform', 'Session transfer is only available in native apps.'); + } + + return url => + Linking.openURL(url).then( + () => undefined, + () => { + throw sessionTransferError('browser_unavailable', 'No browser could open the sign-in link.'); + }, + ); +} diff --git a/packages/expo/src/native-to-web/sessionTransfer.ts b/packages/expo/src/native-to-web/sessionTransfer.ts new file mode 100644 index 00000000000..dd3e65615f0 --- /dev/null +++ b/packages/expo/src/native-to-web/sessionTransfer.ts @@ -0,0 +1,240 @@ +import { Platform } from 'react-native'; + +import { getClerkInstance } from '../provider/singleton'; +import type { FapiResponse } from '../utils/fapiRequest'; +import { getResponseJSON, requestWithSignedOutRetry, toClerkAPIResponseError } from '../utils/fapiRequest'; +import { sessionTransferError } from './errors'; + +/** + * Where the session transfer URL will be opened. Use `webview` for a WebView your app renders, + * and `browser` for the system browser or an in-app browser tab. + */ +export type SessionTransferPresentation = 'webview' | 'browser'; + +/** + * Options for preparing a session transfer. + */ +export type PrepareSessionTransferParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + */ + destination: string; + /** + * Where the URL will be opened. + */ + presentation: SessionTransferPresentation; +}; + +/** + * A one-time URL that signs the active user in on the web. + */ +export type SessionTransfer = { + /** + * Open it once, within 60 seconds, and never log or store it. It is not enumerable, so + * `console.log`, `JSON.stringify`, and object spread leave it out. + */ + readonly url: string; + /** + * When the URL stops working. + */ + readonly expiresAt: Date; +}; + +type SessionTransferJSON = { + object: 'session_transfer'; + url: unknown; + expires_at: unknown; +}; + +type SessionTransferFapiClient = { + request: (requestInit: { + method: 'POST'; + path: string; + body: { redirectUrl: string; presentationMode: SessionTransferPresentation }; + }) => Promise>; +}; + +type FapiCapableClerk = { + getFapiClient: () => SessionTransferFapiClient; +}; + +export type TransferRequest = { + readonly key: string; + readonly clerk: FapiCapableClerk; + readonly sessionId: string; + readonly destination: string; + readonly presentation: SessionTransferPresentation; +}; + +export type TransferLease = { + readonly key: string; + readonly transfer: Promise; +}; + +type LedgerEntry = { + readonly lease: TransferLease; + expiresAt: number | null; +}; + +const MIN_REMAINING_MS = 15_000; +const unclaimed = new Map(); + +/** + * Creates a one-time URL that signs the active session's user in on `destination`. Every call + * creates a new transfer. Rejects with a `SessionTransferError`. + * + * Use it only to present the URL yourself. `useAuthenticatedWebView` and + * `useNativeToWeb().openExternalBrowser` cover WebViews and the system browser. + * + * > [!WARNING] + * > Android browsers redeem the URL only when the app sets `android.intent.extra.REFERRER_NAME` + * > to `android-app://` on the intent that opens it. `expo-web-browser` Custom Tabs + * > cannot set it, so redemption fails there with `cross_site_request`. + * + * @param params - The destination and where the URL will be opened. + * @returns The URL and when it expires. + * + * @example + * ### Open the destination in an in-app browser on iOS + * ```ts + * import { prepareSessionTransfer } from '@clerk/expo/native-to-web/advanced'; + * import * as WebBrowser from 'expo-web-browser'; + * + * const transfer = await prepareSessionTransfer({ + * destination: 'https://app.example.com/reports', + * presentation: 'browser', + * }); + * await WebBrowser.openBrowserAsync(transfer.url); + * ``` + */ +export async function prepareSessionTransfer(params: PrepareSessionTransferParams): Promise { + return mintSessionTransfer(resolveTransferRequest(params)); +} + +export function resolveTransferRequest({ destination, presentation }: PrepareSessionTransferParams): TransferRequest { + if (Platform.OS === 'web') { + throw sessionTransferError('unsupported_platform', 'Session transfer is only available in native apps.'); + } + if (!isAbsoluteHttpUrl(destination)) { + throw sessionTransferError( + 'invalid_destination', + 'The session transfer destination must be an absolute http or https URL.', + ); + } + + const clerk = getLoadedClerk(); + if (!clerk) { + throw sessionTransferError('clerk_not_loaded', 'Clerk must finish loading before preparing a session transfer.'); + } + + const sessionId = clerk.session?.id; + if (!sessionId) { + throw sessionTransferError('signed_out', 'A session transfer requires an active session.'); + } + + return { key: `${sessionId}\n${presentation}\n${destination}`, clerk, sessionId, destination, presentation }; +} + +export function acquireTransfer(request: TransferRequest): TransferLease { + evictExpiring(Date.now()); + + const existing = unclaimed.get(request.key); + if (existing) { + return existing.lease; + } + + const transfer = mintSessionTransfer(request); + const entry: LedgerEntry = { lease: { key: request.key, transfer }, expiresAt: null }; + unclaimed.set(request.key, entry); + transfer.then( + minted => { + entry.expiresAt = minted.expiresAt.getTime(); + }, + () => { + if (unclaimed.get(request.key) === entry) { + unclaimed.delete(request.key); + } + }, + ); + + return entry.lease; +} + +export function claimTransfer(lease: TransferLease): void { + if (unclaimed.get(lease.key)?.lease === lease) { + unclaimed.delete(lease.key); + } +} + +function evictExpiring(now: number): void { + for (const [key, entry] of unclaimed) { + if (entry.expiresAt !== null && entry.expiresAt - now < MIN_REMAINING_MS) { + unclaimed.delete(key); + } + } +} + +async function mintSessionTransfer({ + clerk, + sessionId, + destination, + presentation, +}: TransferRequest): Promise { + let response: FapiResponse; + try { + response = await requestWithSignedOutRetry(() => + clerk.getFapiClient().request({ + method: 'POST', + path: `/client/sessions/${sessionId}/session_transfers`, + body: { redirectUrl: destination, presentationMode: presentation }, + }), + ); + } catch (cause) { + throw sessionTransferError('network_error', 'Clerk could not be reached to prepare the session transfer.', cause); + } + + if (!response.ok) { + const apiError = toClerkAPIResponseError(response, 'Clerk refused to prepare the session transfer.'); + throw sessionTransferError(apiError.errors[0]?.code ?? 'unexpected_response', apiError.message, apiError); + } + + return parseSessionTransfer(getResponseJSON(response.payload, 'session_transfer')); +} + +function parseSessionTransfer(json: SessionTransferJSON | null): SessionTransfer { + if ( + !json || + typeof json.url !== 'string' || + typeof json.expires_at !== 'number' || + !Number.isFinite(json.expires_at) + ) { + throw sessionTransferError('unexpected_response', 'Clerk returned an unexpected session transfer response.'); + } + + const transfer = { url: json.url, expiresAt: new Date(json.expires_at) }; + return Object.defineProperty(transfer, 'url', { enumerable: false, writable: false }); +} + +function getLoadedClerk() { + let clerk: ReturnType; + try { + clerk = getClerkInstance(); + } catch { + return null; + } + + return clerk?.loaded && canRequestFapi(clerk) ? clerk : null; +} + +function canRequestFapi(clerk: C): clerk is C & FapiCapableClerk { + return 'getFapiClient' in clerk && typeof clerk.getFapiClient === 'function'; +} + +function isAbsoluteHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'https:' || protocol === 'http:'; + } catch { + return false; + } +} diff --git a/packages/expo/src/native-to-web/useAuthenticatedWebView.ts b/packages/expo/src/native-to-web/useAuthenticatedWebView.ts new file mode 100644 index 00000000000..13d086bcb1e --- /dev/null +++ b/packages/expo/src/native-to-web/useAuthenticatedWebView.ts @@ -0,0 +1,259 @@ +import { useAuth } from '@clerk/react'; +import type { Dispatch } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; + +import type { SessionTransferError } from './errors'; +import { sessionTransferError, toSessionTransferError } from './errors'; +import type { SessionTransfer, TransferLease } from './sessionTransfer'; +import { acquireTransfer, claimTransfer, resolveTransferRequest } from './sessionTransfer'; +import type { NavigationEvent, NavigationVerdict } from './webViewNavigation'; +import { judgeNavigation } from './webViewNavigation'; + +/** + * Options for `useAuthenticatedWebView`. + */ +export type UseAuthenticatedWebViewParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + * Changing it prepares a new transfer. + */ + destination: string; +}; + +/** + * Props to spread onto `` from `react-native-webview`. They carry the one-time sign-in URL, + * so never log or store them. If you pass your own `onLoadStart`, `onLoadEnd`, or `onHttpError`, + * call the matching handler from these props first. + */ +export type AuthenticatedWebViewProps = { + source: { uri: string }; + onLoadStart: (event: { nativeEvent: { url: string } }) => void; + onLoadEnd: (event: { nativeEvent: { url: string; code?: number } }) => void; + onHttpError: (event: { nativeEvent: { url: string } }) => void; +}; + +/** + * The state of an authenticated WebView. Keep your loading overlay up while `status` is `preparing`. + */ +export type AuthenticatedWebView = + | { + /** + * The sign-in URL is being prepared, or the WebView is still on Clerk's sign-in page. + * `webViewProps` is `null` until the URL is ready. Render the WebView under your overlay once it is set. + */ + status: 'preparing'; + webViewProps: AuthenticatedWebViewProps | null; + error: null; + retry: () => void; + } + | { + /** + * The WebView has left Clerk's sign-in page and shows the destination, signed in. + */ + status: 'ready'; + webViewProps: AuthenticatedWebViewProps; + error: null; + retry: () => void; + } + | { + /** + * There is no active session in the app, or Clerk reported it signed out. + */ + status: 'signed-out'; + webViewProps: null; + error: null; + retry: () => void; + } + | { + /** + * Preparing, loading, or redeeming the URL failed. `webViewProps` stays set when the WebView + * already loaded the URL, so you can keep showing the page it landed on. `retry` prepares a new URL. + */ + status: 'error'; + webViewProps: AuthenticatedWebViewProps | null; + error: SessionTransferError; + retry: () => void; + }; + +type TransferState = + | { phase: 'redeeming'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { phase: 'ready'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { + phase: 'failed'; + requestId: string; + webViewProps: AuthenticatedWebViewProps | null; + error: SessionTransferError; + }; + +type TransferResult = + | { type: 'minted'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { type: 'mint-failed'; requestId: string; error: SessionTransferError }; + +type TransferAction = TransferResult | { type: 'navigated'; requestId: string; verdict: NavigationVerdict }; + +function transferReducer(state: TransferState | null, action: TransferAction): TransferState | null { + switch (action.type) { + case 'minted': + return { phase: 'redeeming', requestId: action.requestId, webViewProps: action.webViewProps }; + case 'mint-failed': + return { phase: 'failed', requestId: action.requestId, webViewProps: null, error: action.error }; + case 'navigated': + return state?.phase === 'redeeming' && state.requestId === action.requestId + ? applyVerdict(state, action.verdict) + : state; + } +} + +function applyVerdict( + state: Extract, + verdict: NavigationVerdict, +): TransferState { + switch (verdict.kind) { + case 'pending': + return state; + case 'ready': + return { ...state, phase: 'ready' }; + case 'failed': + return { + ...state, + phase: 'failed', + error: sessionTransferError( + verdict.code, + verdict.code === 'load_failed' + ? 'The sign-in page failed to load.' + : 'The web page could not sign the user in.', + ), + }; + } +} + +/** + * Prepares a one-time URL that signs the active user in on `destination`, and returns props for a + * `react-native-webview` `` that opens it. The URL is prepared when the hook mounts, so mount it + * on the screen that shows the WebView. Remounts and React Strict Mode reuse a URL the WebView has not received yet. + * + * @param params - The page to open. + * @returns The WebView state, its props, and a `retry` function. + * + * @example + * ### Show a signed-in web page under a loading overlay + * ```tsx + * import { useAuthenticatedWebView } from '@clerk/expo/native-to-web'; + * import { Redirect } from 'expo-router'; + * import { View } from 'react-native'; + * import { WebView } from 'react-native-webview'; + * + * export default function BillingScreen() { + * const web = useAuthenticatedWebView({ destination: 'https://app.example.com/billing' }); + * + * if (web.status === 'signed-out') return ; + * if (web.status === 'error' && !web.webViewProps) return ; + * + * return ( + * + * {web.webViewProps && } + * {web.status === 'preparing' && } + * + * ); + * } + * ``` + * + * @example + * ### Add your own load handler + * ```tsx + * const { webViewProps } = useAuthenticatedWebView({ destination }); + * + * return ( + * webViewProps && ( + * { + * webViewProps.onLoadEnd(event); + * trackPageLoad(event.nativeEvent.url); + * }} + * /> + * ) + * ); + * ``` + */ +export function useAuthenticatedWebView({ destination }: UseAuthenticatedWebViewParams): AuthenticatedWebView { + const { isLoaded, isSignedIn, sessionId } = useAuth(); + const [attempt, setAttempt] = useState(0); + const [state, dispatch] = useReducer(transferReducer, null); + const settledRequestId = useRef(null); + const requestId = isLoaded && isSignedIn && sessionId ? `${sessionId}\n${destination}\n${attempt}` : null; + + useEffect(() => { + if (!requestId || settledRequestId.current === requestId) { + return; + } + + let attached = true; + const settle = (result: TransferResult) => { + if (attached) { + settledRequestId.current = requestId; + dispatch(result); + } + }; + const fail = (error: unknown) => settle({ type: 'mint-failed', requestId, error: toSessionTransferError(error) }); + + let lease: TransferLease; + try { + lease = acquireTransfer(resolveTransferRequest({ destination, presentation: 'webview' })); + } catch (error) { + fail(error); + return; + } + + lease.transfer.then(transfer => { + if (attached) { + claimTransfer(lease); + settle({ type: 'minted', requestId, webViewProps: createWebViewProps(requestId, transfer, dispatch) }); + } + }, fail); + + return () => { + attached = false; + }; + }, [requestId, destination]); + + const retry = useCallback(() => setAttempt(n => n + 1), []); + + if (!isLoaded) { + return { status: 'preparing', webViewProps: null, error: null, retry }; + } + if (!isSignedIn) { + return { status: 'signed-out', webViewProps: null, error: null, retry }; + } + + const current = state?.requestId === requestId ? state : null; + switch (current?.phase) { + case undefined: + return { status: 'preparing', webViewProps: null, error: null, retry }; + case 'redeeming': + return { status: 'preparing', webViewProps: current.webViewProps, error: null, retry }; + case 'ready': + return { status: 'ready', webViewProps: current.webViewProps, error: null, retry }; + case 'failed': + return current.error.code === 'signed_out' + ? { status: 'signed-out', webViewProps: null, error: null, retry } + : { status: 'error', webViewProps: current.webViewProps, error: current.error, retry }; + } +} + +function createWebViewProps( + requestId: string, + transfer: SessionTransfer, + dispatch: Dispatch, +): AuthenticatedWebViewProps { + const judge = (event: NavigationEvent) => + dispatch({ type: 'navigated', requestId, verdict: judgeNavigation(transfer.url, event) }); + + return { + source: { uri: transfer.url }, + onLoadStart: ({ nativeEvent }) => judge({ kind: 'load-start', url: nativeEvent.url }), + onLoadEnd: ({ nativeEvent }) => + judge({ kind: 'load-end', url: nativeEvent.url, failed: typeof nativeEvent.code === 'number' }), + onHttpError: ({ nativeEvent }) => judge({ kind: 'http-error', url: nativeEvent.url }), + }; +} diff --git a/packages/expo/src/native-to-web/useNativeToWeb.ts b/packages/expo/src/native-to-web/useNativeToWeb.ts new file mode 100644 index 00000000000..1c7ed4a1ea2 --- /dev/null +++ b/packages/expo/src/native-to-web/useNativeToWeb.ts @@ -0,0 +1,93 @@ +import { toSessionTransferError } from './errors'; +import { resolveBrowserLauncher } from './launchBrowser'; +import { acquireTransfer, claimTransfer, resolveTransferRequest } from './sessionTransfer'; + +/** + * Options for `openExternalBrowser`. + */ +export type OpenExternalBrowserParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + */ + destination: string; +}; + +/** + * The value `useNativeToWeb` returns. + */ +export type UseNativeToWebReturn = { + /** + * Opens `destination` in the system browser with the active user signed in. Resolves once the + * operating system hands the URL to the browser, and rejects with a `SessionTransferError`. + * Calls for the same destination that overlap share one URL and open one browser tab. + * + * On Android it needs `expo-intent-launcher`, and `expo-application` when the package name is not + * in your app config. Install them with `npx expo install expo-intent-launcher expo-application`. + */ + openExternalBrowser: (params: OpenExternalBrowserParams) => Promise; +}; + +const inFlight = new Map>(); + +function openExternalBrowser({ destination }: OpenExternalBrowserParams): Promise { + try { + const launch = resolveBrowserLauncher(); + const request = resolveTransferRequest({ destination, presentation: 'browser' }); + const existing = inFlight.get(request.key); + if (existing) { + return existing; + } + + const lease = acquireTransfer(request); + const opening = lease.transfer + .then(transfer => { + claimTransfer(lease); + return launch(transfer.url); + }) + .finally(() => { + if (inFlight.get(request.key) === opening) { + inFlight.delete(request.key); + } + }); + inFlight.set(request.key, opening); + return opening; + } catch (error) { + return Promise.reject(toSessionTransferError(error)); + } +} + +const nativeToWeb: UseNativeToWebReturn = { openExternalBrowser }; + +/** + * Returns `openExternalBrowser`, which opens a web page in the user's default browser with the + * active user signed in. + * + * @returns An object holding `openExternalBrowser`. The object and the function never change identity. + * + * @example + * ### Open the web app from a button + * ```tsx + * import { isSessionTransferError, useNativeToWeb } from '@clerk/expo/native-to-web'; + * + * function OpenOnWebButton() { + * const { openExternalBrowser } = useNativeToWeb(); + * + * const onPress = async () => { + * try { + * await openExternalBrowser({ destination: 'https://app.example.com/settings' }); + * } catch (error) { + * if (isSessionTransferError(error) && error.code === 'signed_out') { + * router.replace('/sign-in'); + * return; + * } + * Alert.alert('Could not open the web app'); + * } + * }; + * + * return