0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パスキーの自動入力を待たせたままボタンで2本目の get() を呼ぶと「A request is already pending.」になります【Chromium の仮想認証器で実測】

0
Posted at

パスキーをユーザー名欄の候補に出す 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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?