From 82c783b1e9280cfefec2619c83f793fb6c44583b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 09:09:22 -0700 Subject: [PATCH 01/12] feat(ui): add oauth transport support for external auth flows --- .changeset/oauth-transport.md | 7 + .../clerk-js/src/core/__tests__/clerk.test.ts | 133 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 90 +++++++++++- .../clerk-js/src/core/resources/SignIn.ts | 13 ++ .../clerk-js/src/core/resources/SignUp.ts | 13 ++ .../core/resources/__tests__/SignIn.test.ts | 41 ++++++ .../core/resources/__tests__/SignUp.test.ts | 43 ++++++ .../authenticateWithTransport.test.ts | 111 +++++++++++++++ .../src/utils/authenticateWithTransport.ts | 57 ++++++++ packages/shared/src/types/clerk.ts | 48 +++++++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/oauthTransport.ts | 22 +++ packages/shared/src/types/redirects.ts | 9 ++ .../components/SignIn/SignInSocialButtons.tsx | 26 +++- .../__tests__/SignInSocialButtons.test.tsx | 83 +++++++++++ .../buildOAuthCallbackParams.test.ts | 65 +++++++++ .../SignIn/buildOAuthCallbackParams.ts | 40 ++++++ packages/ui/src/components/SignIn/index.tsx | 26 +--- .../components/SignUp/SignUpSocialButtons.tsx | 18 ++- .../__tests__/SignUpSocialButtons.test.tsx | 102 ++++++++++++++ .../UserProfile/ConnectedAccountsMenu.tsx | 45 +++--- .../UserProfile/ConnectedAccountsSection.tsx | 47 ++++--- .../ConnectedAccountsSection.test.tsx | 54 ++++++- 23 files changed, 1018 insertions(+), 76 deletions(-) create mode 100644 .changeset/oauth-transport.md create mode 100644 packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts create mode 100644 packages/clerk-js/src/utils/authenticateWithTransport.ts create mode 100644 packages/shared/src/types/oauthTransport.ts create mode 100644 packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx create mode 100644 packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts create mode 100644 packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts create mode 100644 packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx diff --git a/.changeset/oauth-transport.md b/.changeset/oauth-transport.md new file mode 100644 index 00000000000..f9608bccbb5 --- /dev/null +++ b/.changeset/oauth-transport.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +--- + +Add an internal OAuth transport (`__internal_oauthTransport`) so native desktop SDK wrappers can run Clerk's prebuilt OAuth/SSO flows through a system browser. Existing redirect and popup behavior is unchanged when no transport is registered. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 979cf6e24fa..5d6af34c6ea 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -158,6 +158,41 @@ describe('Clerk singleton', () => { }); }); + describe('__internal_oauthTransport', () => { + it('defaults to null with no getter access errors', () => { + const sut = new Clerk(productionPublishableKey); + + expect(sut.__internal_hasOAuthTransport).toBe(false); + expect(sut.__internal_oauthTransport).toBeNull(); + }); + + it('exposes the transport registered via options after load', async () => { + const transport = { + getRedirectUrl: () => 'myapp://sso-callback', + open: async (_url: URL) => ({ callbackUrl: 'myapp://sso-callback' }), + }; + const sut = new Clerk(productionPublishableKey); + + await sut.load({ __internal_oauthTransport: transport }); + + expect(sut.__internal_hasOAuthTransport).toBe(true); + expect(sut.__internal_oauthTransport).toBe(transport); + }); + }); + + describe('__internal_handleResourceCallback', () => { + it('handleGoogleOneTapCallback delegates to __internal_handleResourceCallback', async () => { + const clerk = new Clerk(productionPublishableKey); + await clerk.load(); + const spy = vi.spyOn(clerk, '__internal_handleResourceCallback').mockResolvedValue(undefined); + const signInLike = { identifier: 'x' } as any; + + await clerk.handleGoogleOneTapCallback(signInLike, { signInUrl: '/sign-in' }); + + expect(spy).toHaveBeenCalledWith(signInLike, { signInUrl: '/sign-in' }, undefined); + }); + }); + describe('.setActive', () => { describe('with `active` session status', () => { const mockSession = { @@ -1205,6 +1240,104 @@ describe('Clerk singleton', () => { }); }); + it('uses navigateOnSetActive for completed sign in callbacks', async () => { + const sessionId = 'sess_123'; + const mockSession = { id: sessionId, currentTask: null }; + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + onWindowLocationHost: () => false, + }), + ); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn({ + status: 'complete', + created_session_id: sessionId, + } as any as SignInJSON), + signUp: new SignUp(null), + }), + ); + + const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { + expect(session).toBe(mockSession); + expect(redirectUrl).toBe('http://test.host/after-sign-in'); + expect(decorateUrl('/decorated')).toBe('/decorated'); + }); + const mockSetActive = vi.fn(async ({ navigate }) => navigate({ session: mockSession })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + sut.setActive = mockSetActive as any; + + await sut.handleRedirectCallback({ + signInForceRedirectUrl: '/after-sign-in', + navigateOnSetActive, + }); + + expect(mockSetActive).toHaveBeenCalledWith({ + session: sessionId, + navigate: expect.any(Function), + }); + expect(navigateOnSetActive).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses navigateOnSetActive for completed sign up callbacks', async () => { + const sessionId = 'sess_123'; + const mockSession = { id: sessionId, currentTask: null }; + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + onWindowLocationHost: () => false, + }), + ); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + signIn: new SignIn(null), + signUp: new SignUp({ + status: 'complete', + created_session_id: sessionId, + } as any as SignUpJSON), + }), + ); + + const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { + expect(session).toBe(mockSession); + expect(redirectUrl).toBe('http://test.host/after-sign-up'); + expect(decorateUrl('/decorated')).toBe('/decorated'); + }); + const mockSetActive = vi.fn(async ({ navigate }) => navigate({ session: mockSession })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + sut.setActive = mockSetActive as any; + + await sut.handleRedirectCallback({ + signUpForceRedirectUrl: '/after-sign-up', + navigateOnSetActive, + }); + + expect(mockSetActive).toHaveBeenCalledWith({ + session: sessionId, + navigate: expect.any(Function), + }); + expect(navigateOnSetActive).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it('does not initiate the transfer flow when transferable: false is passed', async () => { mockEnvironmentFetch.mockReturnValue( Promise.resolve({ diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b6c7b8d6734..dd639cc3a9d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -98,6 +98,7 @@ import type { LoadedClerk, NavigateOptions, OAuthApplicationNamespace, + OAuthTransport, OrganizationListProps, OrganizationProfileProps, OrganizationResource, @@ -269,6 +270,7 @@ export class Clerk implements ClerkInterface { #listeners: Array<(emission: Resources) => void> = []; #navigationListeners: Array<() => void> = []; #options: ClerkOptions = {}; + #oauthTransport: OAuthTransport | null = null; #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); @@ -295,6 +297,14 @@ export class Clerk implements ClerkInterface { : undefined; } + get __internal_hasOAuthTransport(): boolean { + return this.#oauthTransport !== null; + } + + get __internal_oauthTransport(): OAuthTransport | null { + return this.#oauthTransport; + } + public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) | undefined; @@ -529,6 +539,7 @@ export class Clerk implements ClerkInterface { } this.#options = this.#initOptions(options); + this.#oauthTransport = this.#options.__internal_oauthTransport ?? null; // Initialize ClerkUI if it was provided if (this.#options.ui?.ClerkUI) { @@ -2285,7 +2296,7 @@ export class Clerk implements ClerkInterface { return null; }; - public handleGoogleOneTapCallback = async ( + public __internal_handleResourceCallback = async ( signInOrUp: SignInResource | SignUpResource, params: HandleOAuthCallbackParams, customNavigate?: (to: string) => Promise, @@ -2310,6 +2321,14 @@ export class Clerk implements ClerkInterface { }); }; + public handleGoogleOneTapCallback = async ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ): Promise => { + return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + }; + private _handleRedirectCallback = async ( params: HandleOAuthCallbackParams, { @@ -2446,7 +2465,20 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: si.sessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signInUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + if (params.navigateOnSetActive) { + await params.navigateOnSetActive({ + session, + redirectUrl: redirectUrls.getAfterSignInUrl(), + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + + await setActiveNavigate({ + session, + baseUrl: signInUrl, + redirectUrl: redirectUrls.getAfterSignInUrl(), + }); }, }); } @@ -2461,7 +2493,20 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + if (params.navigateOnSetActive) { + await params.navigateOnSetActive({ + session, + redirectUrl: redirectUrls.getAfterSignInUrl(), + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignInUrl(), + }); }, }); case 'needs_first_factor': @@ -2512,7 +2557,20 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + if (params.navigateOnSetActive) { + await params.navigateOnSetActive({ + session, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + }); }, }); case 'missing_requirements': @@ -2526,7 +2584,20 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: su.sessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + if (params.navigateOnSetActive) { + await params.navigateOnSetActive({ + session, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + }); }, }); } @@ -2552,6 +2623,15 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: sessionId, navigate: async ({ session }) => { + if (params.navigateOnSetActive) { + await params.navigateOnSetActive({ + session, + redirectUrl: redirectUrls.getAfterSignInUrl(), + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + await setActiveNavigate({ session, baseUrl: suUserAlreadySignedIn ? signUpUrl : signInUrl, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 3b19d06d56a..27397193194 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -83,6 +83,7 @@ import { _futureAuthenticateWithPopup, wrapWithPopupRoutes, } from '../../utils/authenticateWithPopup'; +import { _authenticateWithTransport } from '../../utils/authenticateWithTransport'; import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; import { loadZxcvbn } from '../../utils/zxcvbn'; @@ -379,6 +380,18 @@ export class SignIn extends BaseResource implements SignInResource { }; public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise => { + const transport = SignIn.clerk.__internal_oauthTransport; + if (transport) { + return _authenticateWithTransport({ + clerk: SignIn.clerk, + transport, + resource: this, + authenticateMethod: this.authenticateWithRedirectOrPopup, + params, + callbackParams: params.__internal_callbackParams ?? {}, + }); + } + return this.authenticateWithRedirectOrPopup(params, windowNavigate); }; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index bccdfa48919..99c6b09d35a 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -54,6 +54,7 @@ import { _futureAuthenticateWithPopup, wrapWithPopupRoutes, } from '../../utils/authenticateWithPopup'; +import { _authenticateWithTransport } from '../../utils/authenticateWithTransport'; import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; @@ -449,6 +450,18 @@ export class SignUp extends BaseResource implements SignUpResource { unsafeMetadata?: SignUpUnsafeMetadata; }, ): Promise => { + const transport = SignUp.clerk.__internal_oauthTransport; + if (transport) { + return _authenticateWithTransport({ + clerk: SignUp.clerk, + transport, + resource: this, + authenticateMethod: this.authenticateWithRedirectOrPopup, + params, + callbackParams: params.__internal_callbackParams ?? {}, + }); + } + return this.authenticateWithRedirectOrPopup(params, windowNavigate); }; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 2ecd83eec75..70cd8141ed0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -34,6 +34,47 @@ describe('SignIn', () => { expect(snapshot).toBeDefined(); }); + describe('authenticateWithRedirect with an OAuth transport', () => { + afterEach(() => { + vi.clearAllMocks(); + SignIn.clerk = {} as any; + }); + + it('routes through the transport instead of windowNavigate when one is registered', async () => { + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); + const handleResourceCallback = vi.fn().mockResolvedValue(undefined); + SignIn.clerk = { + buildUrlWithAuth: vi.fn(u => u), + __internal_oauthTransport: { getRedirectUrl: () => 'myapp://sso-callback', open }, + __internal_handleResourceCallback: handleResourceCallback, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_123', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://provider.example/auth', + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn(); + await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + __internal_callbackParams: { signInUrl: '/sign-in' }, + } as any); + + expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); + expect(handleResourceCallback).toHaveBeenCalledWith(signIn, { signInUrl: '/sign-in' }); + }); + }); + describe('signIn.create', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 4e3f7911fe8..bc04f32396c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -35,6 +35,49 @@ describe('SignUp', () => { expect(snapshot).toBeDefined(); }); + describe('authenticateWithRedirect with an OAuth transport', () => { + afterEach(() => { + vi.clearAllMocks(); + SignUp.clerk = {} as any; + }); + + it('routes through the transport instead of windowNavigate when one is registered', async () => { + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); + const handleResourceCallback = vi.fn().mockResolvedValue(undefined); + SignUp.clerk = { + buildUrlWithAuth: vi.fn(u => u), + __internal_oauthTransport: { getRedirectUrl: () => 'myapp://sso-callback', open }, + __internal_handleResourceCallback: handleResourceCallback, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + verifications: { + external_account: { + status: 'unverified', + external_verification_redirect_url: 'https://provider.example/auth', + }, + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp(); + await signUp.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + __internal_callbackParams: { signInUrl: '/sign-in' }, + } as any); + + expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); + expect(handleResourceCallback).toHaveBeenCalledWith(signUp, { signInUrl: '/sign-in' }); + }); + }); + describe('create', () => { beforeEach(() => { SignUp.clerk = { diff --git a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts new file mode 100644 index 00000000000..93a3a2ba439 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts @@ -0,0 +1,111 @@ +import type { ClerkRuntimeError } from '@clerk/shared/error'; +import { describe, expect, it, vi } from 'vitest'; + +import { _authenticateWithTransport } from '../authenticateWithTransport'; + +const makeClerk = () => ({ + __internal_handleResourceCallback: vi.fn().mockResolvedValue('done'), +}); + +describe('_authenticateWithTransport', () => { + it('captures the verification URL, opens the transport, reloads with the nonce, then completes', async () => { + const clerk = makeClerk(); + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' }), + }; + const resource = { reload: vi.fn().mockResolvedValue(undefined) } as any; + const authenticateMethod = vi.fn(async (_params, navigate) => { + navigate(new URL('https://provider.example/auth')); + }); + const callbackParams = { signInUrl: '/sign-in' }; + + await _authenticateWithTransport({ + clerk: clerk as any, + transport, + resource, + authenticateMethod, + params: { strategy: 'oauth_google', redirectUrl: '/x', redirectUrlComplete: '/' } as any, + callbackParams, + }); + + expect(authenticateMethod).toHaveBeenCalledWith( + expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: 'myapp://sso-callback' }), + expect.any(Function), + ); + expect(transport.open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); + expect(resource.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' }); + expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, callbackParams); + }); + + it('skips reload when the callback URL has no nonce', async () => { + const clerk = makeClerk(); + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }), + }; + const resource = { reload: vi.fn() } as any; + const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth')); + + await _authenticateWithTransport({ + clerk: clerk as any, + transport, + resource, + authenticateMethod, + params: {} as any, + callbackParams: {}, + }); + + expect(resource.reload).not.toHaveBeenCalled(); + expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, {}); + }); + + it('propagates transport.open rejection and does not complete the callback', async () => { + const clerk = makeClerk(); + const transport = { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open: vi.fn().mockRejectedValue(new Error('cancelled')), + }; + const resource = { reload: vi.fn() } as any; + const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth')); + + await expect( + _authenticateWithTransport({ + clerk: clerk as any, + transport, + resource, + authenticateMethod, + params: {} as any, + callbackParams: {}, + }), + ).rejects.toThrow('cancelled'); + + expect(clerk.__internal_handleResourceCallback).not.toHaveBeenCalled(); + }); + + it('throws a Clerk error and does not open the transport when no verification URL is captured', async () => { + const clerk = makeClerk(); + const transport = { getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), open: vi.fn() }; + const resource = { reload: vi.fn() } as any; + const authenticateMethod = vi.fn(async () => { + return; + }); + + await expect( + _authenticateWithTransport({ + clerk: clerk as any, + transport, + resource, + authenticateMethod, + params: {} as any, + callbackParams: {}, + }), + ).rejects.toMatchObject({ + code: 'oauth_transport_missing_verification_url', + clerkRuntimeError: true, + } satisfies Partial); + + expect(transport.open).not.toHaveBeenCalled(); + expect(clerk.__internal_handleResourceCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts new file mode 100644 index 00000000000..71e78743d39 --- /dev/null +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -0,0 +1,57 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { + AuthenticateWithRedirectParams, + HandleOAuthCallbackParams, + OAuthTransport, + SignInResource, + SignUpResource, +} from '@clerk/shared/types'; + +type AuthenticateMethod = ( + params: AuthenticateWithRedirectParams, + navigateCallback: (url: URL | string) => void, +) => Promise; + +type ClerkWithResourceCallback = { + __internal_handleResourceCallback: ( + resource: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + ) => Promise; +}; + +/** + * Drives an OAuth/SSO flow through a registered OAuth transport while reusing the + * resource's redirect/popup orchestration unchanged. + * + * @internal + */ +export async function _authenticateWithTransport(opts: { + clerk: ClerkWithResourceCallback; + transport: OAuthTransport; + resource: SignInResource | SignUpResource; + authenticateMethod: AuthenticateMethod; + params: AuthenticateWithRedirectParams; + callbackParams: HandleOAuthCallbackParams; +}): Promise { + const redirectUrl = String(await opts.transport.getRedirectUrl()); + + let verificationUrl: URL | string | undefined; + await opts.authenticateMethod({ ...opts.params, redirectUrl, redirectUrlComplete: redirectUrl }, url => { + verificationUrl = url; + }); + + if (!verificationUrl) { + throw new ClerkRuntimeError('OAuth transport did not receive a verification URL.', { + code: 'oauth_transport_missing_verification_url', + }); + } + + const { callbackUrl } = await opts.transport.open(new URL(verificationUrl.toString())); + const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce'); + + if (nonce) { + await opts.resource.reload({ rotatingTokenNonce: nonce }); + } + + await opts.clerk.__internal_handleResourceCallback(opts.resource, opts.callbackParams); +} diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 1d7d9355e0c..69ffb8edf8f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1045,6 +1045,32 @@ export interface Clerk { customNavigate?: (to: string) => Promise, ) => Promise; + /** + * Whether an OAuth transport (e.g. for Electron) has been registered. + * + * @internal + */ + __internal_hasOAuthTransport: boolean; + + /** + * The registered OAuth transport, or null when none is registered. + * + * @internal + */ + __internal_oauthTransport: import('./oauthTransport').OAuthTransport | null; + + /** + * Completes an OAuth/SAML callback using a sign-in or sign-up resource already in hand + * (generalization of `handleGoogleOneTapCallback`). + * + * @internal + */ + __internal_handleResourceCallback: ( + 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). * @@ -1218,6 +1244,18 @@ export type HandleOAuthCallbackParams = TransferableOption & * 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. */ unsafeMetadata?: SignUpUnsafeMetadata; + /** + * Internal navigation hook used by Clerk UI to preserve custom post-activation routing + * when completing an OAuth callback in-process (transport flows). Not set by the web + * redirect/popup paths. + * + * @internal + */ + navigateOnSetActive?: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: (url: string) => string; + }) => Promise; }; export type HandleSamlCallbackParams = HandleOAuthCallbackParams; @@ -1451,6 +1489,16 @@ export type ClerkOptions = ClerkOptionsNavigation & */ __internal_keyless_dismissPrompt?: (() => Promise) | null; + /** + * Provide a transport for OAuth/SSO flows in environments where a same-document + * redirect or popup cannot be used (e.g. Electron, Tauri). When set, Clerk uses the + * transport's `getRedirectUrl` as the FAPI `redirectUrl` and calls `open` instead of + * navigating the document. Intended for native desktop SDK wrappers. + * + * @internal + */ + __internal_oauthTransport?: import('./oauthTransport').OAuthTransport; + /** * Customize the URL paths users are redirected to after sign-in or sign-up when specific * session tasks need to be completed. diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7ab38b098d1..577a38ab18d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -35,6 +35,7 @@ export type * from './localization'; export type * from './multiDomain'; export type * from './oauth'; export type * from './oauthApplication'; +export type * from './oauthTransport'; export type * from './organization'; export type * from './organizationCreationDefaults'; export type * from './organizationDomain'; diff --git a/packages/shared/src/types/oauthTransport.ts b/packages/shared/src/types/oauthTransport.ts new file mode 100644 index 00000000000..4b29d943ea5 --- /dev/null +++ b/packages/shared/src/types/oauthTransport.ts @@ -0,0 +1,22 @@ +type Awaitable = T | Promise; + +/** + * Transport for OAuth/SSO flows in environments where a same-document redirect or popup + * cannot be used (e.g. Electron, Tauri). Injected via `__internal_oauthTransport` in + * `ClerkOptions`. Generic by design: "open a URL externally and wait for the callback + * URL" - it knows nothing about any specific runtime. + * + * @internal + */ +export type OAuthTransport = { + /** + * The callback/redirect URL FAPI should send the provider back to. For native runtimes + * this is an OS-registered deep link, e.g. `myapp://sso-callback`. + */ + getRedirectUrl: () => Awaitable; + /** + * Open the provider verification URL externally and resolve with the callback URL the + * OS routes back to the app after auth. Rejects on user cancellation or error. + */ + open: (url: URL) => Promise<{ callbackUrl: string }>; +}; diff --git a/packages/shared/src/types/redirects.ts b/packages/shared/src/types/redirects.ts index 83b1ba61327..f34ddfe0a87 100644 --- a/packages/shared/src/types/redirects.ts +++ b/packages/shared/src/types/redirects.ts @@ -76,6 +76,15 @@ export type AuthenticateWithRedirectParams = { */ oidcPrompt?: string; + /** + * Internal OAuth callback context, consumed only when an OAuth transport completes the + * flow in-process. Ignored by the web redirect/popup paths. Shape matches the params + * the prebuilt SSO callback route passes to `handleRedirectCallback`. + * + * @internal + */ + __internal_callbackParams?: import('./clerk').HandleOAuthCallbackParams; + /** * @experimental */ diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index ec5b7cc9170..36a1f58ee46 100644 --- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx +++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx @@ -14,6 +14,7 @@ import { useCardState } from '../../elements/contexts'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; import { useRouter } from '../../router'; +import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; export type SignInSocialButtonsProps = SocialButtonsProps & { onAlternativePhoneCodeProviderClick?: (channel: PhoneCodeChannel) => void; @@ -27,7 +28,9 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) const signIn = useCoreSignIn(); const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignInUrl || '/'; - const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup()); + const shouldUsePopup = + !clerk.__internal_hasOAuthTransport && + (ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup())); const { onAlternativePhoneCodeProviderClick, ...rest } = props; const handleError = (err: any) => { @@ -53,7 +56,7 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) { if (shouldUsePopup) { // We create the popup window here with the `about:blank` URL since some browsers will block popups that are @@ -73,8 +76,23 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) } return signIn - .authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete, oidcPrompt: ctx.oidcPrompt }) - .catch(err => handleError(err)); + .authenticateWithRedirect({ + strategy, + redirectUrl, + redirectUrlComplete, + oidcPrompt: ctx.oidcPrompt, + __internal_callbackParams: { + ...buildSignInOAuthCallbackParams(ctx), + navigateOnSetActive: ctx.navigateOnSetActive, + }, + }) + .catch(err => { + const res = handleError(err); + if (clerk.__internal_hasOAuthTransport) { + card.setIdle(); + } + return res; + }); }} web3Callback={strategy => { if (strategy === 'web3_solana_signature') { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx new file mode 100644 index 00000000000..0ff5c2aa21a --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx @@ -0,0 +1,83 @@ +import { waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { SignInSocialButtons } from '../SignInSocialButtons'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const registerOAuthTransport = (clerk: unknown) => { + Object.defineProperty(clerk, '__internal_hasOAuthTransport', { + configurable: true, + value: true, + }); +}; + +describe('SignInSocialButtons', () => { + it('with a transport registered, calls authenticateWithRedirect with __internal_callbackParams and never opens a popup', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + props.setProps({ oauthFlow: 'popup' } as any); + registerOAuthTransport(fixtures.clerk); + fixtures.signIn.authenticateWithRedirect.mockResolvedValue(undefined as any); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ closed: false } as Window); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(screen.getByText('Continue with Google')); + + expect(openSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith( + expect.objectContaining({ + strategy: 'oauth_google', + __internal_callbackParams: expect.objectContaining({ + signInUrl: expect.any(String), + navigateOnSetActive: expect.any(Function), + }), + }), + ); + }); + openSpy.mockRestore(); + }); + + it('with a transport registered, clears loading when authenticateWithRedirect rejects', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + props.setProps({ oauthFlow: 'popup' } as any); + registerOAuthTransport(fixtures.clerk); + fixtures.signIn.authenticateWithRedirect.mockRejectedValue(new Error('cancelled')); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /continue with google/i }); + await userEvent.click(button); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts new file mode 100644 index 00000000000..135da7b32f4 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSignInOAuthCallbackParams, buildSignUpOAuthCallbackParams } from '../buildOAuthCallbackParams'; + +describe('buildSignInOAuthCallbackParams', () => { + it('produces exactly the params the SignIn sso-callback route passes today', () => { + const ctx = { + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + afterSignInUrl: '/after-in', + afterSignUpUrl: '/after-up', + signUpContinueUrl: '/continue', + transferable: true, + unsafeMetadata: { a: 1 }, + } as any; + + expect(buildSignInOAuthCallbackParams(ctx)).toEqual({ + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + signInForceRedirectUrl: '/after-in', + signUpForceRedirectUrl: '/after-up', + continueSignUpUrl: '/continue', + transferable: true, + firstFactorUrl: '../factor-one', + secondFactorUrl: '../factor-two', + resetPasswordUrl: '../reset-password', + unsafeMetadata: { a: 1 }, + }); + }); + + it('does not include navigateOnSetActive', () => { + const ctx = { navigateOnSetActive: () => Promise.resolve() } as any; + expect('navigateOnSetActive' in buildSignInOAuthCallbackParams(ctx)).toBe(false); + }); +}); + +describe('buildSignUpOAuthCallbackParams', () => { + it('produces exactly the params the combined-flow SignUp sso-callback route passes today', () => { + const ctx = { + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + afterSignUpUrl: '/after-up', + afterSignInUrl: '/after-in', + secondFactorUrl: '/factor-two', + unsafeMetadata: { b: 2 }, + } as any; + + expect(buildSignUpOAuthCallbackParams(ctx)).toEqual({ + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + signUpForceRedirectUrl: '/after-up', + signInForceRedirectUrl: '/after-in', + secondFactorUrl: '/factor-two', + continueSignUpUrl: '../continue', + verifyEmailAddressUrl: '../verify-email-address', + verifyPhoneNumberUrl: '../verify-phone-number', + unsafeMetadata: { b: 2 }, + }); + }); + + it('does not include navigateOnSetActive', () => { + const ctx = { navigateOnSetActive: () => Promise.resolve() } as any; + expect('navigateOnSetActive' in buildSignUpOAuthCallbackParams(ctx)).toBe(false); + }); +}); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts new file mode 100644 index 00000000000..8c129774fca --- /dev/null +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -0,0 +1,40 @@ +import type { HandleOAuthCallbackParams } from '@clerk/shared/types'; + +import type { SignInContextType } from '../../contexts/components/SignIn'; +import type { SignUpContextType } from '../../contexts/components/SignUp'; + +/** + * Exact callback params the SignIn `sso-callback` route passes to SSOCallback. + * Excludes `navigateOnSetActive`, which is transport-only. + */ +export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { + return { + 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', + unsafeMetadata: ctx.unsafeMetadata, + }; +} + +/** + * Exact callback params the combined-flow SignUp `sso-callback` route passes to SSOCallback. + */ +export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { + return { + 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', + unsafeMetadata: ctx.unsafeMetadata, + }; +} diff --git a/packages/ui/src/components/SignIn/index.tsx b/packages/ui/src/components/SignIn/index.tsx index f977eb229f9..9fd2fee9239 100644 --- a/packages/ui/src/components/SignIn/index.tsx +++ b/packages/ui/src/components/SignIn/index.tsx @@ -20,6 +20,7 @@ import type { SignUpCtx } from '@/types'; import { SignInFactorOneSolanaWalletsCard } from '@/ui/components/SignIn/SignInFactorOneSolanaWalletsCard'; import { normalizeRoutingOptions } from '@/utils/normalizeRoutingOptions'; +import { buildSignInOAuthCallbackParams, buildSignUpOAuthCallbackParams } from './buildOAuthCallbackParams'; import { LazySignUpContinue, LazySignUpSSOCallback, @@ -68,18 +69,7 @@ function SignInRoutes(): JSX.Element { - + @@ -109,17 +99,7 @@ function SignInRoutes(): JSX.Element { - + { if (shouldUsePopup) { // We create the popup window here with the `about:blank` URL since some browsers will block popups that are @@ -70,8 +73,17 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) unsafeMetadata: ctx.unsafeMetadata, legalAccepted: props.legalAccepted, oidcPrompt: ctx.oidcPrompt, + __internal_callbackParams: { + ...buildSignUpOAuthCallbackParams(ctx), + navigateOnSetActive: ctx.navigateOnSetActive, + }, }) - .catch(err => handleError(err, [], card.setError)); + .catch(err => { + handleError(err, [], card.setError); + if (clerk.__internal_hasOAuthTransport) { + card.setIdle(); + } + }); }} web3Callback={strategy => { if (strategy === 'web3_solana_signature') { diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx new file mode 100644 index 00000000000..4520f087b12 --- /dev/null +++ b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx @@ -0,0 +1,102 @@ +import { waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { SignUpSocialButtons } from '../SignUpSocialButtons'; + +const { createFixtures } = bindCreateFixtures('SignUp'); + +const registerOAuthTransport = (clerk: unknown) => { + Object.defineProperty(clerk, '__internal_hasOAuthTransport', { + configurable: true, + value: true, + }); +}; + +describe('SignUpSocialButtons', () => { + it('with a transport registered, calls authenticateWithRedirect with __internal_callbackParams and never opens a popup', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + props.setProps({ + oauthFlow: 'popup', + unsafeMetadata: { source: 'test' }, + oidcPrompt: 'select_account', + } as any); + registerOAuthTransport(fixtures.clerk); + fixtures.signUp.authenticateWithRedirect.mockResolvedValue(undefined as any); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ closed: false } as Window); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(screen.getByText('Continue with Google')); + + expect(openSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(fixtures.signUp.authenticateWithRedirect).toHaveBeenCalledWith({ + strategy: 'oauth_google', + redirectUrl: 'http://localhost:3000/#/sso-callback', + redirectUrlComplete: '/', + continueSignUp: true, + unsafeMetadata: { source: 'test' }, + legalAccepted: true, + oidcPrompt: 'select_account', + __internal_callbackParams: expect.objectContaining({ + signUpUrl: expect.any(String), + signInUrl: expect.any(String), + signUpForceRedirectUrl: '/', + signInForceRedirectUrl: '/', + secondFactorUrl: expect.any(String), + continueSignUpUrl: '../continue', + verifyEmailAddressUrl: '../verify-email-address', + verifyPhoneNumberUrl: '../verify-phone-number', + unsafeMetadata: { source: 'test' }, + navigateOnSetActive: expect.any(Function), + }), + }); + }); + expect(fixtures.signUp.authenticateWithPopup).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('with a transport registered, clears loading when authenticateWithRedirect rejects', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + props.setProps({ oauthFlow: 'popup' } as any); + registerOAuthTransport(fixtures.clerk); + fixtures.signUp.authenticateWithRedirect.mockRejectedValue(new Error('cancelled')); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /continue with google/i }); + await userEvent.click(button); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx index deaa2c31de5..7ed8f1b3c0e 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx @@ -1,5 +1,5 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; -import { useReverification, useUser } from '@clerk/shared/react'; +import { useClerk, useReverification, useUser } from '@clerk/shared/react'; import type { OAuthProvider, OAuthStrategy } from '@clerk/shared/types'; import { useCardState } from '@/ui/elements/contexts'; @@ -16,27 +16,28 @@ import { useRouter } from '../../router'; const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => void }) => { const { strategy } = props; const card = useCardState(); + const clerk = useClerk(); const { user } = useUser(); const { navigate } = useRouter(); const { strategyToDisplayData } = useEnabledThirdPartyProviders(); const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); const isModal = mode === 'modal'; - const createExternalAccount = useReverification(() => { + const createExternalAccount = useReverification((redirectUrl: string) => { const socialProvider = strategy.replace('oauth_', '') as OAuthProvider; - const redirectUrl = isModal - ? appendModalState({ url: window.location.href, componentName, socialProvider: socialProvider }) - : window.location.href; + const decoratedRedirectUrl = isModal + ? appendModalState({ url: redirectUrl, componentName, socialProvider }) + : redirectUrl; const additionalScopes = additionalOAuthScopes ? additionalOAuthScopes[socialProvider] : []; return user?.createExternalAccount({ strategy, - redirectUrl, + redirectUrl: decoratedRedirectUrl, additionalScopes, }); }); - const connect = () => { + const connect = async () => { if (!user) { return; } @@ -44,17 +45,27 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi // TODO: Decide if we should keep using this strategy // If yes, refactor and cleanup: card.setLoading(strategy); - return createExternalAccount() - .then(res => { - if (res && res.verification?.externalVerificationRedirectURL) { - void sleep(2000).then(() => card.setIdle(strategy)); - void navigate(res.verification.externalVerificationRedirectURL.href); + try { + if (clerk.__internal_oauthTransport) { + const res = await createExternalAccount(String(await clerk.__internal_oauthTransport.getRedirectUrl())); + const url = res?.verification?.externalVerificationRedirectURL; + if (url) { + await clerk.__internal_oauthTransport.open(url); + await user.reload(); } - }) - .catch(err => { - handleError(err, [], card.setError); - card.setIdle(strategy); - }); + void sleep(2000).then(() => card.setIdle(strategy)); + return; + } + + const res = await createExternalAccount(window.location.href); + if (res && res.verification?.externalVerificationRedirectURL) { + void sleep(2000).then(() => card.setIdle(strategy)); + void navigate(res.verification.externalVerificationRedirectURL.href); + } + } catch (err: any) { + handleError(err, [], card.setError); + card.setIdle(strategy); + } }; return ( diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index ad294468763..c2c01146ba6 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx @@ -1,5 +1,5 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; -import { useReverification, useUser } from '@clerk/shared/react'; +import { useClerk, useReverification, useUser } from '@clerk/shared/react'; import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy } from '@clerk/shared/types'; import { Fragment, useState } from 'react'; @@ -95,23 +95,24 @@ export const ConnectedAccountsSection = withCardStateProvider( const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => { const { additionalOAuthScopes, componentName, mode } = useUserProfileContext(); + const clerk = useClerk(); const { navigate } = useRouter(); const { user } = useUser(); const card = useCardState(); const accountId = account.id; const isModal = mode === 'modal'; - const redirectUrl = isModal - ? appendModalState({ - url: window.location.href, - componentName, - }) - : window.location.href; + const label = account.username || account.emailAddress; + const fallbackErrorMessage = account.verification?.error?.longMessage; + const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes); + const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != ''; + const shouldDisplayReconnect = + errorCodesForReconnect.includes(account.verification?.error?.code || '') || reauthorizationRequired; + const strategy = (account.verification?.strategy || `oauth_${account.provider}`) as OAuthStrategy; - const createExternalAccount = useReverification(() => + const createExternalAccount = useReverification((redirectUrl: string) => user?.createExternalAccount({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - strategy: account.verification!.strategy as OAuthStrategy, + strategy, redirectUrl, additionalScopes, }), @@ -122,26 +123,30 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => if (!user) { return null; } - const label = account.username || account.emailAddress; - const fallbackErrorMessage = account.verification?.error?.longMessage; - const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes); - const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != ''; - const shouldDisplayReconnect = - errorCodesForReconnect.includes(account.verification?.error?.code || '') || reauthorizationRequired; - const connectedAccountErrorMessage = shouldDisplayReconnect ? localizationKeys(`userProfile.start.connectedAccountsSection.subtitle__disconnected`) : fallbackErrorMessage; const reconnect = async () => { - const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href; - try { + const redirectUrl = clerk.__internal_oauthTransport + ? String(await clerk.__internal_oauthTransport.getRedirectUrl()) + : window.location.href; + const decoratedRedirectUrl = isModal ? appendModalState({ url: redirectUrl, componentName }) : redirectUrl; let response: ExternalAccountResource | undefined; if (reauthorizationRequired) { - response = await account.reauthorize({ additionalScopes, redirectUrl }); + response = await account.reauthorize({ additionalScopes, redirectUrl: decoratedRedirectUrl }); } else { - response = await createExternalAccount(); + response = await createExternalAccount(decoratedRedirectUrl); + } + + if (clerk.__internal_oauthTransport) { + const url = response?.verification?.externalVerificationRedirectURL; + if (url) { + await clerk.__internal_oauthTransport.open(url); + await user.reload(); + } + return; } if (response) { diff --git a/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx index d200066132d..dab174e627f 100644 --- a/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx @@ -1,6 +1,7 @@ +import { CLERK_MODAL_STATE } from '@clerk/shared/internal/clerk-js/constants'; import type { ExternalAccountResource } from '@clerk/shared/types'; import { act, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen } from '@/test/utils'; @@ -128,12 +129,52 @@ describe('ConnectedAccountsSection ', () => { await userEvent.click(getByText(/connect account/i)); await waitFor(() => getByText('Google')); await userEvent.click(getByText(/Google/i)); + expect(fixtures.clerk.user?.createExternalAccount).toHaveBeenCalledWith({ redirectUrl: window.location.href, strategy: 'oauth_google', additionalScopes: [], }); }); + + it('uses the OAuth transport when one is registered', async () => { + const { wrapper, fixtures, props } = await createFixtures(withoutConnections); + + props.setProps({ componentName: 'UserProfile', mode: 'modal' } as any); + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); + Object.defineProperty(fixtures.clerk, '__internal_oauthTransport', { + configurable: true, + value: { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open, + }, + }); + const reload = vi.spyOn(fixtures.clerk.user!, 'reload').mockResolvedValue(fixtures.clerk.user!); + fixtures.clerk.user?.createExternalAccount.mockResolvedValue({ + verification: { externalVerificationRedirectURL: new URL('https://provider.example/auth') }, + } as ExternalAccountResource); + const { userEvent, getByText } = render(, { wrapper }); + + await userEvent.click(getByText(/connect account/i)); + await waitFor(() => getByText('Google')); + await userEvent.click(getByText(/Google/i)); + + const redirectUrl = fixtures.clerk.user?.createExternalAccount.mock.calls[0][0].redirectUrl; + const modalState = JSON.parse(window.atob(new URL(redirectUrl).searchParams.get(CLERK_MODAL_STATE) || '')); + + expect(fixtures.clerk.user?.createExternalAccount).toHaveBeenCalledWith({ + redirectUrl, + strategy: 'oauth_google', + additionalScopes: [], + }); + expect(redirectUrl).toContain('myapp://sso-callback'); + expect(modalState).toMatchObject({ + componentName: 'UserProfile', + socialProvider: 'google', + }); + expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); + expect(reload).toHaveBeenCalled(); + }); }); describe('Recover from issues', () => { @@ -161,7 +202,11 @@ describe('ConnectedAccountsSection ', () => { getByText('This account has been disconnected.'); getByRole('button', { name: /reconnect/i }); await userEvent.click(getByRole('button', { name: /reconnect/i })); - expect(fixtures.clerk.user?.createExternalAccount).toHaveBeenCalled(); + expect(fixtures.clerk.user?.createExternalAccount).toHaveBeenCalledWith({ + strategy: 'oauth_google', + redirectUrl: window.location.href, + additionalScopes: [], + }); }); it('Additional scopes need reconnection', async () => { @@ -195,7 +240,10 @@ describe('ConnectedAccountsSection ', () => { getByText('This account has been disconnected.'); getByRole('button', { name: /reconnect/i }); await userEvent.click(getByRole('button', { name: /reconnect/i })); - expect(fixtures.clerk.user?.externalAccounts[0].reauthorize).toHaveBeenCalled(); + expect(fixtures.clerk.user?.externalAccounts[0].reauthorize).toHaveBeenCalledWith({ + additionalScopes: ['some_scope'], + redirectUrl: window.location.href, + }); }); it('Unrecoverable errors', async () => { From 09fcb3bdc11ae738c14ce881923f83dec434103c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 11 Jun 2026 10:22:33 -0700 Subject: [PATCH 02/12] chore: bundlewatch fix --- packages/ui/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index ae8ea4a9c4e..562da88391f 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -7,7 +7,7 @@ { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "130KB" }, { "path": "./dist/signin*.js", "maxSize": "16KB" }, - { "path": "./dist/signup*.js", "maxSize": "11KB" }, + { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "13KB" }, { "path": "./dist/userbutton*.js", "maxSize": "3.5KB" }, From 4de6c93785a7f38479768a2fecd8625bf90a8291 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 08:51:24 -0700 Subject: [PATCH 03/12] fix types --- packages/react/src/isomorphicClerk.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 669affee091..226dd3f1a14 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -879,6 +879,14 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.__internal_lastEmittedResources; } + get __internal_hasOAuthTransport(): boolean { + return this.clerkjs?.__internal_hasOAuthTransport || false; + } + + get __internal_oauthTransport() { + return this.clerkjs?.__internal_oauthTransport || null; + } + /** * `setActive` can be used to set the active session and/or organization. */ @@ -1540,6 +1548,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_handleResourceCallback = async ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ): Promise => { + const clerkjs = await this.#waitForClerkJS(); + return clerkjs.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + }; + handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams) => { const callback = () => this.clerkjs?.handleEmailLinkVerification(params); if (this.clerkjs && this.loaded) { From 469b4d0db45640f328c2962cd2545d7a7a22d299 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 09:13:42 -0700 Subject: [PATCH 04/12] fix(ui): harden OAuth connected account transport --- .../UserProfile/ConnectedAccountsMenu.tsx | 16 +++--- .../UserProfile/ConnectedAccountsSection.tsx | 14 ++---- .../ConnectedAccountsSection.test.tsx | 50 ++++++++++++++++++- .../components/UserProfile/oauthTransport.ts | 24 +++++++++ 4 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/components/UserProfile/oauthTransport.ts diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx index 7ed8f1b3c0e..c7bb2827510 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx @@ -12,6 +12,7 @@ import { useUserProfileContext } from '../../contexts'; import { descriptors, localizationKeys } from '../../customizables'; import { useEnabledThirdPartyProviders } from '../../hooks'; import { useRouter } from '../../router'; +import { getExternalVerificationRedirectURL, reloadUserAfterOAuthCallback } from './oauthTransport'; const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => void }) => { const { strategy } = props; @@ -48,20 +49,17 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi try { if (clerk.__internal_oauthTransport) { const res = await createExternalAccount(String(await clerk.__internal_oauthTransport.getRedirectUrl())); - const url = res?.verification?.externalVerificationRedirectURL; - if (url) { - await clerk.__internal_oauthTransport.open(url); - await user.reload(); - } + const url = getExternalVerificationRedirectURL(res); + const { callbackUrl } = await clerk.__internal_oauthTransport.open(url); + await reloadUserAfterOAuthCallback(user, callbackUrl); void sleep(2000).then(() => card.setIdle(strategy)); return; } const res = await createExternalAccount(window.location.href); - if (res && res.verification?.externalVerificationRedirectURL) { - void sleep(2000).then(() => card.setIdle(strategy)); - void navigate(res.verification.externalVerificationRedirectURL.href); - } + const url = getExternalVerificationRedirectURL(res); + void sleep(2000).then(() => card.setIdle(strategy)); + void navigate(url.href); } catch (err: any) { handleError(err, [], card.setError); card.setIdle(strategy); diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index c2c01146ba6..274ed09d566 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx @@ -18,6 +18,7 @@ import { useEnabledThirdPartyProviders } from '../../hooks'; import { useRouter } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; import { AddConnectedAccount } from './ConnectedAccountsMenu'; +import { getExternalVerificationRedirectURL, reloadUserAfterOAuthCallback } from './oauthTransport'; import { RemoveConnectedAccountForm } from './RemoveResourceForm'; type RemoveConnectedAccountScreenProps = { accountId: string }; @@ -140,19 +141,14 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => response = await createExternalAccount(decoratedRedirectUrl); } + const url = getExternalVerificationRedirectURL(response); if (clerk.__internal_oauthTransport) { - const url = response?.verification?.externalVerificationRedirectURL; - if (url) { - await clerk.__internal_oauthTransport.open(url); - await user.reload(); - } + const { callbackUrl } = await clerk.__internal_oauthTransport.open(url); + await reloadUserAfterOAuthCallback(user, callbackUrl); return; } - if (response) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await navigate(response.verification!.externalVerificationRedirectURL?.href || ''); - } + await navigate(url.href); } catch (err: any) { handleError(err, [], card.setError); } diff --git a/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx index dab174e627f..f841d516a0f 100644 --- a/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/ConnectedAccountsSection.test.tsx @@ -141,7 +141,7 @@ describe('ConnectedAccountsSection ', () => { const { wrapper, fixtures, props } = await createFixtures(withoutConnections); props.setProps({ componentName: 'UserProfile', mode: 'modal' } as any); - const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' }); Object.defineProperty(fixtures.clerk, '__internal_oauthTransport', { configurable: true, value: { @@ -173,7 +173,20 @@ describe('ConnectedAccountsSection ', () => { socialProvider: 'google', }); expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); - expect(reload).toHaveBeenCalled(); + expect(reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' }); + }); + + it('shows an error when the provider verification URL is missing', async () => { + const { wrapper, fixtures } = await createFixtures(withoutConnections); + + fixtures.clerk.user?.createExternalAccount.mockResolvedValue({} as ExternalAccountResource); + const { userEvent, getByText } = render(, { wrapper }); + + await userEvent.click(getByText(/connect account/i)); + await waitFor(() => getByText('Google')); + await userEvent.click(getByText(/Google/i)); + + expect(await screen.findByText(/OAuth flow did not receive a verification URL./i)).toBeInTheDocument(); }); }); @@ -207,6 +220,7 @@ describe('ConnectedAccountsSection ', () => { redirectUrl: window.location.href, additionalScopes: [], }); + expect(await screen.findByText(/OAuth flow did not receive a verification URL./i)).toBeInTheDocument(); }); it('Additional scopes need reconnection', async () => { @@ -244,6 +258,38 @@ describe('ConnectedAccountsSection ', () => { additionalScopes: ['some_scope'], redirectUrl: window.location.href, }); + expect(await screen.findByText(/OAuth flow did not receive a verification URL./i)).toBeInTheDocument(); + }); + + it('uses the OAuth transport when reconnecting an account', async () => { + const { wrapper, fixtures } = await createFixtures(withReconnectableConnection); + + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' }); + Object.defineProperty(fixtures.clerk, '__internal_oauthTransport', { + configurable: true, + value: { + getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), + open, + }, + }); + const reload = vi.spyOn(fixtures.clerk.user!, 'reload').mockResolvedValue(fixtures.clerk.user!); + fixtures.clerk.user?.createExternalAccount.mockResolvedValue({ + verification: { externalVerificationRedirectURL: new URL('https://provider.example/auth') }, + } as ExternalAccountResource); + + const { userEvent, getByText, getByRole } = render(, { wrapper }); + + getByText('This account has been disconnected.'); + getByRole('button', { name: /reconnect/i }); + await userEvent.click(getByRole('button', { name: /reconnect/i })); + + expect(fixtures.clerk.user?.createExternalAccount).toHaveBeenCalledWith({ + strategy: 'oauth_google', + redirectUrl: 'myapp://sso-callback', + additionalScopes: [], + }); + expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); + expect(reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' }); }); it('Unrecoverable errors', async () => { diff --git a/packages/ui/src/components/UserProfile/oauthTransport.ts b/packages/ui/src/components/UserProfile/oauthTransport.ts new file mode 100644 index 00000000000..25e144b3d13 --- /dev/null +++ b/packages/ui/src/components/UserProfile/oauthTransport.ts @@ -0,0 +1,24 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { ExternalAccountResource, UserResource } from '@clerk/shared/types'; + +export function getExternalVerificationRedirectURL(response: ExternalAccountResource | undefined): URL { + const url = response?.verification?.externalVerificationRedirectURL; + if (!url) { + throw new ClerkRuntimeError('OAuth flow did not receive a verification URL.', { + code: 'oauth_missing_verification_url', + }); + } + + return url; +} + +export async function reloadUserAfterOAuthCallback(user: UserResource, callbackUrl: string): Promise { + const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce'); + + if (nonce) { + await user.reload({ rotatingTokenNonce: nonce }); + return; + } + + await user.reload(); +} From c5b63f3b275e5e1e43c1fa6610486c6428db5768 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 09:21:18 -0700 Subject: [PATCH 05/12] fix(repo): harden OAuth transport callbacks --- .../clerk-js/src/core/resources/SignIn.ts | 4 +- .../core/resources/__tests__/SignIn.test.ts | 45 ++++++++++++++ .../authenticateWithTransport.test.ts | 4 +- .../src/utils/authenticateWithTransport.ts | 2 +- .../components/SignIn/SignInSocialButtons.tsx | 4 +- .../__tests__/SignInSocialButtons.test.tsx | 3 + .../buildOAuthCallbackParams.test.ts | 59 ++++++++++++++++++- .../SignIn/buildOAuthCallbackParams.ts | 18 ++++++ .../components/SignUp/SignUpSocialButtons.tsx | 4 +- .../__tests__/SignUpSocialButtons.test.tsx | 6 +- 10 files changed, 137 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 27397193194..8142398542f 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -350,8 +350,10 @@ export class SignIn extends BaseResource implements SignInResource { const actionCompleteRedirectUrl = redirectUrlComplete; const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); + const hasPendingRedirect = !!this.firstFactorVerification.externalVerificationRedirectURL; + const wouldReplayStaleRedirect = strategy !== 'enterprise_sso' && hasPendingRedirect; - if (!this.id || !continueSignIn) { + if (!this.id || !continueSignIn || wouldReplayStaleRedirect) { await this.create({ strategy, identifier, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 70cd8141ed0..210ee9f8426 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -73,6 +73,51 @@ describe('SignIn', () => { expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); expect(handleResourceCallback).toHaveBeenCalledWith(signIn, { signInUrl: '/sign-in' }); }); + + it('starts a new OAuth sign-in instead of replaying a stale redirect from a continued sign-in', async () => { + const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); + SignIn.clerk = { + buildUrlWithAuth: vi.fn(u => u), + __internal_oauthTransport: { getRedirectUrl: () => 'myapp://sso-callback', open }, + __internal_handleResourceCallback: vi.fn().mockResolvedValue(undefined), + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + const mockFetch = vi.fn().mockResolvedValueOnce({ + client: null, + response: { + id: 'signin_456', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://provider.example/fresh', + }, + }, + }); + BaseResource._fetch = mockFetch; + + const signIn = new SignIn({ + id: 'signin_123', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://provider.example/stale', + }, + } as any); + + await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + continueSignIn: true, + } as any); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/client/sign_ins', + }), + ); + expect(open).toHaveBeenCalledWith(new URL('https://provider.example/fresh')); + }); }); describe('signIn.create', () => { diff --git a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts index 93a3a2ba439..344e0ec9916 100644 --- a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts +++ b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts @@ -25,12 +25,12 @@ describe('_authenticateWithTransport', () => { transport, resource, authenticateMethod, - params: { strategy: 'oauth_google', redirectUrl: '/x', redirectUrlComplete: '/' } as any, + params: { strategy: 'oauth_google', redirectUrl: '/x', redirectUrlComplete: '/done' } as any, callbackParams, }); expect(authenticateMethod).toHaveBeenCalledWith( - expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: 'myapp://sso-callback' }), + expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: '/done' }), expect.any(Function), ); expect(transport.open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts index 71e78743d39..4a605960831 100644 --- a/packages/clerk-js/src/utils/authenticateWithTransport.ts +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -36,7 +36,7 @@ export async function _authenticateWithTransport(opts: { const redirectUrl = String(await opts.transport.getRedirectUrl()); let verificationUrl: URL | string | undefined; - await opts.authenticateMethod({ ...opts.params, redirectUrl, redirectUrlComplete: redirectUrl }, url => { + await opts.authenticateMethod({ ...opts.params, redirectUrl }, url => { verificationUrl = url; }); diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index 36a1f58ee46..de0302a8b05 100644 --- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx +++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx @@ -14,7 +14,7 @@ import { useCardState } from '../../elements/contexts'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; import { useRouter } from '../../router'; -import { buildSignInOAuthCallbackParams } from './buildOAuthCallbackParams'; +import { buildSignInOAuthTransportCallbackParams } from './buildOAuthCallbackParams'; export type SignInSocialButtonsProps = SocialButtonsProps & { onAlternativePhoneCodeProviderClick?: (channel: PhoneCodeChannel) => void; @@ -82,7 +82,7 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) redirectUrlComplete, oidcPrompt: ctx.oidcPrompt, __internal_callbackParams: { - ...buildSignInOAuthCallbackParams(ctx), + ...buildSignInOAuthTransportCallbackParams(ctx), navigateOnSetActive: ctx.navigateOnSetActive, }, }) diff --git a/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx index 0ff5c2aa21a..4c53da77502 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx @@ -46,6 +46,9 @@ describe('SignInSocialButtons', () => { strategy: 'oauth_google', __internal_callbackParams: expect.objectContaining({ signInUrl: expect.any(String), + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', navigateOnSetActive: expect.any(Function), }), }), diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index 135da7b32f4..f49f4860058 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildSignInOAuthCallbackParams, buildSignUpOAuthCallbackParams } from '../buildOAuthCallbackParams'; +import { + buildSignInOAuthCallbackParams, + buildSignInOAuthTransportCallbackParams, + buildSignUpOAuthCallbackParams, + buildSignUpOAuthTransportCallbackParams, +} from '../buildOAuthCallbackParams'; describe('buildSignInOAuthCallbackParams', () => { it('produces exactly the params the SignIn sso-callback route passes today', () => { @@ -34,6 +39,33 @@ describe('buildSignInOAuthCallbackParams', () => { }); }); +describe('buildSignInOAuthTransportCallbackParams', () => { + it('uses paths relative to the SignIn start route for transport callbacks', () => { + const ctx = { + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + afterSignInUrl: '/after-in', + afterSignUpUrl: '/after-up', + signUpContinueUrl: '/continue', + transferable: true, + unsafeMetadata: { a: 1 }, + } as any; + + expect(buildSignInOAuthTransportCallbackParams(ctx)).toEqual({ + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + signInForceRedirectUrl: '/after-in', + signUpForceRedirectUrl: '/after-up', + continueSignUpUrl: '/continue', + transferable: true, + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', + unsafeMetadata: { a: 1 }, + }); + }); +}); + describe('buildSignUpOAuthCallbackParams', () => { it('produces exactly the params the combined-flow SignUp sso-callback route passes today', () => { const ctx = { @@ -63,3 +95,28 @@ describe('buildSignUpOAuthCallbackParams', () => { expect('navigateOnSetActive' in buildSignUpOAuthCallbackParams(ctx)).toBe(false); }); }); + +describe('buildSignUpOAuthTransportCallbackParams', () => { + it('uses paths relative to the SignUp start route for transport callbacks', () => { + const ctx = { + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + afterSignUpUrl: '/after-up', + afterSignInUrl: '/after-in', + secondFactorUrl: '/factor-two', + unsafeMetadata: { b: 2 }, + } as any; + + expect(buildSignUpOAuthTransportCallbackParams(ctx)).toEqual({ + signUpUrl: '/sign-up', + signInUrl: '/sign-in', + signUpForceRedirectUrl: '/after-up', + signInForceRedirectUrl: '/after-in', + secondFactorUrl: '/factor-two', + continueSignUpUrl: 'continue', + verifyEmailAddressUrl: 'verify-email-address', + verifyPhoneNumberUrl: 'verify-phone-number', + unsafeMetadata: { b: 2 }, + }); + }); +}); diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index 8c129774fca..e4f0b5bac6e 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -22,6 +22,15 @@ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOA }; } +export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { + return { + ...buildSignInOAuthCallbackParams(ctx), + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', + }; +} + /** * Exact callback params the combined-flow SignUp `sso-callback` route passes to SSOCallback. */ @@ -38,3 +47,12 @@ export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOA unsafeMetadata: ctx.unsafeMetadata, }; } + +export function buildSignUpOAuthTransportCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { + return { + ...buildSignUpOAuthCallbackParams(ctx), + continueSignUpUrl: 'continue', + verifyEmailAddressUrl: 'verify-email-address', + verifyPhoneNumberUrl: 'verify-phone-number', + }; +} diff --git a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx index 9748403563e..7d1b15971e1 100644 --- a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx +++ b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx @@ -11,7 +11,7 @@ import { useCoreSignUp, useSignUpContext } from '../../contexts'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; import { useRouter } from '../../router'; -import { buildSignUpOAuthCallbackParams } from '../SignIn/buildOAuthCallbackParams'; +import { buildSignUpOAuthTransportCallbackParams } from '../SignIn/buildOAuthCallbackParams'; export type SignUpSocialButtonsProps = SocialButtonsProps & { continueSignUp?: boolean; @@ -74,7 +74,7 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) legalAccepted: props.legalAccepted, oidcPrompt: ctx.oidcPrompt, __internal_callbackParams: { - ...buildSignUpOAuthCallbackParams(ctx), + ...buildSignUpOAuthTransportCallbackParams(ctx), navigateOnSetActive: ctx.navigateOnSetActive, }, }) diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx index 4520f087b12..621f89c7540 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx @@ -61,9 +61,9 @@ describe('SignUpSocialButtons', () => { signUpForceRedirectUrl: '/', signInForceRedirectUrl: '/', secondFactorUrl: expect.any(String), - continueSignUpUrl: '../continue', - verifyEmailAddressUrl: '../verify-email-address', - verifyPhoneNumberUrl: '../verify-phone-number', + continueSignUpUrl: 'continue', + verifyEmailAddressUrl: 'verify-email-address', + verifyPhoneNumberUrl: 'verify-phone-number', unsafeMetadata: { source: 'test' }, navigateOnSetActive: expect.any(Function), }), From 4af9a4227b7b6043d66926cc62ae220921546e6d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 09:31:08 -0700 Subject: [PATCH 06/12] chore: clean up --- packages/clerk-js/src/utils/authenticateWithTransport.ts | 6 ------ .../SignIn/__tests__/buildOAuthCallbackParams.test.ts | 4 ++-- .../ui/src/components/SignIn/buildOAuthCallbackParams.ts | 7 ------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts index 4a605960831..ea84152b30e 100644 --- a/packages/clerk-js/src/utils/authenticateWithTransport.ts +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -19,12 +19,6 @@ type ClerkWithResourceCallback = { ) => Promise; }; -/** - * Drives an OAuth/SSO flow through a registered OAuth transport while reusing the - * resource's redirect/popup orchestration unchanged. - * - * @internal - */ export async function _authenticateWithTransport(opts: { clerk: ClerkWithResourceCallback; transport: OAuthTransport; diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index f49f4860058..5ee58529895 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -8,7 +8,7 @@ import { } from '../buildOAuthCallbackParams'; describe('buildSignInOAuthCallbackParams', () => { - it('produces exactly the params the SignIn sso-callback route passes today', () => { + it('returns params for the SignIn sso-callback route', () => { const ctx = { signUpUrl: '/sign-up', signInUrl: '/sign-in', @@ -67,7 +67,7 @@ describe('buildSignInOAuthTransportCallbackParams', () => { }); describe('buildSignUpOAuthCallbackParams', () => { - it('produces exactly the params the combined-flow SignUp sso-callback route passes today', () => { + it('returns params for the combined-flow SignUp sso-callback route', () => { const ctx = { signUpUrl: '/sign-up', signInUrl: '/sign-in', diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index e4f0b5bac6e..7588867a9ca 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -3,10 +3,6 @@ import type { HandleOAuthCallbackParams } from '@clerk/shared/types'; import type { SignInContextType } from '../../contexts/components/SignIn'; import type { SignUpContextType } from '../../contexts/components/SignUp'; -/** - * Exact callback params the SignIn `sso-callback` route passes to SSOCallback. - * Excludes `navigateOnSetActive`, which is transport-only. - */ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { return { signUpUrl: ctx.signUpUrl, @@ -31,9 +27,6 @@ export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): }; } -/** - * Exact callback params the combined-flow SignUp `sso-callback` route passes to SSOCallback. - */ export function buildSignUpOAuthCallbackParams(ctx: SignUpContextType): HandleOAuthCallbackParams { return { signUpUrl: ctx.signUpUrl, From 55cfe310acbfb8778dc4e4176792c7f4ac4c515f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 10:01:22 -0700 Subject: [PATCH 07/12] lint fix --- packages/shared/src/types/clerk.ts | 5 +++-- packages/shared/src/types/redirects.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 69ffb8edf8f..c9db906d37f 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -19,6 +19,7 @@ import type { LocalizationResource } from './localization'; import type { DomainOrProxyUrl, MultiDomainAndOrProxy } from './multiDomain'; import type { OAuthProvider, OAuthScope } from './oauth'; import type { OAuthApplicationNamespace } from './oauthApplication'; +import type { OAuthTransport } from './oauthTransport'; import type { OrganizationResource } from './organization'; import type { OrganizationCustomRoleKey } from './organizationMembership'; import type { ClerkPaginationParams } from './pagination'; @@ -1057,7 +1058,7 @@ export interface Clerk { * * @internal */ - __internal_oauthTransport: import('./oauthTransport').OAuthTransport | null; + __internal_oauthTransport: OAuthTransport | null; /** * Completes an OAuth/SAML callback using a sign-in or sign-up resource already in hand @@ -1497,7 +1498,7 @@ export type ClerkOptions = ClerkOptionsNavigation & * * @internal */ - __internal_oauthTransport?: import('./oauthTransport').OAuthTransport; + __internal_oauthTransport?: OAuthTransport; /** * Customize the URL paths users are redirected to after sign-in or sign-up when specific diff --git a/packages/shared/src/types/redirects.ts b/packages/shared/src/types/redirects.ts index f34ddfe0a87..1223d21ffc7 100644 --- a/packages/shared/src/types/redirects.ts +++ b/packages/shared/src/types/redirects.ts @@ -1,3 +1,4 @@ +import type { HandleOAuthCallbackParams } from './clerk'; import type { EnterpriseSSOStrategy, OAuthStrategy } from './strategies'; /** @generateWithEmptyComment */ @@ -83,7 +84,7 @@ export type AuthenticateWithRedirectParams = { * * @internal */ - __internal_callbackParams?: import('./clerk').HandleOAuthCallbackParams; + __internal_callbackParams?: HandleOAuthCallbackParams; /** * @experimental From 4cc16f1a0ed832c25823409fcc200b98221845b7 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 10:57:39 -0700 Subject: [PATCH 08/12] chore: add Fable comments --- .changeset/oauth-transport.md | 3 +- .../clerk-js/src/core/__tests__/clerk.test.ts | 16 +++--- packages/clerk-js/src/core/clerk.ts | 55 ++++--------------- .../authenticateWithTransport.test.ts | 6 +- .../src/utils/authenticateWithTransport.ts | 2 + packages/shared/src/types/clerk.ts | 2 +- .../components/SignIn/SignInSocialButtons.tsx | 2 +- .../__tests__/SignInSocialButtons.test.tsx | 2 +- .../components/SignUp/SignUpSocialButtons.tsx | 2 +- .../__tests__/SignUpSocialButtons.test.tsx | 2 +- 10 files changed, 30 insertions(+), 62 deletions(-) diff --git a/.changeset/oauth-transport.md b/.changeset/oauth-transport.md index f9608bccbb5..042271143d2 100644 --- a/.changeset/oauth-transport.md +++ b/.changeset/oauth-transport.md @@ -1,7 +1,8 @@ --- '@clerk/shared': minor '@clerk/clerk-js': minor +'@clerk/react': minor '@clerk/ui': minor --- -Add an internal OAuth transport (`__internal_oauthTransport`) so native desktop SDK wrappers can run Clerk's prebuilt OAuth/SSO flows through a system browser. Existing redirect and popup behavior is unchanged when no transport is registered. +Add internal OAuth transport support for native desktop SDK wrappers. Transport callbacks now reload the resource before callback handling, and OAuth retries with a pending provider redirect now start a fresh sign-in attempt. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 5d6af34c6ea..65a63bbaa3e 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -1240,7 +1240,7 @@ describe('Clerk singleton', () => { }); }); - it('uses navigateOnSetActive for completed sign in callbacks', async () => { + it('uses __internal_navigateOnSetActive for completed sign in callbacks', async () => { const sessionId = 'sess_123'; const mockSession = { id: sessionId, currentTask: null }; mockEnvironmentFetch.mockReturnValue( @@ -1265,7 +1265,7 @@ describe('Clerk singleton', () => { }), ); - const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { + const internalNavigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { expect(session).toBe(mockSession); expect(redirectUrl).toBe('http://test.host/after-sign-in'); expect(decorateUrl('/decorated')).toBe('/decorated'); @@ -1278,18 +1278,18 @@ describe('Clerk singleton', () => { await sut.handleRedirectCallback({ signInForceRedirectUrl: '/after-sign-in', - navigateOnSetActive, + __internal_navigateOnSetActive: internalNavigateOnSetActive, }); expect(mockSetActive).toHaveBeenCalledWith({ session: sessionId, navigate: expect.any(Function), }); - expect(navigateOnSetActive).toHaveBeenCalledTimes(1); + expect(internalNavigateOnSetActive).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); }); - it('uses navigateOnSetActive for completed sign up callbacks', async () => { + it('uses __internal_navigateOnSetActive for completed sign up callbacks', async () => { const sessionId = 'sess_123'; const mockSession = { id: sessionId, currentTask: null }; mockEnvironmentFetch.mockReturnValue( @@ -1314,7 +1314,7 @@ describe('Clerk singleton', () => { }), ); - const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { + const internalNavigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => { expect(session).toBe(mockSession); expect(redirectUrl).toBe('http://test.host/after-sign-up'); expect(decorateUrl('/decorated')).toBe('/decorated'); @@ -1327,14 +1327,14 @@ describe('Clerk singleton', () => { await sut.handleRedirectCallback({ signUpForceRedirectUrl: '/after-sign-up', - navigateOnSetActive, + __internal_navigateOnSetActive: internalNavigateOnSetActive, }); expect(mockSetActive).toHaveBeenCalledWith({ session: sessionId, navigate: expect.any(Function), }); - expect(navigateOnSetActive).toHaveBeenCalledTimes(1); + expect(internalNavigateOnSetActive).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index dd639cc3a9d..c6c2f377f32 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2440,6 +2440,7 @@ export class Clerk implements ClerkInterface { const signInUrl = params.signInUrl || displayConfig.signInUrl; const signUpUrl = params.signUpUrl || displayConfig.signUpUrl; + const internalNavigateOnSetActive = params.__internal_navigateOnSetActive; const setActiveNavigate = async ({ session, @@ -2450,6 +2451,15 @@ export class Clerk implements ClerkInterface { baseUrl: string; redirectUrl: string; }) => { + if (internalNavigateOnSetActive) { + await internalNavigateOnSetActive({ + session, + redirectUrl, + decorateUrl: url => this.buildUrlWithAuth(url), + }); + return; + } + if (!session.currentTask) { await this.navigate(redirectUrl); return; @@ -2465,15 +2475,6 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: si.sessionId, navigate: async ({ session }) => { - if (params.navigateOnSetActive) { - await params.navigateOnSetActive({ - session, - redirectUrl: redirectUrls.getAfterSignInUrl(), - decorateUrl: url => this.buildUrlWithAuth(url), - }); - return; - } - await setActiveNavigate({ session, baseUrl: signInUrl, @@ -2493,15 +2494,6 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - if (params.navigateOnSetActive) { - await params.navigateOnSetActive({ - session, - redirectUrl: redirectUrls.getAfterSignInUrl(), - decorateUrl: url => this.buildUrlWithAuth(url), - }); - return; - } - await setActiveNavigate({ session, baseUrl: signUpUrl, @@ -2557,15 +2549,6 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - if (params.navigateOnSetActive) { - await params.navigateOnSetActive({ - session, - redirectUrl: redirectUrls.getAfterSignUpUrl(), - decorateUrl: url => this.buildUrlWithAuth(url), - }); - return; - } - await setActiveNavigate({ session, baseUrl: signUpUrl, @@ -2584,15 +2567,6 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: su.sessionId, navigate: async ({ session }) => { - if (params.navigateOnSetActive) { - await params.navigateOnSetActive({ - session, - redirectUrl: redirectUrls.getAfterSignUpUrl(), - decorateUrl: url => this.buildUrlWithAuth(url), - }); - return; - } - await setActiveNavigate({ session, baseUrl: signUpUrl, @@ -2623,15 +2597,6 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: sessionId, navigate: async ({ session }) => { - if (params.navigateOnSetActive) { - await params.navigateOnSetActive({ - session, - redirectUrl: redirectUrls.getAfterSignInUrl(), - decorateUrl: url => this.buildUrlWithAuth(url), - }); - return; - } - await setActiveNavigate({ session, baseUrl: suUserAlreadySignedIn ? signUpUrl : signInUrl, diff --git a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts index 344e0ec9916..92a9e2220c7 100644 --- a/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts +++ b/packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts @@ -38,13 +38,13 @@ describe('_authenticateWithTransport', () => { expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, callbackParams); }); - it('skips reload when the callback URL has no nonce', async () => { + it('reloads without a nonce when the callback URL has no nonce', async () => { const clerk = makeClerk(); const transport = { getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'), open: vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }), }; - const resource = { reload: vi.fn() } as any; + const resource = { reload: vi.fn().mockResolvedValue(undefined) } as any; const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth')); await _authenticateWithTransport({ @@ -56,7 +56,7 @@ describe('_authenticateWithTransport', () => { callbackParams: {}, }); - expect(resource.reload).not.toHaveBeenCalled(); + expect(resource.reload).toHaveBeenCalledWith(); expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, {}); }); diff --git a/packages/clerk-js/src/utils/authenticateWithTransport.ts b/packages/clerk-js/src/utils/authenticateWithTransport.ts index ea84152b30e..64b38addd79 100644 --- a/packages/clerk-js/src/utils/authenticateWithTransport.ts +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -45,6 +45,8 @@ export async function _authenticateWithTransport(opts: { if (nonce) { await opts.resource.reload({ rotatingTokenNonce: nonce }); + } else { + await opts.resource.reload(); } await opts.clerk.__internal_handleResourceCallback(opts.resource, opts.callbackParams); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index c9db906d37f..d6ba7524c1e 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1252,7 +1252,7 @@ export type HandleOAuthCallbackParams = TransferableOption & * * @internal */ - navigateOnSetActive?: (opts: { + __internal_navigateOnSetActive?: (opts: { session: SessionResource; redirectUrl: string; decorateUrl: (url: string) => string; diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index de0302a8b05..546fff0bace 100644 --- a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx +++ b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx @@ -83,7 +83,7 @@ export const SignInSocialButtons = React.memo((props: SignInSocialButtonsProps) oidcPrompt: ctx.oidcPrompt, __internal_callbackParams: { ...buildSignInOAuthTransportCallbackParams(ctx), - navigateOnSetActive: ctx.navigateOnSetActive, + __internal_navigateOnSetActive: ctx.navigateOnSetActive, }, }) .catch(err => { diff --git a/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx index 4c53da77502..9067b99bb70 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx @@ -49,7 +49,7 @@ describe('SignInSocialButtons', () => { firstFactorUrl: 'factor-one', secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', - navigateOnSetActive: expect.any(Function), + __internal_navigateOnSetActive: expect.any(Function), }), }), ); diff --git a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx index 7d1b15971e1..aa27c5e3b82 100644 --- a/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx +++ b/packages/ui/src/components/SignUp/SignUpSocialButtons.tsx @@ -75,7 +75,7 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) oidcPrompt: ctx.oidcPrompt, __internal_callbackParams: { ...buildSignUpOAuthTransportCallbackParams(ctx), - navigateOnSetActive: ctx.navigateOnSetActive, + __internal_navigateOnSetActive: ctx.navigateOnSetActive, }, }) .catch(err => { diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx index 621f89c7540..508d6c37be4 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpSocialButtons.test.tsx @@ -65,7 +65,7 @@ describe('SignUpSocialButtons', () => { verifyEmailAddressUrl: 'verify-email-address', verifyPhoneNumberUrl: 'verify-phone-number', unsafeMetadata: { source: 'test' }, - navigateOnSetActive: expect.any(Function), + __internal_navigateOnSetActive: expect.any(Function), }), }); }); From 274afa9924e68c9bb85eb8abf1e38acb70c92e6d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 11:15:04 -0700 Subject: [PATCH 09/12] chore: clean up premount method --- .changeset/oauth-transport.md | 2 +- .../src/__tests__/isomorphicClerk.test.ts | 47 ++++++++++++++++++- packages/react/src/isomorphicClerk.ts | 8 +++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/.changeset/oauth-transport.md b/.changeset/oauth-transport.md index 042271143d2..4157cd09889 100644 --- a/.changeset/oauth-transport.md +++ b/.changeset/oauth-transport.md @@ -5,4 +5,4 @@ '@clerk/ui': minor --- -Add internal OAuth transport support for native desktop SDK wrappers. Transport callbacks now reload the resource before callback handling, and OAuth retries with a pending provider redirect now start a fresh sign-in attempt. +Add internal OAuth transport support for native desktop SDK wrappers to run Clerk's prebuilt OAuth flows through a system browser. diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 6b1e05a017d..9661b70daf9 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -1,5 +1,11 @@ import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; -import type { Resources, UnsubscribeCallback } from '@clerk/shared/types'; +import type { + BrowserClerk, + HandleOAuthCallbackParams, + Resources, + SignInResource, + UnsubscribeCallback, +} from '@clerk/shared/types'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { IsomorphicClerk } from '../isomorphicClerk'; @@ -126,6 +132,45 @@ describe('isomorphicClerk', () => { expect(listenerCallHistory.length).toBe(0); }); + it('queues __internal_handleResourceCallback until clerk-js has loaded', async () => { + const signInOrUp = {} as unknown as SignInResource; + const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' }; + const customNavigate = vi.fn(); + const handleResourceCallback = vi.fn(); + const clerkjs = { + addListener: vi.fn(), + loaded: true, + __internal_handleResourceCallback: handleResourceCallback, + } satisfies Pick; + const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' }); + + await isomorphicClerk.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + + expect(handleResourceCallback).not.toHaveBeenCalled(); + + (isomorphicClerk as any).replayInterceptedInvocations(clerkjs); + + expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate); + }); + + it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => { + const signInOrUp = {} as unknown as SignInResource; + const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' }; + const customNavigate = vi.fn(); + const handleResourceCallback = vi.fn().mockResolvedValue('done'); + const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' }); + + (isomorphicClerk as any).clerkjs = { + loaded: true, + __internal_handleResourceCallback: handleResourceCallback, + } satisfies Pick; + + await expect(isomorphicClerk.__internal_handleResourceCallback(signInOrUp, params, customNavigate)).resolves.toBe( + 'done', + ); + expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate); + }); + describe('__internal_* URL precedence', () => { it('__internal_clerkJSUrl causes script loading even when Clerk prop is provided', async () => { const mockClerkCtor = vi.fn().mockImplementation(() => ({ diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 226dd3f1a14..87fb3dafe0a 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1553,8 +1553,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { params: HandleOAuthCallbackParams, customNavigate?: (to: string) => Promise, ): Promise => { - const clerkjs = await this.#waitForClerkJS(); - return clerkjs.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + const callback = () => this.clerkjs?.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + if (this.clerkjs && this.loaded) { + return callback(); + } else { + this.premountMethodCalls.set('__internal_handleResourceCallback', callback); + } }; handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams) => { From 4f2f8770fdc961449f6deb0f3c13d9070a9c29db Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 11:25:36 -0700 Subject: [PATCH 10/12] typedoc fix --- packages/react/src/isomorphicClerk.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 87fb3dafe0a..b38b399005b 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -1556,9 +1556,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { const callback = () => this.clerkjs?.__internal_handleResourceCallback(signInOrUp, params, customNavigate); if (this.clerkjs && this.loaded) { return callback(); - } else { - this.premountMethodCalls.set('__internal_handleResourceCallback', callback); } + + this.premountMethodCalls.set('__internal_handleResourceCallback', callback); + return; }; handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams) => { From d78eb2761240ad31ad0b1366de4c9d58c29469f3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 14:10:37 -0700 Subject: [PATCH 11/12] chore: clean up vars --- .../src/components/UserProfile/ConnectedAccountsMenu.tsx | 7 ++++--- .../components/UserProfile/ConnectedAccountsSection.tsx | 9 ++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx index c7bb2827510..d65808ca408 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx @@ -46,11 +46,12 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi // TODO: Decide if we should keep using this strategy // If yes, refactor and cleanup: card.setLoading(strategy); + const transport = clerk.__internal_oauthTransport; try { - if (clerk.__internal_oauthTransport) { - const res = await createExternalAccount(String(await clerk.__internal_oauthTransport.getRedirectUrl())); + if (transport) { + const res = await createExternalAccount(String(await transport.getRedirectUrl())); const url = getExternalVerificationRedirectURL(res); - const { callbackUrl } = await clerk.__internal_oauthTransport.open(url); + const { callbackUrl } = await transport.open(url); await reloadUserAfterOAuthCallback(user, callbackUrl); void sleep(2000).then(() => card.setIdle(strategy)); return; diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index 274ed09d566..dd4a86cc5f2 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx @@ -129,10 +129,9 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => : fallbackErrorMessage; const reconnect = async () => { + const transport = clerk.__internal_oauthTransport; try { - const redirectUrl = clerk.__internal_oauthTransport - ? String(await clerk.__internal_oauthTransport.getRedirectUrl()) - : window.location.href; + const redirectUrl = transport ? String(await transport.getRedirectUrl()) : window.location.href; const decoratedRedirectUrl = isModal ? appendModalState({ url: redirectUrl, componentName }) : redirectUrl; let response: ExternalAccountResource | undefined; if (reauthorizationRequired) { @@ -142,8 +141,8 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => } const url = getExternalVerificationRedirectURL(response); - if (clerk.__internal_oauthTransport) { - const { callbackUrl } = await clerk.__internal_oauthTransport.open(url); + if (transport) { + const { callbackUrl } = await transport.open(url); await reloadUserAfterOAuthCallback(user, callbackUrl); return; } From 229b47a25720d615879a8f8f5fa9616747791679 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Jun 2026 15:09:53 -0700 Subject: [PATCH 12/12] chore: remove wouldReplayStaleRedirect behavior --- .../clerk-js/src/core/resources/SignIn.ts | 4 +- .../core/resources/__tests__/SignIn.test.ts | 45 ------------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8142398542f..27397193194 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -350,10 +350,8 @@ export class SignIn extends BaseResource implements SignInResource { const actionCompleteRedirectUrl = redirectUrlComplete; const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); - const hasPendingRedirect = !!this.firstFactorVerification.externalVerificationRedirectURL; - const wouldReplayStaleRedirect = strategy !== 'enterprise_sso' && hasPendingRedirect; - if (!this.id || !continueSignIn || wouldReplayStaleRedirect) { + if (!this.id || !continueSignIn) { await this.create({ strategy, identifier, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 210ee9f8426..70cd8141ed0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -73,51 +73,6 @@ describe('SignIn', () => { expect(open).toHaveBeenCalledWith(new URL('https://provider.example/auth')); expect(handleResourceCallback).toHaveBeenCalledWith(signIn, { signInUrl: '/sign-in' }); }); - - it('starts a new OAuth sign-in instead of replaying a stale redirect from a continued sign-in', async () => { - const open = vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback' }); - SignIn.clerk = { - buildUrlWithAuth: vi.fn(u => u), - __internal_oauthTransport: { getRedirectUrl: () => 'myapp://sso-callback', open }, - __internal_handleResourceCallback: vi.fn().mockResolvedValue(undefined), - __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, - } as any; - - const mockFetch = vi.fn().mockResolvedValueOnce({ - client: null, - response: { - id: 'signin_456', - first_factor_verification: { - status: 'unverified', - external_verification_redirect_url: 'https://provider.example/fresh', - }, - }, - }); - BaseResource._fetch = mockFetch; - - const signIn = new SignIn({ - id: 'signin_123', - first_factor_verification: { - status: 'unverified', - external_verification_redirect_url: 'https://provider.example/stale', - }, - } as any); - - await signIn.authenticateWithRedirect({ - strategy: 'oauth_google', - redirectUrl: '/sso-callback', - redirectUrlComplete: '/', - continueSignIn: true, - } as any); - - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/client/sign_ins', - }), - ); - expect(open).toHaveBeenCalledWith(new URL('https://provider.example/fresh')); - }); }); describe('signIn.create', () => {