パスキーをユーザー名欄の候補に出す Conditional UI(mediation: 'conditional')は、ページを開いているあいだ navigator.credentials.get() を1本待たせたままにします。その状態で、「パスキーでログイン」ボタンから2本目の get() を呼ぶと、ブラウザは OperationError: A request is already pending. で断ります。1本目を abort() してから呼べば、2本目は通りました。Chromium 141 と、Playwright から使える仮想認証器で測りました。
| 呼び方 | 2本目の結果 |
|---|---|
| A. 1本目(conditional)を待たせたまま、2本目を呼ぶ | OperationError: A request is already pending. |
B. 1本目を abort() して、すぐ2本目を呼ぶ |
ログインできた |
C. 1本目を abort() し、1本目の Promise が片付くのを待ってから2本目 |
ログインできた |
D. ボタンを2回押す(モーダルの get() を2本続けて呼ぶ) |
2回目が OperationError: A request is already pending.
|
1本目を abort() したとき |
結果 |
|---|---|
| 1本目の Promise | 約1ms後に AbortError: signal is aborted without reason で片付いた |
先に断っておくと、Conditional UI の get() が待ち続けることは MDN に書いてあります。CredentialsContainer.get() のページには「the promise remains pending until the user picks an account to sign-in with from available autofill suggestions」とあり、signal で中断した操作は「may complete normally ... or reject with the signal's reason」とされています。ここで書くのは、その待ち続ける1本目を忘れてボタンを付けていた自分の WordPress プラグインで何が起きたかと、2本目がどうなるかを実際に測った結果です。
何が起きたか
自作の WordPress プラグイン Rapls Passkey は、ログイン画面のユーザー名欄にパスキーの候補を出し、その下に「パスキーでログイン」ボタンも置いています。0.13.71 までは、ページを開くと Conditional UI の get() を始め、ボタンが押されると別に get() を呼んでいました。
実際のサイトで、同じパスキーでログインできたりできなかったりしていました。ボタンを2回押すと、かえって悪くなる。ログイン画面で再現させると、表の A と D の OperationError が出ていました。
測った方法
Chromium 141.0.7390.37(Playwright)
仮想認証器:CDP の WebAuthn.addVirtualAuthenticator(ctap2 / internal / resident key あり)
パスキーを1つ登録してから測る
仮想認証器は、automaticPresenceSimulation を切り替えると、利用者が操作したかどうかを決められます。1本目を始めるときは切っておき、利用者がまだ候補を選んでいない状態で待たせます。2本目を呼ぶ直前に入れて、ボタンを押した利用者が認証した状態にします。
const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
});
const presence = on => cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled: on });
A:1本目が残っている限り、2本目は断られる
### A 1本目を待たせたまま、ボタンで2本目
214ms 2本目 OperationError: A request is already pending.
断られたあとも、1本目は待ち続けたままでした。利用者から見ると、ボタンを押しても何も起きず、ユーザー名欄の候補だけが残っています。
B と C:abort() すれば、2本目は通る
### B 1本目を abort() して、すぐ2本目
210ms abort()
210ms 1本目 AbortError: signal is aborted without reason
211ms 2本目 resolved(ログインできた)
Chromium では、abort() を呼ぶと1本目はすぐ AbortError で片付き、その直後に呼んだ2本目は通りました。1本目が片付くのを await してから呼んだ C でも、同じでした。
ただし、abort() した1本目が片付く保証はない
Chromium 単体では、abort() した1本目はすぐ片付きました。プラグインの修正では、ここを無条件には待っていません。
MDN にあるとおり、中断した操作は普通に完了することもあります。Conditional UI の候補は、ブラウザではなくパスワード管理の拡張機能が出していることもあり、その場合に1本目がいつ、どう片付くかは、ページの側からは決められません。
最初の修正では、ボタンを押したら1本目が片付くのを await で待っていました。それで、ボタンが反応しなくなりました。0.13.72 では、待つ時間に上限を付けています。
0.13.72 の直し方
-
get()を呼ぶ枠を1か所で持つ。前の1本を返してもらってから、次を始める - 返してもらうのを待つ時間に上限を付ける
- ボタンは、自分のプロンプトが出ているあいだ押せなくする(D を防ぐ)
- それでも
OperationErrorが来たら、1回だけやり直す。オプションもチャレンジも使っていないので、同じ手続きをもう一度するだけになる - 署名が済んだあとの検証の通信には、
abortの信号を渡さない。署名の後で中断すると、サーバーが受け付けるはずだったログインを捨てることになる
パスキーのボタンを足すとき
- Conditional UI を使っているページで別の
get()を呼ぶ前に、1本目をabort()する -
abort()した1本目が片付くのを、上限なしで待たない - ボタンは、プロンプトが出ているあいだ押せなくする
-
OperationErrorの文言で分岐せず、nameで見る
あなたのログイン画面で、Conditional UI とボタンが同時に get() を持とうとしていませんか。
手元で試すコマンド
Playwright が入っていれば、そのまま動きます。
const { chromium } = require('playwright');
const http = require('http');
const html = `<!doctype html><meta charset=utf-8><input autocomplete="username webauthn"><script>
window.opts = () => ({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: location.hostname, userVerification: 'preferred' } });
window.log = []; window.L = m => log.push(m);
window.startCond = () => { window.ac = new AbortController(); window.first = navigator.credentials.get({ ...opts(), mediation: 'conditional', signal: ac.signal }).then(() => L('1本目 resolved'), e => L('1本目 ' + e.name)); };
window.second = async () => { try { await navigator.credentials.get(opts()); L('2本目 resolved'); } catch (e) { L('2本目 ' + e.name + ': ' + e.message); } };
</script>`;
(async () => {
const srv = http.createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(html); }).listen(0);
const url = `http://localhost:${srv.address().port}/`;
const b = await chromium.launch(); const ctx = await b.newContext(); const p = await ctx.newPage();
const cdp = await ctx.newCDPSession(p);
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });
const presence = on => cdp.send('WebAuthn.setAutomaticPresenceSimulation', { authenticatorId, enabled: on });
await p.goto(url);
await p.evaluate(() => navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rp: { name: 't', id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'min', displayName: 'min' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], authenticatorSelection: { residentKey: 'required' } } }));
for (const [name, withAbort] of [['A 待たせたまま2本目', false], ['B abort() してから2本目', true]]) {
await p.goto(url); await presence(false);
await p.evaluate(() => startCond()); await p.waitForTimeout(200);
await presence(true);
await p.evaluate(async (a) => { if (a) ac.abort(); await second(); }, withAbort);
await p.waitForTimeout(300);
console.log(name, await p.evaluate(() => log));
}
await b.close(); srv.close();
})();
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/