From 6e2f5d1120a3e01490d45192700f1746b9fbcab9 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 19:52:44 +0000 Subject: [PATCH] fix: recover stalled mobile sign-in (Closes #767) --- frontend/login.js | 52 +++++++++++++--- tests/test_login_frontend.py | 114 ++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/frontend/login.js b/frontend/login.js index bcf0149..1517d20 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -33,7 +33,31 @@ const continuation = validShareContinuation(options.continuation); const setIntervalImpl = options.setIntervalImpl || setInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval; + const setTimeoutImpl = options.setTimeoutImpl || setTimeout; + const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout; + const requestTimeoutMs = options.requestTimeoutMs || 15000; let timer = null; + let activeAttempt = false; + + function setAttemptActive(active) { + activeAttempt = active; + button.disabled = active; + if (passkeyButton) passkeyButton.disabled = active || !credentials?.get; + } + + async function requestWithDeadline(url, init) { + const controller = new AbortController(); + const timeout = setTimeoutImpl(() => { + const error = new Error('Sign-in request timed out'); + error.name = 'TimeoutError'; + controller.abort(error); + }, requestTimeoutMs); + try { + return await fetchImpl(url, { ...init, signal: controller.signal }); + } finally { + clearTimeoutImpl(timeout); + } + } function decodeBase64Url(value) { const padded = String(value).replaceAll('-', '+').replaceAll('_', '/') @@ -105,18 +129,24 @@ } async function submit(accessToken, deviceLabel = 'This device') { + if (activeAttempt) return false; + setAttemptActive(true); status.textContent = 'Signing in…'; let response; try { - response = await fetchImpl('api/v1/session', { + response = await requestWithDeadline('api/v1/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }), }); - } catch (_error) { + } catch (error) { form.reset(); - status.textContent = 'Sign-in failed. Check your connection and try again.'; - return; + status.textContent = error?.name === 'TimeoutError' + ? 'Sign-in timed out. Check your connection and try again.' + : 'Sign-in failed. Check your connection and try again.'; + return false; + } finally { + setAttemptActive(false); } form.reset(); if (response.ok) { @@ -135,9 +165,11 @@ status.textContent = 'Passkeys are not supported in this browser. Use the access token.'; return false; } + if (activeAttempt) return false; + setAttemptActive(true); status.textContent = 'Waiting for your passkey…'; try { - const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', { + const optionsResponse = await requestWithDeadline('api/v1/passkeys/authentication/options', { method: 'POST', headers: { Accept: 'application/json' }, }); if (!optionsResponse.ok) throw new Error('No enrolled passkey'); @@ -148,7 +180,7 @@ ...item, id: decodeBase64Url(item.id), })); const credential = await credentials.get({ publicKey }); - const response = await fetchImpl('api/v1/passkeys/authentication/verify', { + const response = await requestWithDeadline('api/v1/passkeys/authentication/verify', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -162,9 +194,13 @@ if (!response.ok) throw new Error('Passkey verification failed'); location.replace(continuation); return true; - } catch (_error) { - status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.'; + } catch (error) { + status.textContent = error?.name === 'TimeoutError' + ? 'Passkey sign-in timed out. Check your connection and try again.' + : 'Passkey sign-in was not completed. Try again or use the access token.'; return false; + } finally { + setAttemptActive(false); } } diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index f74e207..0d902db 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -169,6 +169,56 @@ const controller = createLoginController({{ } +def test_stalled_token_login_aborts_once_and_restores_both_sign_in_controls(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const state = {{ requests: 0, timeout: null, aborted: false }}; +const status = {{ textContent: '' }}; +const button = {{ disabled: false }}; +const passkeyButton = {{ disabled: false }}; +const controller = createLoginController({{ + form: {{ reset: () => {{}} }}, status, button, passkeyButton, + credentials: {{ get: async () => {{ throw new Error('unused'); }} }}, + fetchImpl: (_url, options) => {{ + state.requests += 1; + options.signal.addEventListener('abort', () => state.aborted = true); + return new Promise((_resolve, reject) => {{ + options.signal.addEventListener('abort', () => reject(options.signal.reason)); + }}); + }}, + location: {{ replace: () => {{}} }}, + requestTimeoutMs: 25, + setTimeoutImpl: callback => {{ state.timeout = callback; return 7; }}, + clearTimeoutImpl: () => {{}}, +}}); +(async () => {{ + const first = controller.submit('operator-token'); + const duplicate = controller.submit('operator-token'); + state.during = {{ button: button.disabled, passkey: passkeyButton.disabled }}; + state.timeout(); + await Promise.all([first, duplicate]); + state.after = {{ + button: button.disabled, passkey: passkeyButton.disabled, + status: status.textContent, + }}; + process.stdout.write(JSON.stringify(state)); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + state = json.loads(result.stdout) + + assert state["requests"] == 1 + assert state["during"] == {"button": True, "passkey": True} + assert state["aborted"] is True + assert state["after"] == { + "button": False, + "passkey": False, + "status": "Sign-in timed out. Check your connection and try again.", + } + + def test_successful_login_resumes_valid_shared_capture_continuation(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); @@ -254,6 +304,68 @@ const controller = createLoginController({{ assert "access_token" not in json.dumps(output) +def test_stalled_passkey_network_phases_time_out_without_timing_out_biometrics(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +async function scenario(stalledPhase) {{ + const state = {{ requests: [], timers: [], credentialCalls: 0 }}; + const status = {{textContent:''}}; + const button = {{disabled:false}}; + const passkeyButton = {{disabled:false}}; + const credential = {{ + id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer, + response:{{authenticatorData:Uint8Array.from([2]).buffer, + clientDataJSON:Uint8Array.from([3]).buffer, + signature:Uint8Array.from([4]).buffer,userHandle:null}}, + }}; + const controller=createLoginController({{ + form:{{reset:()=>{{}}}},status,button,passkeyButton, + credentials:{{get:async()=>{{state.credentialCalls+=1;return credential;}}}}, + fetchImpl:(url,options)=>{{ + state.requests.push(url); + const phase=url.endsWith('/options') ? 'options' : 'verify'; + if (phase === stalledPhase) return new Promise((_resolve,reject)=>{{ + options.signal.addEventListener('abort',()=>reject(options.signal.reason)); + }}); + return Promise.resolve(new Response(JSON.stringify({{ + challenge:'AQID',allowCredentials:[] + }}),{{status:200,headers:{{'Content-Type':'application/json'}}}})); + }}, + location:{{replace:()=>{{}}}},requestTimeoutMs:25, + setTimeoutImpl:callback=>{{state.timers.push(callback);return state.timers.length;}}, + clearTimeoutImpl:()=>{{}}, + }}); + const pending=controller.signInWithPasskey('Phone'); + await new Promise(resolve=>setImmediate(resolve)); + if (stalledPhase === 'verify') await new Promise(resolve=>setImmediate(resolve)); + state.timers.at(-1)(); + state.result=await pending; + state.final={{status:status.textContent,button:button.disabled,passkey:passkeyButton.disabled}}; + return state; +}} +(async()=>{{ + process.stdout.write(JSON.stringify({{ + options:await scenario('options'),verify:await scenario('verify'), + }})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + state = json.loads(result.stdout) + + assert state["options"]["credentialCalls"] == 0 + assert state["verify"]["credentialCalls"] == 1 + assert state["verify"]["timers"] == [None, None] + for phase in ("options", "verify"): + assert state[phase]["result"] is False + assert state[phase]["final"] == { + "status": "Passkey sign-in timed out. Check your connection and try again.", + "button": False, + "passkey": False, + } + + def test_login_sends_a_bounded_device_label_with_the_access_token(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); @@ -343,7 +455,7 @@ const elements = {{ 'submit-sign-in': {{ disabled: false }}, }}; const context = {{ - URLSearchParams, Response, setInterval, clearInterval, + URLSearchParams, Response, AbortController, setInterval, clearInterval, setTimeout, clearTimeout, FormData: function () {{ return {{ get: () => 'operator-token' }}; }}, fetch: async () => new Response('{{}}', {{ status: 200 }}), document: {{ getElementById: id => elements[id] }}, -- 2.43.0