パスキー認証

このガイドでは、明示的なボタン トリガーとシームレスなブラウザの自動入力候補(条件付き UI)の両方を使用して、検出可能な認証情報を使用してリピーター認証を実装する方法について詳しく説明します。

サーバー側

オプションの生成

標準に準拠した検証済みのライブラリを使用して、WebAuthn リクエスト パラメータを生成するエンドポイントを作成します。

  1. 事前定義された RP ID を使用する: 事前定義された適切な RP ID を定数文字列として使用します。
  2. チャレンジを生成する: 高エントロピーで暗号的に安全なランダム バッファを生成し、ユーザーのセッションに安全に保存して、Base64URL としてエンコードします。
  3. 検出可能な認証情報のマッピング: allowCredentials に空の配列 [] を指定します。これにより、検出可能な認証情報がリクエストされます。つまり、ユーザーは最初にユーザー名を入力する必要がなく、パスキー プロバイダが利用可能なアカウントを提示します。
  4. ユーザー確認レベル: userVerification: "preferred" に設定します(企業のコンプライアンス ポリシーで明示的に義務付けられている場合は "required")。
    • リクエストされた userVerification 制約レベルは、クエリ文字列を介してクライアントから返されるのではなく、オプション エンドポイントのサーバー セッション レコード内に保持されなければなりません。これにより、検証エンドポイントは、クライアントによる操作のリスクなしに、厳格な一致制約を安全に適用できます。
// Options generation example (discoverable flow)
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
  rpId: "example.com",
  allowCredentials: [], // Request discoverable passkeys
  userVerification: "preferred",
};

// Persist expected UV level to user session
req.session.expectedUserVerification = "preferred";

検証エンドポイント

クライアントから返されたアサーションを安全に検証して、ユーザーを認証します。

  1. セッション チャレンジを検証する: クライアント レスポンスとセッションに保存されている想定されるチャレンジの間で、厳格なチャレンジ マッチングを適用します。
  2. UV 設定を適用する:
    • セッションの expectedUserVerification が "preferred" をリクエストした場合、requireUserVerification: false をサーバーサイド検証ライブラリに渡すことで、UV なしの認証器(認証器の画面ロックが無効になっているなど)を許可します。"required" がリクエストされた場合は、生体認証データまたは PIN の入力を厳格に適用します。
  3. クリーンなサーバーエラー 404: クライアントから返された認証情報 ID がデータベースで見つからない場合は、明示的な HTTP 404 エラーを返して、クライアントが Signal API をトリガーできるようにします。

クライアントサイド ロジック

HTML フォーム アノテーション

ユーザー名とパスワードの入力にアノテーションを付けて、条件付き UI をネイティブに活用します。予測入力トークンは webauthn 仕様パラメータを組み合わせたもので、autofocus は入力がフォーカスされるとすぐにブラウザの自動入力ポップアップをトリガーします。

<!-- Autocomplete tokens must contain webauthn space-separated -->
<form id="signin-form">
  <input
    type="text"
    name="username"
    autocomplete="username webauthn"
    autofocus
    data-testid="username-field"
  />
  <input type="password" name="password" autocomplete="current-password" />
  <button type="submit">Sign in</button>
</form>

明示的なボタンフロー

ユーザーが [パスキーでログイン] ボタンをクリックしたときに、パスキー認証をトリガーします。パスキー プロンプトを呼び出す前に、進行中のフォーム自動入力(条件付き取得)呼び出しを中止します。

条件付きメディエーション フロー(フォームの自動入力)

ページ読み込み時にフォームの自動入力候補を有効にして、ユーザーがログイン フィールドにフォーカスしたときにパスキー認証をネイティブに提供します。

  1. 機能検出: ページ読み込み時に PublicKeyCredential.getClientCapabilities() を呼び出し、conditionalGet が利用できない場合はパスキーによるログインをスキップします。
  2. デコード オプション: 取得した認証情報 JSON オブジェクトを PublicKeyCredential.parseRequestOptionsFromJSON() でデコードします。
  3. 条件付き GET を呼び出す: mediation: "conditional" を指定して navigator.credentials.get() を呼び出し、AbortController シグナルを渡します。これにより、パスキー ダイアログをレンダリングせずに、自動入力がサイレントに登録されます。
  4. Try/Catch 例外の分離: navigator.credentials.get 呼び出しを try/catch ブロックでラップします。
    • NotAllowedError: ユーザーがパスキーのログイン プロンプトをキャンセルしたか、タイムアウトしました。
    • AbortError: 認証リクエストがプログラムによってキャンセルされました。
  5. Signal API を呼び出す: サーバー検証 fetch() 呼び出しを try/catch ブロックでラップします。
    • エラー メッセージを表示して、何が問題だったのかをユーザーが理解できるようにします。
    • signalUnknownCredential() を呼び出すのは、サーバーが HTTP ステータス 404(認証情報が見つかりません)を明示的に返し、ユーザーが認証されていない場合のみです。
    • signalUnknownCredential() に渡される credentialId パラメータは、未加工の ArrayBuffer オブジェクト credential.rawId ではなく、Base64URL でエンコードされた認証情報 ID 文字列(例: encoded.id)である必要があります。
  6. レスポンスをエンコードする: 認証情報 AuthenticatorAssertionResponse を .toJSON() でエンコードしてから、検証のためにサーバーに送信します。
