diff --git a/.changeset/brave-apples-sign.md b/.changeset/brave-apples-sign.md new file mode 100644 index 00000000000..19a347e7d9b --- /dev/null +++ b/.changeset/brave-apples-sign.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-expo': minor +'@clerk/types': patch +--- + +Add native Apple Sign-In support for iOS via `useAppleSignIn()` hook. Requires `expo-apple-authentication` and native build (EAS Build or local prebuild). diff --git a/.changeset/khaki-zoos-take.md b/.changeset/khaki-zoos-take.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/khaki-zoos-take.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/integration/templates/expo-web/metro.config.js b/integration/templates/expo-web/metro.config.js index f1f3a8a25aa..1874df5a11a 100644 --- a/integration/templates/expo-web/metro.config.js +++ b/integration/templates/expo-web/metro.config.js @@ -1,5 +1,5 @@ /** - * DO NOT EDIT THIS FILE UNLESS YOU DEFINITELY KNWO WHAT YOU ARE DOING. + * DO NOT EDIT THIS FILE UNLESS YOU DEFINITELY KNOW WHAT YOU ARE DOING. * THIS ENSURES THAT INTEGRATION TESTS ARE LOADING THE CORRECT DEPENDENCIES. */ const { getDefaultConfig } = require('expo/metro-config'); @@ -30,25 +30,93 @@ const clerkExpoPath = getClerkExpoPath(); const clerkMonorepoPath = clerkExpoPath?.replace(/\/packages\/expo$/, ''); /** @type {import('expo/metro-config').MetroConfig} */ -const config = { - ...getDefaultConfig(__dirname), - watchFolders: [clerkMonorepoPath], - resolver: { - sourceExts: ['js', 'json', 'ts', 'tsx', 'cjs', 'mjs'], - nodeModulesPaths: [ - path.resolve(__dirname, 'node_modules'), - clerkExpoPath && `${clerkMonorepoPath}/node_modules`, - clerkExpoPath && `${clerkExpoPath}/node_modules`, - ], - // This is a workaround for a to prevent multiple versions of react and react-native from being loaded. - // https://github.com/expo/expo/pull/26209 - blockList: [ - clerkExpoPath && new RegExp(`${clerkMonorepoPath}/node_modules/react`), - clerkExpoPath && new RegExp(`${clerkMonorepoPath}/node_modules/react-native`), - ], - }, -}; +const config = getDefaultConfig(__dirname); -module.exports = { - ...config, -}; +// Only customize Metro config when running from monorepo +if (clerkMonorepoPath) { + console.log('[Metro Config] Applying monorepo customizations'); + config.watchFolders = [clerkMonorepoPath]; + + // Disable file watching to prevent infinite reload loops in integration tests + config.watchFolders = [clerkMonorepoPath]; + config.watcher = { + healthCheck: { + enabled: false, + }, + }; + + // Prioritize local node_modules over monorepo node_modules + config.resolver.nodeModulesPaths = [path.resolve(__dirname, 'node_modules'), `${clerkMonorepoPath}/node_modules`]; + + // Explicitly map @clerk packages to their source locations + // Point to the root of the package so Metro can properly resolve subpath exports + config.resolver.extraNodeModules = { + '@clerk/clerk-react': path.resolve(clerkMonorepoPath, 'packages/react'), + '@clerk/clerk-expo': path.resolve(clerkMonorepoPath, 'packages/expo'), + '@clerk/shared': path.resolve(clerkMonorepoPath, 'packages/shared'), + '@clerk/types': path.resolve(clerkMonorepoPath, 'packages/types'), + }; + + // This is a workaround to prevent multiple versions of react and react-native from being loaded. + // Block React/React-Native in both monorepo root and all package node_modules + // Use word boundaries to avoid blocking clerk-react + // https://github.com/expo/expo/pull/26209 + const escapedPath = clerkMonorepoPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + config.resolver.blockList = [ + // Block monorepo root node_modules for react/react-native/react-dom + new RegExp(`${escapedPath}/node_modules/react/`), + new RegExp(`${escapedPath}/node_modules/react$`), + new RegExp(`${escapedPath}/node_modules/react-dom/`), + new RegExp(`${escapedPath}/node_modules/react-dom$`), + new RegExp(`${escapedPath}/node_modules/react-native/`), + new RegExp(`${escapedPath}/node_modules/react-native$`), + // Block react in monorepo's pnpm store + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react/`), + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react$`), + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-dom/`), + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-dom$`), + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-native/`), + new RegExp(`${escapedPath}/node_modules/\\.pnpm/.*/node_modules/react-native$`), + // Block react/react-native/react-dom in all package node_modules + new RegExp(`${escapedPath}/packages/.*/node_modules/react/`), + new RegExp(`${escapedPath}/packages/.*/node_modules/react$`), + new RegExp(`${escapedPath}/packages/.*/node_modules/react-dom/`), + new RegExp(`${escapedPath}/packages/.*/node_modules/react-dom$`), + new RegExp(`${escapedPath}/packages/.*/node_modules/react-native/`), + new RegExp(`${escapedPath}/packages/.*/node_modules/react-native$`), + ]; + + // Custom resolver to handle package.json subpath exports for @clerk packages + // This enables Metro to resolve imports like '@clerk/clerk-react/internal' + const originalResolveRequest = config.resolver.resolveRequest; + config.resolver.resolveRequest = (context, moduleName, platform) => { + // Check if this is a @clerk package with a subpath + const clerkPackageMatch = moduleName.match(/^(@clerk\/[^/]+)\/(.+)$/); + if (clerkPackageMatch && config.resolver.extraNodeModules) { + const [, packageName, subpath] = clerkPackageMatch; + const packageRoot = config.resolver.extraNodeModules[packageName]; + + if (packageRoot) { + // Try to resolve via the subpath-workaround directory (e.g., internal/package.json) + const subpathDir = path.join(packageRoot, subpath); + try { + const subpathPkg = require(path.join(subpathDir, 'package.json')); + if (subpathPkg.main) { + const resolvedPath = path.join(subpathDir, subpathPkg.main); + return { type: 'sourceFile', filePath: resolvedPath }; + } + } catch (e) { + // Subpath directory doesn't exist, continue with default resolution + } + } + } + + // Fall back to default resolution + if (originalResolveRequest) { + return originalResolveRequest(context, moduleName, platform); + } + return context.resolveRequest(context, moduleName, platform); + }; +} + +module.exports = config; diff --git a/packages/expo/package.json b/packages/expo/package.json index 4897620e98f..2a0f544d156 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -94,7 +94,9 @@ "devDependencies": { "@clerk/expo-passkeys": "workspace:*", "@types/base-64": "^1.0.2", + "expo-apple-authentication": "^7.2.4", "expo-auth-session": "^5.4.0", + "expo-crypto": "^15.0.7", "expo-local-authentication": "^13.8.0", "expo-secure-store": "^12.8.1", "expo-web-browser": "^12.8.2", @@ -102,7 +104,9 @@ }, "peerDependencies": { "@clerk/expo-passkeys": ">=0.0.6", + "expo-apple-authentication": ">=7.0.0", "expo-auth-session": ">=5", + "expo-crypto": ">=12", "expo-local-authentication": ">=13.5.0", "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", @@ -114,6 +118,12 @@ "@clerk/expo-passkeys": { "optional": true }, + "expo-apple-authentication": { + "optional": true + }, + "expo-crypto": { + "optional": true + }, "expo-local-authentication": { "optional": true }, diff --git a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts new file mode 100644 index 00000000000..8813defb1ce --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useSignInWithApple } from '../useSignInWithApple.ios'; + +const mocks = vi.hoisted(() => { + return { + useSignIn: vi.fn(), + useSignUp: vi.fn(), + signInAsync: vi.fn(), + isAvailableAsync: vi.fn(), + randomUUID: vi.fn(), + }; +}); + +vi.mock('@clerk/clerk-react', () => { + return { + useSignIn: mocks.useSignIn, + useSignUp: mocks.useSignUp, + }; +}); + +vi.mock('expo-apple-authentication', () => { + return { + signInAsync: mocks.signInAsync, + isAvailableAsync: mocks.isAvailableAsync, + AppleAuthenticationScope: { + FULL_NAME: 0, + EMAIL: 1, + }, + }; +}); + +vi.mock('expo-crypto', () => { + return { + default: { + randomUUID: mocks.randomUUID, + }, + randomUUID: mocks.randomUUID, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +describe('useSignInWithApple', () => { + const mockSignIn = { + create: vi.fn(), + createdSessionId: 'test-session-id', + firstFactorVerification: { + status: 'verified', + }, + }; + + const mockSignUp = { + create: vi.fn(), + createdSessionId: null, + }; + + const mockSetActive = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + mocks.useSignIn.mockReturnValue({ + signIn: mockSignIn, + setActive: mockSetActive, + isLoaded: true, + }); + + mocks.useSignUp.mockReturnValue({ + signUp: mockSignUp, + isLoaded: true, + }); + + mocks.isAvailableAsync.mockResolvedValue(true); + mocks.randomUUID.mockReturnValue('test-nonce-uuid'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('startAppleAuthenticationFlow', () => { + test('should return the hook with startAppleAuthenticationFlow function', () => { + const { result } = renderHook(() => useSignInWithApple()); + + expect(result.current).toHaveProperty('startAppleAuthenticationFlow'); + expect(typeof result.current.startAppleAuthenticationFlow).toBe('function'); + }); + + test('should successfully sign in existing user', async () => { + const mockIdentityToken = 'mock-identity-token'; + mocks.signInAsync.mockResolvedValue({ + identityToken: mockIdentityToken, + }); + + mockSignIn.create.mockResolvedValue(undefined); + mockSignIn.firstFactorVerification.status = 'verified'; + mockSignIn.createdSessionId = 'test-session-id'; + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow(); + + expect(mocks.isAvailableAsync).toHaveBeenCalled(); + expect(mocks.randomUUID).toHaveBeenCalled(); + expect(mocks.signInAsync).toHaveBeenCalledWith( + expect.objectContaining({ + requestedScopes: expect.any(Array), + nonce: 'test-nonce-uuid', + }), + ); + expect(mockSignIn.create).toHaveBeenCalledWith({ + strategy: 'oauth_token_apple', + token: mockIdentityToken, + }); + expect(response.createdSessionId).toBe('test-session-id'); + expect(response.setActive).toBe(mockSetActive); + }); + + test('should handle transfer flow for new user', async () => { + const mockIdentityToken = 'mock-identity-token'; + mocks.signInAsync.mockResolvedValue({ + identityToken: mockIdentityToken, + }); + + mockSignIn.create.mockResolvedValue(undefined); + mockSignIn.firstFactorVerification.status = 'transferable'; + + const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' }; + mocks.useSignUp.mockReturnValue({ + signUp: mockSignUpWithSession, + isLoaded: true, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow({ + unsafeMetadata: { source: 'test' }, + }); + + expect(mockSignIn.create).toHaveBeenCalledWith({ + strategy: 'oauth_token_apple', + token: mockIdentityToken, + }); + expect(mockSignUp.create).toHaveBeenCalledWith({ + transfer: true, + unsafeMetadata: { source: 'test' }, + }); + expect(response.createdSessionId).toBe('new-user-session-id'); + }); + + test('should handle user cancellation gracefully', async () => { + const cancelError = Object.assign(new Error('User canceled'), { code: 'ERR_REQUEST_CANCELED' }); + mocks.signInAsync.mockRejectedValue(cancelError); + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow(); + + expect(response.createdSessionId).toBe(null); + expect(response.setActive).toBe(mockSetActive); + }); + + test('should throw error when Apple Authentication is not available', async () => { + mocks.isAvailableAsync.mockResolvedValue(false); + + const { result } = renderHook(() => useSignInWithApple()); + + await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow( + 'Apple Authentication is not available on this device.', + ); + }); + + test('should throw error when no identity token received', async () => { + mocks.signInAsync.mockResolvedValue({ + identityToken: null, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow( + 'No identity token received from Apple Sign-In.', + ); + }); + + test('should return early when clerk is not loaded', async () => { + mocks.useSignIn.mockReturnValue({ + signIn: mockSignIn, + setActive: mockSetActive, + isLoaded: false, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow(); + + expect(mocks.isAvailableAsync).not.toHaveBeenCalled(); + expect(mocks.signInAsync).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe(null); + }); + }); +}); diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 40ceb2d56f7..2cdac716738 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -11,6 +11,7 @@ export { useReverification, } from '@clerk/clerk-react'; +export * from './useSignInWithApple'; export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts new file mode 100644 index 00000000000..36be6034000 --- /dev/null +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -0,0 +1,149 @@ +import { useSignIn, useSignUp } from '@clerk/clerk-react'; +import type { SetActive, SignInResource, SignUpResource } from '@clerk/types'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import * as Crypto from 'expo-crypto'; + +import { errorThrower } from '../utils/errors'; + +export type StartAppleAuthenticationFlowParams = { + unsafeMetadata?: SignUpUnsafeMetadata; +}; + +export type StartAppleAuthenticationFlowReturnType = { + createdSessionId: string | null; + setActive?: SetActive; + signIn?: SignInResource; + signUp?: SignUpResource; +}; + +/** + * Hook for native Apple Authentication on iOS using expo-apple-authentication. + * + * This hook provides a simplified way to authenticate users with their Apple ID + * using the native iOS Sign in with Apple UI. The authentication flow automatically + * handles the ID token exchange with Clerk's backend and manages the transfer flow + * between sign-in and sign-up. + * + * @example + * ```tsx + * import { useSignInWithApple } from '@clerk/clerk-expo'; + * import { Button } from 'react-native'; + * + * function AppleSignInButton() { + * const { startAppleAuthenticationFlow } = useSignInWithApple(); + * + * const onPress = async () => { + * try { + * const { createdSessionId, setActive } = await startAppleAuthenticationFlow(); + * + * if (createdSessionId && setActive) { + * await setActive({ session: createdSessionId }); + * } + * } catch (err) { + * console.error('Apple Authentication error:', err); + * } + * }; + * + * return