Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/expo-native-to-web.md
Original file line number Diff line number Diff line change
@@ -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` `<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.
4 changes: 4 additions & 0 deletions packages/expo/native-to-web/advanced/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "../../dist/native-to-web/advanced.js",
"types": "../../dist/native-to-web/advanced.d.ts"
}
4 changes: 4 additions & 0 deletions packages/expo/native-to-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "../dist/native-to-web/index.js",
"types": "../dist/native-to-web/index.d.ts"
}
15 changes: 15 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -105,6 +113,7 @@
"apple",
"biometrics",
"hosted-auth",
"native-to-web",
"experimental",
"legacy",
"app.plugin.js",
Expand Down Expand Up @@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -193,6 +205,9 @@
"expo-crypto": {
"optional": true
},
"expo-intent-launcher": {
"optional": true
},
"expo-local-authentication": {
"optional": true
},
Expand Down
2 changes: 1 addition & 1 deletion packages/expo/src/app-version/nativeAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 1 addition & 25 deletions packages/expo/src/hooks/useHostedAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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<HostedAuthClerkInstance> | undefined;
if (typeof hostedAuthClerk?.getFapiClient !== 'function') {
Expand Down
40 changes: 40 additions & 0 deletions packages/expo/src/native-to-web/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Original file line number Diff line number Diff line change
@@ -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<void>) {
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();
});
});
Loading
Loading