[eric] passkeys: an empty credential list falls through to Chromium instead of cancelling silently (ENG-269)

This commit is contained in:
ciregenz
2026-08-12 20:04:52 -07:00
parent e2602f96de
commit 2065484017
2 changed files with 26 additions and 6 deletions
+9 -1
View File
@@ -2362,12 +2362,20 @@ app.whenReady().then(async () => {
ses.on('select-webauthn-account', (event, details, callback) => {
const accounts = (details && details.accounts) || [];
console.log('[passkey] select-webauthn-account rp=', details && details.relyingPartyId, 'n=', accounts.length);
// Nothing to choose from. This authenticator only ever sees credentials in OUR keychain
// access group, so a passkey the user made in Safari or Chrome is invisible here and the
// list arrives empty. Taking over the event and answering null is an explicit CANCEL, which
// the page renders as a prompt that simply never appears. Leave the event alone instead and
// let Chromium run its own default, so the site can fall through to "Try another way".
if (accounts.length === 0) {
console.warn('[passkey] no credential in our keychain group; leaving the choice to Chromium');
return;
}
event.preventDefault();
// One passkey is unambiguous, so answering it directly keeps the flow to a single Touch ID
// prompt. With several, the OS sheet is the right chooser and we must not silently guess a
// credential the user did not pick; a picker is the follow-up, never a blind first().
if (accounts.length === 1) return callback(accounts[0].credentialId);
if (accounts.length === 0) return callback(null);
console.warn('[passkey] multiple passkeys offered; needs a picker, defaulting to the first');
callback(accounts[0].credentialId);
});
+17 -5
View File
@@ -14,9 +14,11 @@ function makeHandler(log = () => {}) {
return (event, details, callback) => {
const accounts = (details && details.accounts) || [];
log(details && details.relyingPartyId, accounts.length);
// Zero credentials: do NOT take the event over. Answering null here is an explicit cancel and
// the page shows a prompt that never appears; leaving it lets Chromium offer "Try another way".
if (accounts.length === 0) return;
event.preventDefault();
if (accounts.length === 1) return callback(accounts[0].credentialId);
if (accounts.length === 0) return callback(null);
callback(accounts[0].credentialId);
};
}
@@ -39,13 +41,14 @@ test('several passkeys still answer a real credentialId (never null)', () => {
assert.equal(r.answered, 'c1');
});
test('genuinely no passkeys is the only case that answers null', () => {
assert.equal(drive({ relyingPartyId: 'google.com', accounts: [] }).answered, null);
});
test('a malformed details object cannot throw or hang the ceremony', () => {
// Same reasoning as the empty list: nothing to choose means stay out of the way, so the callback
// is never invoked and Chromium keeps ownership of the ceremony.
for (const d of [undefined, null, {}, { accounts: undefined }]) {
assert.equal(drive(d).answered, null, JSON.stringify(d));
assert.equal(drive(d).answered, 'NOT CALLED', JSON.stringify(d));
assert.equal(drive(d).prevented, false, JSON.stringify(d));
}
});
@@ -60,3 +63,12 @@ test('the OLD signature would have cancelled every sign-in', () => {
(v) => { answered = v; });
assert.equal(answered, null, 'the old handler answered null even with a real passkey present');
});
test('zero credentials leaves the event alone so the site can offer another way', () => {
// The failure this encodes: our authenticator only sees passkeys in OUR keychain access group, so
// one made in Safari or Chrome arrives as an empty list. Cancelling it is why "Complete sign-in
// using your passkey" sat there with no Touch ID prompt ever appearing (ENG-269, reported live).
const r = drive({ relyingPartyId: 'google.com', accounts: [] });
assert.equal(r.prevented, false, 'took over the event and cancelled instead of falling through');
assert.equal(r.answered, 'NOT CALLED', 'answered the callback when it should have stayed silent');
});