fix: recover stalled mobile sign-in (Closes #767)
This commit is contained in:
parent
9bd755f521
commit
6e2f5d1120
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] }},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user