diff --git a/.changeset/flat-trains-add.md b/.changeset/flat-trains-add.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/flat-trains-add.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/sandbox/README.md b/packages/clerk-js/sandbox/README.md index 3ec390f432c..41dbf60aa8c 100644 --- a/packages/clerk-js/sandbox/README.md +++ b/packages/clerk-js/sandbox/README.md @@ -39,3 +39,33 @@ scenario.setScenario(scenario.UserButtonLoggedIn); ``` Like `setProps`, this command will persist the active scenario to the URL. + +### Protect challenge flow + +To test Clerk Protect challenge handling without running a real Protect decision +service, activate the Protect challenge scenario: + +```js +scenario.setScenario('ProtectChallenge'); +``` + +Then visit `/sign-in` or `/sign-up`. The scenario mocks FAPI responses that +return `protect_check`, loads a sandbox SDK challenge module, submits the proof +token, and continues the flow. + +You can also open `/sign-in?scenario=ProtectChallenge` or +`/sign-up?scenario=ProtectChallenge` directly. + +For visual testing, add `protectChallengeMode=manual` to pause the flow until +you click the sandbox challenge button: + +- `/sign-in?scenario=ProtectChallenge&protectChallengeMode=manual` +- `/sign-up?scenario=ProtectChallenge&protectChallengeMode=manual` + +To render the real Cloudflare Turnstile widget inside the protect-check card +(production pixels, mocked proof token), add `protectChallengeWidget=turnstile`. +This uses Cloudflare's universal test sitekeys — always-passing in auto mode, +force-interactive in manual mode — and loads the Turnstile script from +Cloudflare's CDN, which is the scenario's only network dependency: + +- `/sign-in?scenario=ProtectChallenge&protectChallengeMode=manual&protectChallengeWidget=turnstile` diff --git a/packages/clerk-js/sandbox/integration/protect-challenge.spec.ts b/packages/clerk-js/sandbox/integration/protect-challenge.spec.ts new file mode 100644 index 00000000000..1c4fa631073 --- /dev/null +++ b/packages/clerk-js/sandbox/integration/protect-challenge.spec.ts @@ -0,0 +1,117 @@ +import { clerk } from '@clerk/testing/playwright'; +import { expect, test } from '@playwright/test'; + +const primaryButtonElement = '.cl-formButtonPrimary'; +const sdkChallengeUrl = 'https://protect.example.test/sdk-challenge.js'; + +/** + * These tests encode the INTENDED post-resolution behavior: after a protect + * check resolves, the user is routed to the next step (or the session is + * activated for the `complete` case). They are currently expected to fail: + * on the base branch the flow never advances once the check clears — the + * protect-check card unmounts and no navigation/`setActive` happens (see the + * review thread on #8329). When that is fixed, these flip to "unexpected + * pass" — delete the `test.fail()` annotations to start enforcing. + */ +const BLOCKED_BY_POST_RESOLUTION_NAVIGATION_BUG = + 'blocked by protect-check post-resolution navigation bug on the base branch'; + +function isProtectCheckSubmit(method: string) { + return method === 'PATCH' || method === 'POST'; +} + +test('sign up resolves a Protect challenge and continues to email verification', async ({ page }) => { + test.fail(true, BLOCKED_BY_POST_RESOLUTION_NAVIGATION_BUG); + await page.goto('/sign-up?scenario=ProtectChallenge'); + await clerk.loaded({ page }); + + await page.locator('#emailAddress-field').fill(`protect-sign-up-${Date.now()}@example.com`); + + const sdkRequest = page.waitForRequest(sdkChallengeUrl); + const protectCheckPatch = page.waitForRequest( + request => + isProtectCheckSubmit(request.method()) && + request.url().includes('/v1/client/sign_ups/') && + request.url().includes('/protect_check'), + ); + + await page.locator(primaryButtonElement).click(); + + await sdkRequest; + await protectCheckPatch; + + await expect(page).toHaveURL(/verify-email-address/); + await expect(page.getByText('Verify your email')).toBeVisible(); +}); + +test('sign up can pause on a manual Protect challenge', async ({ page }) => { + test.fail(true, BLOCKED_BY_POST_RESOLUTION_NAVIGATION_BUG); + await page.goto('/sign-up?scenario=ProtectChallenge&protectChallengeMode=manual'); + await clerk.loaded({ page }); + + let submittedProtectCheck = false; + page.on('request', request => { + if ( + isProtectCheckSubmit(request.method()) && + request.url().includes('/v1/client/sign_ups/') && + request.url().includes('/protect_check') + ) { + submittedProtectCheck = true; + } + }); + + await page.locator('#emailAddress-field').fill(`protect-manual-sign-up-${Date.now()}@example.com`); + + const sdkRequest = page.waitForRequest(sdkChallengeUrl); + await page.locator(primaryButtonElement).click(); + await sdkRequest; + + await expect(page.getByTestId('protect-challenge-sdk')).toBeVisible(); + await expect(page.getByTestId('protect-challenge-complete')).toBeVisible(); + expect(submittedProtectCheck).toBe(false); + + const protectCheckPatch = page.waitForRequest( + request => + isProtectCheckSubmit(request.method()) && + request.url().includes('/v1/client/sign_ups/') && + request.url().includes('/protect_check'), + ); + + await page.getByTestId('protect-challenge-complete').click(); + await protectCheckPatch; + + await expect(page).toHaveURL(/verify-email-address/); + await expect(page.getByText('Verify your email')).toBeVisible(); +}); + +test('sign in resolves a Protect challenge and creates a session', async ({ page }) => { + test.fail(true, BLOCKED_BY_POST_RESOLUTION_NAVIGATION_BUG); + await page.goto('/sign-in?scenario=ProtectChallenge'); + await clerk.loaded({ page }); + + await page.locator('#identifier-field').fill('protect-sign-in@example.com'); + await page.locator(primaryButtonElement).click(); + await expect(page).toHaveURL(/factor-one/); + + await page.locator('#password-field').fill('Password123!'); + + const sdkRequest = page.waitForRequest(sdkChallengeUrl); + const protectCheckPatch = page.waitForRequest( + request => + isProtectCheckSubmit(request.method()) && + request.url().includes('/v1/client/sign_ins/') && + request.url().includes('/protect_check'), + ); + + await page.locator(primaryButtonElement).click(); + + await sdkRequest; + await protectCheckPatch; + + // Assert a real hydrated user (the mock session carries a full UserJSON), + // not just a non-null placeholder. Bounded poll rather than + // page.waitForFunction: a test-level timeout is not covered by the + // test.fail() annotation above, but a failed assertion is. + await expect.poll(() => page.evaluate(() => window.Clerk?.user?.id ?? null), { timeout: 10_000 }).toBeTruthy(); + await expect(page.locator('.cl-signIn-root')).toBeHidden(); +}); diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index d06dcc56b80..6228acf79ad 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -5,3 +5,4 @@ export { OrgProfileSeatLimit } from './org-profile-seat-limit'; export { PricingTableSBB } from './pricing-table-sbb'; export { AnnualOnlyPlans } from './annual-only-plans'; export { XProviderEnabled } from './x-provider-enabled'; +export { ProtectChallenge } from './protect-challenge'; diff --git a/packages/clerk-js/sandbox/scenarios/protect-challenge.ts b/packages/clerk-js/sandbox/scenarios/protect-challenge.ts new file mode 100644 index 00000000000..ef8f0e47f80 --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/protect-challenge.ts @@ -0,0 +1,485 @@ +import { + clerkHandlers, + EnvironmentService, + HttpResponse, + http, + SessionService, + setClerkState, + SignInService, + SignUpService, + type MockScenario, +} from '@clerk/msw'; + +const sdkChallengeUrl = 'https://protect.example.test/sdk-challenge.js'; +const challengeTTL = 5 * 60 * 1000; +const signedInIdentifierStorageKey = 'clerk-js-sandbox-protect-challenge-signed-in-identifier'; +const challengeModeSearchParam = 'protectChallengeMode'; +const challengeWidgetSearchParam = 'protectChallengeWidget'; + +type ChallengeMode = 'auto' | 'manual'; + +// Cloudflare's universal Turnstile test sitekeys (valid on any domain). Used +// by the opt-in `protectChallengeWidget=turnstile` mode so the real widget +// renders inside the protect-check card for visual QA while the proof-token +// plumbing stays mocked. Loading Turnstile from Cloudflare's CDN is the only +// network dependency in this scenario, which is why it is not the default. +const turnstileAlwaysPassesSitekey = '1x00000000000000000000AA'; +const turnstileForceInteractiveSitekey = '3x00000000000000000000FF'; + +function getStoredSignedInIdentifier() { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.sessionStorage.getItem(signedInIdentifierStorageKey); + } catch { + return null; + } +} + +function storeSignedInIdentifier(identifier: string) { + if (typeof window === 'undefined') { + return; + } + + try { + window.sessionStorage.setItem(signedInIdentifierStorageKey, identifier); + } catch { + return; + } +} + +function getChallengeMode(): ChallengeMode { + if (typeof window === 'undefined') { + return 'auto'; + } + + try { + return new URLSearchParams(window.location.search).get(challengeModeSearchParam) === 'manual' ? 'manual' : 'auto'; + } catch { + return 'auto'; + } +} + +/** + * The msw session template ships with `user: null`, so a session piggybacked + * on a resolver response hydrates an empty `Clerk.user` after `setActive`. + * Graft a minimal UserJSON onto it so the signed-in state carries the real + * user the integration spec asserts on. + */ +function attachSessionUser(session: unknown, userId: string, identifier: string) { + (session as { user: unknown }).user = { + object: 'user', + id: userId, + primary_email_address_id: 'email_signed_in_user', + email_addresses: [ + { + object: 'email_address', + id: 'email_signed_in_user', + email_address: identifier, + verification: { + object: 'verification', + status: 'verified', + strategy: 'ticket', + attempts: null, + expire_at: null, + }, + linked_to: [], + }, + ], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + enterprise_accounts: [], + passkeys: [], + organization_memberships: [], + }; +} + +function createNoStoreResponse(data: unknown, options?: { status?: number }) { + return HttpResponse.json(data, { + status: options?.status, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + }, + }); +} + +async function parseUrlEncodedBody(request: Request): Promise> { + const body: Record = {}; + const params = new URLSearchParams(await request.text()); + params.forEach((value, key) => { + body[key] = value; + }); + return body; +} + +function createProtectCheck(token: string) { + return { + status: 'pending', + token, + sdk_url: sdkChallengeUrl, + expires_at: Date.now() + challengeTTL, + ui_hints: { + mode: getChallengeMode(), + source: 'clerk-js-sandbox', + }, + }; +} + +function createMissingProofTokenResponse() { + return createNoStoreResponse( + { + errors: [ + { + code: 'form_param_nil', + long_message: 'Proof token is required.', + message: 'is missing or empty', + meta: { param_name: 'proof_token' }, + }, + ], + }, + { status: 422 }, + ); +} + +async function resolveSignUpProtectCheck(request: Request) { + const body = await parseUrlEncodedBody(request); + if (!body.proof_token) { + return createMissingProofTokenResponse(); + } + + const signUpResponse = SignUpService.createSignUpResponse({ + email: SignUpService.getEmail(), + firstName: SignUpService.getFirstName(), + lastName: SignUpService.getLastName(), + status: 'missing_requirements', + unverifiedFields: ['email_address'], + verificationAttempts: 0, + verificationStatus: 'unverified', + }); + (signUpResponse as any).missing_fields = []; + (signUpResponse as any).protect_check = null; + + const clientState = SessionService.getClientState(null); + clientState.response.sign_up = signUpResponse as any; + + return createNoStoreResponse({ + client: clientState.response, + response: signUpResponse, + }); +} + +async function resolveSignInProtectCheck(request: Request, environment: typeof EnvironmentService.SINGLE_SESSION) { + const body = await parseUrlEncodedBody(request); + if (!body.proof_token) { + return createMissingProofTokenResponse(); + } + + const { clientState, newSession, newUser, signInResponse } = SignInService.createUser(null); + attachSessionUser(newSession, newUser.id, SignInService.getIdentifier()); + setClerkState({ environment, session: newSession as any, user: newUser }); + storeSignedInIdentifier(SignInService.getIdentifier()); + clientState.response.sign_in = signInResponse as any; + + return createNoStoreResponse({ + client: clientState.response, + response: signInResponse, + }); +} + +export function ProtectChallenge(): MockScenario { + const environment = structuredClone(EnvironmentService.SINGLE_SESSION); + environment.config.user_settings.social = {}; + + const signedInIdentifier = getStoredSignedInIdentifier(); + + if (signedInIdentifier) { + SignInService.setIdentifier(signedInIdentifier); + const { newSession, newUser } = SignInService.createUser(null); + attachSessionUser(newSession, newUser.id, signedInIdentifier); + setClerkState({ environment, session: newSession as any, user: newUser }); + } else { + setClerkState({ environment, session: null }); + } + + return { + description: 'Sign-in and sign-up pages gated by a Clerk Protect SDK challenge', + handlers: [ + http.get(sdkChallengeUrl, () => { + return HttpResponse.text( + ` + export default async function runProtectChallenge(container, { token, uiHints, signal }) { + const searchParams = new URLSearchParams(window.location.search); + const modeFromUrl = searchParams.get('${challengeModeSearchParam}'); + const mode = uiHints?.mode === 'manual' || modeFromUrl === 'manual' ? 'manual' : 'auto'; + const widgetKind = searchParams.get('${challengeWidgetSearchParam}') === 'turnstile' ? 'turnstile' : 'box'; + + if (signal?.aborted) { + const error = new Error('Protect challenge aborted'); + error.name = 'AbortError'; + throw error; + } + + container.dataset.protectChallengeToken = token; + container.dataset.protectChallengeSource = uiHints?.source || ''; + container.dataset.protectChallengeMode = mode; + + const marker = document.createElement('div'); + marker.setAttribute('data-testid', 'protect-challenge-sdk'); + marker.style.cssText = [ + 'display:flex', + 'flex-direction:column', + 'gap:10px', + 'margin:12px 0', + 'padding:14px', + 'border:1px solid #d8d1ff', + 'border-radius:8px', + 'background:#f7f5ff', + 'color:#27145c', + 'font:14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + ].join(';'); + + const title = document.createElement('strong'); + title.textContent = 'Protect challenge'; + marker.appendChild(title); + + const description = document.createElement('span'); + description.textContent = + mode === 'manual' + ? 'Manual sandbox mode is waiting for you to complete the challenge.' + : 'Sandbox challenge completed automatically.'; + marker.appendChild(description); + + container.appendChild(marker); + + if (widgetKind === 'turnstile') { + // Render the real Turnstile widget with a Cloudflare test + // sitekey so the card can be visually QA'd with production + // pixels; the proof token below is still the sandbox fake. + const sitekey = mode === 'manual' ? '${turnstileForceInteractiveSitekey}' : '${turnstileAlwaysPassesSitekey}'; + description.textContent = 'Cloudflare Turnstile via test sitekey (' + sitekey + ').'; + const slot = document.createElement('div'); + marker.appendChild(slot); + + await new Promise((resolve, reject) => { + let widgetId; + let settled = false; + // Settle exactly once: whichever of abort / script-error / + // widget callback fires first wins, and later events no-op. + const settle = fn => value => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', onAbort); + fn(value); + }; + const succeed = settle(resolve); + const fail = settle(reject); + const onAbort = () => { + try { window.turnstile?.remove(widgetId); } catch {} + const error = new Error('Protect challenge aborted'); + error.name = 'AbortError'; + fail(error); + }; + if (signal?.aborted) { onAbort(); return; } + signal?.addEventListener('abort', onAbort, { once: true }); + + const render = () => { + // The script may finish loading after an abort settled the + // run — don't render into an abandoned slot. + if (settled || signal?.aborted) return; + widgetId = window.turnstile.render(slot, { + sitekey, + callback: () => { + description.textContent = 'Turnstile challenge completed.'; + succeed(); + }, + 'error-callback': () => fail(new Error('Turnstile widget errored')), + }); + }; + + if (window.turnstile) { render(); return; } + // Reuse an in-flight script tag from a previous run, but + // always attach BOTH load and error handlers (they no-op + // once settled). + let script = document.querySelector('script[data-sandbox-turnstile]'); + if (!script) { + script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; + script.dataset.sandboxTurnstile = 'true'; + document.head.appendChild(script); + } + script.addEventListener('load', render, { once: true }); + script.addEventListener('error', () => fail(new Error('Failed to load the Turnstile script')), { once: true }); + }); + + return 'sandbox-proof-token:' + token; + } + + if (mode === 'manual') { + await new Promise((resolve, reject) => { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-testid', 'protect-challenge-complete'); + button.textContent = 'Complete challenge'; + button.style.cssText = [ + 'align-self:flex-start', + 'border:0', + 'border-radius:6px', + 'background:#6c47ff', + 'color:#fff', + 'cursor:pointer', + 'font:600 13px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + 'padding:9px 12px', + ].join(';'); + + const abort = () => { + button.removeEventListener('click', complete); + const error = new Error('Protect challenge aborted'); + error.name = 'AbortError'; + reject(error); + }; + + const complete = () => { + signal?.removeEventListener('abort', abort); + button.disabled = true; + button.textContent = 'Challenge complete'; + description.textContent = 'Manual sandbox challenge completed.'; + resolve(); + }; + + if (signal?.aborted) { + abort(); + return; + } + + signal?.addEventListener('abort', abort, { once: true }); + button.addEventListener('click', complete, { once: true }); + marker.appendChild(button); + }); + } else { + await new Promise(resolve => setTimeout(resolve, 10)); + } + + return 'sandbox-proof-token:' + token; + } + `, + { + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + 'Content-Type': 'text/javascript', + }, + }, + ); + }), + + http.post('*/v1/client/sign_ups', async ({ request }) => { + const body = await parseUrlEncodedBody(request); + const email = body.email_address || body.emailAddress || 'user@example.com'; + const firstName = body.first_name || body.firstName || null; + const lastName = body.last_name || body.lastName || null; + + SignUpService.setEmail(email); + SignUpService.setFirstName(firstName); + SignUpService.setLastName(lastName); + + const signUpResponse = SignUpService.createSignUpResponse({ + email, + firstName, + lastName, + status: 'missing_requirements', + unverifiedFields: ['email_address'], + verificationAttempts: 0, + verificationStatus: 'unverified', + }); + (signUpResponse as any).missing_fields = ['protect_check']; + (signUpResponse as any).protect_check = createProtectCheck('sandbox-sign-up-challenge'); + + const clientState = SessionService.getClientState(null); + clientState.response.sign_up = signUpResponse as any; + + return createNoStoreResponse({ + client: clientState.response, + response: signUpResponse, + }); + }), + + http.patch('*/v1/client/sign_ups/:signUpId/protect_check', async ({ request }) => { + return resolveSignUpProtectCheck(request); + }), + + http.post('*/v1/client/sign_ups/:signUpId/protect_check', async ({ request }) => { + return resolveSignUpProtectCheck(request); + }), + + http.post('*/v1/client/sign_ins', async ({ request }) => { + const body = await parseUrlEncodedBody(request); + const identifier = body.identifier || 'user@example.com'; + SignInService.setIdentifier(identifier); + + const signInResponse = SignInService.createSignInResponse({ + identifier, + status: 'needs_first_factor', + verificationAttempts: 0, + verificationStatus: 'unverified', + }); + + const clientState = SessionService.getClientState(null); + clientState.response.sign_in = signInResponse as any; + + return createNoStoreResponse({ + client: clientState.response, + response: signInResponse, + }); + }), + + http.post('*/v1/client/sign_ins/:signInId/attempt_first_factor', async ({ request }) => { + const body = await parseUrlEncodedBody(request); + if (!body.password) { + return createNoStoreResponse( + { + errors: [ + { + code: 'form_password_incorrect', + long_message: 'Password is incorrect. Try again, or use another method.', + message: 'is incorrect', + meta: { param_name: 'password' }, + }, + ], + }, + { status: 422 }, + ); + } + + const signInResponse = SignInService.createSignInResponse({ + status: 'needs_first_factor', + verificationAttempts: 1, + verificationStatus: 'verified', + }); + (signInResponse as any).status = 'needs_protect_check'; + (signInResponse as any).protect_check = createProtectCheck('sandbox-sign-in-challenge'); + + const clientState = SessionService.getClientState(null); + clientState.response.sign_in = signInResponse as any; + + return createNoStoreResponse({ + client: clientState.response, + response: signInResponse, + }); + }), + + http.patch('*/v1/client/sign_ins/:signInId/protect_check', async ({ request }) => { + return resolveSignInProtectCheck(request, environment); + }), + + http.post('*/v1/client/sign_ins/:signInId/protect_check', async ({ request }) => { + return resolveSignInProtectCheck(request, environment); + }), + + ...clerkHandlers, + ], + name: 'protect-challenge', + }; +}