diff --git a/electron/main.js b/electron/main.js index 4e029337..3cb956b8 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); }); diff --git a/electron/selectWebauthnAccount.test.js b/electron/selectWebauthnAccount.test.js index a8620913..51b16bea 100644 --- a/electron/selectWebauthnAccount.test.js +++ b/electron/selectWebauthnAccount.test.js @@ -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'); +});