Skip to content

Commit 85b5acc

Browse files
authored
feat(clerk-expo): Add native Apple Sign-In support for iOS (#7053)
1 parent b09b29e commit 85b5acc

File tree

12 files changed

+570
-23
lines changed

12 files changed

+570
-23
lines changed

.changeset/brave-apples-sign.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-expo': minor
3+
'@clerk/types': patch
4+
---
5+
6+
Add native Apple Sign-In support for iOS via `useAppleSignIn()` hook. Requires `expo-apple-authentication` and native build (EAS Build or local prebuild).

integration/templates/expo-web/metro.config.js

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* DO NOT EDIT THIS FILE UNLESS YOU DEFINITELY KNWO WHAT YOU ARE DOING.
2+
* DO NOT EDIT THIS FILE UNLESS YOU DEFINITELY KNOW WHAT YOU ARE DOING.
33
* THIS ENSURES THAT INTEGRATION TESTS ARE LOADING THE CORRECT DEPENDENCIES.
44
*/
55
const { getDefaultConfig } = require('expo/metro-config');
@@ -30,25 +30,93 @@ const clerkExpoPath = getClerkExpoPath();
3030
const clerkMonorepoPath = clerkExpoPath?.replace(/\/packages\/expo$/, '');
3131

3232
/** @type {import('expo/metro-config').MetroConfig} */
33-
const config = {
34-
...getDefaultConfig(__dirname),
35-
watchFolders: [clerkMonorepoPath],
36-
resolver: {
37-
sourceExts: ['js', 'json', 'ts', 'tsx', 'cjs', 'mjs'],
38-
nodeModulesPaths: [
39-
path.resolve(__dirname, 'node_modules'),
40-
clerkExpoPath && `${clerkMonorepoPath}/node_modules`,
41-
clerkExpoPath && `${clerkExpoPath}/node_modules`,
42-
],
43-
// This is a workaround for a to prevent multiple versions of react and react-native from being loaded.
44-
// https://github.com/expo/expo/pull/26209
45-
blockList: [
46-
clerkExpoPath && new RegExp(`${clerkMonorepoPath}/node_modules/react`),
47-
clerkExpoPath && new RegExp(`${clerkMonorepoPath}/node_modules/react-native`),
48-
],
49-
},
50-
};
33+
const config = getDefaultConfig(__dirname);
5134

52-
module.exports = {
53-
...config,
54-
};
35+
// Only customize Metro config when running from monorepo
36+
if (clerkMonorepoPath) {
37+
console.log('[Metro Config] Applying monorepo customizations');
38+
config.watchFolders = [clerkMonorepoPath];
39+
40+
// Disable file watching to prevent infinite reload loops in integration tests
41+
config.watchFolders = [clerkMonorepoPath];
42+
config.watcher = {
43+
healthCheck: {
44+
enabled: false,
45+
},
46+
};
47+
48+
// Prioritize local node_modules over monorepo node_modules
49+
config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules'), `${clerkMonorepoPath}/node_modules`];
50+
51+
// Explicitly map @clerk packages to their source locations
52+
// Point to the root of the package so Metro can properly resolve subpath exports
53+
config.resolver.extraNodeModules = {
54+
'@clerk/clerk-react': path.resolve(clerkMonorepoPath, 'packages/react'),
55+
'@clerk/clerk-expo': path.resolve(clerkMonorepoPath, 'packages/expo'),
56+
'@clerk/shared': path.resolve(clerkMonorepoPath, 'packages/shared'),
57+
'@clerk/types': path.resolve(clerkMonorepoPath, 'packages/types'),
58+
};
59+
60+
// This is a workaround to prevent multiple versions of react and react-native from being loaded.
61+
// Block React/React-Native in both monorepo root and all package node_modules
62+
// Use word boundaries to avoid blocking clerk-react
63+
// https://github.com/expo/expo/pull/26209
64+
const escapedPath = clerkMonorepoPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
65+
config.resolver.blockList = [
66+
// Block monorepo root node_modules for react/react-native/react-dom
67+
new RegExp(`${escapedPath}/node_modules/react/`),
68+
new RegExp(`${escapedPath}/node_modules/react$`),
69+
new RegExp(`${escapedPath}/node_modules/react-dom/`),
70+
new RegExp(`${escapedPath}/node_modules/react-dom$`),
71+
new RegExp(`${escapedPath}/node_modules/react-native/`),
72+
new RegExp(`${escapedPath}/node_modules/react-native$`),
73+
// Block react in monorepo's pnpm store
74+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react/`),
75+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react$`),
76+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-dom/`),
77+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-dom$`),
78+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-native/`),
79+
new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-native$`),
80+
// Block react/react-native/react-dom in all package node_modules
81+
new RegExp(`${escapedPath}/packages/.*/node_modules/react/`),
82+
new RegExp(`${escapedPath}/packages/.*/node_modules/react$`),
83+
new RegExp(`${escapedPath}/packages/.*/node_modules/react-dom/`),
84+
new RegExp(`${escapedPath}/packages/.*/node_modules/react-dom$`),
85+
new RegExp(`${escapedPath}/packages/.*/node_modules/react-native/`),
86+
new RegExp(`${escapedPath}/packages/.*/node_modules/react-native$`),
87+
];
88+
89+
// Custom resolver to handle package.json subpath exports for @clerk packages
90+
// This enables Metro to resolve imports like '@clerk/clerk-react/internal'
91+
const originalResolveRequest = config.resolver.resolveRequest;
92+
config.resolver.resolveRequest = (context, moduleName, platform) => {
93+
// Check if this is a @clerk package with a subpath
94+
const clerkPackageMatch = moduleName.match(/^(@clerk\/[^/]+)\/(.+)$/);
95+
if (clerkPackageMatch && config.resolver.extraNodeModules) {
96+
const [, packageName, subpath] = clerkPackageMatch;
97+
const packageRoot = config.resolver.extraNodeModules[packageName];
98+
99+
if (packageRoot) {
100+
// Try to resolve via the subpath-workaround directory (e.g., internal/package.json)
101+
const subpathDir = path.join(packageRoot, subpath);
102+
try {
103+
const subpathPkg = require(path.join(subpathDir, 'package.json'));
104+
if (subpathPkg.main) {
105+
const resolvedPath = path.join(subpathDir, subpathPkg.main);
106+
return { type: 'sourceFile', filePath: resolvedPath };
107+
}
108+
} catch (e) {
109+
// Subpath directory doesn't exist, continue with default resolution
110+
}
111+
}
112+
}
113+
114+
// Fall back to default resolution
115+
if (originalResolveRequest) {
116+
return originalResolveRequest(context, moduleName, platform);
117+
}
118+
return context.resolveRequest(context, moduleName, platform);
119+
};
120+
}
121+
122+
module.exports = config;

