diff --git a/.changeset/native-external-auth.md b/.changeset/native-external-auth.md new file mode 100644 index 00000000000..d8ea55fd2fe --- /dev/null +++ b/.changeset/native-external-auth.md @@ -0,0 +1,7 @@ +--- +"@clerk/shared": minor +"@clerk/ui": minor +"@clerk/clerk-js": minor +--- + +Add `__internal_nativeOAuthHandler` to `ClerkOptions` for SDK wrappers (e.g. `@clerk/electron`) that need to handle OAuth flows outside the browser. When registered, Clerk uses the handler's `getRedirectUrl` as the FAPI redirect URL and calls `open` instead of navigating the browser, routing the callback through the native runtime. The `NativeOAuthHandler` type is exported from `@clerk/shared/types`. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index a762311e98e..10dd70dec35 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -136,6 +136,7 @@ import type { WaitlistProps, WaitlistResource, Web3Provider, + NativeOAuthHandler, } from '@clerk/shared/types'; import type { ClerkUI } from '@clerk/shared/ui'; import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; @@ -254,6 +255,7 @@ export class Clerk implements ClerkInterface { protected environment?: EnvironmentResource | null; #queryClient: QueryClient | undefined; + #nativeOAuthHandler: NativeOAuthHandler | null = null; #publishableKey = ''; #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; @@ -273,6 +275,14 @@ export class Clerk implements ClerkInterface { #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + get __internal_hasNativeOAuthHandler(): boolean { + return this.#nativeOAuthHandler !== null; + } + + __internal_getNativeOAuthHandler(): NativeOAuthHandler | null { + return this.#nativeOAuthHandler; + } + get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { if (!this.#queryClient) { void import('./query-core') @@ -609,6 +619,9 @@ export class Clerk implements ClerkInterface { }); } this.#protect?.load(this.environment as Environment); + + this.#nativeOAuthHandler = options?.__internal_nativeOAuthHandler ?? null; + debugLogger.info('load() complete', {}, 'clerk'); } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); @@ -2322,6 +2335,29 @@ export class Clerk implements ClerkInterface { }); }; + public __internal_handleNativeOAuthCallback = async ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ): Promise => { + if (!this.loaded || !this.environment || !this.client) { + return; + } + const { signIn: _signIn, signUp: _signUp } = this.client; + + const signIn = 'identifier' in (signInOrUp || {}) ? (signInOrUp as SignInResource) : _signIn; + const signUp = 'missingFields' in (signInOrUp || {}) ? (signInOrUp as SignUpResource) : _signUp; + + const navigate = (to: string) => + customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to); + + return this._handleRedirectCallback(params, { + signUp, + signIn, + navigate, + }); + }; + private _handleRedirectCallback = async ( params: HandleOAuthCallbackParams, { @@ -2443,6 +2479,14 @@ export class Clerk implements ClerkInterface { baseUrl: string; redirectUrl: string; }) => { + if (params.navigateOnSetActive) { + return params.navigateOnSetActive({ + session, + redirectUrl, + decorateUrl: url => this.buildUrlWithAuth(url), + }); + } + if (!session.currentTask) { await this.navigate(redirectUrl); return; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 3b19d06d56a..1d0a3bfd2a3 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -346,9 +346,8 @@ export class SignIn extends BaseResource implements SignInResource { ): Promise => { const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn, enterpriseConnectionId } = params || {}; - const actionCompleteRedirectUrl = redirectUrlComplete; - const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); + const actionCompleteRedirectUrl = redirectUrlComplete; if (!this.id || !continueSignIn) { await this.create({ diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index bccdfa48919..05388a3426b 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -406,13 +406,14 @@ export class SignUp extends BaseResource implements SignUpResource { enterpriseConnectionId, } = params; - const redirectUrlWithAuthToken = SignUp.clerk.buildUrlWithAuth(redirectUrl); + const effectiveRedirectUrl = SignUp.clerk.buildUrlWithAuth(redirectUrl); + const effectiveActionCompleteRedirectUrl = redirectUrlComplete; const authenticateFn = () => { const authParams = { strategy, - redirectUrl: redirectUrlWithAuthToken, - actionCompleteRedirectUrl: redirectUrlComplete, + redirectUrl: effectiveRedirectUrl, + actionCompleteRedirectUrl: effectiveActionCompleteRedirectUrl, unsafeMetadata, emailAddress, legalAccepted, diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 9a2c4a015d6..da44501ea1a 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1052,6 +1052,17 @@ export interface Clerk { customNavigate?: (to: string) => Promise, ) => Promise; + /** + * Completes an OAuth or SAML callback using the provided sign-in or sign-up resource. + * + * @internal + */ + __internal_handleNativeOAuthCallback: ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ) => Promise; + /** * Completes a custom OAuth or SAML redirect flow that was started by calling [`SignIn.authenticateWithRedirect(params)`](https://clerk.com/docs/reference/objects/sign-in) or [`SignUp.authenticateWithRedirect(params)`](https://clerk.com/docs/reference/objects/sign-up). * @@ -1177,6 +1188,20 @@ export interface Clerk { * This API is in early access and may change in future releases. */ __experimental_checkout: __experimental_CheckoutFunction; + + /** + * Whether a native OAuth handler (e.g. for Electron) has been registered. + * + * @internal + */ + __internal_hasNativeOAuthHandler: boolean; + + /** + * Returns the registered native OAuth handler, or null when none is registered. + * + * @internal + */ + __internal_getNativeOAuthHandler(): import('./electron').NativeOAuthHandler | null; } /** @generateWithEmptyComment */ @@ -1221,6 +1246,16 @@ export type HandleOAuthCallbackParams = TransferableOption & * The underlying resource to optionally reload before processing an OAuth callback. */ reloadResource?: 'signIn' | 'signUp'; + /** + * Internal navigation hook used by Clerk UI to preserve custom post-activation routing behavior. + * + * @internal + */ + navigateOnSetActive?: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: DecorateUrl; + }) => Promise; /** * Metadata that can be read and set from the frontend. Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata. One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created `User` object. */ @@ -1467,6 +1502,17 @@ export type ClerkOptions = ClerkOptionsNavigation & * @default undefined */ taskUrls?: Partial>; + + /** + * Provide a handler for OAuth/SSO flows in environments where a browser redirect or popup + * cannot be used (e.g. Electron, Tauri). When set, Clerk uses the handler's `getRedirectUrl` + * as the FAPI `redirectUrl` and calls `open` instead of navigating the browser. + * + * Intended for use by native desktop SDK wrappers such as `@clerk/electron`. + * + * @internal + */ + __internal_nativeOAuthHandler?: import('./electron').NativeOAuthHandler; }; /** @inline */ diff --git a/packages/shared/src/types/electron.ts b/packages/shared/src/types/electron.ts new file mode 100644 index 00000000000..680e24789d1 --- /dev/null +++ b/packages/shared/src/types/electron.ts @@ -0,0 +1,22 @@ +type Awaitable = T | Promise; + +/** + * Handler for OAuth/SSO flows in environments where a browser redirect or popup cannot be used + * (e.g. Electron, Tauri). Register via `__internal_nativeOAuthHandler` in `ClerkOptions`. + * + * @internal + */ +export type NativeOAuthHandler = { + /** + * Returns the deep-link callback URL that the host runtime has registered with the OS + * (e.g. `myapp://sso-callback`). Clerk passes this to FAPI as the `redirectUrl` so the + * provider redirects back through the native callback instead of a web route. + */ + getRedirectUrl: () => Awaitable; + /** + * Opens the provider verification URL through the host runtime (e.g. via + * `shell.openExternal` in Electron) and resolves with the callback URL that the OS routes + * back to the app after auth completes. Rejects on cancellation or error. + */ + open: (url: URL) => Promise<{ callbackUrl: string }>; +}; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7ab38b098d1..21447b73d73 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -13,6 +13,7 @@ export type * from './customPages'; export type * from './deletedObject'; export type * from './devtools'; export type * from './displayConfig'; +export type * from './electron'; export type * from './elementIds'; export type * from './emailAddress'; export type * from './enterpriseAccount'; diff --git a/packages/shared/src/types/redirects.ts b/packages/shared/src/types/redirects.ts index 83b1ba61327..3edc481d0a9 100644 --- a/packages/shared/src/types/redirects.ts +++ b/packages/shared/src/types/redirects.ts @@ -84,6 +84,11 @@ export type AuthenticateWithRedirectParams = { export type AuthenticateWithPopupParams = AuthenticateWithRedirectParams & { popup: Window | null }; +/** + * @experimental This API is subject to change. + */ +export type AuthenticateWithNativeRedirectParams = AuthenticateWithRedirectParams; + /** @generateWithEmptyComment */ export type RedirectUrlProp = { /** diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index ec5b7cc9170..fb527b793c2 100644 --- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx +++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx @@ -6,6 +6,7 @@ import type { PhoneCodeChannel } from '@clerk/shared/types'; import React from 'react'; import { handleError as _handleError } from '@/ui/utils/errorHandler'; +import { authenticateSignInWithNativeTransport } from '@/ui/utils/nativeOAuthTransport'; import { originPrefersPopup } from '@/ui/utils/originPrefersPopup'; import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler'; @@ -46,15 +47,16 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) } } - return _handleError(err, [], card.setError); + _handleError(err, [], card.setError); + throw err; }; return ( { + idleAfterDelay={!shouldUsePopup && !clerk.__internal_hasNativeOAuthHandler} + oauthCallback={async strategy => { if (shouldUsePopup) { // We create the popup window here with the `about:blank` URL since some browsers will block popups that are // opened within async functions. The `signInWithPopup` method handles setting the URL of the popup. @@ -72,8 +74,39 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) .catch(err => handleError(err)); } + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + return authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy, + oidcPrompt: ctx.oidcPrompt, + callbackParams: { + signUpUrl: ctx.signUpUrl, + signInUrl: ctx.signInUrl, + signInForceRedirectUrl: ctx.afterSignInUrl, + signUpForceRedirectUrl: ctx.afterSignUpUrl, + continueSignUpUrl: ctx.signUpContinueUrl, + transferable: ctx.transferable, + firstFactorUrl: '../factor-one', + secondFactorUrl: '../factor-two', + resetPasswordUrl: '../reset-password', + navigateOnSetActive: ctx.navigateOnSetActive, + unsafeMetadata: ctx.unsafeMetadata, + }, + }) + .catch(err => handleError(err)) + .finally(() => card.setIdle()); + } + return signIn - .authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt }) + .authenticateWithRedirect({ + strategy, + redirectUrl, + redirectUrlComplete, + oidcPrompt: ctx.oidcPrompt, + }) .catch(err => handleError(err)); }} web3Callback={strategy => { diff --git a/packages/ui/src/components/SignIn/SignInStart.tsx b/packages/ui/src/components/SignIn/SignInStart.tsx index e1455edb773..fbe373b437a 100644 --- a/packages/ui/src/components/SignIn/SignInStart.tsx +++ b/packages/ui/src/components/SignIn/SignInStart.tsx @@ -21,6 +21,7 @@ import { LoadingCard } from '@/ui/elements/LoadingCard'; import { SocialButtonsReversibleContainerWithDivider } from '@/ui/elements/ReversibleContainer'; import { handleError } from '@/ui/utils/errorHandler'; import { isMobileDevice } from '@/ui/utils/isMobileDevice'; +import { authenticateSignInWithNativeTransport } from '@/ui/utils/nativeOAuthTransport'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; @@ -129,7 +130,6 @@ function SignInStartInternal(): JSX.Element { }); const [alternativePhoneCodeProvider, setAlternativePhoneCodeProvider] = useState(null); - const showAlternativePhoneCodeProviders = userSettings.alternativePhoneCodeChannels.length > 0; const onAlternativePhoneCodeUseAnotherMethod = () => { @@ -258,7 +258,7 @@ function SignInStartInternal(): JSX.Element { // This is necessary because there's a brief delay between initiating the SSO flow // and the actual redirect to the external Identity Provider const isRedirectingToSSOProvider = !!hasOnlyEnterpriseSSOFirstFactors(signIn); - if (isRedirectingToSSOProvider) { + if (isRedirectingToSSOProvider && !clerk.__internal_hasNativeOAuthHandler) { return; } @@ -415,6 +415,31 @@ function SignInStartInternal(): JSX.Element { const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + return authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'enterprise_sso', + continueSignIn: true, + oidcPrompt: ctx.oidcPrompt, + callbackParams: { + signUpUrl: ctx.signUpUrl, + signInUrl: ctx.signInUrl, + signInForceRedirectUrl: ctx.afterSignInUrl, + signUpForceRedirectUrl: ctx.afterSignUpUrl, + continueSignUpUrl: ctx.signUpContinueUrl, + transferable: ctx.transferable, + firstFactorUrl: '../factor-one', + secondFactorUrl: '../factor-two', + resetPasswordUrl: '../reset-password', + navigateOnSetActive: ctx.navigateOnSetActive, + unsafeMetadata: ctx.unsafeMetadata, + }, + }); + } + return signIn.authenticateWithRedirect({ strategy: 'enterprise_sso', redirectUrl, diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..1a869f8c1f3 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -1,17 +1,21 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; -import type { SignInResource } from '@clerk/shared/types'; import { waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { fireEvent, mockWebAuthn, render, screen } from '@/test/utils'; import { CardStateProvider } from '@/ui/elements/contexts'; +import { authenticateSignInWithNativeTransport } from '@/ui/utils/nativeOAuthTransport'; import { OptionsProvider } from '../../../contexts'; import { AppearanceProvider } from '../../../customizables'; import { SignInStart } from '../SignInStart'; +vi.mock('@/ui/utils/nativeOAuthTransport', () => ({ + authenticateSignInWithNativeTransport: vi.fn().mockResolvedValue(undefined), +})); + const { createFixtures } = bindCreateFixtures('SignIn'); describe('SignInStart', () => { @@ -21,6 +25,8 @@ describe('SignInStart', () => { const mockGetComputedStyle = vi.fn(); beforeEach(() => { + vi.mocked(authenticateSignInWithNativeTransport).mockResolvedValue(undefined); + // Mock window.getComputedStyle mockGetComputedStyle.mockReset(); mockGetComputedStyle.mockReturnValue({ @@ -52,6 +58,7 @@ describe('SignInStart', () => { writable: true, configurable: true, }); + delete (window as any).__clerk_internal_electron; }); it('renders the component', async () => { @@ -284,6 +291,91 @@ describe('SignInStart', () => { }); }); }); + + it('uses native OAuth transport when registered', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn(), + }; + + Object.defineProperty(fixtures.clerk, '__internal_getNativeOAuthHandler', { + value: () => transport, + configurable: true, + }); + Object.defineProperty(fixtures.clerk, '__internal_hasNativeOAuthHandler', { + get: () => true, + configurable: true, + }); + + render(, { wrapper }); + fireEvent.click(screen.getByText('Continue with Google')); + + await waitFor(() => { + expect(authenticateSignInWithNativeTransport).toHaveBeenCalledWith( + expect.objectContaining({ + transport, + signIn: fixtures.signIn, + clerk: fixtures.clerk, + strategy: 'oauth_google', + oidcPrompt: undefined, + callbackParams: expect.objectContaining({ + signInUrl: 'https://dashboard.clerk.com/sign-in', + signUpUrl: 'https://dashboard.clerk.com/sign-up', + signInForceRedirectUrl: '/', + signUpForceRedirectUrl: '/', + transferable: true, + firstFactorUrl: '../factor-one', + secondFactorUrl: '../factor-two', + resetPasswordUrl: '../reset-password', + navigateOnSetActive: expect.any(Function), + unsafeMetadata: undefined, + }), + }), + ); + }); + await waitFor(() => { + expect(screen.getByText('Continue with Google').closest('button')).not.toBeDisabled(); + }); + expect(fixtures.signIn.authenticateWithRedirect).not.toHaveBeenCalled(); + }); + + it('clears the loading state when native OAuth sign-in fails', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn(), + }; + + Object.defineProperty(fixtures.clerk, '__internal_getNativeOAuthHandler', { + value: () => transport, + configurable: true, + }); + Object.defineProperty(fixtures.clerk, '__internal_hasNativeOAuthHandler', { + get: () => true, + configurable: true, + }); + + vi.mocked(authenticateSignInWithNativeTransport).mockRejectedValueOnce( + new ClerkAPIResponseError('Unable to complete authentication', { + data: [{ code: 'native_redirect_incomplete', message: 'Native redirect incomplete', long_message: '' }], + status: 400, + }), + ); + + render(, { wrapper }); + fireEvent.click(screen.getByText('Continue with Google')); + + await waitFor(() => { + expect(screen.getByText('Continue with Google').closest('button')).not.toBeDisabled(); + }); + }); }); describe('navigation', () => { @@ -359,12 +451,14 @@ describe('SignInStart', () => { await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com'); await userEvent.click(screen.getByText('Continue')); expect(fixtures.signIn.create).toHaveBeenCalled(); - expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith({ - strategy: 'enterprise_sso', - redirectUrl: 'http://localhost:3000/#/sso-callback', - redirectUrlComplete: '/', - continueSignIn: true, - }); + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: 'enterprise_sso', + redirectUrl: 'http://localhost:3000/#/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + }), + ); }); }); diff --git a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx index cff829e0307..e3b43da0bec 100644 --- a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx +++ b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCardState } from '@/ui/elements/contexts'; import { handleError } from '@/ui/utils/errorHandler'; +import { authenticateSignUpWithNativeTransport } from '@/ui/utils/nativeOAuthTransport'; import { originPrefersPopup } from '@/ui/utils/originPrefersPopup'; import { web3CallbackErrorHandler } from '@/ui/utils/web3CallbackErrorHandler'; @@ -33,8 +34,8 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) { + idleAfterDelay={!shouldUsePopup && !clerk.__internal_hasNativeOAuthHandler} + oauthCallback={async (strategy: OAuthStrategy) => { if (shouldUsePopup) { // We create the popup window here with the `about:blank` URL since some browsers will block popups that are // opened within async functions. The `signUpWithPopup` method handles setting the URL of the popup. @@ -61,6 +62,37 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) .catch(err => handleError(err, [], card.setError)); } + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + return authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy, + continueSignUp, + unsafeMetadata: ctx.unsafeMetadata, + legalAccepted: props.legalAccepted, + oidcPrompt: ctx.oidcPrompt, + callbackParams: { + signUpUrl: ctx.signUpUrl, + signInUrl: ctx.signInUrl, + signUpForceRedirectUrl: ctx.afterSignUpUrl, + signInForceRedirectUrl: ctx.afterSignInUrl, + secondFactorUrl: ctx.secondFactorUrl, + continueSignUpUrl: '../continue', + verifyEmailAddressUrl: '../verify-email-address', + verifyPhoneNumberUrl: '../verify-phone-number', + navigateOnSetActive: ctx.navigateOnSetActive, + unsafeMetadata: ctx.unsafeMetadata, + }, + }) + .catch(err => { + handleError(err, [], card.setError); + throw err; + }) + .finally(() => card.setIdle()); + } + return signUp .authenticateWithRedirect({ continueSignUp, @@ -71,7 +103,10 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) legalAccepted: props.legalAccepted, oidcPrompt: ctx.oidcPrompt, }) - .catch(err => handleError(err, [], card.setError)); + .catch(err => { + handleError(err, [], card.setError); + throw err; + }); }} web3Callback={strategy => { if (strategy === 'web3_solana_signature') { diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx index deaa2c31de5..76f5d56602f 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx @@ -1,10 +1,12 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { useReverification, useUser } from '@clerk/shared/react'; +import { useClerk } from '@clerk/shared/react'; import type { OAuthProvider, OAuthStrategy } from '@clerk/shared/types'; import { useCardState } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; import { handleError } from '@/ui/utils/errorHandler'; +import { connectExternalAccountWithTransport } from '@/ui/utils/externalVerificationRedirect'; import { sleep } from '@/ui/utils/sleep'; import { ProviderIcon } from '../../common'; @@ -15,6 +17,7 @@ import { useRouter } from '../../router'; const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => void }) => { const { strategy } = props; + const clerk = useClerk(); const card = useCardState(); const { user } = useUser(); const { navigate } = useRouter(); @@ -22,7 +25,7 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); const isModal = mode === 'modal'; - const createExternalAccount = useReverification(() => { + const createExternalAccount = useReverification((nativeRedirectUrl?: string) => { const socialProvider = strategy.replace('oauth_', '') as OAuthProvider; const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName, socialProvider: socialProvider }) @@ -31,7 +34,7 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi return user?.createExternalAccount({ strategy, - redirectUrl, + redirectUrl: nativeRedirectUrl || redirectUrl, additionalScopes, }); }); @@ -41,9 +44,15 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi return; } - // TODO: Decide if we should keep using this strategy - // If yes, refactor and cleanup: card.setLoading(strategy); + + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + return connectExternalAccountWithTransport({ transport, createExternalAccount, user }).finally(() => + card.setIdle(strategy), + ); + } + return createExternalAccount() .then(res => { if (res && res.verification?.externalVerificationRedirectURL) { diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index ad294468763..d621f6939ce 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx @@ -1,5 +1,6 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { useReverification, useUser } from '@clerk/shared/react'; +import { useClerk } from '@clerk/shared/react'; import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy } from '@clerk/shared/types'; import { Fragment, useState } from 'react'; @@ -8,6 +9,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; +import { connectExternalAccountWithTransport } from '@/ui/utils/externalVerificationRedirect'; import { ProviderIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; @@ -97,6 +99,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); const { navigate } = useRouter(); const { user } = useUser(); + const clerk = useClerk(); const card = useCardState(); const accountId = account.id; @@ -108,11 +111,11 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => }) : window.location.href; - const createExternalAccount = useReverification(() => + const createExternalAccount = useReverification((nativeRedirectUrl?: string) => user?.createExternalAccount({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion strategy: account.verification!.strategy as OAuthStrategy, - redirectUrl, + redirectUrl: nativeRedirectUrl || redirectUrl, additionalScopes, }), ); @@ -134,12 +137,26 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => : fallbackErrorMessage; const reconnect = async () => { - const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href; + const webRedirectUrl = isModal + ? appendModalState({ url: window.location.href, componentName }) + : window.location.href; try { + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + const createWithTransport = (nativeRedirectUrl: string) => { + if (reauthorizationRequired) { + return account.reauthorize({ additionalScopes, redirectUrl: nativeRedirectUrl }); + } + return createExternalAccount(nativeRedirectUrl); + }; + await connectExternalAccountWithTransport({ transport, createExternalAccount: createWithTransport, user }); + return; + } + let response: ExternalAccountResource | undefined; if (reauthorizationRequired) { - response = await account.reauthorize({ additionalScopes, redirectUrl }); + response = await account.reauthorize({ additionalScopes, redirectUrl: webRedirectUrl }); } else { response = await createExternalAccount(); } diff --git a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx index 8e5f7bd8e4f..98ef9b34146 100644 --- a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx @@ -1,6 +1,7 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { __internal_useUserEnterpriseConnections, useReverification, useUser } from '@clerk/shared/react'; +import { useClerk } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; import { Fragment, useState } from 'react'; @@ -8,6 +9,7 @@ import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; import { handleError } from '@/ui/utils/errorHandler'; +import { connectExternalAccountWithTransport } from '@/ui/utils/externalVerificationRedirect'; import { sleep } from '@/ui/utils/sleep'; import { ProviderIcon } from '../../common'; @@ -16,18 +18,19 @@ import { Badge, Box, descriptors, Flex, localizationKeys, Text } from '../../cus import { Action } from '../../elements/Action'; const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionResource }) => { const { connection } = props; + const clerk = useClerk(); const card = useCardState(); const { user } = useUser(); const { componentName, mode } = useUserProfileContext(); const isModal = mode === 'modal'; const loadingKey = `enterprise_${connection.id}`; - const createExternalAccount = useReverification(() => { + const createExternalAccount = useReverification((nativeRedirectUrl?: string) => { const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href; return user?.createExternalAccount({ enterpriseConnectionId: connection.id, - redirectUrl, + redirectUrl: nativeRedirectUrl || redirectUrl, }); }); @@ -38,6 +41,13 @@ const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionRe card.setLoading(loadingKey); + const transport = clerk.__internal_getNativeOAuthHandler(); + if (transport) { + return connectExternalAccountWithTransport({ transport, createExternalAccount, user }).finally(() => + card.setIdle(loadingKey), + ); + } + return createExternalAccount() .then(res => { if (res?.verification?.externalVerificationRedirectURL) { diff --git a/packages/ui/src/utils/__tests__/nativeOAuthTransport.test.ts b/packages/ui/src/utils/__tests__/nativeOAuthTransport.test.ts new file mode 100644 index 00000000000..07fdcd25152 --- /dev/null +++ b/packages/ui/src/utils/__tests__/nativeOAuthTransport.test.ts @@ -0,0 +1,327 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it, vi } from 'vitest'; + +import { authenticateSignInWithNativeTransport, authenticateSignUpWithNativeTransport } from '../nativeOAuthTransport'; + +function makeMockClerk(overrides?: object) { + return { + __internal_handleNativeOAuthCallback: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as any; +} + +function makeMockTransport(callbackUrl: string) { + return { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn().mockResolvedValue({ callbackUrl }), + }; +} + +describe('authenticateSignInWithNativeTransport', () => { + it('creates signIn with transport URL, opens transport, and handles successful nonce callback', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback?rotating_token_nonce=test-nonce'); + const clerk = makeMockClerk(); + const callbackParams = { signInUrl: '/sign-in', signUpUrl: '/sign-up' }; + + const signIn = { id: undefined, firstFactorVerification: {} } as any; + signIn.create = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { externalVerificationRedirectURL }; + return signIn; + }); + signIn.reload = vi.fn().mockImplementation(async () => signIn); + + await authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'oauth_google', + callbackParams, + }); + + expect(signIn.create).toHaveBeenCalledWith({ + strategy: 'oauth_google', + identifier: undefined, + redirectUrl: 'myapp://sso-callback', + actionCompleteRedirectUrl: 'myapp://sso-callback', + }); + expect(transport.open).toHaveBeenCalledWith(externalVerificationRedirectURL); + expect(signIn.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'test-nonce' }); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signIn, callbackParams); + }); + + it('skips create and calls prepareFirstFactor for enterprise_sso with continueSignIn', async () => { + const externalVerificationRedirectURL = new URL('https://sso.example.com/auth'); + const transport = makeMockTransport('myapp://sso-callback?rotating_token_nonce=test-nonce'); + const clerk = makeMockClerk(); + + const signIn = { id: 'signin_123', firstFactorVerification: {} } as any; + signIn.create = vi.fn(); + signIn.prepareFirstFactor = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { externalVerificationRedirectURL }; + return signIn; + }); + signIn.reload = vi.fn().mockImplementation(async () => signIn); + + await authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'enterprise_sso', + continueSignIn: true, + enterpriseConnectionId: 'ec_123', + callbackParams: {}, + }); + + expect(signIn.create).not.toHaveBeenCalled(); + expect(signIn.prepareFirstFactor).toHaveBeenCalledWith({ + strategy: 'enterprise_sso', + redirectUrl: 'myapp://sso-callback', + actionCompleteRedirectUrl: 'myapp://sso-callback', + oidcPrompt: undefined, + enterpriseConnectionId: 'ec_123', + }); + expect(transport.open).toHaveBeenCalledWith(externalVerificationRedirectURL); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signIn, {}); + }); + + it('handles callback URL with no nonce by reloading the signIn resource before continuing', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback'); + const clerk = makeMockClerk(); + const callbackParams = { signInUrl: '/sign-in', signUpUrl: '/sign-up' }; + + const signIn = { id: undefined, firstFactorVerification: {} } as any; + signIn.create = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { externalVerificationRedirectURL }; + return signIn; + }); + signIn.reload = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = {}; + return signIn; + }); + + await authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'oauth_google', + callbackParams, + }); + + expect(signIn.reload).toHaveBeenCalledWith(); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signIn, callbackParams); + }); + + it('throws the reloaded signIn verification error for callback URLs with no nonce', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback'); + const clerk = makeMockClerk(); + + const signIn = { id: undefined, firstFactorVerification: {} } as any; + signIn.create = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { externalVerificationRedirectURL }; + return signIn; + }); + signIn.reload = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { + error: { + code: 'oauth_access_denied', + message: 'You did not grant access to your Google account', + longMessage: 'You did not grant access to your Google account', + }, + }; + return signIn; + }); + + await expect( + authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'oauth_google', + callbackParams: {}, + }), + ).rejects.toMatchObject({ + status: 400, + errors: [ + { + code: 'oauth_access_denied', + message: 'You did not grant access to your Google account', + }, + ], + }); + + expect(signIn.reload).toHaveBeenCalledWith(); + expect(signIn.create).toHaveBeenLastCalledWith({}); + expect(clerk.__internal_handleNativeOAuthCallback).not.toHaveBeenCalled(); + }); + + it('propagates errors from callback URL error params', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport( + 'myapp://sso-callback?error_code=oauth_access_denied&error_message=Access%20denied', + ); + const clerk = makeMockClerk(); + + const signIn = { id: undefined, firstFactorVerification: {} } as any; + signIn.create = vi.fn().mockImplementation(async () => { + signIn.firstFactorVerification = { externalVerificationRedirectURL }; + return signIn; + }); + + await expect( + authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'oauth_google', + callbackParams: {}, + }), + ).rejects.toSatisfy(isClerkAPIResponseError); + }); +}); + +describe('authenticateSignUpWithNativeTransport', () => { + it('creates signUp with transport URL, opens transport, and handles successful nonce callback', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback?rotating_token_nonce=test-nonce'); + const clerk = makeMockClerk(); + const callbackParams = { signInUrl: '/sign-in', signUpUrl: '/sign-up' }; + + const signUp = { id: undefined, verifications: { externalAccount: {} } } as any; + signUp.create = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = { externalVerificationRedirectURL }; + return signUp; + }); + signUp.reload = vi.fn().mockImplementation(async () => signUp); + + await authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy: 'oauth_google', + unsafeMetadata: { plan: 'pro' }, + legalAccepted: true, + callbackParams, + }); + + expect(signUp.create).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: 'oauth_google', + redirectUrl: 'myapp://sso-callback', + actionCompleteRedirectUrl: 'myapp://sso-callback', + unsafeMetadata: { plan: 'pro' }, + legalAccepted: true, + }), + ); + expect(transport.open).toHaveBeenCalledWith(externalVerificationRedirectURL); + expect(signUp.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'test-nonce' }); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signUp, callbackParams); + }); + + it('uses update instead of create when continueSignUp is true and signUp has an id', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback?rotating_token_nonce=test-nonce'); + const clerk = makeMockClerk(); + + const signUp = { id: 'signup_123', verifications: { externalAccount: {} } } as any; + signUp.create = vi.fn(); + signUp.update = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = { externalVerificationRedirectURL }; + return signUp; + }); + signUp.reload = vi.fn().mockImplementation(async () => signUp); + + await authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy: 'oauth_google', + continueSignUp: true, + callbackParams: {}, + }); + + expect(signUp.create).not.toHaveBeenCalled(); + expect(signUp.update).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: 'oauth_google', + redirectUrl: 'myapp://sso-callback', + actionCompleteRedirectUrl: 'myapp://sso-callback', + }), + ); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signUp, {}); + }); + + it('handles callback URL with no nonce by reloading the signUp resource before continuing', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback'); + const clerk = makeMockClerk(); + const callbackParams = { signInUrl: '/sign-in', signUpUrl: '/sign-up' }; + + const signUp = { id: undefined, verifications: { externalAccount: {} } } as any; + signUp.create = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = { externalVerificationRedirectURL }; + return signUp; + }); + signUp.reload = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = {}; + return signUp; + }); + + await authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy: 'oauth_google', + callbackParams, + }); + + expect(signUp.reload).toHaveBeenCalledWith(); + expect(clerk.__internal_handleNativeOAuthCallback).toHaveBeenCalledWith(signUp, callbackParams); + }); + + it('throws the reloaded signUp verification error for callback URLs with no nonce', async () => { + const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth'); + const transport = makeMockTransport('myapp://sso-callback'); + const clerk = makeMockClerk(); + + const signUp = { id: undefined, verifications: { externalAccount: {} } } as any; + signUp.create = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = { externalVerificationRedirectURL }; + return signUp; + }); + signUp.reload = vi.fn().mockImplementation(async () => { + signUp.verifications.externalAccount = { + error: { + code: 'oauth_access_denied', + message: 'You did not grant access to your Google account', + longMessage: 'You did not grant access to your Google account', + }, + }; + return signUp; + }); + + await expect( + authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy: 'oauth_google', + callbackParams: {}, + }), + ).rejects.toMatchObject({ + status: 400, + errors: [ + { + code: 'oauth_access_denied', + message: 'You did not grant access to your Google account', + }, + ], + }); + + expect(signUp.reload).toHaveBeenCalledWith(); + expect(signUp.create).toHaveBeenLastCalledWith({}); + expect(clerk.__internal_handleNativeOAuthCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/__tests__/nativeRedirectCallback.test.ts b/packages/ui/src/utils/__tests__/nativeRedirectCallback.test.ts new file mode 100644 index 00000000000..669ddbc6d69 --- /dev/null +++ b/packages/ui/src/utils/__tests__/nativeRedirectCallback.test.ts @@ -0,0 +1,105 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { + getRotatingTokenNonceFromNativeRedirectCallback, + throwIfNativeRedirectCallbackHasError, + throwIfNativeRedirectResourceHasError, +} from '../nativeRedirectCallback'; + +describe('nativeRedirectCallback', () => { + it('wraps callback errors as Clerk API response errors', () => { + let err: unknown; + + try { + throwIfNativeRedirectCallbackHasError( + 'myapp://callback?error_code=oauth_access_denied&error_message=Access%20denied&long_message=You%20did%20not%20grant%20access&status=422', + ); + } catch (e) { + err = e; + } + + expect(isClerkAPIResponseError(err)).toBe(true); + expect(err).toMatchObject({ + status: 422, + errors: [ + { + code: 'oauth_access_denied', + message: 'Access denied', + longMessage: 'You did not grant access', + }, + ], + }); + }); + + it('supports provider-style error params', () => { + let err: unknown; + + try { + throwIfNativeRedirectCallbackHasError( + 'myapp://callback?error=access_denied&error_description=The%20user%20cancelled', + ); + } catch (e) { + err = e; + } + + expect(isClerkAPIResponseError(err)).toBe(true); + expect(err).toMatchObject({ + status: 400, + errors: [ + { + code: 'access_denied', + message: 'The user cancelled', + }, + ], + }); + }); + + it('does nothing when the callback has no error params', () => { + expect(() => + throwIfNativeRedirectCallbackHasError('myapp://callback?rotating_token_nonce=test-nonce'), + ).not.toThrow(); + }); + + it('returns the rotating token nonce from a successful callback', () => { + expect(getRotatingTokenNonceFromNativeRedirectCallback('myapp://callback?rotating_token_nonce=test-nonce')).toBe( + 'test-nonce', + ); + }); + + it('returns null when the callback has no rotating token nonce', () => { + expect(getRotatingTokenNonceFromNativeRedirectCallback('myapp://callback')).toBeNull(); + }); + + it('wraps resource verification errors as Clerk API response errors', () => { + let err: unknown; + + try { + throwIfNativeRedirectResourceHasError({ + code: 'oauth_access_denied', + message: 'Access denied', + longMessage: 'You did not grant access', + meta: { + sessionId: 'sess_123', + }, + }); + } catch (e) { + err = e; + } + + expect(isClerkAPIResponseError(err)).toBe(true); + expect(err).toMatchObject({ + status: 400, + errors: [ + { + code: 'oauth_access_denied', + message: 'Access denied', + longMessage: 'You did not grant access', + meta: { + sessionId: 'sess_123', + }, + }, + ], + }); + }); +}); diff --git a/packages/ui/src/utils/errorHandler.ts b/packages/ui/src/utils/errorHandler.ts index 7d31438faad..eb0a4c8472a 100644 --- a/packages/ui/src/utils/errorHandler.ts +++ b/packages/ui/src/utils/errorHandler.ts @@ -75,8 +75,11 @@ export const handleError: HandleError = (err, fieldStates, setGlobalError) => { return handleClerkApiError(err, fieldStates, setGlobalError); } - if (isClerkRuntimeError(err) && err.code === 'reverification_cancelled') { - // Don't log or display an error for cancelled reverification, the user simply closed the modal. + if ( + isClerkRuntimeError(err) && + (err.code === 'reverification_cancelled' || err.code === 'native_redirect_cancelled') + ) { + // Don't log or display an error for user-abandoned flows. return; } diff --git a/packages/ui/src/utils/externalVerificationRedirect.ts b/packages/ui/src/utils/externalVerificationRedirect.ts new file mode 100644 index 00000000000..0b8ef1a63e5 --- /dev/null +++ b/packages/ui/src/utils/externalVerificationRedirect.ts @@ -0,0 +1,26 @@ +import type { ExternalAccountResource, NativeOAuthHandler, UserResource } from '@clerk/shared/types'; + +import { throwIfNativeRedirectCallbackHasError } from './nativeRedirectCallback'; + +type CreateExternalAccount = (redirectUrl: string) => Promise; + +export async function connectExternalAccountWithTransport(opts: { + transport: NativeOAuthHandler; + createExternalAccount: CreateExternalAccount; + user: UserResource; +}): Promise { + const redirectUrl = await opts.transport.getRedirectUrl(); + const externalAccount = await opts.createExternalAccount(redirectUrl); + const verificationUrl = externalAccount?.verification?.externalVerificationRedirectURL; + + if (!verificationUrl) { + return; + } + + const { callbackUrl } = await opts.transport.open(verificationUrl); + throwIfNativeRedirectCallbackHasError(callbackUrl); + + // The reloaded user surfaces any stored verification error inline on the connected-account row, + // matching the web flow. + await opts.user.reload(); +} diff --git a/packages/ui/src/utils/nativeOAuthTransport.ts b/packages/ui/src/utils/nativeOAuthTransport.ts new file mode 100644 index 00000000000..40fd3eab47c --- /dev/null +++ b/packages/ui/src/utils/nativeOAuthTransport.ts @@ -0,0 +1,137 @@ +import type { + EnterpriseSSOStrategy, + HandleOAuthCallbackParams, + LoadedClerk, + NativeOAuthHandler, + OAuthStrategy, + SignInResource, + SignUpResource, +} from '@clerk/shared/types'; + +import { + createNativeRedirectResourceError, + getRotatingTokenNonceFromNativeRedirectCallback, + throwIfNativeRedirectCallbackHasError, +} from './nativeRedirectCallback'; + +type ClerkForNativeOAuth = Pick; + +type NativeSignInTransportOpts = { + transport: NativeOAuthHandler; + signIn: SignInResource; + clerk: ClerkForNativeOAuth; + strategy: OAuthStrategy | EnterpriseSSOStrategy; + identifier?: string; + oidcPrompt?: string; + continueSignIn?: boolean; + enterpriseConnectionId?: string; + callbackParams: HandleOAuthCallbackParams; +}; + +export async function authenticateSignInWithNativeTransport(opts: NativeSignInTransportOpts): Promise { + const redirectUrl = String(await opts.transport.getRedirectUrl()); + + if (!opts.signIn.id || !opts.continueSignIn) { + await opts.signIn.create({ + strategy: opts.strategy, + identifier: opts.identifier, + redirectUrl, + actionCompleteRedirectUrl: redirectUrl, + }); + } + + if (opts.strategy === 'enterprise_sso') { + await opts.signIn.prepareFirstFactor({ + strategy: 'enterprise_sso', + redirectUrl, + actionCompleteRedirectUrl: redirectUrl, + oidcPrompt: opts.oidcPrompt, + enterpriseConnectionId: opts.enterpriseConnectionId, + }); + } + + const verificationUrl = opts.signIn.firstFactorVerification.externalVerificationRedirectURL; + if (!verificationUrl) { + return; + } + + const { callbackUrl } = await opts.transport.open(verificationUrl); + throwIfNativeRedirectCallbackHasError(callbackUrl); + + const nonce = getRotatingTokenNonceFromNativeRedirectCallback(callbackUrl); + if (nonce) { + await opts.signIn.reload({ rotatingTokenNonce: nonce }); + await opts.clerk.__internal_handleNativeOAuthCallback(opts.signIn, opts.callbackParams); + return; + } + + await opts.signIn.reload(); + const error = opts.signIn.firstFactorVerification.error; + if (error) { + const nativeRedirectError = createNativeRedirectResourceError(error); + await opts.signIn.create({}); + throw nativeRedirectError; + } + + await opts.clerk.__internal_handleNativeOAuthCallback(opts.signIn, opts.callbackParams); +} + +type NativeSignUpTransportOpts = { + transport: NativeOAuthHandler; + signUp: SignUpResource; + clerk: ClerkForNativeOAuth; + strategy: OAuthStrategy | EnterpriseSSOStrategy; + continueSignUp?: boolean; + unsafeMetadata?: SignUpResource['unsafeMetadata']; + emailAddress?: string; + legalAccepted?: boolean; + oidcPrompt?: string; + enterpriseConnectionId?: string; + callbackParams: HandleOAuthCallbackParams; +}; + +export async function authenticateSignUpWithNativeTransport(opts: NativeSignUpTransportOpts): Promise { + const redirectUrl = String(await opts.transport.getRedirectUrl()); + + const authParams = { + strategy: opts.strategy, + redirectUrl, + actionCompleteRedirectUrl: redirectUrl, + unsafeMetadata: opts.unsafeMetadata, + emailAddress: opts.emailAddress, + legalAccepted: opts.legalAccepted, + oidcPrompt: opts.oidcPrompt, + enterpriseConnectionId: opts.enterpriseConnectionId, + }; + + if (opts.continueSignUp && opts.signUp.id) { + await opts.signUp.update(authParams); + } else { + await opts.signUp.create(authParams); + } + + const verificationUrl = opts.signUp.verifications.externalAccount.externalVerificationRedirectURL; + if (!verificationUrl) { + return; + } + + const { callbackUrl } = await opts.transport.open(verificationUrl); + throwIfNativeRedirectCallbackHasError(callbackUrl); + + const nonce = getRotatingTokenNonceFromNativeRedirectCallback(callbackUrl); + if (nonce) { + await opts.signUp.reload({ rotatingTokenNonce: nonce }); + await opts.clerk.__internal_handleNativeOAuthCallback(opts.signUp, opts.callbackParams); + return; + } + + await opts.signUp.reload(); + const error = opts.signUp.verifications.externalAccount.error; + if (error) { + const nativeRedirectError = createNativeRedirectResourceError(error); + await opts.signUp.create({}); + throw nativeRedirectError; + } + + await opts.clerk.__internal_handleNativeOAuthCallback(opts.signUp, opts.callbackParams); +} diff --git a/packages/ui/src/utils/nativeRedirectCallback.ts b/packages/ui/src/utils/nativeRedirectCallback.ts new file mode 100644 index 00000000000..0cd23c89c0b --- /dev/null +++ b/packages/ui/src/utils/nativeRedirectCallback.ts @@ -0,0 +1,90 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIError } from '@clerk/shared/types'; + +const ERROR_CODE_PARAM_NAMES = ['clerk_error_code', 'error_code', 'code', 'error'] as const; +const ERROR_MESSAGE_PARAM_NAMES = ['clerk_error_message', 'error_message', 'message', 'error_description'] as const; +const ERROR_LONG_MESSAGE_PARAM_NAMES = ['clerk_error_long_message', 'long_message', 'longMessage'] as const; +const ERROR_STATUS_PARAM_NAMES = ['clerk_error_status', 'error_status', 'status'] as const; + +function getFirstParam(searchParams: URLSearchParams, names: readonly string[]): string | null { + for (const name of names) { + const value = searchParams.get(name); + if (value) { + return value; + } + } + + return null; +} + +function getErrorStatus(searchParams: URLSearchParams): number { + const status = getFirstParam(searchParams, ERROR_STATUS_PARAM_NAMES); + if (!status) { + return 400; + } + + const parsedStatus = Number(status); + return Number.isInteger(parsedStatus) ? parsedStatus : 400; +} + +export function throwIfNativeRedirectCallbackHasError(callbackUrl: string): void { + const url = new URL(callbackUrl); + const code = getFirstParam(url.searchParams, ERROR_CODE_PARAM_NAMES); + + if (!code) { + return; + } + + const message = getFirstParam(url.searchParams, ERROR_MESSAGE_PARAM_NAMES) || code; + const longMessage = getFirstParam(url.searchParams, ERROR_LONG_MESSAGE_PARAM_NAMES) || undefined; + + throw new ClerkAPIResponseError(longMessage || message, { + status: getErrorStatus(url.searchParams), + data: [ + { + code, + message, + long_message: longMessage, + }, + ], + }); +} + +/** + * Native OAuth callbacks can return to the app without a rotating token nonce when the + * provider stores an error on the pending sign-in/sign-up resource. Surface that resource + * error through the same API response error path used by regular resource requests. + */ +export function createNativeRedirectResourceError(error: ClerkAPIError): ClerkAPIResponseError { + return new ClerkAPIResponseError(error.longMessage || error.message, { + status: 400, + data: [ + { + code: error.code, + message: error.message, + long_message: error.longMessage, + meta: { + param_name: error.meta?.paramName, + session_id: error.meta?.sessionId, + email_addresses: error.meta?.emailAddresses, + identifiers: error.meta?.identifiers, + zxcvbn: error.meta?.zxcvbn, + plan: error.meta?.plan, + is_plan_upgrade_possible: error.meta?.isPlanUpgradePossible, + }, + }, + ], + }); +} + +export function throwIfNativeRedirectResourceHasError(error: ClerkAPIError | null | undefined): void { + if (!error) { + return; + } + + throw createNativeRedirectResourceError(error); +} + +export function getRotatingTokenNonceFromNativeRedirectCallback(callbackUrl: string): string | null { + return new URL(callbackUrl).searchParams.get('rotating_token_nonce'); +}