Merge pull request 'Recover mobile sign-in from stalled requests' (#768) from timmy/767-stalled-login-recovery into main
This commit is contained in:
commit
18f56583ba
|
|
@ -33,7 +33,31 @@
|
||||||
const continuation = validShareContinuation(options.continuation);
|
const continuation = validShareContinuation(options.continuation);
|
||||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||||
|
const setTimeoutImpl = options.setTimeoutImpl || setTimeout;
|
||||||
|
const clearTimeoutImpl = options.clearTimeoutImpl || clearTimeout;
|
||||||
|
const requestTimeoutMs = options.requestTimeoutMs || 15000;
|
||||||
let timer = null;
|
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) {
|
function decodeBase64Url(value) {
|
||||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||||
|
|
@ -105,18 +129,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(accessToken, deviceLabel = 'This device') {
|
async function submit(accessToken, deviceLabel = 'This device') {
|
||||||
|
if (activeAttempt) return false;
|
||||||
|
setAttemptActive(true);
|
||||||
status.textContent = 'Signing in…';
|
status.textContent = 'Signing in…';
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetchImpl('api/v1/session', {
|
response = await requestWithDeadline('api/v1/session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
form.reset();
|
form.reset();
|
||||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
status.textContent = error?.name === 'TimeoutError'
|
||||||
return;
|
? '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();
|
form.reset();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|
@ -135,9 +165,11 @@
|
||||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (activeAttempt) return false;
|
||||||
|
setAttemptActive(true);
|
||||||
status.textContent = 'Waiting for your passkey…';
|
status.textContent = 'Waiting for your passkey…';
|
||||||
try {
|
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' },
|
method: 'POST', headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||||
|
|
@ -148,7 +180,7 @@
|
||||||
...item, id: decodeBase64Url(item.id),
|
...item, id: decodeBase64Url(item.id),
|
||||||
}));
|
}));
|
||||||
const credential = await credentials.get({ publicKey });
|
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',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -162,9 +194,13 @@
|
||||||
if (!response.ok) throw new Error('Passkey verification failed');
|
if (!response.ok) throw new Error('Passkey verification failed');
|
||||||
location.replace(continuation);
|
location.replace(continuation);
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
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;
|
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():
|
def test_successful_login_resumes_valid_shared_capture_continuation():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||||
|
|
@ -254,6 +304,68 @@ const controller = createLoginController({{
|
||||||
assert "access_token" not in json.dumps(output)
|
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():
|
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||||
|
|
@ -343,7 +455,7 @@ const elements = {{
|
||||||
'submit-sign-in': {{ disabled: false }},
|
'submit-sign-in': {{ disabled: false }},
|
||||||
}};
|
}};
|
||||||
const context = {{
|
const context = {{
|
||||||
URLSearchParams, Response, setInterval, clearInterval,
|
URLSearchParams, Response, AbortController, setInterval, clearInterval, setTimeout, clearTimeout,
|
||||||
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
|
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
|
||||||
fetch: async () => new Response('{{}}', {{ status: 200 }}),
|
fetch: async () => new Response('{{}}', {{ status: 200 }}),
|
||||||
document: {{ getElementById: id => elements[id] }},
|
document: {{ getElementById: id => elements[id] }},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user