From c17526809f317d84d352aae5a406af7400d4c9ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:56:48 +0000 Subject: [PATCH 1/6] fix(expo): pass through user name on native Apple/Google sign-in useSignInWithApple and useSignInWithGoogle called signIn.create() first and only fell back to signUp.create({ transfer: true }) for new users, so the name Apple/Google hand back on the credential was never read and never recorded. Restructure both hooks to sign-up-first (passing firstName/lastName from the native credential), falling back to signIn.create({ transfer: true }) only when the account already exists, matching clerk-ios's signUpWithIdToken + handleTransferFlow pattern. Public hook signatures and return types are unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0158VB1fS18rJ8AGoqBR3ZrB --- .changeset/fix-expo-native-oauth-name.md | 5 + .../__tests__/useSignInWithApple.test.ts | 96 ++++++++++---- .../__tests__/useSignInWithGoogle.test.ts | 124 ++++++++++++------ .../expo/src/hooks/useSignInWithApple.ios.ts | 30 +++-- .../src/hooks/useSignInWithGoogle.shared.ts | 44 ++++--- 5 files changed, 200 insertions(+), 99 deletions(-) create mode 100644 .changeset/fix-expo-native-oauth-name.md diff --git a/.changeset/fix-expo-native-oauth-name.md b/.changeset/fix-expo-native-oauth-name.md new file mode 100644 index 00000000000..dcefeedcbbe --- /dev/null +++ b/.changeset/fix-expo-native-oauth-name.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix `useSignInWithApple` and `useSignInWithGoogle` dropping the user's first and last name on native sign-up. The hooks now default to sign-up-first (passing the name from the Apple/Google credential), falling back to sign-in-with-transfer only when the account already exists, matching clerk-ios's behavior. diff --git a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts index c918d2ee07d..e9679a1ad85 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts @@ -52,14 +52,16 @@ describe('useSignInWithApple', () => { const mockSignIn = { create: vi.fn(), createdSessionId: 'test-session-id', - firstFactorVerification: { - status: 'verified', - }, }; const mockSignUp = { create: vi.fn(), - createdSessionId: null, + createdSessionId: 'new-user-session-id', + verifications: { + externalAccount: { + status: 'unverified', + }, + }, }; const mockSetActive = vi.fn(); @@ -94,19 +96,25 @@ describe('useSignInWithApple', () => { expect(typeof result.current.startAppleAuthenticationFlow).toBe('function'); }); - test('should successfully sign in existing user', async () => { + test('should sign up a new user and pass through their name', async () => { const mockIdentityToken = 'mock-identity-token'; mocks.signInAsync.mockResolvedValue({ identityToken: mockIdentityToken, + fullName: { + givenName: 'Jane', + familyName: 'Appleseed', + }, }); - mockSignIn.create.mockResolvedValue(undefined); - mockSignIn.firstFactorVerification.status = 'verified'; - mockSignIn.createdSessionId = 'test-session-id'; + mockSignUp.create.mockResolvedValue(undefined); + mockSignUp.verifications.externalAccount.status = 'unverified'; + mockSignUp.createdSessionId = 'new-user-session-id'; const { result } = renderHook(() => useSignInWithApple()); - const response = await result.current.startAppleAuthenticationFlow(); + const response = await result.current.startAppleAuthenticationFlow({ + unsafeMetadata: { source: 'test' }, + }); expect(mocks.isAvailableAsync).toHaveBeenCalled(); expect(mocks.randomUUID).toHaveBeenCalled(); @@ -116,44 +124,84 @@ describe('useSignInWithApple', () => { nonce: 'test-nonce-uuid', }), ); - expect(mockSignIn.create).toHaveBeenCalledWith({ + expect(mockSignUp.create).toHaveBeenCalledWith({ strategy: 'oauth_token_apple', token: mockIdentityToken, + firstName: 'Jane', + lastName: 'Appleseed', + unsafeMetadata: { source: 'test' }, }); - expect(response.createdSessionId).toBe('test-session-id'); + expect(mockSignIn.create).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe('new-user-session-id'); expect(response.setActive).toBe(mockSetActive); }); - test('should handle transfer flow for new user', async () => { + test('should transfer to sign-in when the account already exists', async () => { const mockIdentityToken = 'mock-identity-token'; mocks.signInAsync.mockResolvedValue({ identityToken: mockIdentityToken, + fullName: { + givenName: 'Jane', + familyName: 'Appleseed', + }, }); - mockSignIn.create.mockResolvedValue(undefined); - mockSignIn.firstFactorVerification.status = 'transferable'; + mockSignUp.create.mockResolvedValue(undefined); + mockSignUp.verifications.externalAccount.status = 'transferable'; - const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' }; - mocks.useSignUp.mockReturnValue({ - signUp: mockSignUpWithSession, + const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; + mocks.useSignIn.mockReturnValue({ + signIn: mockSignInWithSession, + setActive: mockSetActive, isLoaded: true, }); const { result } = renderHook(() => useSignInWithApple()); - const response = await result.current.startAppleAuthenticationFlow({ - unsafeMetadata: { source: 'test' }, - }); + const response = await result.current.startAppleAuthenticationFlow(); - expect(mockSignIn.create).toHaveBeenCalledWith({ + expect(mockSignUp.create).toHaveBeenCalledWith({ strategy: 'oauth_token_apple', token: mockIdentityToken, + firstName: 'Jane', + lastName: 'Appleseed', + unsafeMetadata: undefined, }); - expect(mockSignUp.create).toHaveBeenCalledWith({ + expect(mockSignInWithSession.create).toHaveBeenCalledWith({ transfer: true, - unsafeMetadata: { source: 'test' }, }); - expect(response.createdSessionId).toBe('new-user-session-id'); + expect(response.createdSessionId).toBe('existing-user-session-id'); + }); + + test('should handle a credential without a name (returning user)', async () => { + const mockIdentityToken = 'mock-identity-token'; + mocks.signInAsync.mockResolvedValue({ + identityToken: mockIdentityToken, + fullName: null, + }); + + mockSignUp.create.mockResolvedValue(undefined); + mockSignUp.verifications.externalAccount.status = 'transferable'; + + const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; + mocks.useSignIn.mockReturnValue({ + signIn: mockSignInWithSession, + setActive: mockSetActive, + isLoaded: true, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow(); + + expect(mockSignUp.create).toHaveBeenCalledWith({ + strategy: 'oauth_token_apple', + token: mockIdentityToken, + firstName: undefined, + lastName: undefined, + unsafeMetadata: undefined, + }); + expect(response.createdSessionId).toBe('existing-user-session-id'); }); test('should handle user cancellation gracefully', async () => { diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index 36c38445505..a24d7164141 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -94,14 +94,16 @@ describe('useSignInWithGoogle', () => { const mockSignIn = { create: vi.fn(), createdSessionId: 'test-session-id', - firstFactorVerification: { - status: 'verified', - }, }; const mockSignUp = { create: vi.fn(), - createdSessionId: null, + createdSessionId: 'new-user-session-id', + verifications: { + externalAccount: { + status: 'unverified', + }, + }, }; const mockSetActive = vi.fn(); @@ -134,21 +136,33 @@ describe('useSignInWithGoogle', () => { expect(typeof result.current.startGoogleAuthenticationFlow).toBe('function'); }); - test('should successfully sign in existing user', async () => { + test('should sign up a new user and pass through their name', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { idToken: mockIdToken }, + data: { + idToken: mockIdToken, + user: { + id: 'google-user-id', + email: 'jane@example.com', + name: 'Jane Doe', + givenName: 'Jane', + familyName: 'Doe', + photo: null, + }, + }, }); mocks.isSuccessResponse.mockReturnValue(true); - mockSignIn.create.mockResolvedValue(undefined); - mockSignIn.firstFactorVerification.status = 'verified'; - mockSignIn.createdSessionId = 'test-session-id'; + mockSignUp.create.mockResolvedValue(undefined); + mockSignUp.verifications.externalAccount.status = 'unverified'; + mockSignUp.createdSessionId = 'new-user-session-id'; const { result } = renderHook(() => useSignInWithGoogle()); - const response = await result.current.startGoogleAuthenticationFlow(); + const response = await result.current.startGoogleAuthenticationFlow({ + unsafeMetadata: { source: 'test' }, + }); expect(mocks.ClerkGoogleOneTapSignIn.configure).toHaveBeenCalledWith({ webClientId: 'mock-web-client-id.apps.googleusercontent.com', @@ -156,32 +170,46 @@ describe('useSignInWithGoogle', () => { expect(mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn).toHaveBeenCalledWith({ nonce: 'mock-uuid-nonce', }); - expect(mockSignIn.create).toHaveBeenCalledWith({ + expect(mockSignUp.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, + firstName: 'Jane', + lastName: 'Doe', + unsafeMetadata: { source: 'test' }, }); - expect(response.createdSessionId).toBe('test-session-id'); + expect(mockSignIn.create).not.toHaveBeenCalled(); + expect(response.createdSessionId).toBe('new-user-session-id'); expect(response.setActive).toBe(mockSetActive); }); - test('should handle transfer flow for new user', async () => { + test('should transfer to sign-in when the account already exists', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { idToken: mockIdToken }, + data: { + idToken: mockIdToken, + user: { + id: 'google-user-id', + email: 'jane@example.com', + name: 'Jane Doe', + givenName: 'Jane', + familyName: 'Doe', + photo: null, + }, + }, }); mocks.isSuccessResponse.mockReturnValue(true); - mockSignIn.create.mockResolvedValue(undefined); - mockSignIn.firstFactorVerification.status = 'transferable'; + mockSignUp.create.mockResolvedValue(undefined); + mockSignUp.verifications.externalAccount.status = 'transferable'; - const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' }; + const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; mocks.useClerk.mockReturnValue({ loaded: true, setActive: mockSetActive, client: { - signIn: mockSignIn, - signUp: mockSignUpWithSession, + signIn: mockSignInWithSession, + signUp: mockSignUp, }, }); @@ -191,15 +219,17 @@ describe('useSignInWithGoogle', () => { unsafeMetadata: { source: 'test' }, }); - expect(mockSignIn.create).toHaveBeenCalledWith({ + expect(mockSignUp.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, + firstName: 'Jane', + lastName: 'Doe', + unsafeMetadata: { source: 'test' }, }); - expect(mockSignUpWithSession.create).toHaveBeenCalledWith({ + expect(mockSignInWithSession.create).toHaveBeenCalledWith({ transfer: true, - unsafeMetadata: { source: 'test' }, }); - expect(response.createdSessionId).toBe('new-user-session-id'); + expect(response.createdSessionId).toBe('existing-user-session-id'); }); test('should handle user cancellation gracefully', async () => { @@ -245,36 +275,46 @@ describe('useSignInWithGoogle', () => { expect(response.createdSessionId).toBe(null); }); - test('should fall back to signUp when external_account_not_found error occurs', async () => { + test('should fall back to sign-in when external_account_exists error occurs', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { idToken: mockIdToken }, + data: { + idToken: mockIdToken, + user: { + id: 'google-user-id', + email: 'jane@example.com', + name: 'Jane Doe', + givenName: 'Jane', + familyName: 'Doe', + photo: null, + }, + }, }); mocks.isSuccessResponse.mockReturnValue(true); - // Mock signIn.create to throw external_account_not_found Clerk error + // Mock signUp.create to throw external_account_exists Clerk error const externalAccountError = { - errors: [{ code: 'external_account_not_found' }], - message: 'External account not found', + errors: [{ code: 'external_account_exists' }], + message: 'External account exists', }; - mockSignIn.create.mockRejectedValue(externalAccountError); + mockSignUp.create.mockRejectedValue(externalAccountError); // Mock isClerkAPIResponseError to return true for this error mocks.isClerkAPIResponseError.mockReturnValue(true); - // Mock signUp.create to succeed with a new session - const mockSignUpWithSession = { - ...mockSignUp, + // Mock signIn.create to succeed with a new session + const mockSignInWithSession = { + ...mockSignIn, create: vi.fn().mockResolvedValue(undefined), - createdSessionId: 'new-signup-session-id', + createdSessionId: 'existing-user-session-id', }; mocks.useClerk.mockReturnValue({ loaded: true, setActive: mockSetActive, client: { - signIn: mockSignIn, - signUp: mockSignUpWithSession, + signIn: mockSignInWithSession, + signUp: mockSignUp, }, }); @@ -284,21 +324,23 @@ describe('useSignInWithGoogle', () => { unsafeMetadata: { referral: 'google' }, }); - // Verify signIn.create was called first - expect(mockSignIn.create).toHaveBeenCalledWith({ + // Verify signUp.create was called first + expect(mockSignUp.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, + firstName: 'Jane', + lastName: 'Doe', + unsafeMetadata: { referral: 'google' }, }); - // Verify signUp.create was called as fallback with the token - expect(mockSignUpWithSession.create).toHaveBeenCalledWith({ + // Verify signIn.create was called as fallback with the token + expect(mockSignInWithSession.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, - unsafeMetadata: { referral: 'google' }, }); // Verify the session was created - expect(response.createdSessionId).toBe('new-signup-session-id'); + expect(response.createdSessionId).toBe('existing-user-session-id'); expect(response.setActive).toBe(mockSetActive); }); }); diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts index 76ae982d687..ab772708d98 100644 --- a/packages/expo/src/hooks/useSignInWithApple.ios.ts +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -99,40 +99,44 @@ export function useSignInWithApple() { nonce, }); - // Extract the identity token from the credential - const { identityToken } = credential; + // Apple only returns the name on the first authorization, so it must be + // captured now and passed straight into sign-up or it's lost for good. + const { identityToken, fullName } = credential; if (!identityToken) { return errorThrower.throw('No identity token received from Apple Sign-In.'); } - // Create a SignIn with the Apple ID token strategy - await signIn.create({ + // Sign-up first so a new user's name is recorded; an existing account + // resolves as transferable below instead of throwing. + await signUp.create({ strategy: 'oauth_token_apple', token: identityToken, + firstName: fullName?.givenName ?? undefined, + lastName: fullName?.familyName ?? undefined, + unsafeMetadata: startAppleAuthenticationFlowParams?.unsafeMetadata, }); - // Check if we need to transfer to SignUp (user doesn't exist yet) - const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; + // Check if the account already exists (needs to transfer to SignIn) + const accountAlreadyExists = signUp.verifications.externalAccount.status === 'transferable'; - if (userNeedsToBeCreated) { - // User doesn't exist - create a new SignUp with transfer - await signUp.create({ + if (accountAlreadyExists) { + // Account exists - transfer to SignIn to complete authentication + await signIn.create({ transfer: true, - unsafeMetadata: startAppleAuthenticationFlowParams?.unsafeMetadata, }); return { - createdSessionId: signUp.createdSessionId, + createdSessionId: signIn.createdSessionId, setActive, signIn, signUp, }; } - // User exists - return the SignIn session + // New user - the SignUp completed with the name attached return { - createdSessionId: signIn.createdSessionId, + createdSessionId: signUp.createdSessionId, setActive, signIn, signUp, diff --git a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts index c2c7dabcb15..d93338ffbe5 100644 --- a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts +++ b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts @@ -140,55 +140,57 @@ export async function executeGoogleAuthenticationFlow( }; } - const { idToken } = response.data; + const { idToken, user } = response.data; try { - // Try to sign in with the Google One Tap strategy - await signIn.create({ + // Sign up first so a new user's name is recorded; an existing account + // resolves as transferable below instead of throwing. + await signUp.create({ strategy: 'google_one_tap', token: idToken, + firstName: user?.givenName ?? undefined, + lastName: user?.familyName ?? undefined, + unsafeMetadata: params?.unsafeMetadata, }); - // Check if we need to transfer to SignUp (user doesn't exist yet) - const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; + // Check if the account already exists (needs to transfer to SignIn) + const accountAlreadyExists = signUp.verifications.externalAccount.status === 'transferable'; - if (userNeedsToBeCreated) { - // User doesn't exist - create a new SignUp with transfer - await signUp.create({ + if (accountAlreadyExists) { + // Account exists - transfer to SignIn to complete authentication + await signIn.create({ transfer: true, - unsafeMetadata: params?.unsafeMetadata, }); return { - createdSessionId: signUp.createdSessionId, + createdSessionId: signIn.createdSessionId, setActive, signIn, signUp, }; } - // User exists - return the SignIn session + // New user - the SignUp completed with the name attached return { - createdSessionId: signIn.createdSessionId, + createdSessionId: signUp.createdSessionId, setActive, signIn, signUp, }; - } catch (signInError: unknown) { - // Handle the case where the user doesn't exist (external_account_not_found) + } catch (signUpError: unknown) { + // Handle the case where the account already exists (external_account_exists) if ( - isClerkAPIResponseError(signInError) && - signInError.errors?.some(err => err.code === 'external_account_not_found') + isClerkAPIResponseError(signUpError) && + signUpError.errors?.some(err => err.code === 'external_account_exists') ) { - // User doesn't exist - create a new SignUp with the token - await signUp.create({ + // Account exists - sign in with the token directly + await signIn.create({ strategy: 'google_one_tap', token: idToken, - unsafeMetadata: params?.unsafeMetadata, }); return { - createdSessionId: signUp.createdSessionId, + createdSessionId: signIn.createdSessionId, setActive, signIn, signUp, @@ -196,7 +198,7 @@ export async function executeGoogleAuthenticationFlow( } // Re-throw if it's a different error - throw signInError; + throw signUpError; } } catch (error: unknown) { // Handle Google Sign-In cancellation errors From cb2b052b26537a4f635845bb607a338885f4588d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:39:27 +0000 Subject: [PATCH 2/6] chore(changeset): simplify changelog wording and condense rationale comments Address Codex review feedback on PR #9214: keep the changeset focused on user-facing behavior only, and condense the multi-line rationale comments in useSignInWithApple.ios.ts and useSignInWithGoogle.shared.ts to single terse lines per the repo's comment convention. --- .changeset/fix-expo-native-oauth-name.md | 2 +- packages/expo/src/hooks/useSignInWithApple.ios.ts | 6 ++---- packages/expo/src/hooks/useSignInWithGoogle.shared.ts | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.changeset/fix-expo-native-oauth-name.md b/.changeset/fix-expo-native-oauth-name.md index dcefeedcbbe..d95cd9e14f4 100644 --- a/.changeset/fix-expo-native-oauth-name.md +++ b/.changeset/fix-expo-native-oauth-name.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Fix `useSignInWithApple` and `useSignInWithGoogle` dropping the user's first and last name on native sign-up. The hooks now default to sign-up-first (passing the name from the Apple/Google credential), falling back to sign-in-with-transfer only when the account already exists, matching clerk-ios's behavior. +Fixed `useSignInWithApple` and `useSignInWithGoogle` dropping the user's first and last name when signing up with native Apple/Google authentication. diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts index ab772708d98..43f8a01d1ac 100644 --- a/packages/expo/src/hooks/useSignInWithApple.ios.ts +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -99,16 +99,14 @@ export function useSignInWithApple() { nonce, }); - // Apple only returns the name on the first authorization, so it must be - // captured now and passed straight into sign-up or it's lost for good. + // Apple only returns the name on first authorization, so capture it now before it's lost. const { identityToken, fullName } = credential; if (!identityToken) { return errorThrower.throw('No identity token received from Apple Sign-In.'); } - // Sign-up first so a new user's name is recorded; an existing account - // resolves as transferable below instead of throwing. + // Sign up first so the name is recorded; existing accounts fall through as transferable. await signUp.create({ strategy: 'oauth_token_apple', token: identityToken, diff --git a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts index d93338ffbe5..f6f7745be8f 100644 --- a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts +++ b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts @@ -143,8 +143,7 @@ export async function executeGoogleAuthenticationFlow( const { idToken, user } = response.data; try { - // Sign up first so a new user's name is recorded; an existing account - // resolves as transferable below instead of throwing. + // Sign up first so the name is recorded; existing accounts fall through as transferable. await signUp.create({ strategy: 'google_one_tap', token: idToken, From 1358f0f417e919a171d0770f61cae3575e22c4ff Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 22 Jul 2026 09:28:03 -0700 Subject: [PATCH 3/6] fix(expo): scope name passthrough to Apple and handle restricted sign-up mode --- .changeset/fix-expo-native-oauth-name.md | 2 +- .../__tests__/useSignInWithApple.test.ts | 53 ++++++++ .../__tests__/useSignInWithGoogle.test.ts | 124 ++++++------------ .../expo/src/hooks/useSignInWithApple.ios.ts | 43 ++++-- .../src/hooks/useSignInWithGoogle.shared.ts | 43 +++--- 5 files changed, 151 insertions(+), 114 deletions(-) diff --git a/.changeset/fix-expo-native-oauth-name.md b/.changeset/fix-expo-native-oauth-name.md index d95cd9e14f4..b675189f0c2 100644 --- a/.changeset/fix-expo-native-oauth-name.md +++ b/.changeset/fix-expo-native-oauth-name.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Fixed `useSignInWithApple` and `useSignInWithGoogle` dropping the user's first and last name when signing up with native Apple/Google authentication. +Fix `useSignInWithApple` dropping the user's first and last name on native sign-up. Apple only provides the name in the native credential on first authorization (never in the identity token), so the hook now signs up first with the name attached and falls back to sign-in when the account already exists, including on instances with restricted or waitlist sign-up mode. diff --git a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts index e9679a1ad85..1ebe3d1312a 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts @@ -1,3 +1,4 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -204,6 +205,58 @@ describe('useSignInWithApple', () => { expect(response.createdSessionId).toBe('existing-user-session-id'); }); + test('should fall back to sign-in when the instance restricts sign-ups', async () => { + const mockIdentityToken = 'mock-identity-token'; + mocks.signInAsync.mockResolvedValue({ + identityToken: mockIdentityToken, + fullName: null, + }); + + mockSignUp.create.mockRejectedValue( + new ClerkAPIResponseError('Sign-ups restricted', { + data: [{ code: 'sign_up_mode_restricted', message: 'Sign-ups restricted' }], + status: 403, + }), + ); + + const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; + mocks.useSignIn.mockReturnValue({ + signIn: mockSignInWithSession, + setActive: mockSetActive, + isLoaded: true, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + const response = await result.current.startAppleAuthenticationFlow(); + + expect(mockSignInWithSession.create).toHaveBeenCalledWith({ + strategy: 'oauth_token_apple', + token: mockIdentityToken, + }); + expect(response.createdSessionId).toBe('existing-user-session-id'); + expect(response.setActive).toBe(mockSetActive); + }); + + test('should rethrow sign-up errors that are not sign-up restrictions', async () => { + mocks.signInAsync.mockResolvedValue({ + identityToken: 'mock-identity-token', + fullName: null, + }); + + mockSignUp.create.mockRejectedValue( + new ClerkAPIResponseError('Invalid token', { + data: [{ code: 'oauth_token_invalid', message: 'Invalid token' }], + status: 422, + }), + ); + + const { result } = renderHook(() => useSignInWithApple()); + + await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow('Invalid token'); + expect(mockSignIn.create).not.toHaveBeenCalled(); + }); + test('should handle user cancellation gracefully', async () => { const cancelError = Object.assign(new Error('User canceled'), { code: 'ERR_REQUEST_CANCELED' }); mocks.signInAsync.mockRejectedValue(cancelError); diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index a24d7164141..36c38445505 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -94,16 +94,14 @@ describe('useSignInWithGoogle', () => { const mockSignIn = { create: vi.fn(), createdSessionId: 'test-session-id', + firstFactorVerification: { + status: 'verified', + }, }; const mockSignUp = { create: vi.fn(), - createdSessionId: 'new-user-session-id', - verifications: { - externalAccount: { - status: 'unverified', - }, - }, + createdSessionId: null, }; const mockSetActive = vi.fn(); @@ -136,33 +134,21 @@ describe('useSignInWithGoogle', () => { expect(typeof result.current.startGoogleAuthenticationFlow).toBe('function'); }); - test('should sign up a new user and pass through their name', async () => { + test('should successfully sign in existing user', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { - idToken: mockIdToken, - user: { - id: 'google-user-id', - email: 'jane@example.com', - name: 'Jane Doe', - givenName: 'Jane', - familyName: 'Doe', - photo: null, - }, - }, + data: { idToken: mockIdToken }, }); mocks.isSuccessResponse.mockReturnValue(true); - mockSignUp.create.mockResolvedValue(undefined); - mockSignUp.verifications.externalAccount.status = 'unverified'; - mockSignUp.createdSessionId = 'new-user-session-id'; + mockSignIn.create.mockResolvedValue(undefined); + mockSignIn.firstFactorVerification.status = 'verified'; + mockSignIn.createdSessionId = 'test-session-id'; const { result } = renderHook(() => useSignInWithGoogle()); - const response = await result.current.startGoogleAuthenticationFlow({ - unsafeMetadata: { source: 'test' }, - }); + const response = await result.current.startGoogleAuthenticationFlow(); expect(mocks.ClerkGoogleOneTapSignIn.configure).toHaveBeenCalledWith({ webClientId: 'mock-web-client-id.apps.googleusercontent.com', @@ -170,46 +156,32 @@ describe('useSignInWithGoogle', () => { expect(mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn).toHaveBeenCalledWith({ nonce: 'mock-uuid-nonce', }); - expect(mockSignUp.create).toHaveBeenCalledWith({ + expect(mockSignIn.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, - firstName: 'Jane', - lastName: 'Doe', - unsafeMetadata: { source: 'test' }, }); - expect(mockSignIn.create).not.toHaveBeenCalled(); - expect(response.createdSessionId).toBe('new-user-session-id'); + expect(response.createdSessionId).toBe('test-session-id'); expect(response.setActive).toBe(mockSetActive); }); - test('should transfer to sign-in when the account already exists', async () => { + test('should handle transfer flow for new user', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { - idToken: mockIdToken, - user: { - id: 'google-user-id', - email: 'jane@example.com', - name: 'Jane Doe', - givenName: 'Jane', - familyName: 'Doe', - photo: null, - }, - }, + data: { idToken: mockIdToken }, }); mocks.isSuccessResponse.mockReturnValue(true); - mockSignUp.create.mockResolvedValue(undefined); - mockSignUp.verifications.externalAccount.status = 'transferable'; + mockSignIn.create.mockResolvedValue(undefined); + mockSignIn.firstFactorVerification.status = 'transferable'; - const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; + const mockSignUpWithSession = { ...mockSignUp, createdSessionId: 'new-user-session-id' }; mocks.useClerk.mockReturnValue({ loaded: true, setActive: mockSetActive, client: { - signIn: mockSignInWithSession, - signUp: mockSignUp, + signIn: mockSignIn, + signUp: mockSignUpWithSession, }, }); @@ -219,17 +191,15 @@ describe('useSignInWithGoogle', () => { unsafeMetadata: { source: 'test' }, }); - expect(mockSignUp.create).toHaveBeenCalledWith({ + expect(mockSignIn.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, - firstName: 'Jane', - lastName: 'Doe', - unsafeMetadata: { source: 'test' }, }); - expect(mockSignInWithSession.create).toHaveBeenCalledWith({ + expect(mockSignUpWithSession.create).toHaveBeenCalledWith({ transfer: true, + unsafeMetadata: { source: 'test' }, }); - expect(response.createdSessionId).toBe('existing-user-session-id'); + expect(response.createdSessionId).toBe('new-user-session-id'); }); test('should handle user cancellation gracefully', async () => { @@ -275,46 +245,36 @@ describe('useSignInWithGoogle', () => { expect(response.createdSessionId).toBe(null); }); - test('should fall back to sign-in when external_account_exists error occurs', async () => { + test('should fall back to signUp when external_account_not_found error occurs', async () => { const mockIdToken = 'mock-id-token'; mocks.ClerkGoogleOneTapSignIn.presentExplicitSignIn.mockResolvedValue({ type: 'success', - data: { - idToken: mockIdToken, - user: { - id: 'google-user-id', - email: 'jane@example.com', - name: 'Jane Doe', - givenName: 'Jane', - familyName: 'Doe', - photo: null, - }, - }, + data: { idToken: mockIdToken }, }); mocks.isSuccessResponse.mockReturnValue(true); - // Mock signUp.create to throw external_account_exists Clerk error + // Mock signIn.create to throw external_account_not_found Clerk error const externalAccountError = { - errors: [{ code: 'external_account_exists' }], - message: 'External account exists', + errors: [{ code: 'external_account_not_found' }], + message: 'External account not found', }; - mockSignUp.create.mockRejectedValue(externalAccountError); + mockSignIn.create.mockRejectedValue(externalAccountError); // Mock isClerkAPIResponseError to return true for this error mocks.isClerkAPIResponseError.mockReturnValue(true); - // Mock signIn.create to succeed with a new session - const mockSignInWithSession = { - ...mockSignIn, + // Mock signUp.create to succeed with a new session + const mockSignUpWithSession = { + ...mockSignUp, create: vi.fn().mockResolvedValue(undefined), - createdSessionId: 'existing-user-session-id', + createdSessionId: 'new-signup-session-id', }; mocks.useClerk.mockReturnValue({ loaded: true, setActive: mockSetActive, client: { - signIn: mockSignInWithSession, - signUp: mockSignUp, + signIn: mockSignIn, + signUp: mockSignUpWithSession, }, }); @@ -324,23 +284,21 @@ describe('useSignInWithGoogle', () => { unsafeMetadata: { referral: 'google' }, }); - // Verify signUp.create was called first - expect(mockSignUp.create).toHaveBeenCalledWith({ + // Verify signIn.create was called first + expect(mockSignIn.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, - firstName: 'Jane', - lastName: 'Doe', - unsafeMetadata: { referral: 'google' }, }); - // Verify signIn.create was called as fallback with the token - expect(mockSignInWithSession.create).toHaveBeenCalledWith({ + // Verify signUp.create was called as fallback with the token + expect(mockSignUpWithSession.create).toHaveBeenCalledWith({ strategy: 'google_one_tap', token: mockIdToken, + unsafeMetadata: { referral: 'google' }, }); // Verify the session was created - expect(response.createdSessionId).toBe('existing-user-session-id'); + expect(response.createdSessionId).toBe('new-signup-session-id'); expect(response.setActive).toBe(mockSetActive); }); }); diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts index 43f8a01d1ac..794d5e2ff7b 100644 --- a/packages/expo/src/hooks/useSignInWithApple.ios.ts +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -1,4 +1,5 @@ import { useSignIn, useSignUp } from '@clerk/react/legacy'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { SetActive, SignInResource, SignUpResource } from '@clerk/shared/types'; import { errorThrower } from '../utils/errors'; @@ -106,14 +107,40 @@ export function useSignInWithApple() { return errorThrower.throw('No identity token received from Apple Sign-In.'); } - // Sign up first so the name is recorded; existing accounts fall through as transferable. - await signUp.create({ - strategy: 'oauth_token_apple', - token: identityToken, - firstName: fullName?.givenName ?? undefined, - lastName: fullName?.familyName ?? undefined, - unsafeMetadata: startAppleAuthenticationFlowParams?.unsafeMetadata, - }); + try { + // Sign-up first so a new user's name is recorded; an existing account + // resolves as transferable below instead of throwing. + await signUp.create({ + strategy: 'oauth_token_apple', + token: identityToken, + firstName: fullName?.givenName ?? undefined, + lastName: fullName?.familyName ?? undefined, + unsafeMetadata: startAppleAuthenticationFlowParams?.unsafeMetadata, + }); + } catch (signUpError: unknown) { + // Restricted/waitlist instances reject sign-up creation before checking + // whether the account exists, so fall back to sign-in for existing users. + if ( + isClerkAPIResponseError(signUpError) && + signUpError.errors?.some( + err => err.code === 'sign_up_mode_restricted' || err.code === 'sign_up_restricted_waitlist', + ) + ) { + await signIn.create({ + strategy: 'oauth_token_apple', + token: identityToken, + }); + + return { + createdSessionId: signIn.createdSessionId, + setActive, + signIn, + signUp, + }; + } + + throw signUpError; + } // Check if the account already exists (needs to transfer to SignIn) const accountAlreadyExists = signUp.verifications.externalAccount.status === 'transferable'; diff --git a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts index f6f7745be8f..c2c7dabcb15 100644 --- a/packages/expo/src/hooks/useSignInWithGoogle.shared.ts +++ b/packages/expo/src/hooks/useSignInWithGoogle.shared.ts @@ -140,56 +140,55 @@ export async function executeGoogleAuthenticationFlow( }; } - const { idToken, user } = response.data; + const { idToken } = response.data; try { - // Sign up first so the name is recorded; existing accounts fall through as transferable. - await signUp.create({ + // Try to sign in with the Google One Tap strategy + await signIn.create({ strategy: 'google_one_tap', token: idToken, - firstName: user?.givenName ?? undefined, - lastName: user?.familyName ?? undefined, - unsafeMetadata: params?.unsafeMetadata, }); - // Check if the account already exists (needs to transfer to SignIn) - const accountAlreadyExists = signUp.verifications.externalAccount.status === 'transferable'; + // Check if we need to transfer to SignUp (user doesn't exist yet) + const userNeedsToBeCreated = signIn.firstFactorVerification.status === 'transferable'; - if (accountAlreadyExists) { - // Account exists - transfer to SignIn to complete authentication - await signIn.create({ + if (userNeedsToBeCreated) { + // User doesn't exist - create a new SignUp with transfer + await signUp.create({ transfer: true, + unsafeMetadata: params?.unsafeMetadata, }); return { - createdSessionId: signIn.createdSessionId, + createdSessionId: signUp.createdSessionId, setActive, signIn, signUp, }; } - // New user - the SignUp completed with the name attached + // User exists - return the SignIn session return { - createdSessionId: signUp.createdSessionId, + createdSessionId: signIn.createdSessionId, setActive, signIn, signUp, }; - } catch (signUpError: unknown) { - // Handle the case where the account already exists (external_account_exists) + } catch (signInError: unknown) { + // Handle the case where the user doesn't exist (external_account_not_found) if ( - isClerkAPIResponseError(signUpError) && - signUpError.errors?.some(err => err.code === 'external_account_exists') + isClerkAPIResponseError(signInError) && + signInError.errors?.some(err => err.code === 'external_account_not_found') ) { - // Account exists - sign in with the token directly - await signIn.create({ + // User doesn't exist - create a new SignUp with the token + await signUp.create({ strategy: 'google_one_tap', token: idToken, + unsafeMetadata: params?.unsafeMetadata, }); return { - createdSessionId: signIn.createdSessionId, + createdSessionId: signUp.createdSessionId, setActive, signIn, signUp, @@ -197,7 +196,7 @@ export async function executeGoogleAuthenticationFlow( } // Re-throw if it's a different error - throw signUpError; + throw signInError; } } catch (error: unknown) { // Handle Google Sign-In cancellation errors From 0b8f8b1881001a05aad8bc199f57a4bda0d18505 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 22 Jul 2026 09:35:45 -0700 Subject: [PATCH 4/6] chore(expo): condense comments and changeset wording --- .changeset/fix-expo-native-oauth-name.md | 2 +- packages/expo/src/hooks/useSignInWithApple.ios.ts | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-expo-native-oauth-name.md b/.changeset/fix-expo-native-oauth-name.md index b675189f0c2..03d16df3e51 100644 --- a/.changeset/fix-expo-native-oauth-name.md +++ b/.changeset/fix-expo-native-oauth-name.md @@ -2,4 +2,4 @@ '@clerk/expo': patch --- -Fix `useSignInWithApple` dropping the user's first and last name on native sign-up. Apple only provides the name in the native credential on first authorization (never in the identity token), so the hook now signs up first with the name attached and falls back to sign-in when the account already exists, including on instances with restricted or waitlist sign-up mode. +Fix `useSignInWithApple` dropping the user's first and last name on native sign-up. The hook now signs up first with the name from the Apple credential and falls back to sign-in when the account already exists, including on instances with restricted or waitlist sign-up mode. diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts index 794d5e2ff7b..91197189b03 100644 --- a/packages/expo/src/hooks/useSignInWithApple.ios.ts +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -108,8 +108,7 @@ export function useSignInWithApple() { } try { - // Sign-up first so a new user's name is recorded; an existing account - // resolves as transferable below instead of throwing. + // Sign-up first so a new user's name is recorded; an existing account resolves as transferable below. await signUp.create({ strategy: 'oauth_token_apple', token: identityToken, @@ -118,8 +117,7 @@ export function useSignInWithApple() { unsafeMetadata: startAppleAuthenticationFlowParams?.unsafeMetadata, }); } catch (signUpError: unknown) { - // Restricted/waitlist instances reject sign-up creation before checking - // whether the account exists, so fall back to sign-in for existing users. + // Restricted/waitlist instances reject sign-up before the account-exists check, so existing users sign in. if ( isClerkAPIResponseError(signUpError) && signUpError.errors?.some( @@ -142,11 +140,9 @@ export function useSignInWithApple() { throw signUpError; } - // Check if the account already exists (needs to transfer to SignIn) const accountAlreadyExists = signUp.verifications.externalAccount.status === 'transferable'; if (accountAlreadyExists) { - // Account exists - transfer to SignIn to complete authentication await signIn.create({ transfer: true, }); @@ -159,7 +155,6 @@ export function useSignInWithApple() { }; } - // New user - the SignUp completed with the name attached return { createdSessionId: signUp.createdSessionId, setActive, From 27580545381d3466de3302c05cfa2c4dc568680d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 22 Jul 2026 09:46:36 -0700 Subject: [PATCH 5/6] fix(expo): surface restriction error for new users in restricted mode --- .../__tests__/useSignInWithApple.test.ts | 35 ++++++++++++++++++- .../expo/src/hooks/useSignInWithApple.ios.ts | 5 +++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts index 1ebe3d1312a..e5f6d4db55e 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithApple.test.ts @@ -219,7 +219,11 @@ describe('useSignInWithApple', () => { }), ); - const mockSignInWithSession = { ...mockSignIn, createdSessionId: 'existing-user-session-id' }; + const mockSignInWithSession = { + ...mockSignIn, + createdSessionId: 'existing-user-session-id', + firstFactorVerification: { status: 'verified' }, + }; mocks.useSignIn.mockReturnValue({ signIn: mockSignInWithSession, setActive: mockSetActive, @@ -238,6 +242,35 @@ describe('useSignInWithApple', () => { expect(response.setActive).toBe(mockSetActive); }); + test('should surface the restriction error for a new user when sign-ups are restricted', async () => { + mocks.signInAsync.mockResolvedValue({ + identityToken: 'mock-identity-token', + fullName: null, + }); + + mockSignUp.create.mockRejectedValue( + new ClerkAPIResponseError('Sign-ups restricted', { + data: [{ code: 'sign_up_mode_restricted', message: 'Sign-ups restricted' }], + status: 403, + }), + ); + + const mockSignInTransferable = { + ...mockSignIn, + createdSessionId: null, + firstFactorVerification: { status: 'transferable' }, + }; + mocks.useSignIn.mockReturnValue({ + signIn: mockSignInTransferable, + setActive: mockSetActive, + isLoaded: true, + }); + + const { result } = renderHook(() => useSignInWithApple()); + + await expect(result.current.startAppleAuthenticationFlow()).rejects.toThrow('Sign-ups restricted'); + }); + test('should rethrow sign-up errors that are not sign-up restrictions', async () => { mocks.signInAsync.mockResolvedValue({ identityToken: 'mock-identity-token', diff --git a/packages/expo/src/hooks/useSignInWithApple.ios.ts b/packages/expo/src/hooks/useSignInWithApple.ios.ts index 91197189b03..4dccb714f6b 100644 --- a/packages/expo/src/hooks/useSignInWithApple.ios.ts +++ b/packages/expo/src/hooks/useSignInWithApple.ios.ts @@ -129,6 +129,11 @@ export function useSignInWithApple() { token: identityToken, }); + // A transferable sign-in means the user doesn't exist; surface the original restriction error. + if (signIn.firstFactorVerification.status === 'transferable') { + throw signUpError; + } + return { createdSessionId: signIn.createdSessionId, setActive, From 607b5e0fd65aae616bdbe2073ca76fe0a7d9cb97 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 22 Jul 2026 11:19:38 -0700 Subject: [PATCH 6/6] chore: update changeset --- .changeset/fix-expo-native-oauth-name.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fix-expo-native-oauth-name.md b/.changeset/fix-expo-native-oauth-name.md index 03d16df3e51..4bd6cc6196b 100644 --- a/.changeset/fix-expo-native-oauth-name.md +++ b/.changeset/fix-expo-native-oauth-name.md @@ -3,3 +3,5 @@ --- Fix `useSignInWithApple` dropping the user's first and last name on native sign-up. The hook now signs up first with the name from the Apple credential and falls back to sign-in when the account already exists, including on instances with restricted or waitlist sign-up mode. + +Note: Apple only shares the name on the app's first authorization. To verify the fix with an Apple ID that has already signed in to your app, delete the user in the Clerk Dashboard and remove the app's entry under Settings > Sign in with Apple on the device, then sign in again.