Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
42 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
76490e7
Merge branch 'main' of https://github.com/clerk/javascript into chris…
chriscanin Oct 31, 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).
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"devDependencies": {
"@clerk/expo-passkeys": "workspace:*",
"@types/base-64": "^1.0.2",
"expo-apple-authentication": "^7.2.4",
"expo-auth-session": "^5.4.0",
"expo-local-authentication": "^13.8.0",
"expo-secure-store": "^12.8.1",
Expand All @@ -102,6 +103,7 @@
},
"peerDependencies": {
"@clerk/expo-passkeys": ">=0.0.6",
"expo-apple-authentication": ">=7.0.0",
"expo-auth-session": ">=5",
"expo-local-authentication": ">=13.5.0",
"expo-secure-store": ">=12.4.0",
Expand All @@ -114,6 +116,9 @@
"@clerk/expo-passkeys": {
"optional": true
},
"expo-apple-authentication": {
"optional": true
},
"expo-local-authentication": {
"optional": true
},
Expand Down
197 changes: 197 additions & 0 deletions packages/expo/src/hooks/__tests__/useAppleSignIn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { useAppleSignIn } from '../useAppleSignIn';

const mocks = vi.hoisted(() => {
return {
useSignIn: vi.fn(),
useSignUp: vi.fn(),
signInAsync: vi.fn(),
isAvailableAsync: 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('react-native', () => {
return {
Platform: {
OS: 'ios',
},
};
});

describe('useAppleSignIn', () => {
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);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('startAppleSignInFlow', () => {
test('should return the hook with startAppleSignInFlow function', () => {
const { result } = renderHook(() => useAppleSignIn());

expect(result.current).toHaveProperty('startAppleSignInFlow');
expect(typeof result.current.startAppleSignInFlow).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(() => useAppleSignIn());

const response = await result.current.startAppleSignInFlow();

expect(mocks.isAvailableAsync).toHaveBeenCalled();
expect(mocks.signInAsync).toHaveBeenCalledWith(
expect.objectContaining({
requestedScopes: expect.any(Array),
nonce: expect.any(String),
}),
);
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(() => useAppleSignIn());

const response = await result.current.startAppleSignInFlow({
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(() => useAppleSignIn());

const response = await result.current.startAppleSignInFlow();

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(() => useAppleSignIn());

await expect(result.current.startAppleSignInFlow()).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(() => useAppleSignIn());

await expect(result.current.startAppleSignInFlow()).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(() => useAppleSignIn());

const response = await result.current.startAppleSignInFlow();

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 './useAppleSignIn';
export * from './useSSO';
export * from './useOAuth';
export * from './useAuth';
158 changes: 158 additions & 0 deletions packages/expo/src/hooks/useAppleSignIn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useSignIn, useSignUp } from '@clerk/clerk-react';
import type { SetActive, SignInResource, SignUpResource, SignUpUnsafeMetadata } from '@clerk/types';
import * as AppleAuthentication from 'expo-apple-authentication';
import { Platform } from 'react-native';

import { errorThrower } from '../utils/errors';

export type StartAppleSignInFlowParams = {
unsafeMetadata?: SignUpUnsafeMetadata;
};

export type StartAppleSignInFlowReturnType = {
createdSessionId: string | null;
setActive?: SetActive;
signIn?: SignInResource;
signUp?: SignUpResource;
};

/**
* Hook for native Apple Sign-In 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 { useAppleSignIn } from '@clerk/clerk-expo';
* import { Button } from 'react-native';
*
* function AppleSignInButton() {
* const { startAppleSignInFlow } = useAppleSignIn();
*
* const onPress = async () => {
* try {
* const { createdSessionId, setActive } = await startAppleSignInFlow();
*
* if (createdSessionId && setActive) {
* await setActive({ session: createdSessionId });
* }
* } catch (err) {
* console.error('Apple Sign-In error:', err);
* }
* };
*
* return <Button title="Sign in with Apple" onPress={onPress} />;
* }
* ```
*
* @requires expo-apple-authentication - Must be installed as a peer dependency
* @platform iOS - This hook only works on iOS. On other platforms, it will throw an error.
*
* @returns An object containing the `startAppleSignInFlow` function
*/
export function useAppleSignIn() {
const { signIn, setActive, isLoaded: isSignInLoaded } = useSignIn();
const { signUp, isLoaded: isSignUpLoaded } = useSignUp();

async function startAppleSignInFlow(
startAppleSignInFlowParams?: StartAppleSignInFlowParams,
): Promise<StartAppleSignInFlowReturnType> {
// Check platform compatibility
if (Platform.OS !== 'ios') {
return errorThrower.throw(
'Apple Sign-In is only available on iOS. Please use the web-based OAuth flow (useSSO with strategy: "oauth_apple") on other platforms.',
);
}

if (!isSignInLoaded || !isSignUpLoaded) {
return {
createdSessionId: null,
signIn,
signUp,
setActive,
};
}

// Check if Apple Authentication is available on the device
const isAvailable = await AppleAuthentication.isAvailableAsync();
if (!isAvailable) {
return errorThrower.throw('Apple Authentication is not available on this device.');
}

try {
// Generate a nonce for the Apple Sign-In request (required by Clerk)
// Note: Using Math.random() is acceptable here as the identity token is validated by Clerk's backend
const nonce = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);

// Request Apple authentication with requested scopes
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce,
});

// Extract the identity token from the credential
const { identityToken } = credential;

if (!identityToken) {
return errorThrower.throw('No identity token received from Apple Sign-In.');
}

// Create a SignIn with the Apple ID token strategy
// Note: Type assertions needed until @clerk/clerk-react propagates the new oauth_token_apple strategy type
await signIn.create({
strategy: 'oauth_token_apple' as any,
token: identityToken,
} as any);

// Check if we need to transfer to SignUp (user doesn't exist yet)
const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable';

if (userNeedsToBeCreated) {
// User doesn't exist - create a new SignUp with transfer
await signUp.create({
transfer: true,
unsafeMetadata: startAppleSignInFlowParams?.unsafeMetadata,
});

return {
createdSessionId: signUp.createdSessionId,
setActive,
signIn,
signUp,
};
}

// User exists - return the SignIn session
return {
createdSessionId: signIn.createdSessionId,
setActive,
signIn,
signUp,
};
} catch (error: any) {
// Handle Apple Authentication errors
if (error?.code === 'ERR_REQUEST_CANCELED') {
// User canceled the sign-in flow
return {
createdSessionId: null,
setActive,
signIn,
signUp,
};
}

// Re-throw other errors
throw error;
}
}

return {
startAppleSignInFlow,
};
}
Loading
Loading