Skip to content

Instantly share code, notes, and snippets.

@tendo-mnx
Last active July 16, 2026 11:33
Show Gist options
  • Select an option

  • Save tendo-mnx/045d9f7068e4cde5865b84dabc896808 to your computer and use it in GitHub Desktop.

Select an option

Save tendo-mnx/045d9f7068e4cde5865b84dabc896808 to your computer and use it in GitHub Desktop.
パスキー対応サイトを E2E テストする ~Playwright の仮想認証器で秘密鍵を扱う~ サンプルコード
/**
* フィクスチャ作成フェーズ: 仮想オーセンティケータでパスキーを登録し、秘密鍵を取り出す。
* 取り出した鍵は E2E テストのフィクスチャとして再利用する。
*
* 前提:
* - テスト対象サイトに既にログイン済みで、パスキー登録 UI まで到達できる状態
* (必要ならログイン用の Cookie を context.addCookies() で流し込む)
*
* 実行:
* bun run 01-register.ts
* (または npx tsx 01-register.ts)
*
* 出力:
* ./passkey.json に秘密鍵を含むクレデンシャルを保存する。
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { chromium } from "playwright";
import type { StoredPasskey } from "./types.ts";
// 対象サイトのパスキー管理ページ。実際の URL に置き換える。
const PASSKEY_SETTINGS_URL = "https://example.com/settings/security";
const OUTPUT_PATH = resolve(import.meta.dirname, "passkey.json");
const USERNAME = process.argv[2] ?? "user@example.com";
async function main() {
// headed で開き、登録 UI を人が操作できるようにする。
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
// ログイン済みセッションが要るなら、ここで Cookie を流し込む。
// await context.addCookies(JSON.parse(readFileSync("cookies.json", "utf-8")));
const page = await context.newPage();
// 仮想オーセンティケータを有効化する。
const client = await context.newCDPSession(page);
await client.send("WebAuthn.enable", { enableUI: false });
const { authenticatorId } = (await client.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2", // 現代のパスキーは CTAP2
transport: "internal", // 内蔵(プラットフォーム)認証器として振る舞わせる
hasResidentKey: true, // discoverable credential(パスキーの前提)
hasUserVerification: true, // 生体認証 / PIN 相当の検証を持つ
isUserVerified: true, // その検証を「済み」の状態にする
},
})) as { authenticatorId: string };
console.log("仮想オーセンティケータを有効化した");
// この時点で、対象サイトが navigator.credentials.create() を呼ぶと
// 実機ではなくこの仮想認証器が応答する。
await page.goto(PASSKEY_SETTINGS_URL, { waitUntil: "networkidle", timeout: 30_000 });
console.log(`
ブラウザ側で操作してください:
1. パスキー登録ページに移動する
2. 「パスキーを追加」を押す
3. 仮想オーセンティケータが WebAuthn 呼び出しを自動で処理する
最大3分間、登録を待ちます...
`);
// クレデンシャルが登録されるまでポーリングし、見つけたら即保存する。
for (let i = 0; i < 180; i++) {
await new Promise((r) => setTimeout(r, 1000));
const { credentials } = (await client.send("WebAuthn.getCredentials", {
authenticatorId,
})) as { credentials: Array<Record<string, string | number>> };
if (credentials.length > 0) {
// getCredentials が返す各要素に privateKey(PKCS#8 の Base64)が含まれる。
const cred = credentials[credentials.length - 1];
const passkey: StoredPasskey = {
username: USERNAME,
rpId: String(cred.rpId),
credentialId: String(cred.credentialId),
privateKey: String(cred.privateKey), // ← 鍵の実体
userHandle: String(cred.userHandle ?? ""),
signCount: Number(cred.signCount ?? 0),
};
writeFileSync(OUTPUT_PATH, JSON.stringify(passkey, null, 2));
console.log(`\n登録を検出 (${i + 1}秒) rpId=${passkey.rpId}`);
console.log(`保存先: ${OUTPUT_PATH}`);
await browser.close();
return;
}
}
console.error("タイムアウト: 3分以内にクレデンシャルが登録されなかった");
await browser.close();
process.exit(1);
}
main().catch((err) => {
console.error("Fatal:", err instanceof Error ? err.message : err);
process.exit(1);
});
/**
* テスト実行フェーズ: 保存した秘密鍵から仮想オーセンティケータを復元し、
* パスキーログインを検証する。
*
* 前提:
* - 01-register.ts で作った passkey.json(テスト用フィクスチャ)があること
*
* 実行:
* bun run 02-login.ts
*
* ポイント:
* - addCredential で空の仮想認証器に保存済みの鍵を復元する
* - ログイン後に signCount を読み直してフィクスチャを更新する(複製検知で弾かれないため)
*/
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { chromium } from "playwright";
import type { StoredPasskey } from "./types.ts";
const PASSKEY_PATH = resolve(import.meta.dirname, "passkey.json");
const LOGIN_URL = "https://example.com/login";
// ログイン成功後に到達するはずの、認証が要るページ。
const DESTINATION_URL = "https://example.com/mypage";
async function main() {
const passkey = JSON.parse(readFileSync(PASSKEY_PATH, "utf-8")) as StoredPasskey;
console.log(`パスキーでログイン: username=${passkey.username} rpId=${passkey.rpId}`);
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
// 仮想オーセンティケータを有効化する(登録時と同じオプション)。
const client = await context.newCDPSession(page);
await client.send("WebAuthn.enable", { enableUI: false });
const { authenticatorId } = (await client.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
})) as { authenticatorId: string };
// 保存した秘密鍵を復元する。これで仮想認証器がこのパスキーを「持っている」状態になる。
await client.send("WebAuthn.addCredential", {
authenticatorId,
credential: {
credentialId: passkey.credentialId,
isResidentCredential: true,
rpId: passkey.rpId,
privateKey: passkey.privateKey,
userHandle: passkey.userHandle,
signCount: passkey.signCount,
},
});
console.log("仮想オーセンティケータに保存済みの鍵を復元した");
// ログインページを開き、ユーザー ID を入れて「パスキーでログイン」を押す。
// navigator.credentials.get() の署名要求は仮想認証器が処理する。
await page.goto(LOGIN_URL, { waitUntil: "networkidle", timeout: 30_000 });
await page
.fill('input[type="email"], input[type="text"]', passkey.username, { timeout: 5000 })
.catch(() => {});
await page
.click('button:has-text("パスキー"), button:has-text("次へ"), button[type="submit"]', {
timeout: 5000,
})
.catch(() => {});
// 認証が済むと目的のページへ遷移する。
await page
.waitForURL((url) => url.href.startsWith(DESTINATION_URL), { timeout: 60_000 })
.catch(() => {});
const ok = page.url().startsWith(DESTINATION_URL);
console.log(ok ? `ログイン成功: ${page.url()}` : `ログイン失敗: ${page.url()}`);
if (ok) {
// 署名でカウンタが進んでいる。読み直して保存し直す。
const { credentials } = (await client.send("WebAuthn.getCredentials", {
authenticatorId,
})) as { credentials: Array<{ credentialId: string; signCount: number }> };
const cred = credentials.find((c) => c.credentialId === passkey.credentialId);
if (cred && cred.signCount !== passkey.signCount) {
console.log(`signCount: ${passkey.signCount} → ${cred.signCount}`);
passkey.signCount = cred.signCount;
writeFileSync(PASSKEY_PATH, JSON.stringify(passkey, null, 2));
}
// ここで context.cookies() を保存すれば、次回はパスキーなしで Cookie 再利用もできる。
}
await browser.close();
if (!ok) process.exit(1);
}
main().catch((err) => {
console.error("Fatal:", err instanceof Error ? err.message : err);
process.exit(1);
});
/**
* 応用: 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);
});

サンプルコード

Playwright の仮想オーセンティケータ(CDP WebAuthn ドメイン)でパスキーの秘密鍵を取り出し、 E2E テストのフィクスチャとして再利用する最小構成のサンプル。

example.com はテスト対象サイトのプレースホルダ。実際の URL とセレクタに置き換えて使う。

セットアップ

bun install            # または npm install
bunx playwright install chromium

Node で動かす場合は bun runnpx tsx に読み替える。

ファイル

ファイル 役割
types.ts フィクスチャ(秘密鍵を含む)の型定義
01-register.ts 仮想オーセンティケータで登録し、秘密鍵を passkey.json に保存する
02-login.ts passkey.json から鍵を復元してパスキーログインを検証する
03-jit-authenticator.ts 既存パスキー確認と両立させる just-in-time 有効化の応用

流れ

# 1. 一度だけ登録して秘密鍵を取り出す(要ログイン済みセッション)
bun run 01-register.ts user@example.com

# 2. 取り出した鍵を復元してログインテストを回す
bun run 02-login.ts

passkey.json には平文の秘密鍵が入る。リポジトリに含めず、シークレットとして扱う。 テスト専用アカウントの鍵を使い、本番ユーザーのパスキーは扱わない。

{
"name": "playwright-passkey-examples",
"private": true,
"type": "module",
"description": "Playwright の仮想オーセンティケータでパスキーの秘密鍵を取り出し、自動ログインに再利用するサンプル",
"scripts": {
"register": "bun run 01-register.ts",
"login": "bun run 02-login.ts",
"jit": "bun run 03-jit-authenticator.ts"
},
"dependencies": {
"playwright": "^1.49.0"
}
}
/**
* 仮想オーセンティケータから取り出し、ファイルに保存するパスキーの形。
* privateKey が鍵の実体なので、このオブジェクトはシークレットとして扱う。
*/
export interface StoredPasskey {
/** ログインフォームに入力するユーザー識別子(メールアドレスや ID) */
username: string;
/** Relying Party ID。通常は対象サイトの登録ドメイン(例: "example.com") */
rpId: string;
/** Base64URL でエンコードされた credential ID */
credentialId: string;
/** PKCS#8 の秘密鍵を Base64 でエンコードしたもの。これが鍵の実体 */
privateKey: string;
/** resident key に紐づくユーザーハンドル(Base64URL) */
userHandle: string;
/** 署名カウンタ。RP がクレデンシャル複製を検知するために使う */
signCount: number;
}

Comments are disabled for this gist.