diff --git a/.changeset/oauth-transport.md b/.changeset/oauth-transport.md new file mode 100644 index 00000000000..4157cd09889 --- /dev/null +++ b/.changeset/oauth-transport.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/react': minor +'@clerk/ui': minor +--- + +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/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 979cf6e24fa..65a63bbaa3e 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 __internal_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 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'); + }); + 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', + __internal_navigateOnSetActive: internalNavigateOnSetActive, + }); + + expect(mockSetActive).toHaveBeenCalledWith({ + session: sessionId, + navigate: expect.any(Function), + }); + expect(internalNavigateOnSetActive).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses __internal_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 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'); + }); + 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', + __internal_navigateOnSetActive: internalNavigateOnSetActive, + }); + + expect(mockSetActive).toHaveBeenCalledWith({ + session: sessionId, + navigate: expect.any(Function), + }); + expect(internalNavigateOnSetActive).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..c6c2f377f32 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, { @@ -2421,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, @@ -2431,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; @@ -2446,7 +2475,11 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: si.sessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signInUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + await setActiveNavigate({ + session, + baseUrl: signInUrl, + redirectUrl: redirectUrls.getAfterSignInUrl(), + }); }, }); } @@ -2461,7 +2494,11 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignInUrl() }); + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignInUrl(), + }); }, }); case 'needs_first_factor': @@ -2512,7 +2549,11 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: res.createdSessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + }); }, }); case 'missing_requirements': @@ -2526,7 +2567,11 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: su.sessionId, navigate: async ({ session }) => { - await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() }); + await setActiveNavigate({ + session, + baseUrl: signUpUrl, + redirectUrl: redirectUrls.getAfterSignUpUrl(), + }); }, }); } 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..92a9e2220c7 --- /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: '/done' } as any, + callbackParams, + }); + + expect(authenticateMethod).toHaveBeenCalledWith( + expect.objectContaining({ redirectUrl: 'myapp://sso-callback', redirectUrlComplete: '/done' }), + 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('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().mockResolvedValue(undefined) } 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).toHaveBeenCalledWith(); + 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..64b38addd79 --- /dev/null +++ b/packages/clerk-js/src/utils/authenticateWithTransport.ts @@ -0,0 +1,53 @@ +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; +}; + +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 }, 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 }); + } else { + await opts.resource.reload(); + } + + await opts.clerk.__internal_handleResourceCallback(opts.resource, opts.callbackParams); +} 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 669affee091..b38b399005b 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,20 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_handleResourceCallback = async ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ): Promise => { + const callback = () => this.clerkjs?.__internal_handleResourceCallback(signInOrUp, params, customNavigate); + if (this.clerkjs && this.loaded) { + return callback(); + } + + this.premountMethodCalls.set('__internal_handleResourceCallback', callback); + return; + }; + handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams) => { const callback = () => this.clerkjs?.handleEmailLinkVerification(params); if (this.clerkjs && this.loaded) { diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 1d7d9355e0c..d6ba7524c1e 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'; @@ -1045,6 +1046,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: 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 +1245,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 + */ + __internal_navigateOnSetActive?: (opts: { + session: SessionResource; + redirectUrl: string; + decorateUrl: (url: string) => string; + }) => Promise; }; export type HandleSamlCallbackParams = HandleOAuthCallbackParams; @@ -1451,6 +1490,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?: 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..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 */ @@ -76,6 +77,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?: HandleOAuthCallbackParams; + /** * @experimental */ 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" }, diff --git a/packages/ui/src/components/SignIn/SignInSocialButtons.tsx b/packages/ui/src/components/SignIn/SignInSocialButtons.tsx index ec5b7cc9170..546fff0bace 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 { buildSignInOAuthTransportCallbackParams } 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: { + ...buildSignInOAuthTransportCallbackParams(ctx), + __internal_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..9067b99bb70 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/SignInSocialButtons.test.tsx @@ -0,0 +1,86 @@ +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), + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', + __internal_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..5ee58529895 --- /dev/null +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSignInOAuthCallbackParams, + buildSignInOAuthTransportCallbackParams, + buildSignUpOAuthCallbackParams, + buildSignUpOAuthTransportCallbackParams, +} from '../buildOAuthCallbackParams'; + +describe('buildSignInOAuthCallbackParams', () => { + it('returns params for the SignIn sso-callback route', () => { + 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('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('returns params for the combined-flow SignUp sso-callback route', () => { + 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); + }); +}); + +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 new file mode 100644 index 00000000000..7588867a9ca --- /dev/null +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -0,0 +1,51 @@ +import type { HandleOAuthCallbackParams } from '@clerk/shared/types'; + +import type { SignInContextType } from '../../contexts/components/SignIn'; +import type { SignUpContextType } from '../../contexts/components/SignUp'; + +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, + }; +} + +export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { + return { + ...buildSignInOAuthCallbackParams(ctx), + firstFactorUrl: 'factor-one', + secondFactorUrl: 'factor-two', + resetPasswordUrl: 'reset-password', + }; +} + +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, + }; +} + +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/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: { + ...buildSignUpOAuthTransportCallbackParams(ctx), + __internal_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..508d6c37be4 --- /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' }, + __internal_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..d65808ca408 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'; @@ -12,31 +12,33 @@ 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; 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 +46,25 @@ 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); - } - }) - .catch(err => { - handleError(err, [], card.setError); - card.setIdle(strategy); - }); + const transport = clerk.__internal_oauthTransport; + try { + if (transport) { + const res = await createExternalAccount(String(await transport.getRedirectUrl())); + const url = getExternalVerificationRedirectURL(res); + const { callbackUrl } = await transport.open(url); + await reloadUserAfterOAuthCallback(user, callbackUrl); + void sleep(2000).then(() => card.setIdle(strategy)); + return; + } + + const res = await createExternalAccount(window.location.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); + } }; return ( diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index ad294468763..dd4a86cc5f2 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'; @@ -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 }; @@ -95,23 +96,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,32 +124,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; - + const transport = clerk.__internal_oauthTransport; try { + const redirectUrl = transport ? String(await transport.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 (response) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await navigate(response.verification!.externalVerificationRedirectURL?.href || ''); + const url = getExternalVerificationRedirectURL(response); + if (transport) { + const { callbackUrl } = await transport.open(url); + await reloadUserAfterOAuthCallback(user, callbackUrl); + return; } + + 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 d200066132d..f841d516a0f 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,65 @@ 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?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 } = 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).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(); + }); }); describe('Recover from issues', () => { @@ -161,7 +215,12 @@ 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: [], + }); + expect(await screen.findByText(/OAuth flow did not receive a verification URL./i)).toBeInTheDocument(); }); it('Additional scopes need reconnection', async () => { @@ -195,7 +254,42 @@ 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, + }); + 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(); +}