From aa610558a99661d73e00740f889daff5c32de89b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 29 Jun 2026 14:54:44 -0700 Subject: [PATCH] fix(js): handle native OAuth transfer callbacks --- .changeset/native-oauth-transfer-signals.md | 6 ++++ .../clerk-js/src/core/__tests__/clerk.test.ts | 7 ++-- packages/clerk-js/src/core/clerk.ts | 3 +- .../clerk-js/src/core/resources/SignUp.ts | 3 +- .../authenticateWithTransport.test.ts | 33 +++++++++++++++++++ .../src/utils/authenticateWithTransport.ts | 11 ++++++- .../shared/src/internal/clerk-js/constants.ts | 1 + 7 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 .changeset/native-oauth-transfer-signals.md diff --git a/.changeset/native-oauth-transfer-signals.md b/.changeset/native-oauth-transfer-signals.md new file mode 100644 index 00000000000..616a8afa032 --- /dev/null +++ b/.changeset/native-oauth-transfer-signals.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Fix native OAuth transport handling for combined sign-in-or-up flows so transfer callbacks can continue instead of surfacing a generic OAuth callback failure. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 6e3283a0cb4..521f9c4b55e 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1,4 +1,5 @@ import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { ActiveSessionResource, PendingSessionResource, @@ -1487,7 +1488,7 @@ describe('Clerk singleton', () => { strategy: 'oauth_google', external_verification_redirect_url: null, error: { - code: 'external_account_exists', + code: ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS, long_message: 'This external account already exists.', message: 'already exists', }, @@ -1499,7 +1500,7 @@ describe('Clerk singleton', () => { strategy: 'oauth_google', external_verification_redirect_url: null, error: { - code: 'external_account_exists', + code: ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS, long_message: 'This external account already exists.', message: 'already exists', }, @@ -2085,7 +2086,7 @@ describe('Clerk singleton', () => { external_account: { status: 'transferable', error: { - code: 'external_account_exists', + code: ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS, }, }, }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9450cbd6040..2264ca42f58 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2493,7 +2493,8 @@ export class Clerk implements ClerkInterface { } const userExistsButNeedsToSignIn = - su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists'; + su.externalAccountStatus === 'transferable' && + su.externalAccountErrorCode === ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS; if (userExistsButNeedsToSignIn) { const res = await signIn.create({ transfer: true }); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 428975beb26..c961aae926b 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -1,5 +1,6 @@ import { inBrowser } from '@clerk/shared/browser'; import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password'; import { Poller } from '@clerk/shared/poller'; import type { @@ -794,7 +795,7 @@ class SignUpFuture implements SignUpFutureResource { // TODO: we can likely remove the error code check as the status should be sufficient return ( this.#resource.verifications.externalAccount.status === 'transferable' && - this.#resource.verifications.externalAccount.error?.code === 'external_account_exists' + this.#resource.verifications.externalAccount.error?.code === ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS ); } diff --git a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts index 3424060650d..c1883cb1658 100644 --- a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts +++ b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts @@ -1,4 +1,5 @@ import type { ClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import { describe, expect, it, vi } from 'vitest'; import { _authenticateWithTransport } from '../authenticateWithTransport'; @@ -64,6 +65,38 @@ describe('_authenticateWithTransport', () => { expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, {}); }); + it.each([ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND, ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS])( + 'continues to callback handling for native OAuth transfer signal %s', + async errorCode => { + const clerk = makeClerk(); + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn().mockResolvedValue({ + callbackUrl: `myapp://sso-callback?__clerk_status=failed&__clerk_error_code=${errorCode}`, + }), + }; + const resource = { + reload: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + } as any; + const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth')); + const callbackParams = { signInUrl: '/sign-in' }; + + await _authenticateWithTransport({ + clerk: clerk as any, + transport, + resource, + authenticateMethod, + params: {} as any, + callbackParams, + }); + + expect(resource.reload).toHaveBeenCalledWith(); + expect(resource.create).not.toHaveBeenCalled(); + expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, callbackParams); + }, + ); + it('rejects with a localizable error without reloading when the native callback reports OAuth failure', async () => { const clerk = makeClerk(); const transport = { diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts index dff6d7acd39..27bf11eac30 100644 --- a/packages/clerk-js/src/utils/authenticateWithTransport.ts +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -1,4 +1,5 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; +import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { AuthenticateWithRedirectParams, HandleOAuthCallbackParams, @@ -21,8 +22,12 @@ type ClerkWithResourceCallback = { const NATIVE_OAUTH_FAILED_STATUS = 'failed'; const NATIVE_OAUTH_ERROR_FALLBACK_CODE = 'oauth_callback_failed'; +const NATIVE_OAUTH_TRANSFER_SIGNAL_CODES = new Set([ + ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND, + ERROR_CODES.EXTERNAL_ACCOUNT_EXISTS, +]); const NATIVE_OAUTH_ERROR_MESSAGES: Record = { - oauth_access_denied: 'You did not grant access to your account.', + [ERROR_CODES.OAUTH_ACCESS_DENIED]: 'You did not grant access to your account.', }; function getNativeOAuthCallbackFailure(callbackUrl: string): { code: string; message: string } | null { @@ -34,6 +39,10 @@ function getNativeOAuthCallbackFailure(callbackUrl: string): { code: string; mes } const unsafeCode = searchParams.get('__clerk_error_code') || NATIVE_OAUTH_ERROR_FALLBACK_CODE; + if (NATIVE_OAUTH_TRANSFER_SIGNAL_CODES.has(unsafeCode)) { + return null; + } + const code = NATIVE_OAUTH_ERROR_MESSAGES[unsafeCode] ? unsafeCode : NATIVE_OAUTH_ERROR_FALLBACK_CODE; return { diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index 652b63db067..3a9ab74fc89 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -33,6 +33,7 @@ export const ERROR_CODES = { SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing', USER_LOCKED: 'user_locked', EXTERNAL_ACCOUNT_NOT_FOUND: 'external_account_not_found', + EXTERNAL_ACCOUNT_EXISTS: 'external_account_exists', SESSION_EXISTS: 'session_exists', SIGN_UP_MODE_RESTRICTED: 'sign_up_mode_restricted', SIGN_UP_MODE_RESTRICTED_WAITLIST: 'sign_up_restricted_waitlist',