Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
05a7607
feat: add Apple Sign-In support with useAppleSignIn hook
chriscanin Oct 16, 2025
f78267e
feat: add native Apple Sign-In support with useAppleSignIn hook and c…
chriscanin Oct 21, 2025
4eb2f18
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 22, 2025
b791faf
fix(pnpm-lock): clean up dependency versions and remove unused turbo-…
chriscanin Oct 22, 2025
f643dfe
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 22, 2025
a3d83bd
fix(useAppleSignIn): add missing type import and improve nonce genera…
chriscanin Oct 22, 2025
03235ee
feat: add expo-crypto dependency and integrate randomUUID for nonce g…
chriscanin Oct 22, 2025
fdb49e3
fix(useAppleSignIn): refine error handling and remove unused type import
chriscanin Oct 22, 2025
f314e21
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 22, 2025
669581a
fix(pnpm-lock): clean up dependency version formatting for consistenc…
chriscanin Oct 22, 2025
3fa5f8f
feat(package): add expo-crypto as an optional dependency
chriscanin Oct 22, 2025
f3a7d02
fix(useAppleSignIn): lazy load expo-crypto to prevent import issues o…
chriscanin Oct 22, 2025
7e8cdec
fix(tests): update expo-crypto mock to include default export for ran…
chriscanin Oct 22, 2025
b6d1e20
fix(metro.config): enhance handling of clerkExpoPath and improve bloc…
chriscanin Oct 23, 2025
b78ff24
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 23, 2025
3575bfa
fix(metro.config): enhance blockList for React/React-Native and add c…
chriscanin Oct 24, 2025
3157caf
fix(metro.config): disable file watching to prevent infinite reload l…
chriscanin Oct 24, 2025
811564c
feat(longRunningApplication): add detailed logging during initializat…
chriscanin Oct 24, 2025
129064a
temp-log-fix(application): logging for detached dev server and handl…
chriscanin Oct 24, 2025
d7de3b9
fix(application): improve logging for detached dev server startup and…
chriscanin Oct 24, 2025
fc35816
Revert "fix(application): improve logging for detached dev server sta…
chriscanin Oct 24, 2025
76d1014
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 24, 2025
ff0a778
fix(application): improve server startup error handling and logging
chriscanin Oct 24, 2025
b4fdde7
fix(application): enhance logging for detached dev server startup
chriscanin Oct 24, 2025
62f9e55
fix(application): enhance early and late logging for dev server output
chriscanin Oct 24, 2025
ff8d018
fix(hooks): lazy load expo-apple-authentication to prevent import iss…
chriscanin Oct 24, 2025
1ea2d40
fix(hooks): implement web stub for Apple Sign-In with error handling
chriscanin Oct 24, 2025
11b71b3
fix(application): streamline server startup logging and error handling
chriscanin Oct 24, 2025
ab2774e
fix(hooks): Resolve CodeRabbit: update useAppleSignIn return type to …
chriscanin Oct 24, 2025
676621b
fix(metro.config): correct typo in warning message and enhance debug …
chriscanin Oct 24, 2025
1ba03c4
fix(application): enhance server wait logic with exit condition and m…
chriscanin Oct 24, 2025
d1a4d70
fix(metro.config): refactor clerk path handling and remove debug logging
chriscanin Oct 24, 2025
bed16e5
fix(metro.config): CodeRabit fix: reorder variable declarations for i…
chriscanin Oct 24, 2025
ea5e36a
fix(package.json): revert version to 2.17.0 for consistency
chriscanin Oct 27, 2025
6e7bb2c
Merge branch 'main' of https://github.com/clerk/javascript into chris…
chriscanin Oct 27, 2025
d20e523
pnpm lock file resolution.
chriscanin Oct 27, 2025
ecd27ad
refactor: rename useAppleAuthentication to useSignInWithApple for con…
chriscanin Oct 27, 2025
b4c317b
feat: rename useSignInWithApple
chriscanin Oct 27, 2025
6839fde
Resolve coderabbit comment about unneccesary any
chriscanin Oct 27, 2025
884ba62
refactor: update import and function names in useSignInWithApple test…
chriscanin Oct 27, 2025
8a30335
Merge branch 'main' into chris/mobile-279-expo-sign-in-with-apple
chriscanin Oct 28, 2025
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
6 changes: 6 additions & 0 deletions .changeset/brave-apples-sign.md
Original file line number Diff line number Diff line change
@@ -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).
116 changes: 94 additions & 22 deletions integration/templates/expo-web/metro.config.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -19,32 +19,104 @@ const getClerkExpoPath = () => {
return clerkExpoPath.replace('file:', '');
}

if (clerkExpoPath?.startsWith('link:')) {
return clerkExpoPath.replace('link:', '');
}

return undefined;
};

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;
12 changes: 11 additions & 1 deletion packages/expo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clerk/clerk-expo",
"version": "2.17.1",
"version": "2.17.0",
"description": "Clerk React Native/Expo library",
"keywords": [
"react",
Expand Down Expand Up @@ -94,15 +94,19 @@
"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",
"react-native": "^0.81.4"
},
"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",
Expand All @@ -114,6 +118,12 @@
"@clerk/expo-passkeys": {
"optional": true
},
"expo-apple-authentication": {
"optional": true
},
"expo-crypto": {
"optional": true
},
"expo-local-authentication": {
"optional": true
},
Expand Down
209 changes: 209 additions & 0 deletions packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
1 change: 1 addition & 0 deletions packages/expo/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
useReverification,
} from '@clerk/clerk-react';

export * from './useSignInWithApple';
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
Loading