即時 UI モードによるクイック ログイン

認証されていないユーザーが [ログイン] リンクをクリックしたり、購入手続きを開始したり、アイテムをお気に入りに保存したり、記事のペイウォールに到達したりしたときに、専用のログインページに移動すると、コンテキストが失われ、ユーザーの離脱が増加します。即時 UI モード(navigator.credentials.get() の uiMode: 'immediate')では、デバイスにローカル パスキー(publicKey)または保存されたパスワード(password: true)が存在する場合に、統合されたブラウザ Account Chooser をすぐに表示することで、コンテキストに応じたクイック ログインが可能になります。

標準のモーダル WebAuthn プロンプトは、常にブラウザ UI を表示し、クロスデバイス QR コードまたは外部セキュリティ キーにフォールバックします。これとは異なり、即時 UI モードでは、デバイスでローカルに使用できる適切な認証情報がない場合、NotAllowedError でフェイルファストします。これにより、アプリケーションはリピーターを 1 回のタップで認証できると同時に、ローカル認証情報を持たないユーザーを標準のログインページ、ログインせずに決済、またはインライン ログイン フォームにシームレスに移行できます。

現在のページから移動する直前に、ユーザーの意図が明確なタイミングで、ユーザーが開始したイベント ハンドラ(一時的なユーザー アクティベーションが必要)内で即時 UI モードをトリガーします。即時認証が成功した場合は、ユーザーの意図をすぐに完了します(現在のページをその場で更新するか、認証済みの宛先に直接進みます)。NotAllowedError で失敗した場合は、完了時にユーザーのインテントを完了するためのパスを含むログインフローまたは登録フローを表示します。

考えられるユースケース:

  • ヘッダーのログインリンクまたはボタンのインターセプト: グローバルな [ログイン] リンク(event.preventDefault())のクリックをインターセプトして、リピーターをすぐに認証するか、NotAllowedError の専用ログインページに移動します。
  • 購入手続き前の動的ログイン: リピーターが [購入手続きに進む] をクリックしたときに、1 回のタップで認証を行い、配送と支払いのステップに直接移動するか、代替で標準のログイン画面またはログインせずに決済の画面に移動します。
  • マイクロ アクション(お気に入り、高評価、ブックマーク、保存)のインライン ログイン: ログアウトしたユーザーがマイクロ アクション ボタンをクリックしたときに、その場で認証してすぐにアクションを完了する(例: aria-pressed="true" の切り替え)か、戻り URL パラメータを使用してフォールバック ログイン モーダルまたはログインページを開きます。
  • 記事内のソフト ペイウォール: 読者がペイウォール バナーの [アカウントで続きを読む] をクリックしたときに、その場で読者を認証します。または、フォールバック時にインラインのログイン フォームまたは登録フォームを開きます。

避けるべきアンチパターン:

  • 専用のログインページで即時 UI モードを使用しない: 最終的なログインページのメインの [ログイン] ボタンまたは [パスキーでログイン] ボタンに uiMode: 'immediate' をアタッチしないでください。ユーザーが専用のログインページにアクセスするのは、現在のデバイスにローカル認証情報がない場合、クロスデバイス パスキー(スマートフォンで QR コードをスキャンする)を使用する場合、外部ハードウェア セキュリティ キーを使用する場合です。uiMode: 'immediate' は、クロスデバイスの QR コードとセキュリティ キーのオプションを抑制し、ローカル認証情報が存在しない場合はすぐに NotAllowedError をスローするため、専用のログインページで使用すると、ユーザーはボタンをクリックしても何も起こらないという行き止まりに陥ります。専用のログインページでは、標準のモーダル navigator.credentials.get({ publicKey }) ボタンと組み合わせたフォーム入力で条件付き UI(autocomplete="username webauthn" を指定した mediation: 'conditional')を使用します(パスキー認証を参照)。
  • ステップアップ再認証に即時 UI モードを使用しないでください。機密性の高い操作(パスワードの変更、配送先住所の更新、保存されたお支払い方法の編集、新しいパスキーの追加など)を行う前に、uiMode: 'immediate' を使用して、すでにログインしているユーザーを再認証しないでください。再認証では、空でない allowCredentials リストを介して、ログインしているユーザーの認証情報にプロンプトを制限する必要があります。即時 UI モードでは NotAllowedError で拒否され、allowCredentials を省略すると、ユーザーが現在ログインしているアカウントとは異なるアカウントを誤って選択する可能性があります。ステップアップ再認証については、パスキーの再認証をご覧ください。

プライバシーとセキュリティの制約

