Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { openSupport } from '~/services/support'
import { isCapgoDomainReferrer, isDirectLoginLanding } from '~/utils/capgoReferrer'
import { getLoginActionVisibility } from '~/utils/loginActions'
import { validateRedirectPath } from '~/utils/safeRedirect'
import { safeResetTurnstile } from '~/utils/turnstile'
import { safeResetTurnstile, shouldRetryTurnstile, TURNSTILE_MAX_RETRIES } from '~/utils/turnstile'

const route = useRoute('/login')
const supabase = useSupabase()
Expand Down Expand Up @@ -52,6 +52,7 @@ const domainCheckTimeoutMs = 5000
const domainCheckDebounceMs = 350
const isCheckingSavedSession = ref(true)
const captchaStatus = ref<'disabled' | 'loading' | 'ready' | 'unavailable'>(captchaKey.value ? 'loading' : 'disabled')
const captchaRetries = ref(0)
let captchaInitTimeout: ReturnType<typeof setTimeout> | null = null
let domainCheckTimer: ReturnType<typeof setTimeout> | null = null
let domainCheckSeq = 0
Expand Down Expand Up @@ -170,7 +171,33 @@ function scheduleCaptchaInitTimeout() {
function handleCaptchaUnavailable(reason: string, error?: unknown) {
captchaStatus.value = 'unavailable'
clearCaptchaInitTimeout()
console.error(reason, error)
if (error !== undefined)
console.error(reason, error)
else
console.error(reason)
}

// Turnstile error 300010 and the rest of the 3xxxxx/6xxxxx families are
// transient (network, WebView, ad-blocker). Reset the widget and let the
// challenge run again instead of stranding the user on a dead captcha. Log at
// error level only once the retry budget is spent.
function handleCaptchaError(code: string) {
// Clearing the token drives captchaStatus back to 'loading' through its watcher.
turnstileToken.value = ''
if (isLoginStep.value && shouldRetryTurnstile(code, captchaRetries.value)) {
captchaRetries.value += 1
console.warn(`Turnstile error ${code}, retrying (${captchaRetries.value}/${TURNSTILE_MAX_RETRIES})`)
safeResetTurnstile(captchaComponent.value)
return
}
handleCaptchaUnavailable(`Turnstile error ${code}`)
}

// Cloudflare does not reset the widget when a token expires, so the stale token
// stays bound. Clear it and reset so the next submit carries a fresh token.
function handleCaptchaExpired() {
turnstileToken.value = ''
safeResetTurnstile(captchaComponent.value)
}

watch(turnstileToken, (token) => {
Expand All @@ -181,6 +208,7 @@ watch(turnstileToken, (token) => {

if (token) {
captchaStatus.value = 'ready'
captchaRetries.value = 0
clearCaptchaInitTimeout()
}
else if (isLoginStep.value) {
Expand Down Expand Up @@ -990,9 +1018,17 @@ onMounted(checkLogin)
v-model="turnstileToken"
size="flexible"
:site-key="captchaKey"
@error="handleCaptchaUnavailable('Turnstile error', $event)"
@error="handleCaptchaError($event)"
@expired="handleCaptchaExpired"
@unsupported="handleCaptchaUnavailable('Turnstile unsupported')"
/>
<p
v-if="captchaStatus === 'unavailable'"
class="mt-2 text-sm text-red-600 dark:text-red-400"
data-test="captcha-unavailable"
>
{{ t('captcha-not-available') }}
</p>
</div>
</div>

Expand Down
24 changes: 24 additions & 0 deletions src/utils/turnstile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** VueTurnstile instance ref shape — only reset() is needed for safe cleanup. */
export type TurnstileComponentRef = { reset?: () => void } | null | undefined

/** Reset attempts allowed before we declare Turnstile unavailable. */
export const TURNSTILE_MAX_RETRIES = 2

/**
* Reset a Cloudflare Turnstile widget without throwing when the container
* was destroyed (v-if step change, remount, or failed init).
Expand All @@ -16,3 +19,24 @@ export function safeResetTurnstile(component: TurnstileComponentRef): void {
// Cloudflare Turnstile throws TurnstileError when nothing to reset.
}
}

/**
* Report whether a Turnstile error code clears after a widget reset.
*
* The 3xxxxx (generic client execution) and 6xxxxx (challenge / timeout)
* families are transient — a slow network, a WebView, or an ad-blocker — and a
* reset lets the challenge run again. Other families (bad sitekey, unsupported
* browser, wrong domain) do not clear on retry.
* See https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
*/
export function isRecoverableTurnstileError(code: string): boolean {
return /^[36]\d+$/.test(code)
}

/**
* Decide whether to reset and retry the widget after a Turnstile error. Retry
* only transient errors, and only while under the retry budget.
*/
export function shouldRetryTurnstile(code: string, retries: number, maxRetries: number = TURNSTILE_MAX_RETRIES): boolean {
return isRecoverableTurnstileError(code) && retries < maxRetries
}
35 changes: 34 additions & 1 deletion tests/turnstile.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { safeResetTurnstile } from '../src/utils/turnstile.ts'
import { isRecoverableTurnstileError, safeResetTurnstile, shouldRetryTurnstile, TURNSTILE_MAX_RETRIES } from '../src/utils/turnstile.ts'

describe('safeResetTurnstile', () => {
it('calls reset when the component is present', () => {
Expand All @@ -21,3 +21,36 @@ describe('safeResetTurnstile', () => {
expect(reset).toHaveBeenCalledOnce()
})
})

describe('isRecoverableTurnstileError', () => {
it('treats the reported 300010 login failure as recoverable', () => {
expect(isRecoverableTurnstileError('300010')).toBe(true)
})

it('treats other 3xxxxx and 6xxxxx codes as recoverable', () => {
expect(isRecoverableTurnstileError('300030')).toBe(true)
expect(isRecoverableTurnstileError('600010')).toBe(true)
})

it('treats config and browser codes as not recoverable', () => {
// 110xxx bad sitekey/domain, 100xxx init, unsupported browser.
expect(isRecoverableTurnstileError('110200')).toBe(false)
expect(isRecoverableTurnstileError('100000')).toBe(false)
expect(isRecoverableTurnstileError('')).toBe(false)
})
})

describe('shouldRetryTurnstile', () => {
it('retries a transient error while under the budget', () => {
expect(shouldRetryTurnstile('300010', 0)).toBe(true)
expect(shouldRetryTurnstile('300010', TURNSTILE_MAX_RETRIES - 1)).toBe(true)
})

it('stops retrying once the budget is spent', () => {
expect(shouldRetryTurnstile('300010', TURNSTILE_MAX_RETRIES)).toBe(false)
})

it('never retries a non-recoverable error', () => {
expect(shouldRetryTurnstile('110200', 0)).toBe(false)
})
})
Loading