Skip to content

Commit e120819

Browse files
committed
fix(expo): harden hosted auth completion
1 parent a812d19 commit e120819

6 files changed

Lines changed: 99 additions & 49 deletions

File tree

.changeset/hosted-auth-expo.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
---
22
'@clerk/expo': patch
3-
'@clerk/clerk-js': patch
4-
'@clerk/shared': patch
53
---
64

75
Add `@clerk/expo/hosted-auth` for signing in or signing up through Account Portal from native Expo apps.

packages/clerk-js/src/core/__tests__/fapiClient.test.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,6 @@ describe('buildUrl(options)', () => {
151151
);
152152
});
153153

154-
it('adds rotating token nonce', () => {
155-
const url = fapiClient.buildUrl({
156-
path: '/client',
157-
rotatingTokenNonce: 'nonce_123',
158-
});
159-
160-
expect(url.searchParams.get('rotating_token_nonce')).toBe('nonce_123');
161-
});
162-
163154
// The return value isn't as expected.
164155
// The buildUrl function converts an undefined value to the string 'undefined'
165156
// and includes it in the search parameters.

packages/expo/src/hooks/__tests__/useHostedAuth.test.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const mockCodeChallenge = 'mock-code-challenge-_';
8585
describe('useHostedAuth', () => {
8686
const mockClient = {
8787
lastActiveSessionId: null as string | null,
88+
sessions: [] as Array<{ id: string }>,
8889
reload: vi.fn(),
8990
fromJSON: vi.fn(),
9091
};
@@ -128,8 +129,10 @@ describe('useHostedAuth', () => {
128129
mocks.platformOS = 'ios';
129130
mocks.expoConfig = undefined;
130131
mockClient.lastActiveSessionId = null;
132+
mockClient.sessions = [];
131133
mockClient.fromJSON.mockImplementation((clientJSON: ClientJSON) => {
132134
mockClient.lastActiveSessionId = clientJSON.last_active_session_id;
135+
mockClient.sessions = clientJSON.sessions.map(session => ({ id: session.id }));
133136
return mockClient;
134137
});
135138
mocks.makeRedirectUri.mockReturnValue('myapp:///hosted-auth-callback');
@@ -253,6 +256,28 @@ describe('useHostedAuth', () => {
253256
expect(mocks.makeRedirectUri).not.toHaveBeenCalled();
254257
});
255258

259+
test('uses Expo universal-link callbacks for HTTPS redirect URLs', async () => {
260+
const redirectUrl = 'https://mobile.example.com/hosted-auth-callback';
261+
mockHostedAuthResponse();
262+
mocks.openAuthSessionAsync.mockResolvedValue({
263+
type: 'success',
264+
url: `${redirectUrl}?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_123`,
265+
});
266+
mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' });
267+
268+
const { result } = renderHook(() => useHostedAuth());
269+
await result.current.startHostedAuth({
270+
redirectUrl,
271+
state: 'state-123',
272+
authSessionOptions: { showInRecents: true },
273+
});
274+
275+
expect(mocks.openAuthSessionAsync).toHaveBeenCalledWith('https://example.accounts.dev/sign-in', redirectUrl, {
276+
preferUniversalLinks: true,
277+
showInRecents: true,
278+
});
279+
});
280+
256281
test('falls back to the reloaded client session when callback session id is absent', async () => {
257282
mockHostedAuthResponse();
258283
mocks.openAuthSessionAsync.mockResolvedValue({
@@ -270,7 +295,7 @@ describe('useHostedAuth', () => {
270295
expect(response.client).toBe(mockClient);
271296
});
272297

273-
test('does not activate a session when the callback does not return one', async () => {
298+
test('rejects a redeemed client response without a created session', async () => {
274299
mockHostedAuthResponse();
275300
mocks.openAuthSessionAsync.mockResolvedValue({
276301
type: 'success',
@@ -279,15 +304,17 @@ describe('useHostedAuth', () => {
279304
mockHostedAuthRedeemResponse();
280305

281306
const { result } = renderHook(() => useHostedAuth());
282-
const response = await result.current.startHostedAuth({ state: 'state-123' });
307+
308+
await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow(
309+
'Hosted auth completion did not include the created session.',
310+
);
283311

284312
expect(mockFapiRequest).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: '/client' }));
285313
expect(mockUpdateClient).toHaveBeenCalledWith(mockClient);
286314
expect(mockSetActive).not.toHaveBeenCalled();
287-
expect(response.createdSessionId).toBeNull();
288315
});
289316

290-
test('does not fall back to an existing active session when callback session params are missing', async () => {
317+
test('rejects a successful callback without a rotating token nonce', async () => {
291318
mockClient.lastActiveSessionId = 'sess_existing';
292319
mockHostedAuthResponse();
293320
mocks.openAuthSessionAsync.mockResolvedValue({
@@ -296,11 +323,29 @@ describe('useHostedAuth', () => {
296323
});
297324

298325
const { result } = renderHook(() => useHostedAuth());
299-
const response = await result.current.startHostedAuth({ state: 'state-123' });
326+
327+
await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow(
328+
'Hosted auth callback did not include a rotating token nonce.',
329+
);
300330

301331
expect(mockFapiRequest).toHaveBeenCalledTimes(1);
302332
expect(mockSetActive).not.toHaveBeenCalled();
303-
expect(response.createdSessionId).toBeNull();
333+
});
334+
335+
test('rejects a callback session that is absent from the redeemed client', async () => {
336+
mockHostedAuthResponse();
337+
mocks.openAuthSessionAsync.mockResolvedValue({
338+
type: 'success',
339+
url: 'myapp:///hosted-auth-callback?state=state-123&rotating_token_nonce=nonce-123&created_session_id=sess_other',
340+
});
341+
mockHostedAuthRedeemResponse({ lastActiveSessionId: 'sess_123' });
342+
343+
const { result } = renderHook(() => useHostedAuth());
344+
345+
await expect(result.current.startHostedAuth({ state: 'state-123' })).rejects.toThrow(
346+
'Hosted auth completion did not include the created session.',
347+
);
348+
expect(mockSetActive).not.toHaveBeenCalled();
304349
});
305350

306351
test('surfaces browser session open failures', async () => {
@@ -486,7 +531,13 @@ function mockHostedAuthResponse(url = 'https://example.accounts.dev/sign-in') {
486531
});
487532
}
488533

489-
function mockHostedAuthRedeemResponse({ lastActiveSessionId = null }: { lastActiveSessionId?: string | null } = {}) {
534+
function mockHostedAuthRedeemResponse({
535+
lastActiveSessionId = null,
536+
sessionIds = lastActiveSessionId ? [lastActiveSessionId] : [],
537+
}: {
538+
lastActiveSessionId?: string | null;
539+
sessionIds?: string[];
540+
} = {}) {
490541
mockFapiRequest.mockResolvedValueOnce({
491542
ok: true,
492543
status: 200,
@@ -495,7 +546,7 @@ function mockHostedAuthRedeemResponse({ lastActiveSessionId = null }: { lastActi
495546
response: {
496547
object: 'client',
497548
id: 'client_123',
498-
sessions: [],
549+
sessions: sessionIds.map(id => ({ id })),
499550
sign_in: null,
500551
sign_up: null,
501552
last_active_session_id: lastActiveSessionId,

packages/expo/src/hooks/useHostedAuth.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ export type HostedAuthMode = 'sign-in' | 'sign-up';
1818
*/
1919
export type StartHostedAuthParams = {
2020
/**
21-
* Native deep-link URL that Account Portal redirects to after auth completes.
21+
* Native callback URL that Account Portal redirects to after auth completes.
2222
* Defaults to the canonical callback Clerk registers for the configured iOS
2323
* bundle identifier or Android package name. Expo Go and projects without a
2424
* configured application identifier fall back to `AuthSession.makeRedirectUri`.
25+
* HTTPS callbacks use Expo's universal-link auth session support on iOS.
2526
*/
2627
redirectUrl?: string;
2728
/**
@@ -36,9 +37,7 @@ export type StartHostedAuthParams = {
3637
/**
3738
* Options forwarded to `expo-web-browser` when opening the hosted auth session.
3839
*/
39-
authSessionOptions?: {
40-
showInRecents?: boolean;
41-
};
40+
authSessionOptions?: WebBrowser.AuthSessionOpenOptions;
4241
};
4342

4443
/**
@@ -57,7 +56,7 @@ export type StartHostedAuthReturnType = {
5756
url?: string | null;
5857
} | null;
5958
/**
60-
* The current Clerk client after hosted auth completes.
59+
* The current Clerk client after the hosted auth attempt.
6160
*/
6261
client?: {
6362
id?: string;
@@ -120,10 +119,14 @@ export function useHostedAuth(): {
120119
clerk,
121120
);
122121

122+
const authSessionOptions =
123+
Platform.OS === 'ios' && isHTTPSRedirectUrl(redirectUrl)
124+
? { preferUniversalLinks: true, ...params.authSessionOptions }
125+
: params.authSessionOptions;
123126
const authSessionResult = await WebBrowserModule.openAuthSessionAsync(
124127
hostedAuth.url,
125128
redirectUrl,
126-
params.authSessionOptions,
129+
authSessionOptions,
127130
);
128131
if (authSessionResult.type !== 'success' || !authSessionResult.url) {
129132
return {
@@ -148,30 +151,30 @@ export function useHostedAuth(): {
148151
return errorThrower.throw('Hosted auth callback state did not match the initiated state.');
149152
}
150153

151-
let updatedClient: ClientResource | undefined;
152-
let createdSessionId: string | null = null;
153154
const rotatingTokenNonce = callbackParams.get('rotating_token_nonce') ?? '';
154-
if (rotatingTokenNonce) {
155-
updatedClient = await redeemHostedAuth(
156-
{
157-
rotatingTokenNonce,
158-
codeVerifier: pkce.codeVerifier,
159-
},
160-
clerk.client,
161-
clerk,
162-
);
163-
if (updatedClient) {
164-
getClientUpdater(clerk)?.(updatedClient);
165-
createdSessionId = normalizeSessionId(
166-
callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId,
167-
);
168-
}
155+
if (!rotatingTokenNonce) {
156+
return errorThrower.throw('Hosted auth callback did not include a rotating token nonce.');
169157
}
170-
if (createdSessionId) {
171-
await clerk.setActive({
172-
session: createdSessionId,
173-
});
158+
159+
const updatedClient = await redeemHostedAuth(
160+
{
161+
rotatingTokenNonce,
162+
codeVerifier: pkce.codeVerifier,
163+
},
164+
clerk.client,
165+
clerk,
166+
);
167+
getClientUpdater(clerk)?.(updatedClient);
168+
169+
const createdSessionId = normalizeSessionId(
170+
callbackParams.get('created_session_id') || updatedClient.lastActiveSessionId,
171+
);
172+
if (!createdSessionId || !updatedClient.sessions.some(session => session.id === createdSessionId)) {
173+
return errorThrower.throw('Hosted auth completion did not include the created session.');
174174
}
175+
await clerk.setActive({
176+
session: createdSessionId,
177+
});
175178

176179
return {
177180
createdSessionId,
@@ -288,3 +291,11 @@ function callbackUrlMatchesRedirectUrl(callbackUrl: URL, redirectUrl: string): b
288291

289292
return callbackUrl.pathname === expectedUrl.pathname;
290293
}
294+
295+
function isHTTPSRedirectUrl(redirectUrl: string): boolean {
296+
try {
297+
return new URL(redirectUrl).protocol === 'https:';
298+
} catch {
299+
return false;
300+
}
301+
}

packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1067,7 +1067,6 @@ describe('ClerkProvider native client sync', () => {
10671067

10681068
test('ignores native client events that echo a JS-originated sync', async () => {
10691069
mocks.tokenCache.getToken.mockResolvedValue(null);
1070-
mocks.getClientToken.mockResolvedValue(null);
10711070

10721071
const { rerender } = render(
10731072
<ClerkProvider

packages/expo/src/utils/hostedAuth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ function hasObjectType(payload: unknown, object: string): boolean {
171171
}
172172

173173
function applyClientJSON(client: ClientResource, clientJSON: ClientJSON): ClientResource {
174-
// Hosted auth gets the same /client payload as Client.reload(), but the verifier-bound
175-
// exchange is Expo-specific. Apply it to the existing ClerkJS client instance here
174+
// Hosted auth gets the same /client payload as Client.reload(), but its verifier-bound
175+
// exchange uses a request body. Apply it to the existing ClerkJS client instance here
176176
// instead of adding a hosted-auth branch to every resource reload path.
177177
const mutableClient = client as ClientResource & {
178178
fromJSON?: (data: ClientJSON) => ClientResource;

0 commit comments

Comments
 (0)