Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
All checks were successful
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-13 19:54:59 +00:00
commit 18f56583ba
2 changed files with 157 additions and 9 deletions

View File

@ -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);
}
}

View File

@ -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] }},