diff --git a/.changeset/violet-badgers-change.md b/.changeset/violet-badgers-change.md new file mode 100644 index 00000000000..b8cfe425445 --- /dev/null +++ b/.changeset/violet-badgers-change.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Adding iframeContext to SignIn and SignUp params when a CHIPS build is running in an iframe context diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index db39781cc6f..9eb35c1035c 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -56,6 +56,7 @@ import type { } from '@clerk/types'; import { debugLogger } from '@/utils/debug'; +import { inIframe } from '@/utils/runtime'; import { generateSignatureWithBase, @@ -309,12 +310,18 @@ export class SignIn extends BaseResource implements SignInResource { const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); if (!this.id || !continueSignIn) { - await this.create({ + const createParams: SignInCreateParams = { strategy, identifier, redirectUrl, actionCompleteRedirectUrl, - }); + }; + + if (__BUILD_VARIANT_CHIPS__ && inIframe()) { + createParams.clientId = BaseResource.clerk.client?.id; + } + + await this.create(createParams); } if (strategy === 'saml' || strategy === 'enterprise_sso') { @@ -670,9 +677,15 @@ class SignInFuture implements SignInFutureResource { async create(params: SignInFutureCreateParams): Promise<{ error: unknown }> { return runAsyncResourceTask(this.resource, async () => { + const createParams: SignInFutureCreateParams = { ...params }; + + if (__BUILD_VARIANT_CHIPS__ && inIframe()) { + createParams.clientId = BaseResource.clerk.client?.id; + } + await this.resource.__internal_basePost({ path: this.resource.pathRoot, - body: params, + body: createParams, }); }); } diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 6948345274a..9aa300adcb4 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -37,6 +37,7 @@ import type { } from '@clerk/types'; import { debugLogger } from '@/utils/debug'; +import { inIframe } from '@/utils/runtime'; import { generateSignatureWithBase, @@ -151,6 +152,10 @@ export class SignUp extends BaseResource implements SignUpResource { let finalParams = { ...params }; + if (__BUILD_VARIANT_CHIPS__ && inIframe()) { + finalParams.clientId = BaseResource.clerk.client?.id; + } + if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) { const captchaChallenge = new CaptchaChallenge(SignUp.clerk); const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signup' }); @@ -435,8 +440,14 @@ export class SignUp extends BaseResource implements SignUpResource { }; update = (params: SignUpUpdateParams): Promise => { + const finalParams = { ...params }; + + if (__BUILD_VARIANT_CHIPS__ && inIframe()) { + finalParams.clientId = BaseResource.clerk.client?.id; + } + return this._basePatch({ - body: normalizeUnsafeMetadata(params), + body: normalizeUnsafeMetadata(finalParams), }); }; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.spec.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.spec.ts new file mode 100644 index 00000000000..7d6708c36d9 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.spec.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { inIframe } from '../../../utils/runtime'; +import { BaseResource } from '../internal'; +import { SignIn } from '../SignIn'; + +vi.mock('../../../utils/runtime', () => ({ + inIframe: vi.fn(), +})); + +const originalBuildVariant = (globalThis as any).__BUILD_VARIANT_CHIPS__; +(globalThis as any).__BUILD_VARIANT_CHIPS__ = true; + +describe('SignIn', () => { + let signIn: SignIn; + let mockCreate: any; + let mockBuildUrlWithAuth: any; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockClerk = { + buildUrlWithAuth: vi.fn((url: string) => `https://clerk.example.com${url}`), + client: { + id: 'test-client-id', + }, + }; + + const mockFapiClient = { + buildEmailAddress: vi.fn(() => 'support@clerk.com'), + }; + + SignIn.clerk = mockClerk as any; + + // Mock BaseResource.clerk for client ID access + BaseResource.clerk = mockClerk as any; + + Object.defineProperty(SignIn, 'fapiClient', { + get: () => mockFapiClient, + configurable: true, + }); + + signIn = new SignIn({ + id: 'test-signin-id', + status: 'needs_first_factor', + first_factor_verification: { + status: 'unverified', + external_verification_redirect_url: 'https://oauth.provider.com/auth', + }, + } as any); + + mockCreate = vi.fn().mockResolvedValue({}); + signIn.create = mockCreate; + + mockBuildUrlWithAuth = vi.fn((url: string) => `https://clerk.example.com${url}`); + SignIn.clerk.buildUrlWithAuth = mockBuildUrlWithAuth; + }); + + afterEach(() => { + (globalThis as any).__BUILD_VARIANT_CHIPS__ = originalBuildVariant; + }); + + describe('authenticateWithRedirectOrPopup', () => { + it('should set clientId to true when CHIPS build and in iframe', async () => { + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + const mockNavigate = vi.fn(); + + Object.defineProperty(signIn, 'firstFactorVerification', { + value: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + writable: true, + }); + + await signIn.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockCreate).toHaveBeenCalledWith({ + strategy: 'oauth_google', + identifier: 'test@example.com', + redirectUrl: 'https://clerk.example.com/callback', + actionCompleteRedirectUrl: undefined, + clientId: 'test-client-id', + }); + }); + + it('should not set clientId when not in iframe', async () => { + vi.mocked(inIframe).mockReturnValue(false); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + const mockNavigate = vi.fn(); + + Object.defineProperty(signIn, 'firstFactorVerification', { + value: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + writable: true, + }); + + await signIn.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockCreate).toHaveBeenCalledWith({ + strategy: 'oauth_google', + identifier: 'test@example.com', + redirectUrl: 'https://clerk.example.com/callback', + actionCompleteRedirectUrl: undefined, + }); + expect(mockCreate).toHaveBeenCalledWith(expect.not.objectContaining({ clientId: 'test-client-id' })); + }); + + it('should not set clientId when not CHIPS build', async () => { + (globalThis as any).__BUILD_VARIANT_CHIPS__ = false; + + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + const mockNavigate = vi.fn(); + + Object.defineProperty(signIn, 'firstFactorVerification', { + value: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + writable: true, + }); + + await signIn.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockCreate).toHaveBeenCalledWith(expect.not.objectContaining({ clientId: 'test-client-id' })); + }); + + it('should not set clientId when continueSignIn is true', async () => { + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + continueSignIn: true, + }; + + const mockNavigate = vi.fn(); + + Object.defineProperty(signIn, 'firstFactorVerification', { + value: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + writable: true, + }); + + await signIn.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('SignInFuture.create', () => { + it('should set clientId to true when CHIPS build and in iframe', async () => { + (globalThis as any).__BUILD_VARIANT_CHIPS__ = true; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + const signInFuture = signIn.__internal_future; + + const mockBasePost = vi.fn().mockResolvedValue({}); + signInFuture.resource.__internal_basePost = mockBasePost; + + await signInFuture.create(params); + + expect(mockBasePost).toHaveBeenCalledWith({ + path: '/client/sign_ins', + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + clientId: 'test-client-id', + }, + }); + }); + + it('should not set clientId when not in iframe', async () => { + vi.mocked(inIframe).mockReturnValue(false); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + const signInFuture = signIn.__internal_future; + + const mockBasePost = vi.fn().mockResolvedValue({}); + signInFuture.resource.__internal_basePost = mockBasePost; + + await signInFuture.create(params); + + expect(mockBasePost).toHaveBeenCalledWith({ + path: '/client/sign_ins', + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + }, + }); + }); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.spec.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.spec.ts new file mode 100644 index 00000000000..4ae21d8db2d --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.spec.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { inIframe } from '../../../utils/runtime'; +import { BaseResource } from '../internal'; +import { SignUp } from '../SignUp'; + +vi.mock('../../../utils/runtime', () => ({ + inIframe: vi.fn(), +})); + +const originalBuildVariant = global.__BUILD_VARIANT_CHIPS__; +global.__BUILD_VARIANT_CHIPS__ = true; + +describe('SignUp', () => { + let signUp: SignUp; + let mockCreate: any; + let mockUpdate: any; + let mockBuildUrlWithAuth: any; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockClerk = { + buildUrlWithAuth: vi.fn((url: string) => `https://clerk.example.com${url}`), + client: { + id: 'test-client-id', + }, + __unstable__environment: { + displayConfig: { + captchaOauthBypass: [], + }, + userSettings: { + signUp: { + captcha_enabled: false, + }, + }, + }, + }; + + const mockFapiClient = { + buildEmailAddress: vi.fn(() => 'support@clerk.com'), + }; + + SignUp.clerk = mockClerk as any; + + // Mock BaseResource.clerk for client ID access + BaseResource.clerk = mockClerk as any; + + Object.defineProperty(SignUp, 'fapiClient', { + get: () => mockFapiClient, + configurable: true, + }); + + signUp = new SignUp({ + id: 'test-signup-id', + status: 'missing_requirements', + verifications: { + external_account: { + status: 'unverified', + external_verification_redirect_url: 'https://oauth.provider.com/auth', + }, + }, + } as any); + + mockCreate = vi.fn().mockResolvedValue({ + verifications: { + externalAccount: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + }, + }); + mockUpdate = vi.fn().mockResolvedValue({ + verifications: { + externalAccount: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + }, + }); + signUp._basePost = mockCreate; + signUp._basePatch = mockUpdate; + + mockBuildUrlWithAuth = vi.fn((url: string) => `https://clerk.example.com${url}`); + SignUp.clerk.buildUrlWithAuth = mockBuildUrlWithAuth; + }); + + afterEach(() => { + global.__BUILD_VARIANT_CHIPS__ = originalBuildVariant; + }); + + describe('create', () => { + it('should set clientId to true when CHIPS build and in iframe', async () => { + global.__BUILD_VARIANT_CHIPS__ = true; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.create(params); + + expect(mockCreate).toHaveBeenCalledWith({ + path: '/client/sign_ups', + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + clientId: 'test-client-id', + }, + }); + }); + + it('should not set clientId when not in iframe', async () => { + vi.mocked(inIframe).mockReturnValue(false); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.create(params); + + expect(mockCreate).toHaveBeenCalledWith({ + path: '/client/sign_ups', + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + }, + }); + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ body: expect.objectContaining({ clientId: 'test-client-id' }) }), + ); + }); + + it('should not set clientId when not CHIPS build', async () => { + global.__BUILD_VARIANT_CHIPS__ = false; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.create(params); + + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ body: expect.objectContaining({ clientId: 'test-client-id' }) }), + ); + }); + }); + + describe('update', () => { + it('should set clientId to true when CHIPS build and in iframe', async () => { + global.__BUILD_VARIANT_CHIPS__ = true; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.update(params); + + expect(mockUpdate).toHaveBeenCalledWith({ + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + clientId: 'test-client-id', + }, + }); + }); + + it('should not set clientId when not in iframe', async () => { + vi.mocked(inIframe).mockReturnValue(false); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.update(params); + + expect(mockUpdate).toHaveBeenCalledWith({ + body: { + strategy: 'oauth_google', + redirectUrl: '/callback', + identifier: 'test@example.com', + }, + }); + expect(mockUpdate).toHaveBeenCalledWith( + expect.not.objectContaining({ body: expect.objectContaining({ clientId: 'test-client-id' }) }), + ); + }); + + it('should not set clientId when not CHIPS build', async () => { + global.__BUILD_VARIANT_CHIPS__ = false; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + }; + + await signUp.update(params); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.not.objectContaining({ body: expect.objectContaining({ clientId: 'test-client-id' }) }), + ); + }); + }); + + describe('authenticateWithRedirectOrPopup', () => { + it('should set clientId to true when CHIPS build and in iframe for create flow', async () => { + global.__BUILD_VARIANT_CHIPS__ = true; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + continueSignUp: false, + }; + + const mockNavigate = vi.fn(); + + const mockVerifications = { + externalAccount: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + }; + Object.defineProperty(signUp, 'verifications', { + value: mockVerifications, + writable: true, + }); + + await signUp.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockCreate).toHaveBeenCalledWith({ + path: '/client/sign_ups', + body: { + strategy: 'oauth_google', + redirectUrl: 'https://clerk.example.com/callback', + actionCompleteRedirectUrl: undefined, + unsafeMetadata: undefined, + emailAddress: undefined, + legalAccepted: undefined, + oidcPrompt: undefined, + clientId: 'test-client-id', + }, + }); + }); + + it('should set clientId to true when CHIPS build and in iframe for update flow', async () => { + global.__BUILD_VARIANT_CHIPS__ = true; + vi.mocked(inIframe).mockReturnValue(true); + + const params = { + strategy: 'oauth_google' as const, + redirectUrl: '/callback', + identifier: 'test@example.com', + continueSignUp: true, + }; + + const mockNavigate = vi.fn(); + + const mockVerifications = { + externalAccount: { + status: 'unverified', + externalVerificationRedirectURL: 'https://oauth.provider.com/auth', + }, + }; + Object.defineProperty(signUp, 'verifications', { + value: mockVerifications, + writable: true, + }); + + await signUp.authenticateWithRedirectOrPopup(params, mockNavigate); + + expect(mockUpdate).toHaveBeenCalledWith({ + body: { + strategy: 'oauth_google', + redirectUrl: 'https://clerk.example.com/callback', + actionCompleteRedirectUrl: undefined, + unsafeMetadata: undefined, + emailAddress: undefined, + legalAccepted: undefined, + oidcPrompt: undefined, + clientId: 'test-client-id', + }, + }); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx b/packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx index 641951fc17a..3c681a8517c 100644 --- a/packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx +++ b/packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx @@ -21,7 +21,7 @@ export const ActionTrigger = (props: ActionTriggerProps) => { } return cloneElement(validChildren, { - //@ts-ignore + // @ts-ignore - onClick prop type mismatch with cloned element // eslint-disable-next-line @typescript-eslint/no-misused-promises onClick: async () => { await validChildren.props.onClick?.(); diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index 1874116aa04..30ad231b279 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -14,7 +14,7 @@ interface BaseRouterProps { startPath: string; getPath: () => string; getQueryString: () => string; - internalNavigate: (toURL: URL, options?: NavigateOptions) => Promise | any; + internalNavigate: (toURL: URL, options?: NavigateOptions) => Promise; refreshEvents?: Array; preservedParams?: string[]; urlStateParam?: { diff --git a/packages/clerk-js/src/ui/router/PathRouter.tsx b/packages/clerk-js/src/ui/router/PathRouter.tsx index 6c1edc35045..1e2f5c44ebd 100644 --- a/packages/clerk-js/src/ui/router/PathRouter.tsx +++ b/packages/clerk-js/src/ui/router/PathRouter.tsx @@ -21,10 +21,7 @@ export const PathRouter = ({ basePath, preservedParams, children }: PathRouterPr throw new Error('Clerk: Missing navigate option.'); } - const internalNavigate = (toURL: URL | string | undefined, options?: NavigateOptions) => { - if (!toURL) { - return; - } + const internalNavigate = async (toURL: URL, options?: NavigateOptions): Promise => { // Only send the path return navigate(stripOrigin(toURL), options); }; @@ -41,7 +38,7 @@ export const PathRouter = ({ basePath, preservedParams, children }: PathRouterPr const convertHashToPath = async () => { if (hasUrlInFragment(window.location.hash)) { const url = mergeFragmentIntoUrl(new URL(window.location.href)); - await internalNavigate(url.href, { replace: true }); + await internalNavigate(url, { replace: true }); setStripped(true); } }; diff --git a/packages/clerk-js/src/ui/router/VirtualRouter.tsx b/packages/clerk-js/src/ui/router/VirtualRouter.tsx index 3b152d4fd24..c7cd402cda9 100644 --- a/packages/clerk-js/src/ui/router/VirtualRouter.tsx +++ b/packages/clerk-js/src/ui/router/VirtualRouter.tsx @@ -1,4 +1,5 @@ import { useClerk } from '@clerk/shared/react'; +import type { NavigateOptions } from '@clerk/types'; import React, { useEffect } from 'react'; import { useClerkModalStateParams } from '../hooks'; @@ -40,11 +41,9 @@ export const VirtualRouter = ({ removeQueryParam(); } - const internalNavigate = (toURL: URL | undefined) => { - if (!toURL) { - return; - } + const internalNavigate = async (toURL: URL, _options?: NavigateOptions): Promise => { setCurrentURL(toURL); + return Promise.resolve(); }; const getPath = () => currentURL.pathname; diff --git a/packages/types/src/signInCommon.ts b/packages/types/src/signInCommon.ts index 52fbaaa946c..3fc2ef347e7 100644 --- a/packages/types/src/signInCommon.ts +++ b/packages/types/src/signInCommon.ts @@ -128,6 +128,10 @@ export type SignInCreateParams = ( identifier?: string; oidcPrompt?: string; oidcLoginHint?: string; + /** + * @internal Used to identify the client making the request in iframe context. + */ + clientId?: string; } | { strategy: TicketStrategy; diff --git a/packages/types/src/signInFuture.ts b/packages/types/src/signInFuture.ts index 6438b934c97..8927856ddac 100644 --- a/packages/types/src/signInFuture.ts +++ b/packages/types/src/signInFuture.ts @@ -10,6 +10,10 @@ export interface SignInFutureCreateParams { redirectUrl?: string; actionCompleteRedirectUrl?: string; transfer?: boolean; + /** + * @internal Used to identify the client making the request in iframe context. + */ + clientId?: string; } export type SignInFuturePasswordParams = diff --git a/packages/types/src/signUpCommon.ts b/packages/types/src/signUpCommon.ts index 573b38d6341..9142e7f0492 100644 --- a/packages/types/src/signUpCommon.ts +++ b/packages/types/src/signUpCommon.ts @@ -100,6 +100,10 @@ export type SignUpCreateParams = Partial< oidcPrompt: string; oidcLoginHint: string; channel: PhoneCodeChannel; + /** + * @internal Used to identify the client making the request in iframe context. + */ + clientId?: string; } & Omit>, 'legalAccepted'> >;