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 b6c7b8d6734..3fdf80e20fa 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'); @@ -2310,6 +2323,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, { @@ -2431,6 +2467,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 57c60510be6..2b4cf2cb8eb 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1044,6 +1044,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). * @@ -1169,6 +1180,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 */ @@ -1213,6 +1238,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. */ @@ -1459,6 +1494,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..a7dfb042b6b 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({ @@ -284,6 +290,79 @@ 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', + callbackParams: expect.any(Object), + }), + ); + }); + 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: 'oauth_error', message: 'Unable to complete authentication.', 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 +438,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..7b44d07d095 --- /dev/null +++ b/packages/ui/src/utils/__tests__/nativeOAuthTransport.test.ts @@ -0,0 +1,208 @@ +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('ignores callback URLs with no nonce until FAPI provides a native error contract', 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); + + await authenticateSignInWithNativeTransport({ + transport, + signIn, + clerk, + strategy: 'oauth_google', + callbackParams, + }); + + expect(signIn.reload).not.toHaveBeenCalled(); + expect(clerk.__internal_handleNativeOAuthCallback).not.toHaveBeenCalled(); + }); +}); + +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('ignores callback URLs with no nonce until FAPI provides a native error contract', 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); + + await authenticateSignUpWithNativeTransport({ + transport, + signUp, + clerk, + strategy: 'oauth_google', + callbackParams, + }); + + expect(signUp.reload).not.toHaveBeenCalled(); + expect(clerk.__internal_handleNativeOAuthCallback).not.toHaveBeenCalled(); + }); +}); 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..a1cd30e5b80 --- /dev/null +++ b/packages/ui/src/utils/externalVerificationRedirect.ts @@ -0,0 +1,23 @@ +import type { ExternalAccountResource, NativeOAuthHandler, UserResource } from '@clerk/shared/types'; + +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; + } + + await opts.transport.open(verificationUrl); + + // 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..8e61ebb681e --- /dev/null +++ b/packages/ui/src/utils/nativeOAuthTransport.ts @@ -0,0 +1,121 @@ +import type { + EnterpriseSSOStrategy, + HandleOAuthCallbackParams, + LoadedClerk, + NativeOAuthHandler, + OAuthStrategy, + SignInResource, + SignUpResource, +} from '@clerk/shared/types'; + +type ClerkForNativeOAuth = Pick; + +type CompleteNativeOAuthCallbackOpts = { + callbackUrl: string; + reloadWithNonce: (nonce: string) => Promise; + handleCallback: () => Promise; +}; + +async function completeNativeOAuthCallback(opts: CompleteNativeOAuthCallbackOpts): Promise { + const nonce = new URL(opts.callbackUrl).searchParams.get('rotating_token_nonce'); + if (!nonce) { + return; + } + + await opts.reloadWithNonce(nonce); + await opts.handleCallback(); +} + +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); + await completeNativeOAuthCallback({ + callbackUrl, + reloadWithNonce: nonce => opts.signIn.reload({ rotatingTokenNonce: nonce }), + handleCallback: () => 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); + await completeNativeOAuthCallback({ + callbackUrl, + reloadWithNonce: nonce => opts.signUp.reload({ rotatingTokenNonce: nonce }), + handleCallback: () => opts.clerk.__internal_handleNativeOAuthCallback(opts.signUp, opts.callbackParams), + }); +}