uiMode: 'immediate' がリクエストされると、ブラウザは厳格なプライバシー ルールとセキュリティ ルールを適用します。

  • 一時的なユーザー アクティベーションが必要: 認証情報のサイレント プロービングを防ぐため、ユーザーが開始したイベント ハンドラ(<button> 要素または <a> 要素の click リスナーなど)内で navigator.credentials.get({ uiMode: 'immediate' }) を呼び出す必要があります。ページ読み込み時に自動的に呼び出さないでください(DOMContentLoaded)。API を呼び出しても、一時的な有効化は消費されません。
  • 空でない allowCredentials リストを渡さない: uiMode: 'immediate' が指定されている場合は、publicKey.allowCredentials に値を入力しないでください。ブラウザは、allowCredentials リストが空でない Immediate UI モードのリクエストを NotAllowedError をスローして直ちに拒否します。これにより、リライング パーティは特定の認証情報 ID をプローブして、以前の訪問を推測したり、セッション間でユーザーを追跡したりできなくなります。
  • AbortSignal(signal)を渡さない: uiMode: 'immediate' が指定されている場合は、navigator.credentials.get() の signal プロパティを設定しないでください。ブラウザでは、即時ログイン ダイアログをプログラムで閉じることができないためです。ページでバックグラウンドの条件付き UI(mediation: 'conditional')リクエストも実行する場合は、即時 UI モードを呼び出す前に、その条件付き UI の AbortController で abort() を呼び出し、即時リクエスト自体から signal を省略します。
  • シークレット モードとプライベート ブラウジング セッション: シークレット モードまたはプライベート ブラウジング ウィンドウで行われたリクエストは常に NotAllowedError で拒否されるため、プライベート セッションはローカル認証情報のないセッションと区別できません。

サーバーサイドの設定

オプション生成エンドポイント

未認証の訪問者向けに、検出可能な WebAuthn リクエスト オプションを生成するエンドポイントを公開します。

  1. セッションにバインドされたチャレンジを生成する: 暗号的にランダムなチャレンジ バッファを作成し、サーバー セッションに保存して、Base64URL としてエンコードします。
  2. ユーザー確認設定を保持する: userVerification: 'preferred' を設定し、expectedUserVerification: 'preferred' をセッションに保存します。
  3. allowCredentials を省略する: ブラウザは NotAllowedError を含む空でない許可リストを拒否するため、Immediate UI モードのオプションに空でない allowCredentials リストを含めないでください。
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
  const options = {
    challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
    rpId: 'example.com',
    userVerification: 'preferred',
  };

  req.session.challenge = options.challenge;
  req.session.expectedUserVerification = 'preferred';
  return res.json(options);
});

デュアル認証情報検証エンドポイント

navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' }) はパスキー(PublicKeyCredential)または保存済みパスワード(PasswordCredential)のいずれかで解決できるため、両方の認証情報タイプに検証ハンドラを指定します。

  1. WebAuthn アサーションの検証(/api/login/verify): セッション チャレンジと保存されている公開鍵に対してアサーションを検証します。データベースに認証情報 ID が見つからない場合は、HTTP ステータス 404 を返します。これにより、クライアントは PublicKeyCredential.signalUnknownCredential() を介してパスワード マネージャーに通知できます。
  2. パスワード検証(/api/login/password): username(cred.id)と password(cred.password)をユーザー データベースと照合して検証し、認証済みセッションを確立します。

クライアントサイドの実装

統合された認証情報リクエスト

  1. パスキーとパスワードの両方をリクエストする: PublicKeyCredential.parseRequestOptionsFromJSON() を使用してサーバー オプションをデコードし、password: true、publicKey、uiMode: 'immediate' を指定して、ユーザーが開始したクリック ハンドラ内で navigator.credentials.get() を呼び出します(signal は渡しません)。
  2. cred.type によるブランチの確認:
    • cred.type === 'public-key' の場合は、cred.toJSON() を使用してアサーションをシリアル化し、WebAuthn 検証エンドポイントに POST します。サーバーが HTTP 404 で応答したら、PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }) を呼び出します。
    • cred.type === 'password' の場合は、パスワード検証エンドポイントに { username: cred.id, password: cred.password } を POST します。
  3. ローカル認証情報が存在しない場合に NotAllowedError を処理する: NotAllowedError(デバイスにローカル パスキーまたは保存されたパスワードが存在しない場合、またはユーザーが選択ツールを閉じた場合にすぐにスローされる)をキャッチし、標準のログインフローに移行します(インライン ログイン ダイアログを開く、戻り URL を使用して /signin に移動するなど)。
let isAuthenticating = false;

