使用立即 UI 模式快速登入

當未經驗證的使用者點選「登入」連結、開始結帳、將商品加入收藏,或遇到文章付費牆時,如果將他們導向專屬登入頁面,會導致情境中斷,並增加使用者流失率。即時 UI 模式 (navigator.credentials.get() 中的 uiMode: 'immediate') 可在裝置上有本機密碼金鑰 (publicKey) 或已儲存的密碼 (password: true) 時,立即顯示統一的瀏覽器帳戶選擇工具,讓使用者快速登入。

標準模態 WebAuthn 提示一律會顯示瀏覽器 UI,並改用跨裝置 QR code 或外部安全金鑰。與之不同的是,如果裝置本機沒有符合資格的憑證,立即 UI 模式會快速失敗,並傳回 NotAllowedError。這樣一來,應用程式就能讓回訪者輕觸一下,在原地完成驗證,同時順暢地將沒有本機憑證的使用者,轉移到標準登入頁面、訪客結帳頁面或內嵌登入表單。

在使用者意圖明確時,就在離開目前頁面前,於使用者啟動的事件處理常式 (需要暫時使用者啟用) 內觸發「立即」UI 模式。如果立即驗證成功,請立即完成使用者的意圖 (無論是更新目前頁面,還是直接前往已驗證的目的地)。如果失敗並顯示 NotAllowedError,請顯示登入或註冊流程,並提供完成使用者意圖的路徑。

可能的應用情境:

  • 攔截標頭登入連結或按鈕:攔截對全域「登入」連結 (event.preventDefault()) 的點擊,立即驗證回訪者,或繼續前往 NotAllowedError 的專屬登入頁面。
  • 結帳前動態登入:回訪購物者點選「前往結帳」時,輕觸一下即可驗證身分,直接進入運送和付款步驟,或在備用方案中轉換至標準登入或訪客結帳畫面。
  • 微動作 (加入收藏、按讚、加入書籤、儲存) 的內嵌登入:當未登入的使用者點選微動作按鈕時,在原處進行驗證並立即完成動作 (例如切換 aria-pressed="true"),或開啟備用登入模式或含有返回網址參數的登入頁面。
  • 文章內軟付費牆:讀者點選付費牆橫幅上的「使用帳戶繼續閱讀」時,系統會立即驗證讀者身分,或在備用廣告上展開內嵌登入或註冊表單。

應避免的反面模式:

  • 請勿在專屬登入頁面上使用即時 UI 模式:請勿將 uiMode: 'immediate' 附加至最終到達網頁登入頁面上的主要「登入」或「使用密碼金鑰登入」按鈕。使用者在目前裝置上沒有本機憑證,或打算使用跨裝置密碼金鑰 (用手機掃描 QR code) 或外部實體安全金鑰時,就會前往專屬登入頁面。由於 uiMode: 'immediate' 會隱藏跨裝置 QR code 和安全金鑰選項,並在沒有本機憑證時立即擲回 NotAllowedError,因此在專用登入頁面上使用該方法會導致使用者陷入死胡同,點選按鈕後不會有任何反應。在專屬登入頁面上,使用表單輸入內容的條件式 UI (mediation: 'conditional' 搭配 autocomplete="username webauthn"),並搭配標準互動視窗 navigator.credentials.get({ publicKey }) 按鈕 (請參閱「密碼金鑰驗證」)。
  • 請勿使用即時 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 清單不為空的立即 UI 模式要求,並擲回 NotAllowedError,因此依賴方無法探測特定憑證 ID,推斷先前的造訪記錄或跨工作階段追蹤使用者。
  • 請勿傳遞 AbortSignal (signal):指定 uiMode: 'immediate' 時,請勿在 navigator.credentials.get() 上設定 signal 屬性,因為瀏覽器不允許以程式輔助方式關閉立即登入對話方塊。如果網頁也執行背景條件式 UI (mediation: 'conditional') 要求,請先對該條件式 UI AbortController 呼叫 abort(),再叫用即時 UI 模式,並從即時要求中省略 signal。
  • 無痕模式和私密瀏覽工作階段:在無痕模式或私密瀏覽視窗中提出的要求一律會遭到拒絕,並傳回 NotAllowedError,因此私密工作階段與沒有本機憑證的工作階段仍無法區分。

伺服器端設定

選項生成端點

公開端點,為未經驗證的訪客產生可供偵測的 WebAuthn 要求選項:

  1. 產生工作階段繫結驗證碼:建立加密隨機驗證碼緩衝區,儲存在伺服器工作階段中,並編碼為 Base64URL。
  2. 保留使用者驗證偏好設定:設定 userVerification: 'preferred' 並將 expectedUserVerification: 'preferred' 儲存在工作階段中。
  3. 省略 allowCredentials:請勿在即時 UI 模式的選項中加入非空白的 allowCredentials 清單,因為瀏覽器會拒絕含有 NotAllowedError 的非空白許可清單。
// 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() 序列化斷言,並將其 POST 到 WebAuthn 驗證端點。當伺服器回應 HTTP 404 時,請呼叫 PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id })。
    • 如果 cred.type === 'password',請將 { username: cred.id, password: cred.password } POST 到密碼驗證端點。
  3. 在本機憑證不存在時處理 NotAllowedError:擷取 NotAllowedError (當裝置上沒有本機密碼金鑰或已儲存的密碼,或使用者關閉選擇器時,系統會立即擲回),並轉換至標準登入流程 (例如開啟內嵌登入對話方塊,或使用傳回網址前往 /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 code 對話方塊。如果 capabilities.immediateGet 為 falsy,或 navigator.credentials.get() 拒絕 NotAllowedError,請改用應用程式的標準登入體驗 (例如開啟內嵌登入模式,或使用傳回網址參數前往專屬登入頁面),讓不支援瀏覽器的使用者看到現有的登入流程不變:
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() 中同時傳遞 password: true 和 publicKey,可讓支援的瀏覽器在統一的即時帳戶選擇工具中,納入瀏覽器密碼管理工具儲存的密碼。

  • 備用體驗:不支援 PasswordCredential 的瀏覽器會忽略 password: true 選項,或透過 capabilities.immediateGet 功能檢查,改用標準登入表單,其中 <input autocomplete="username webauthn"> 和 <input type="password" autocomplete="current-password"> 提供原生密碼自動填入功能。

輕鬆備用 JSON 序列化

請一律安裝 'webauthn-polyfills',並將其匯入用戶端套件,讓 PublicKeyCredential.parseRequestOptionsFromJSON() 和 PublicKeyCredential.prototype.toJSON() 在支援 PublicKeyCredential 的瀏覽器中運作一致:

import 'webauthn-polyfills';

Signal API 同步處理備用方案

WebAuthn Signal API (webauthn-signals) 是一項漸進增強功能,可在伺服器回應 HTTP 404 時,從使用者的密碼管理工具中移除過時的密碼金鑰。

  • 備用體驗:在 if (PublicKeyCredential.signalUnknownCredential) 後方閘道呼叫。如果系統不支援,請略過信號呼叫,並顯示清楚的訊息,引導使用者改用其他方式登入。