PlaywrightでWebAuthnAPIのブラウザ(E2E)テストをする
はじめに
この記事ではPlaywrightでパスキーの登録/認証の正常系/異常系のテストを実装します。
このテストにおいて仮想認証器を扱い、その操作にはChrome DevTools Protocolを利用します。そのため、対象ブラウザはChromium派生ブラウザに限定される点に注意してください。
忙しい人向けまとめ
- 仮想認証器をブラウザに追加してパスキー関連処理の正常系テストを実装する
- 自動テストで扱うVirtual Authenticatorの取り扱いはCDPのドキュメントを参照
-
page.addInitScriptでWebAuthnAPIを上書きしてエラーを強制発生させることで異常系テストを実装する
正常系テスト
パスキーの登録における正常フローは以下のように実装ができます。
test('登録成功', async ({ page }) => {
// ブラウザに仮想認証器を追加する
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send('WebAuthn.enable');
await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
automaticPresenceSimulation: true,
isUserVerified: true,
defaultBackupEligibility: true,
defaultBackupState: true,
},
});
// 登録試行
await page.goto('https://webauthn.io/');
await page.getByPlaceholder('example_username').fill('kinmemodoki@example.com');
await page.getByRole('button', { name: 'Register' }).click();
// 登録成功後の検証
await expect(page.getByText('Success! Now try to authenticate...')).toBeVisible({ timeout: 5000 });
// 認証試行(CondtionalGetが発動して自動成功する)
await page.goto('https://webauthn.io/');
// 認証成功後の検証
await expect(page.getByText("You're logged in!")).toBeVisible({ timeout: 5000 });
});
ここで把握しておくべきなのはaddVirtualAuthenticatorでブラウザに追加する仮想認証器の生成オプションです。
以下で一般的なパスキーの実装のブラウザテストを実装する際に理解しておくべきプロパティ一覧を説明します。他にもプロパティはあるので興味がある場合はChromeDevtoolsProtocolsのドキュメントを参照してください。
| プロパティ | 概要 |
|---|---|
| protocol |
u2forctap2を指定します。デフォルトではu2fのため、パスキーの検証を行う場合はctap2を指定する必要があります。 |
| transport | 認証器の対応transport(usb,internal)を指定します。isUVPAA()をtrueで返すためにはinternalを指定した仮想認証器が1つ以上ブラウザに登録されている必要があります。デフォルトではusbのため注意。 |
| hasResidentKey | trueを指定するとDiscoverableCredentialsが作成される認証器となります。なのでAutofill(ConditionalGet)を発動させたい場合はtrueを指定する必要があります。 |
| hasUserVerification | 仮想認証器がUserVerificationを行う機構を持つかどうかを指定します。サーバ側でUVを要求している場合はここをtrueで指定する必要があります。 |
| automaticPresenceSimulation | WebAuthnAPIが呼び出された際その呼び出しに応答をするかどうかを指定します。一般的にConditionalGetはページ読み込み時にnavigator.credentials.getをawaitをして認証器からの応答を待ちます。そのためこの値がtrueだった場合は即座に認証処理が開始します。この挙動を避けたい場合はfalseを指定する必要があります。setAutomaticPresenceSimulationで値をtrueに変更することで再度呼び出しに応答をするようになります。ただ、ConditionalGetの即完了を避けたい場合はDiscoverableCredentialsを利用しなければいいので、基本true指定でも問題ないと思います。 |
| isUserVerified | UserVerificationを実施するかを指定します。hasUserVerificationがtrueの場合、この値がfalseの場合ユーザキャンセル(NotAllowedError)扱いとなります。 |
| defaultBackupEligibility | この認証器で作成されるパスキーのBackupEligibility(BE)の値を指定します。 |
| defaultBackupState | この認証器で作成されるパスキーのBackupState(BS)の値を指定します。 |
異常系テスト
WebAuthnAPIがthrowsした例外から適切なエラー処理が発生するかを検証するためには以下のような実装をします。
test(`登録失敗`, async ({ page }) => {
// isUVPAA=trueのために認証器を追加する
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send('WebAuthn.enable');
await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
},
});
// createメソッドを上書きして任意例外をスローさせる
await page.addInitScript(
({ message, name }) => {
navigator.credentials.create = async () => new Promise((_, reject) => {
reject(new DOMException(message, name));
});
},
// ArgにDOMExceptionオブジェクトを渡せないのでプリミティブ型で渡す
// (これはテーブル駆動テストを想定した記述方法)
{ message: 'InvalidStateError', name: 'The authenticator was previously registered' },
);
// 登録試行
await page.goto('https://webauthn.io/');
await page.getByPlaceholder('example_username').fill('kinmemodoki@example.com');
await page.getByRole('button', { name: 'Register' }).click();
// 登録失敗後の検証
await expect(page.getByText('The authenticator was previously registered')).toBeVisible({ timeout: 5000 });
});
test(`認証失敗`, async ({ page }) => {
// isUVPAA=trueのために認証器を追加する(パスキーは登録してなくても良い)
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send('WebAuthn.enable');
await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
// conditionalGetが自動発動しないようにfalseで
automaticPresenceSimulation: false,
},
});
// getメソッドを上書きして任意例外をスローさせる
await page.addInitScript(
({ message, name }) => {
navigator.credentials.get = async () => new Promise((_, reject) => {
reject(new DOMException(message, name));
});
},
{ message: 'NotAllowedError', name: 'The operation either timed out or was not allowed' },
);
// 認証試行
await page.goto('https://webauthn.io/');
await page.getByPlaceholder('example_username').fill('kinmemodoki@example.com');
await page.getByRole('button', { name: 'Authenticate' }).click();
// 認証失敗後の検証
await expect(page.getByText('The operation either timed out or was not allowed')).toBeVisible({ timeout: 5000 });
});
このサンプルコードのようにpage.addInitScriptでブラウザ上のJavaScript APIを上書きします。ここでは任意の例外を投げるような実装としています。
基本このような例外はUnitTestでの担保でよいとは思いますが、失敗時にエラーモーダルの表示を出しわけしている場合はブラウザテストでも担保してあげると安心感は上がるかなと思います(実行時間とのトレードオフなのでE2Eテストの増加は注意)
今回取り扱ったものではできない検証
ブラウザテストにおいては上記で説明した内容で十分だと考えていますが、開発チームの方針によってはより細かいケースの検証が必要な場合があります。
WebAuthnAPIの特定機能を未サポートのブラウザ挙動の検証
例えば、ConditionalGetをサポートしているかどうかを判定するisConditionalMediationAvailable()、その他様々な機能のサポートを検証するgetClientCapabilities()など、特定の機能をブラウザがサポートしているかを判定する関数があります。
この関数によって特定機能を発動する/しないを検証したいケースが発生するかもしれません。
サポート判定関数をaddInitScriptで上書きすることで検証ができます。
await page.addInitScript(
() => {
PublicKeyCredential.isConditionalMediationAvailable = async () => false;
PublicKeyCredential.getClientCapabilities = async () => ({
conditionalGet: false,
});
},
);
InvalidなAssertion/Attestationの検証
今回はブラウザの仮想認証器を用いた検証のため、仮想認証器では再現できないInvalidなAssertion/Attestationの検証エラーは再現できません。
ここの検証をしたい場合は以下のような認証器エミュレータを活用する必要があります。
負荷テスト
もはやブラウザテストは関係ない感じはありますが...
ヘッドレスブラウザの場合はオーバーヘッドの関係でPlaywrightでは負荷を再現できないことがあります。その場合はCLI上で扱える認証器エミュレータを活用できるとよいかなと思います。
2回目の紹介ですが、以下はNode.jsで実行できるためブラウザのオーバーヘッドを避けられそうです。
(本当に負荷テストに採用できるかは検証していないので不明)
おわりに
昨今パスキーやWebAuthn周りで様々な機能拡張があり、RP側で多くの変更が発生するようになりました。この際、今までの機能でデグレが発生してしまわないように、既存の機能を担保するテストを用意していきたいですね。
(ブラウザテストは不安定で遅いので拡張はほどほどにして、UnitTestをちゃんと作りましょう)
webauthn.ioのブラウザテスト実装例を以下で公開しているのでこちらも参考になれば幸いです。
参考リンク
Discussion