async function authenticateWithImmediateUi() {
  const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
  if (!optionsResponse.ok) {
    throw new Error('Failed to fetch authentication options');
  }
  const optionsJSON = await optionsResponse.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);

  // MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
  const cred = await navigator.credentials.get({
    password: true,
    publicKey,
    uiMode: 'immediate',
  });

  if (!cred) {
    throw new DOMException('No credential returned', 'NotAllowedError');
  }

  if (cred.type === 'public-key') {
    const encoded = cred.toJSON();
    const verifyResponse = await fetch('/api/login/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(encoded),
    });

    if (verifyResponse.status === 404) {
      await PublicKeyCredential.signalUnknownCredential({
        rpId: publicKey.rpId || window.location.hostname,
        credentialId: encoded.id, // Base64URL-encoded credential ID string
      });
      throw new Error('Passkey credential is no longer recognized by the server');
    }

    if (!verifyResponse.ok) {
      throw new Error('Passkey verification failed');
    }
    return await verifyResponse.json();
  }

  if (cred.type === 'password') {
    const verifyResponse = await fetch('/api/login/password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: cred.id,
        password: cred.password,
      }),
    });

    if (!verifyResponse.ok) {
      throw new Error('Password verification failed');
    }
    return await verifyResponse.json();
  }

  throw new Error(`Unsupported credential type: ${cred.type}`);
}

// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');

favoriteButton.addEventListener('click', async (event) => {
  event.preventDefault();
  if (isAuthenticating) return;

  // If already signed in, toggle the micro-action state immediately
  if (document.body.dataset.signedIn === 'true') {
    const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
    favoriteButton.setAttribute('aria-pressed', String(nextPressed));
    return;
  }

  isAuthenticating = true;
  try {
    const user = await authenticateWithImmediateUi();
    // Complete the user's intended action immediately
    document.body.dataset.signedIn = 'true';
    favoriteButton.setAttribute('aria-pressed', 'true');
    updateHeaderUserState(user);
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // No local credentials available or user dismissed dialog:
      // transition smoothly to fallback sign-in UI or redirect with return URL
      showFallbackSignInUi({ returnAction: 'favorite' });
      return;
    }
    console.error('Authentication error:', err);
  } finally {
    isAuthenticating = false;
  }
});

代替戦略

即時 UI モードのフォールバック

  • プログレッシブ エンハンスメント エクスペリエンス: uiMode: 'immediate' をプログレッシブ エンハンスメントとして扱います。ブラウザの名前やバージョンからサポートを推測しないでください。信頼できる唯一のサポート シグナルは、PublicKeyCredential.getClientCapabilities() を介した capabilities.immediateGet ランタイム チェックです。必須: navigator.credentials.get({ uiMode: 'immediate' }) を呼び出す前に必ず capabilities.immediateGet を確認してください。uiMode: 'immediate' を認識しないブラウザは、不明な辞書キーを無視して、ブロックするモーダル パスキーと QR コードのダイアログを開きます。capabilities.immediateGet が falsy の場合、または navigator.credentials.get() が NotAllowedError で拒否された場合は、アプリケーションの標準のログイン エクスペリエンス(インライン ログイン モーダルを開く、戻り URL パラメータを使用して専用のログインページに移動するなど)にフォールバックします。これにより、サポートされていないブラウザのユーザーには、既存のログインフローがそのまま表示されます。
async function isImmediateUiSupported() {
  if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
    return false;
  }
  try {
    const capabilities = await PublicKeyCredential.getClientCapabilities();
    return Boolean(capabilities?.immediateGet);
  } catch {
    return false;
  }
}

async function authenticateWithImmediateUi() {
  // MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
  const supported = await isImmediateUiSupported();
  if (!supported) {
    // Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
    throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
  }
  // Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}

クライアント機能の検出フォールバック

'webauthn-polyfills' を常にインストールし、クライアント モジュールにインポートして、PublicKeyCredential がサポートされている場合は常に PublicKeyCredential.getClientCapabilities() を使用できるようにします。

import 'webauthn-polyfills';

パスワード認証情報へのフォールバック

navigator.credentials.get() で publicKey とともに password: true を渡すと、サポートされているブラウザで、ブラウザのパスワード マネージャーに保存されているパスワードを統合された即時アカウント選択ツールに含めることができます。

  • フォールバック エクスペリエンス: PasswordCredential をサポートしていないブラウザは、password: true オプションを無視するか、capabilities.immediateGet 機能チェックを介して標準のログイン フォームにフォールバックします。このフォームでは、<input autocomplete="username webauthn"> と <input type="password" autocomplete="current-password"> がネイティブのパスワード自動入力を提供します。

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

PublicKeyCredential をサポートするブラウザ間で PublicKeyCredential.parseRequestOptionsFromJSON() と PublicKeyCredential.prototype.toJSON() が一貫して動作するように、常に 'webauthn-polyfills' をインストールしてクライアント バンドルにインポートします。

import 'webauthn-polyfills';

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

WebAuthn Signal API(webauthn-signals)は、サーバーが HTTP 404 を返したときに、ユーザーのパスワード マネージャーから古いパスキーを削除するプログレッシブ エンハンスメントです。

  • 代替エクスペリエンス: if (PublicKeyCredential.signalUnknownCredential) の背後で通話をゲートします。サポートされていない場合は、シグナル呼び出しを適切にスキップし、別の方法でログインするようユーザーに案内する明確なメッセージを表示します。