// optionsFetch and loginVerifyFetch are app-defined HTTP methods
import { optionsFetch, loginVerifyFetch } from "./api.js";

let autofillAbortController = new AbortController();

async function initializeConditionalAutofill() {
  // Feature detect Conditional Get autofill support
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (capabilities.conditionalGet === true) {
    const loginOptionsJSON = await optionsFetch();
    const publicKey =
      PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

    try {
      // Initiate Conditional UI form autofill suggestions
      const credential = await navigator.credentials.get({
        publicKey,
        signal: autofillAbortController.signal,
        mediation: "conditional",
      });

      // Segregated verification fetch
      const encoded = credential.toJSON();
      const response = await loginVerifyFetch(encoded);
      if (!response.ok && response.status === 404) {
        // Note: this code path runs pre-authentication, satisfying the unauth precondition
        if (PublicKeyCredential.signalUnknownCredential) {
          await PublicKeyCredential.signalUnknownCredential({
            rpId, // RP ID must match the one defined on the server
            credentialId: encoded.id,
          });
        }
      }
    } catch (err) {
      // Silently swallow expected client WebAuthn exceptions
      if (["NotAllowedError", "AbortError"].includes(err.name)) {
        return;
      }
      console.error("Unexpected conditional get error:", err);
    }
  }
}

async function triggerButtonAuthentication() {
  // Abort any pending Conditional Get call to prevent passkey prompt collisions
  autofillAbortController.abort();
  autofillAbortController = new AbortController(); // Reset controller for next triggers

  const loginOptionsJSON = await optionsFetch();
  const publicKey =
    PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

  let credential;
  try {
    // Passkey explicit prompt trigger
    credential = await navigator.credentials.get({
      publicKey,
      signal: autofillAbortController.signal,
    });
  } catch (err) {
    if (err.name === "NotAllowedError") {
      console.log("User cancelled passkey login.");
    } else if (err.name === "AbortError") {
      console.log("The authentication operation was aborted.");
    }
    // Re-arm Conditional autofill Suggestions after cancelled explicit button prompts
    initializeConditionalAutofill();
    return; // Safe exit
  }

  // Segregated verification try/catch (HTTP 404 trigger)
  const encoded = credential.toJSON();
  try {
    const response = await loginVerifyFetch(encoded);
    if (!response.ok && response.status === 404) {
      // Note: this code path runs pre-authentication, satisfying the unauth precondition
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encoded.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Verification request error:", serverErr);
  }
}

// Trigger Conditional Get on load
window.addEventListener("DOMContentLoaded", initializeConditionalAutofill);

代替戦略

パスキー機能検出のフォールバック

常に「webauthn-polyfills」をインストールし、コンテキストにインポートします。PublicKeyCredential がサポートされている限り、PublicKeyCredential.getClientCapabilities もサポートされます。

import 'webauthn-polyfills';

Signal API の同期フォールバック

WebAuthn Signal API(webauthn-signals)は、パスワード マネージャーとサーバーの認証情報の状態を同期するために使用されるプログレッシブ最適化です。

  • フォールバック エクスペリエンス: if (PublicKeyCredential.signalUnknownCredential) を介して制限されます。サポートされていない場合、バックグラウンド検証の同期は、ブラウザ例外をスローすることなく、正常にバイパスされます。

簡単な JSON シリアル化フォールバック

常に「webauthn-polyfills」をインストールし、コンテキストにインポートします。PublicKeyCredential がサポートされている限り、PublicKeyCredential.parseRequestOptionsFromJSON と PublicKeyCredential.prototype.toJSON もサポートされます。

import 'webauthn-polyfills';