frontend: Turnstile widget on registration pages
Renders the widget (explicit render of challenges.cloudflare.com's api.js) on the password/passkey and SSO registration forms only when describeServer advertises turnstileSiteKey, keeps submit disabled until a token arrives, sends it as x-turnstile-token, and resets the widget after a failed attempt since tokens are single-use.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667<script lang="ts"> import { _ } from '../lib/i18n' import { loadTurnstile } from '../lib/turnstile'
interface Props { siteKey: string resetKey?: number onToken: (token: string | null) => void }
let { siteKey, resetKey = 0, onToken }: Props = $props()
let container: HTMLDivElement let loadFailed = $state(false) let reset: (() => void) | null = null let resetArmed = false
$effect(() => { const sitekey = siteKey let cancelled = false let remove: (() => void) | null = null loadTurnstile() .then((turnstile) => { if (cancelled) return const widgetId = turnstile.render(container, { sitekey, theme: 'auto', callback: (token) => onToken(token), 'expired-callback': () => onToken(null), 'error-callback': () => onToken(null), }) reset = () => turnstile.reset(widgetId) remove = () => turnstile.remove(widgetId) }) .catch(() => { if (!cancelled) loadFailed = true }) return () => { cancelled = true remove?.() reset = null } })
$effect(() => { void resetKey if (!resetArmed) { resetArmed = true return } onToken(null) reset?.() })</script>
<div class="turnstile" aria-label={$_('captcha.label')}> <div bind:this={container}></div> {#if loadFailed} <p class="hint warning">{$_('captcha.loadFailed')}</p> {/if}</div>
<style> .turnstile { min-height: 65px; }</style>21 unmodified lines222324252627282930375 unmodified linesExpand all406407408409410411412413414415416417418419752 unmodified linesExpand all11721173117411751173117611771178117911801181118211831184346 unmodified linesExpand all21 unmodified lines createDPoPProofForRequest, getDPoPNonce, setDPoPNonce,} from "./oauth.ts";import { captchaHeaders } from "./turnstile.ts";import type { AccountInfo, AccountState, ApiErrorCode,375 unmodified linesExpand allexport const api = { async createAccount( params: CreateAccountParams, byodToken?: string, captchaToken?: string | null, ): Promise<CreateAccountResult> { const url = `${API_BASE}/com.atproto.server.createAccount`; const headers: Record<string, string> = { "Content-Type": "application/json", ...captchaHeaders(captchaToken), }; if (byodToken) { headers["Authorization"] = `Bearer ${byodToken}`; }752 unmodified linesExpand all verificationChannel?: VerificationChannel; discordUsername?: string; telegramUsername?: string; signalUsername?: string; }, byodToken?: string): Promise<PasskeyAccountCreateResponse> { }, byodToken?: string, captchaToken?: string | null): Promise<PasskeyAccountCreateResponse> { const url = `${API_BASE}/_account.createPasskeyAccount`; const headers: Record<string, string> = { "Content-Type": "application/json", ...captchaHeaders(captchaToken), }; if (byodToken) { headers["Authorization"] = `Bearer ${byodToken}`; }346 unmodified linesExpand all12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152export const TURNSTILE_SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
export const TURNSTILE_TOKEN_HEADER = "x-turnstile-token";
export interface TurnstileRenderOptions { sitekey: string; callback: (token: string) => void; "expired-callback"?: () => void; "error-callback"?: () => void; theme?: "auto" | "light" | "dark";}
export interface TurnstileApi { render(container: HTMLElement, options: TurnstileRenderOptions): string; reset(widgetId: string): void; remove(widgetId: string): void;}
declare global { interface Window { turnstile?: TurnstileApi; }}
let pending: Promise<TurnstileApi> | null = null;
export function loadTurnstile(): Promise<TurnstileApi> { if (window.turnstile) return Promise.resolve(window.turnstile); pending ??= new Promise<TurnstileApi>((resolve, reject) => { const script = document.createElement("script"); script.src = TURNSTILE_SCRIPT_URL; script.async = true; script.onload = () => window.turnstile ? resolve(window.turnstile) : reject(new Error("Turnstile did not initialise")); script.onerror = () => { pending = null; script.remove(); reject(new Error("Failed to load Turnstile")); }; document.head.appendChild(script); }); return pending;}
export function captchaHeaders( token: string | null | undefined,): Record<string, string> { return token ? { [TURNSTILE_TOKEN_HEADER]: token } : {};}116 unmodified linesExpand all1171181191201211221231241251261271281291142 unmodified linesExpand all116 unmodified linesExpand all "telegramRequired": "Telegram username is required for Telegram verification", "signalRequired": "Signal username is required for Signal verification" } }, "captcha": { "label": "Human verification", "required": "Please complete the human verification check", "loadFailed": "The human verification check could not load. Allow challenges.cloudflare.com (content blockers can stop it) and reload the page." }, "dashboard": { "title": "Dashboard", "accountManager": "Account manager", "switchAccount": "Switch Account",1142 unmodified linesExpand all15 unmodified lines1617181920212223242 unmodified lines27282930313233343566 unmodified lines10210310410510610710810911011111235 unmodified lines148149150151152153154155156157158277 unmodified linesExpand all43643743843944044144244344444544644744844945043645145245345445552 unmodified lines15 unmodified lines import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' import HandleInput from '../components/HandleInput.svelte' import IdentityTypeSection from '../components/IdentityTypeSection.svelte' import CommsChannelPicker from '../components/CommsChannelPicker.svelte' import TurnstileWidget from '../components/TurnstileWidget.svelte' import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
const mode: RegistrationMode = getCurrentPath().includes('register-password') ? 'password' : 'passkey' const isPasskey = mode === 'passkey'2 unmodified lines availableUserDomains: string[] inviteCodeRequired: boolean availableCommsChannels?: import('../lib/types/api').VerificationChannel[] selfHostedDidWebEnabled?: boolean turnstileSiteKey?: string } | null>(null) let loadingServerInfo = $state(true) let serverInfoLoaded = false let ssoAvailable = $state(false)66 unmodified lines })
let creatingStarted = false $effect(() => { if (flow?.state.step === 'info') creatingStarted = false }) $effect(() => { if (flow?.state.step === 'creating' && !creatingStarted) { creatingStarted = true if (isPasskey) { flow.createPasskeyAccount()35 unmodified lines } if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { return $_('registerPasskey.errors.inviteRequired') } if (serverInfo?.turnstileSiteKey && !flow.state.captchaToken) { return $_('captcha.required') } if (info.didType === 'web-external') { if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired') if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat') }277 unmodified linesExpand all /> </div> {/if}
{#if serverInfo?.turnstileSiteKey} <TurnstileWidget siteKey={serverInfo.turnstileSiteKey} onToken={(token) => flow?.setCaptchaToken(token)} /> {/if}
<div class="form-actions"> <button type="button" class="secondary" onclick={handleCancel} disabled={flow.state.submitting}> {$_('common.cancel')} </button> <button type="submit" class="primary" disabled={flow.state.submitting || flow.state.handleAvailable === false || flow.state.checkingHandle}> <button type="submit" class="primary" disabled={flow.state.submitting || flow.state.handleAvailable === false || flow.state.checkingHandle || (!!serverInfo?.turnstileSiteKey && !flow.state.captchaToken)}> {flow.state.submitting ? $_('common.loading') : $_('common.continue')} </button> </div> </form>52 unmodified lines3 unmodified lines4567891011121345 unmodified lines596061626364656667686970717246 unmodified lines119120121122123124125126127160 unmodified linesExpand all28828929029129229329429529611 unmodified lines30830931031131231331431531615 unmodified lines332333334335336337338339340188 unmodified linesExpand all52953053153253353453553653753853954052354154254354454513 unmodified lines3 unmodified lines import { toast } from '../lib/toast.svelte' import SsoIcon from '../components/SsoIcon.svelte' import HandleInput from '../components/HandleInput.svelte' import IdentityTypeSection from '../components/IdentityTypeSection.svelte' import TurnstileWidget from '../components/TurnstileWidget.svelte' import { captchaHeaders } from '../lib/turnstile'
interface PendingRegistration { request_uri: string provider: string45 unmodified lines let serverInfo = $state<{ availableUserDomains: string[] inviteCodeRequired: boolean selfHostedDidWebEnabled: boolean turnstileSiteKey?: string } | null>(null)
let captchaToken = $state<string | null>(null) let captchaResetKey = $state(0)
let commsChannels = $state<CommsChannelConfig>({ email: true, discord: false, telegram: false,46 unmodified lines serverInfo = { availableUserDomains: data.availableUserDomains || [], inviteCodeRequired: data.inviteCodeRequired ?? false, selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false, turnstileSiteKey: data.turnstileSiteKey, } const available: string[] = data.availableCommsChannels ?? ['email'] commsChannels = { email: available.includes('email'),160 unmodified linesExpand all method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', ...captchaHeaders(captchaToken), }, body: JSON.stringify({ token, handle: fullHandle,11 unmodified lines const data = await response.json()
if (!response.ok) { toast.error(data.message || data.error_description || data.error || $_('common.error')) captchaResetKey++ submitting = false return }
15 unmodified lines } } catch (err) { console.error('SSO registration failed:', err) toast.error(err instanceof Error ? err.message : $_('common.error')) captchaResetKey++ submitting = false } }</script>188 unmodified linesExpand all /> </div> {/if}
{#if serverInfo?.turnstileSiteKey} <TurnstileWidget siteKey={serverInfo.turnstileSiteKey} resetKey={captchaResetKey} onToken={(t) => captchaToken = t} /> {/if}
<button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid()}> <button type="submit" disabled={submitting || !handle || handle.length < 3 || handleAvailable === false || checkingHandle || !isChannelValid() || (!!serverInfo?.turnstileSiteKey && !captchaToken)}> {submitting ? $_('common.creating') : $_('sso_register.submit')} </button> </form> </div>13 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158import { beforeEach, describe, expect, it, vi } from "vitest";import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";import { clearMocks, jsonResponse, mockEndpoint, setupFetchMock } from "./mocks.ts";import { captchaHeaders, loadTurnstile, TURNSTILE_SCRIPT_URL, TURNSTILE_TOKEN_HEADER, type TurnstileApi, type TurnstileRenderOptions,} from "../lib/turnstile.ts";import { api } from "../lib/api.ts";import { unsafeAsEmail, unsafeAsHandle } from "../lib/types/branded.ts";import SsoRegisterComplete from "../routes/SsoRegisterComplete.svelte";
interface FakeTurnstile extends TurnstileApi { rendered: TurnstileRenderOptions[]; resets: string[];}
function installFakeTurnstile(): FakeTurnstile { const fake: FakeTurnstile = { rendered: [], resets: [], render: (_el, options) => { fake.rendered.push(options); return `widget-${fake.rendered.length}`; }, reset: (id) => { fake.resets.push(id); }, remove: () => {}, }; window.turnstile = fake; return fake;}
function turnstileScripts(): HTMLScriptElement[] { return [...document.querySelectorAll("script")].filter((s) => s.src.startsWith("https://challenges.cloudflare.com/") );}
function headerOf(init: RequestInit | undefined, name: string) { return new Headers(init?.headers).get(name);}
beforeEach(() => { delete window.turnstile; turnstileScripts().forEach((s) => s.remove()); clearMocks(); setupFetchMock();});
describe("turnstile helpers", () => { it("builds the token header only when a token exists", () => { expect(captchaHeaders("tok")).toEqual({ [TURNSTILE_TOKEN_HEADER]: "tok" }); expect(captchaHeaders(null)).toEqual({}); expect(captchaHeaders(undefined)).toEqual({}); });
it("loads the explicit-render script once", async () => { const first = loadTurnstile(); const second = loadTurnstile(); const scripts = turnstileScripts(); expect(scripts).toHaveLength(1); expect(scripts[0].src).toBe(TURNSTILE_SCRIPT_URL); const fake = installFakeTurnstile(); scripts[0].dispatchEvent(new Event("load")); await expect(first).resolves.toBe(fake); await expect(second).resolves.toBe(fake); });});
describe("account creation API", () => { it("sends the captcha token as a header and nothing when absent", async () => { const seen: (string | null)[] = []; mockEndpoint("com.atproto.server.createAccount", (_url, init) => { seen.push(headerOf(init, TURNSTILE_TOKEN_HEADER)); return jsonResponse({ did: "did:plc:x", handle: "a.test" }); }); mockEndpoint("_account.createPasskeyAccount", (_url, init) => { seen.push(headerOf(init, TURNSTILE_TOKEN_HEADER)); return jsonResponse({ did: "did:plc:x", handle: "a.test", setupToken: "s" }); }); const params = { handle: "a.test", email: "a@example.com", password: "Testpass123!" }; await api.createAccount(params, undefined, "tok-1"); await api.createAccount(params); await api.createPasskeyAccount( { handle: unsafeAsHandle("a.test"), email: unsafeAsEmail("a@example.com") }, undefined, "tok-2", ); expect(seen).toEqual(["tok-1", null, "tok-2"]); });});
describe("SSO registration page", () => { function mockSsoPage(siteKey: string | undefined) { Object.defineProperty(globalThis.location, "search", { value: "?token=pending-token", writable: true, configurable: true, }); mockEndpoint("/oauth/sso/pending-registration", () => jsonResponse({ request_uri: "urn:mock", provider: "github", provider_user_id: "1", provider_username: "octocat", provider_email: "octo@example.com", provider_email_verified: true, })); mockEndpoint("com.atproto.server.describeServer", () => jsonResponse({ availableUserDomains: ["fae.test"], inviteCodeRequired: false, availableCommsChannels: ["email"], ...(siteKey ? { turnstileSiteKey: siteKey } : {}), })); mockEndpoint("/oauth/sso/check-handle-available", () => jsonResponse({ available: true })); }
it("renders no widget and loads no script without a site key", async () => { mockSsoPage(undefined); render(SsoRegisterComplete); const submit = await screen.findByRole("button", { name: /create|submit|continue/i }); await waitFor(() => expect(submit).toBeEnabled()); expect(turnstileScripts()).toHaveLength(0); });
it("gates submit on the widget and resets it after a failed attempt", async () => { const fake = installFakeTurnstile(); mockSsoPage("site-key-123"); const tokens: (string | null)[] = []; mockEndpoint("/oauth/sso/complete-registration", (_url, init) => { tokens.push(headerOf(init, TURNSTILE_TOKEN_HEADER)); return jsonResponse( { error: "InvalidCaptcha", message: "The captcha challenge failed" }, 400, ); }); render(SsoRegisterComplete); await waitFor(() => expect(fake.rendered).toHaveLength(1)); expect(fake.rendered[0].sitekey).toBe("site-key-123"); const submit = await screen.findByRole("button", { name: /create|submit|continue/i }); await waitFor(() => expect(submit).toBeDisabled());
fake.rendered[0].callback("solved-token"); await waitFor(() => expect(submit).toBeEnabled()); await fireEvent.click(submit);
await waitFor(() => expect(tokens).toEqual(["solved-token"])); await waitFor(() => expect(fake.resets).toEqual(["widget-1"])); await waitFor(() => expect(submit).toBeDisabled()); });});36 unmodified lines37383940414243444524 unmodified lines707172737475767778157 unmodified linesExpand all2362372382392402412422432442452462472482492502512522532542552562572492582592602612621 unmodified line2642652662672682692702712722732742752762772782792802811 unmodified line2832842852862762872882892902912 unmodified lines294295296297298299300301302193 unmodified linesExpand all49649749849950050150250350450550628 unmodified lines36 unmodified lines pdsHostname: string; selectedDomain: string; handleAvailable: boolean | null; checkingHandle: boolean; captchaToken: string | null;}
export function createRegistrationFlow( mode: RegistrationMode,24 unmodified lines pdsHostname, selectedDomain: "", handleAvailable: null, checkingHandle: false, captchaToken: null, });
function getPdsEndpoint(): string { return `https://${state.pdsHostname}`;157 unmodified linesExpand all signalUsername: state.info.signalUsername?.trim() || undefined, }; }
function takeCaptchaToken(): string | null { const token = state.captchaToken; state.captchaToken = null; return token; }
async function createPasswordAccount() { state.submitting = true; state.error = null; const captchaToken = takeCaptchaToken();
try { const byodToken = await generateByodToken(); const result = await api.createAccount({ handle: getFullHandle(), email: state.info.email.trim(), password: state.info.password!, ...commonAccountParams(), }, byodToken); }, byodToken, captchaToken);
state.account = { did: result.did, handle: result.handle,1 unmodified line state.step = "verify"; persistState(); } catch (err) { setError(err); if (captchaToken) state.step = "info"; } finally { state.submitting = false; } }
async function createPasskeyAccount() { state.submitting = true; state.error = null; const captchaToken = takeCaptchaToken();
try { const byodToken = await generateByodToken(); const result = await api.createPasskeyAccount({1 unmodified line email: state.info.email?.trim() ? unsafeAsEmail(state.info.email.trim()) : undefined, ...commonAccountParams(), }, byodToken); }, byodToken, captchaToken);
state.account = { did: result.did, handle: result.handle,2 unmodified lines state.step = "passkey"; persistState(); } catch (err) { setError(err); if (captchaToken) state.step = "info"; } finally { state.submitting = false; } }193 unmodified linesExpand all }, setSubmitting(val: boolean) { state.submitting = val; }, setCaptchaToken(token: string | null) { state.captchaToken = token; }, };}
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>;28 unmodified lines203 unmodified linesExpand all204205206207208209210211212340 unmodified linesExpand all203 unmodified linesExpand all selfHostedDidWebEnabled?: boolean; telegramBotUsername?: string; discordBotUsername?: string; discordAppId?: string; turnstileSiteKey?: string;}
export interface UpdateNotificationPrefsResponse { success: boolean;340 unmodified linesExpand all