packages/expo/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,19 @@
9494
"devDependencies": {
9595
"@clerk/expo-passkeys": "workspace:*",
9696
"@types/base-64": "^1.0.2",
97+
"expo-apple-authentication": "^7.2.4",
9798
"expo-auth-session": "^5.4.0",
99+
"expo-crypto": "^15.0.7",
98100
"expo-local-authentication": "^13.8.0",
99101
"expo-secure-store": "^12.8.1",
100102
"expo-web-browser": "^12.8.2",
101103
"react-native": "^0.81.4"
102104
},
103105
"peerDependencies": {
104106
"@clerk/expo-passkeys": ">=0.0.6",
107+
"expo-apple-authentication": ">=7.0.0",
105108
"expo-auth-session": ">=5",
109+
"expo-crypto": ">=12",
106110
"expo-local-authentication": ">=13.5.0",
107111
"expo-secure-store": ">=12.4.0",
108112
"expo-web-browser": ">=12.5.0",
@@ -114,6 +118,12 @@
114118
"@clerk/expo-passkeys": {
115119
"optional": true
116120
},
121+
"expo-apple-authentication": {
122+
"optional": true
123+
},
124+
"expo-crypto": {
125+
"optional": true
126+
},
117127
"expo-local-authentication": {
118128
"optional": true
119129
},
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
3+
4+
import { useSignInWithApple } from '../useSignInWithApple.ios';
5+
6+
const mocks = vi.hoisted(() => {
7+
return {
8+
useSignIn: vi.fn(),
9+
useSignUp: vi.fn(),
10+
signInAsync: vi.fn(),
11+
isAvailableAsync: vi.fn(),
12+
randomUUID: vi.fn(),
13+
};
14+
});
15+
16+
vi.mock('@clerk/clerk-react', () => {
17+
return {
18+
useSignIn: mocks.useSignIn,
19+
useSignUp: mocks.useSignUp,
20+
};
21+
});
22+
23+
vi.mock('expo-apple-authentication', () => {
24+
return {
25+
signInAsync: mocks.signInAsync,
26+
isAvailableAsync: mocks.isAvailableAsync,
27+
AppleAuthenticationScope: {
28+
FULL_NAME: 0,
29+
EMAIL: 1,
30+
},
31+
};
32+
});
33+
34+
vi.mock('expo-crypto', () => {
35+
return {
36+
default: {
37+
randomUUID: mocks.randomUUID,
38+
},
39+
randomUUID: mocks.randomUUID,
40+
};
41+
});
42+
43+
vi.mock('react-native', () => {
44+
return {
45+
Platform: {
46+
OS: 'ios',
47+
},
48+
};
49+
});
50+
51+
describe('useSignInWithApple', () => {
52+
const mockSignIn = {
53+
create: vi.fn(),
54+
createdSessionId: 'test-session-id',
55+
firstFactorVerification: {
56+
status: 'verified',
57+
},
58+
};
59+
60+
const mockSignUp = {
61+
create: vi.fn(),
62+
createdSessionId: null,
63+
};
64+
65+
const mockSetActive = vi.fn();
66+
67+
beforeEach(() => {
68+
vi.clearAllMocks();
69+
70+
mocks.useSignIn.mockReturnValue({
71+
signIn: mockSignIn,
72+
setActive: mockSetActive,
73+
isLoaded: true,
74+
});
75+
76+
mocks.useSignUp.mockReturnValue({
77+
signUp: mockSignUp,
78+
isLoaded: true,
79+
});
80+
81+
mocks.isAvailableAsync.mockResolvedValue(true);
82+
mocks.randomUUID.mockReturnValue('test-nonce-uuid');
83+
});
84+
85+
afterEach(() => {
86+
vi.restoreAllMocks();
87+
});
88+
89+
describe('startAppleAuthenticationFlow', () => {
90+
test('should return the hook with startAppleAuthenticationFlow function', () => {
91+
const { result } = renderHook(() => useSignInWithApple());
92+
93+
expect(result.current).toHaveProperty('startAppleAuthenticationFlow');
94+
expect(typeof result.current.startAppleAuthenticationFlow).toBe('function');
95+
});
96+
97+
test('should successfully sign in existing user', async () => {
98+
const mockIdentityToken = 'mock-identity-token';
99+
mocks.signInAsync.mockResolvedValue({
100+
identityToken: mockIdentityToken,
101+
});
102+
103+
mockSignIn.create.mockResolvedValue(undefined);
104+
mockSignIn.firstFactorVerification.status = 'verified';
105+
mockSignIn.createdSessionId = 'test-session-id';
106+
107+
const { result } = renderHook(() => useSignInWithApple());
108+
109+
const response = await result.current.startAppleAuthenticationFlow();
110+
111+
expect(mocks.isAvailableAsync).toHaveBeenCalled();
112+
expect(mocks.randomUUID).toHaveBeenCalled();
113+
expect(mocks.signInAsync).toHaveBeenCalledWith(
114+
expect.objectContaining({
115+
requestedScopes: expect.any(Array),
116+
nonce: 'test-nonce-uuid',
117+
}),
118+
);
119+
expect(mockSignIn.create).toHaveBeenCalledWith({
120+
strategy: 'oauth_token_apple',
121+
token: mockIdentityToken,
122+
});
123+
expect(response.createdSessionId).toBe('test-session-id');
124+
expect(response.setActive).toBe(mockSetActive);
125+
});
126+
127+
test('should handle transfer flow for new user', async () => {
128+
const mockIdentityToken = 'mock-identity-token';
129+
mocks.signInAsync.mockResolvedValue({
130+
identityToken: mockIdentityToken,
131+
});
132+
133+
mockSignIn.create.mockResolvedValue(undefined);
134+
mockSignIn.firstFactorVerification.status = 'transferable';
135+
136+
const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' };
137+
mocks.useSignUp.mockReturnValue({
138+
signUp: mockSignUpWithSession,
139+
isLoaded: true,
140+
});
141+
142+
const { result } = renderHook(() => useSignInWithApple());
143+
144+
const response = await result.current.startAppleAuthenticationFlow({
145+
unsafeMetadata: { source: 'test' },
146+
});
147+
148+
expect(mockSignIn.create).toHaveBeenCalledWith({
149+
strategy: 'oauth_token_apple',
150+
token: mockIdentityToken,
151+
});
152+
expect(mockSignUp.create).toHaveBeenCalledWith({
153+
transfer: true,
154+
unsafeMetadata: { source: 'test' },
155+
});
156+
expect(response.createdSessionId).toBe('new-user-session-id');
157+
});
158+
159+
test('should handle user cancellation gracefully', async () => {
160+
const cancelError = Object.assign(new Error('User canceled'), { code: 'ERR_REQUEST_CANCELED' });
161+
mocks.signInAsync.mockRejectedValue(cancelError);
162+
163+
const { result } = renderHook(() => useSignInWithApple());
164+
165+
const response = await result.current.startAppleAuthenticationFlow();
166+
167+
expect(response.createdSessionId).toBe(null);
168+
expect(response.setActive).toBe(mockSetActive);
169+
});
170+
171+
test('should throw error when Apple Authentication is not available', async () => {
172+
mocks.isAvailableAsync.mockResolvedValue(false);
173+
174+
const { result } = renderHook(() => useSignInWithApple());
175+
176+
await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow(
177+
'Apple Authentication is not available on this device.',
178+
);
179+
});
180+
181+
test('should throw error when no identity token received', async () => {
182+
mocks.signInAsync.mockResolvedValue({
183+
identityToken: null,
184+
});
185+
186+
const { result } = renderHook(() => useSignInWithApple());
187+
188+
await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow(
189+
'No identity token received from Apple Sign-In.',
190+
);
191+
});
192+
193+
test('should return early when clerk is not loaded', async () => {
194+
mocks.useSignIn.mockReturnValue({
195+
signIn: mockSignIn,
196+
setActive: mockSetActive,
197+
isLoaded: false,
198+
});
199+
200+
const { result } = renderHook(() => useSignInWithApple());
201+
202+
const response = await result.current.startAppleAuthenticationFlow();
203+
204+
expect(mocks.isAvailableAsync).not.toHaveBeenCalled();
205+
expect(mocks.signInAsync).not.toHaveBeenCalled();
206+
expect(response.createdSessionId).toBe(null);
207+
});
208+
});
209+
});

packages/expo/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {
1111
useReverification,
1212
} from '@clerk/clerk-react';
1313

14+
export * from './useSignInWithApple';
1415
export * from './useSSO';
1516
export * from './useOAuth';
1617
export * from './useAuth';

0 commit comments

Comments
 (0)