|
/** |
|
* 応用: create() の直前だけ仮想オーセンティケータを有効にする(just-in-time)。 |
|
* |
|
* なぜ必要か: |
|
* サイトによっては、ログイン時にまず navigator.credentials.get() で |
|
* 「この端末にパスキーはあるか」を確認し、無いと分かってから登録 create() を呼ぶ。 |
|
* 仮想認証器を最初から有効にしていると、この get() の確認まで仮想認証器が奪い、 |
|
* 実機のパスキー確認フローが壊れることがある。 |
|
* そこで create() の呼び出し直前でだけ仮想認証器を立ち上げる。 |
|
* |
|
* 仕組み: |
|
* - addInitScript で navigator.credentials.create をラップする |
|
* - create が呼ばれた瞬間、exposeBinding で公開した Node 側の関数を叩き、 |
|
* そこで初めて WebAuthn.addVirtualAuthenticator を送る |
|
*/ |
|
|
|
import type { BrowserContext, CDPSession, Page } from "playwright"; |
|
import { chromium } from "playwright"; |
|
|
|
async function setupJitAuthenticator(context: BrowserContext) { |
|
let client: CDPSession | null = null; |
|
let authenticatorId = ""; |
|
let ready = false; |
|
|
|
async function enable(page: Page) { |
|
if (ready) return; |
|
client = await context.newCDPSession(page); |
|
await client.send("WebAuthn.enable", { enableUI: false }); |
|
const result = (await client.send("WebAuthn.addVirtualAuthenticator", { |
|
options: { |
|
protocol: "ctap2", |
|
ctap2Version: "ctap2_1", |
|
transport: "internal", |
|
hasResidentKey: true, |
|
hasUserVerification: true, |
|
isUserVerified: true, |
|
automaticPresenceSimulation: true, // ユーザープレゼンス(タッチ)を自動で満たす |
|
}, |
|
})) as { authenticatorId: string }; |
|
authenticatorId = result.authenticatorId; |
|
await client.send("WebAuthn.setUserVerified", { authenticatorId, isUserVerified: true }); |
|
ready = true; |
|
console.log("create() を検出したので仮想オーセンティケータを有効化した"); |
|
} |
|
|
|
// ページ内 JS から呼べる Node 側の関数を公開する。 |
|
await context.exposeBinding("__enableVirtualAuthenticator", async (source) => { |
|
if (!source.page) throw new Error("no source page"); |
|
await enable(source.page); |
|
}); |
|
|
|
// 全ページに注入する初期スクリプト。 |
|
await context.addInitScript(() => { |
|
// パスキー UI を出させるため、可用性チェックを true に上書きする。 |
|
if (window.PublicKeyCredential) { |
|
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = async () => true; |
|
window.PublicKeyCredential.isConditionalMediationAvailable = async () => true; |
|
} |
|
// create の直前で Node 側の有効化を待ってから本来の処理へ進む。 |
|
const originalCreate = navigator.credentials.create.bind(navigator.credentials); |
|
navigator.credentials.create = async (options) => { |
|
await (window as unknown as { __enableVirtualAuthenticator: () => Promise<void> }) |
|
.__enableVirtualAuthenticator(); |
|
return originalCreate(options); |
|
}; |
|
}); |
|
|
|
return { |
|
get client() { |
|
return client; |
|
}, |
|
get authenticatorId() { |
|
return authenticatorId; |
|
}, |
|
}; |
|
} |
|
|
|
async function main() { |
|
const browser = await chromium.launch({ headless: false }); |
|
const context = await browser.newContext(); |
|
const auth = await setupJitAuthenticator(context); |
|
const page = await context.newPage(); |
|
|
|
await page.goto("https://example.com/settings/security", { waitUntil: "networkidle" }); |
|
console.log("ブラウザで既存パスキー確認 → 新規登録の順に操作してください"); |
|
|
|
// 登録が起きたら auth.client / auth.authenticatorId で getCredentials を叩き、 |
|
// 01-register.ts と同じ要領で privateKey を保存する。 |
|
for (let i = 0; i < 300; i++) { |
|
await new Promise((r) => setTimeout(r, 1000)); |
|
if (!auth.client || !auth.authenticatorId) continue; |
|
const { credentials } = (await auth.client.send("WebAuthn.getCredentials", { |
|
authenticatorId: auth.authenticatorId, |
|
})) as { credentials: Array<Record<string, unknown>> }; |
|
if (credentials.length > 0) { |
|
console.log("登録を検出:", credentials[credentials.length - 1]); |
|
break; |
|
} |
|
} |
|
|
|
await browser.close(); |
|
} |
|
|
|
main().catch((err) => { |
|
console.error("Fatal:", err instanceof Error ? err.message : err); |
|
process.exit(1); |
|
}); |