diff --git a/.changeset/beige-sloths-provide.md b/.changeset/beige-sloths-provide.md new file mode 100644 index 00000000000..e36f9a48bf5 --- /dev/null +++ b/.changeset/beige-sloths-provide.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": minor +"@clerk/shared": minor +--- + +Introduce ProtectConfig resource and Protect loader integration. diff --git a/integration/tests/protect-service.test.ts b/integration/tests/protect-service.test.ts new file mode 100644 index 00000000000..f42530bb679 --- /dev/null +++ b/integration/tests/protect-service.test.ts @@ -0,0 +1,76 @@ +import type { ProtectConfigJSON } from '@clerk/shared/types'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +const mockProtectSettings = async (page: Page, config?: ProtectConfigJSON) => { + await page.route('*/**/v1/environment*', async route => { + const response = await route.fetch(); + const json = await response.json(); + const newJson = { + ...json, + ...(config ? { protect_config: config } : {}), + }; + await route.fulfill({ response, json: newJson }); + }); +}; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('Clerk Protect checks @generic', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('should add loader script when protect_config.loader is set', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page, { + object: 'protect_config', + id: 'n', + loaders: [ + { + rollout: 1.0, + type: 'script', + target: 'body', + attributes: { id: 'test-protect-loader-1', type: 'module', src: 'data:application/json;base64,Cgo=' }, + }, + ], + }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await expect(page.locator('#test-protect-loader-1')).toHaveAttribute('type', 'module'); + }); + + test('should not add loader script when protect_config.loader is set and rollout 0.00', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page, { + object: 'protect_config', + id: 'n', + loaders: [ + { + rollout: 0, // force 0% rollout, should not materialize + type: 'script', + target: 'body', + attributes: { id: 'test-protect-loader-2', type: 'module', src: 'data:application/json;base64,Cgo=' }, + }, + ], + }); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + await expect(page.locator('#test-protect-loader-2')).toHaveCount(0); + }); + + test('should not create loader element when protect_config.loader is not set', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await mockProtectSettings(page); + await u.page.goToAppHome(); + await u.page.waitForClerkJsLoaded(); + + // Playwright locators are always objects, never undefined + await expect(page.locator('#test-protect-loader')).toHaveCount(0); + }); +}); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c61b05e0018..ced45dc5008 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "63.2KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, { "path": "./dist/ui-common*.js", "maxSize": "117.1KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" }, { "path": "./dist/vendors*.js", "maxSize": "47KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 034cf2df0ce..6353d4c09d3 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -158,6 +158,7 @@ import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; import { Billing } from './modules/billing'; import { createCheckoutInstance } from './modules/checkout/instance'; +import { Protect } from './protect'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { getTaskEndpoint, navigateIfTaskExists, warnMissingPendingTaskHandlers } from './sessionTasks'; import { State } from './state'; @@ -227,6 +228,7 @@ export class Clerk implements ClerkInterface { #domain: DomainOrProxyUrl['domain']; #proxyUrl: DomainOrProxyUrl['proxyUrl']; #authService?: AuthCookieService; + #protect?: Protect; #captchaHeartbeat?: CaptchaHeartbeat; #broadcastChannel: BroadcastChannel | null = null; #componentControls?: ReturnType | null; @@ -429,6 +431,7 @@ export class Clerk implements ClerkInterface { // This line is used for the piggy-backing mechanism BaseResource.clerk = this; + this.#protect = new Protect(); } public getFapiClient = (): FapiClient => this.#fapiClient; @@ -516,6 +519,7 @@ export class Clerk implements ClerkInterface { ...(telemetryEnabled && this.telemetry ? { telemetryCollector: this.telemetry } : {}), }); } + this.#protect?.load(this.environment as Environment); debugLogger.info('load() complete', {}, 'clerk'); } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); diff --git a/packages/clerk-js/src/core/protect.ts b/packages/clerk-js/src/core/protect.ts new file mode 100644 index 00000000000..569ec4e7969 --- /dev/null +++ b/packages/clerk-js/src/core/protect.ts @@ -0,0 +1,97 @@ +import { inBrowser } from '@clerk/shared/browser'; +import { logger } from '@clerk/shared/logger'; +import type { ProtectLoader } from '@clerk/shared/types'; + +import type { Environment } from './resources'; +export class Protect { + #initialized: boolean = false; + + load(env: Environment): void { + const config = env?.protectConfig; + + if (!config?.loaders || !Array.isArray(config.loaders) || config.loaders.length === 0) { + // not enabled or no protect config available + return; + } else if (this.#initialized) { + // already initialized - do nothing + return; + } else if (!inBrowser()) { + // no document: not running browser? + return; + } + + // here rather than at end to mark as initialized even if load fails. + this.#initialized = true; + + for (const loader of config.loaders) { + try { + this.applyLoader(loader); + } catch (error) { + logger.warnOnce(`[protect] failed to apply loader: ${error}`); + } + } + } + + // apply individual loader + applyLoader(loader: ProtectLoader) { + // we use rollout for percentage based rollouts (as the environment file is cached) + if (loader.rollout !== undefined) { + const rollout = loader.rollout; + if (typeof rollout !== 'number' || rollout < 0) { + // invalid rollout percentage - do nothing + logger.warnOnce(`[protect] loader rollout value is invalid: ${rollout}`); + return; + } + if (rollout === 0 || Math.random() > rollout) { + // not in rollout percentage - do nothing + return; + } + } + + const type = loader.type || 'script'; + const target = loader.target || 'body'; + + const element = document.createElement(type); + + if (loader.attributes) { + for (const [key, value] of Object.entries(loader.attributes)) { + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + element.setAttribute(key, String(value)); + break; + default: + // illegal to set. + logger.warnOnce(`[protect] loader attribute is invalid type: ${key}=${value}`); + break; + } + } + } + + if (loader.textContent && typeof loader.textContent === 'string') { + element.textContent = loader.textContent; + } + + switch (target) { + case 'head': + document.head.appendChild(element); + break; + case 'body': + document.body.appendChild(element); + break; + default: + if (target?.startsWith('#')) { + const targetElement = document.getElementById(target.substring(1)); + if (!targetElement) { + logger.warnOnce(`[protect] loader target element not found: ${target}`); + return; + } + targetElement.appendChild(element); + return; + } + logger.warnOnce(`[protect] loader target is invalid: ${target}`); + break; + } + } +} diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index bb8322de994..f057787a747 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -6,12 +6,13 @@ import type { EnvironmentJSONSnapshot, EnvironmentResource, OrganizationSettingsResource, + ProtectConfigResource, UserSettingsResource, } from '@clerk/shared/types'; import { eventBus, events } from '../../core/events'; import { APIKeySettings } from './APIKeySettings'; -import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, UserSettings } from './internal'; +import { AuthConfig, BaseResource, CommerceSettings, DisplayConfig, ProtectConfig, UserSettings } from './internal'; import { OrganizationSettings } from './OrganizationSettings'; export class Environment extends BaseResource implements EnvironmentResource { @@ -26,6 +27,7 @@ export class Environment extends BaseResource implements EnvironmentResource { organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); commerceSettings: CommerceSettingsResource = new CommerceSettings(); apiKeysSettings: APIKeySettings = new APIKeySettings(); + protectConfig: ProtectConfigResource = new ProtectConfig(); public static getInstance(): Environment { if (!Environment.instance) { @@ -54,6 +56,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); this.apiKeysSettings = new APIKeySettings(data.api_keys_settings); + this.protectConfig = new ProtectConfig(data.protect_config); return this; } @@ -95,6 +98,7 @@ export class Environment extends BaseResource implements EnvironmentResource { user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), api_keys_settings: this.apiKeysSettings.__internal_toSnapshot(), + protect_config: this.protectConfig.__internal_toSnapshot(), }; } } diff --git a/packages/clerk-js/src/core/resources/ProtectConfig.ts b/packages/clerk-js/src/core/resources/ProtectConfig.ts new file mode 100644 index 00000000000..93e46083c9e --- /dev/null +++ b/packages/clerk-js/src/core/resources/ProtectConfig.ts @@ -0,0 +1,39 @@ +import type { + ProtectConfigJSON, + ProtectConfigJSONSnapshot, + ProtectConfigResource, + ProtectLoader, +} from '@clerk/shared/types'; + +import { BaseResource } from './internal'; + +export class ProtectConfig extends BaseResource implements ProtectConfigResource { + id: string = ''; + loaders?: ProtectLoader[]; + rollout?: number; + + public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) { + super(); + + this.fromJSON(data); + } + + protected fromJSON(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.id = this.withDefault(data.id, this.id); + this.loaders = this.withDefault(data.loaders, this.loaders); + + return this; + } + + public __internal_toSnapshot(): ProtectConfigJSONSnapshot { + return { + object: 'protect_config', + id: this.id, + loaders: this.loaders, + }; + } +} diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 2c27131bbae..e9e1e1f5fa8 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -29,6 +29,7 @@ export * from './OrganizationSuggestion'; export * from './SamlAccount'; export * from './Session'; export * from './Passkey'; +export * from './ProtectConfig'; export * from './PublicUserData'; export * from './SessionWithActivities'; export * from './SignIn'; diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index fbe1725b412..5a81ea75d8f 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -3,6 +3,7 @@ import type { AuthConfigResource } from './authConfig'; import type { CommerceSettingsResource } from './commerceSettings'; import type { DisplayConfigResource } from './displayConfig'; import type { OrganizationSettingsResource } from './organizationSettings'; +import type { ProtectConfigResource } from './protectConfig'; import type { ClerkResource } from './resource'; import type { EnvironmentJSONSnapshot } from './snapshots'; import type { UserSettingsResource } from './userSettings'; @@ -14,6 +15,7 @@ export interface EnvironmentResource extends ClerkResource { displayConfig: DisplayConfigResource; commerceSettings: CommerceSettingsResource; apiKeysSettings: APIKeysSettingsResource; + protectConfig: ProtectConfigResource; isSingleSession: () => boolean; isProduction: () => boolean; isDevelopmentOrStaging: () => boolean; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 40f96733b9f..833538f382b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -46,6 +46,7 @@ export type * from './permission'; export type * from './phoneCodeChannel'; export type * from './phoneNumber'; export type * from './protect'; +export type * from './protectConfig'; export type * from './redirects'; export type * from './resource'; export type * from './role'; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 5bcaed20a7f..6b2070b8283 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -25,6 +25,7 @@ import type { OrganizationCustomRoleKey, OrganizationPermissionKey } from './org import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { PhoneCodeChannel } from './phoneCodeChannel'; +import type { ProtectConfigJSON } from './protectConfig'; import type { SamlIdpSlug } from './saml'; import type { SessionStatus, SessionTask } from './session'; import type { SessionVerificationLevel, SessionVerificationStatus } from './sessionVerification'; @@ -90,6 +91,7 @@ export interface EnvironmentJSON extends ClerkResourceJSON { maintenance_mode: boolean; organization_settings: OrganizationSettingsJSON; user_settings: UserSettingsJSON; + protect_config: ProtectConfigJSON; } export type LastAuthenticationStrategy = diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts new file mode 100644 index 00000000000..515546aa64d --- /dev/null +++ b/packages/shared/src/types/protectConfig.ts @@ -0,0 +1,22 @@ +import type { ClerkResource } from './resource'; +import type { ProtectConfigJSONSnapshot } from './snapshots'; + +export interface ProtectLoader { + rollout?: number; + target: 'head' | 'body' | `#${string}`; + type: string; + attributes?: Record; + textContent?: string; +} + +export interface ProtectConfigJSON { + object: 'protect_config'; + id: string; + loaders?: ProtectLoader[]; +} + +export interface ProtectConfigResource extends ClerkResource { + id: string; + loaders?: ProtectLoader[]; + __internal_toSnapshot: () => ProtectConfigJSONSnapshot; +} diff --git a/packages/shared/src/types/snapshots.ts b/packages/shared/src/types/snapshots.ts index 36524a5cf28..5f36eab401c 100644 --- a/packages/shared/src/types/snapshots.ts +++ b/packages/shared/src/types/snapshots.ts @@ -31,6 +31,7 @@ import type { Web3WalletJSON, } from './json'; import type { OrganizationSettingsJSON } from './organizationSettings'; +import type { ProtectConfigJSON } from './protectConfig'; import type { SignInJSON } from './signIn'; import type { UserSettingsJSON } from './userSettings'; import type { Nullable, Override } from './utils'; @@ -119,6 +120,8 @@ export type EnvironmentJSONSnapshot = EnvironmentJSON; export type DisplayConfigJSONSnapshot = DisplayConfigJSON; +export type ProtectConfigJSONSnapshot = ProtectConfigJSON; + export type EmailAddressJSONSnapshot = Override< EmailAddressJSON, {