כניסה מהירה לחשבון באמצעות מצב ממשק משתמש מיידי

כשמשתמש לא מאומת מועבר לדף כניסה ייעודי אחרי שהוא לוחץ על קישור "כניסה", מתחיל תהליך תשלום, שומר פריט במועדפים או מגיע לחומת תשלום של מאמר, הוא מאבד את ההקשר וזה מגדיל את שיעור הנטישה של המשתמשים. מצב ממשק משתמש מיידי (uiMode: 'immediate' ב-navigator.credentials.get()) מאפשר כניסות מהירות בהתאם להקשר על ידי הצגת Account Chooser מאוחד בדפדפן באופן מיידי, אם קיימים במכשיר מפתחות גישה מקומיים (publicKey) או סיסמאות שמורות (password: true).

בניגוד להנחיות מודאליות רגילות של WebAuthn – שתמיד מציגות את ממשק המשתמש של הדפדפן וחוזרות לקודי QR חוצי-מכשירים או למפתחות אבטחה חיצוניים – מצב ממשק משתמש מיידי נכשל במהירות עם NotAllowedError אם אין פרטי כניסה כשירים שזמינים באופן מקומי במכשיר. כך האפליקציה יכולה לאמת משתמשים חוזרים במקום בהקשה אחת, ובמקביל להעביר בצורה חלקה משתמשים ללא פרטי כניסה מקומיים לדף הכניסה הרגיל, לתשלום כאורח או לטופס התחברות מוטבע.

מפעילים את מצב "ממשק משתמש מיידי" בתוך גורם מטפל באירועים שהמשתמש יזם (שמחייב הפעלה זמנית על ידי המשתמש) ברגעים שבהם כוונת המשתמש ברורה, ממש לפני שהוא עובר מהדף הנוכחי. אם האימות המיידי מצליח, צריך להשלים את הפעולה שהמשתמש התכוון לבצע באופן מיידי (על ידי עדכון הדף הנוכחי או מעבר ישירות ליעד המאומת). אם הפעולה נכשלת עם NotAllowedError, צריך להציג תהליך כניסה או הרשמה עם נתיב להשלמת הפעולה שהמשתמש התכוון לבצע.

תרחישים לדוגמה:

  • יירוט של קישור או כפתור כניסה בכותרת: יירוט של קליק על קישור "כניסה" גלובלי (event.preventDefault()) כדי לאמת משתמשים חוזרים באופן מיידי, או להמשיך בניווט לדף הכניסה הייעודי ב-NotAllowedError.
  • כניסה דינמית לפני התשלום: קונים חוזרים יכולים לאמת את הזהות שלהם בלחיצה אחת על "המשך לתשלום" כדי לעבור ישירות לשלב המשלוח והתשלום, או לעבור למסך הכניסה הרגיל או לתשלום כאורח כגיבוי.
  • כניסה מוטמעת לפעולות קטנות (מועדפים, לייק, סימנייה, שמירה): אימות במקום כשמשתמש שלא מחובר לוחץ על כפתור של פעולה קטנה, והשלמה מיידית של הפעולה (לדוגמה, החלפת מצב aria-pressed="true"), או פתיחה של מודל כניסה חלופי או דף כניסה עם פרמטר של כתובת URL להחזרה.
  • חומות תשלום גמישות בגוף המאמר: מאמתות את הקוראים במקום כשהם לוחצים על "המשך קריאה באמצעות החשבון שלך" בבאנר של חומת התשלום, או מרחיבות טופס הרשמה או כניסה מוטבע כחלופה.

דפוסים שצריך להימנע מהם:

  • אל תשתמשו במצב ממשק משתמש מיידי בדף כניסה ייעודי: אף פעם אל תצרפו את uiMode: 'immediate' לכפתור הראשי "כניסה" או "כניסה באמצעות מפתח גישה" בדף הכניסה של היעד הסופי. המשתמשים מגיעים לדף הכניסה הייעודי רק אם אין להם פרטי כניסה מקומיים במכשיר הנוכחי, או אם הם מתכוונים להשתמש במפתח גישה בין מכשירים (סריקת קוד QR באמצעות טלפון) או במפתח אבטחה פיזי חיצוני. מכיוון שהאפשרות uiMode: 'immediate' מבטלת את האפשרויות של קוד QR ומפתח אבטחה במכשירים שונים ומפעילה מיד את NotAllowedError כשאין פרטי כניסה מקומיים, השימוש בה בדף כניסה ייעודי לוכד את המשתמש במבוי סתום שבו לחיצה על הכפתור לא עושה כלום. בדף כניסה ייעודי, משתמשים בממשק משתמש מותנה (mediation: 'conditional' עם autocomplete="username webauthn") בשדות להזנת נתונים בטופס, בשילוב עם כפתור navigator.credentials.get({ publicKey }) מודאלי רגיל (ראו אימות באמצעות מפתח גישה).
  • אל תשתמשו במצב Immediate UI לאימות מחדש של שלב מתקדם: אף פעם אל תשתמשו ב-uiMode: 'immediate' כדי לאמת מחדש משתמש שכבר מחובר לפני פעולות רגישות (כמו שינוי סיסמה, עדכון כתובת למשלוח, עריכת אמצעי תשלום שמורים או הוספת מפתח גישה חדש). האימות מחדש חייב להגביל את הבקשה לפרטי הכניסה של המשתמש המחובר באמצעות רשימת allowCredentials לא ריקה, שמצב ממשק המשתמש המיידי דוחה עם NotAllowedError, והשמטת allowCredentials תאפשר למשתמש לבחור בטעות חשבון אחר מזה שמחובר כרגע. כדי לבצע אימות מחדש ברמה גבוהה יותר, פועלים לפי ההוראות במאמר אימות מחדש באמצעות מפתח גישה.

מגבלות פרטיות ואבטחה

דפדפנים אוכפים כללי פרטיות ואבטחה מחמירים כשמבקשים את uiMode: 'immediate':

  • נדרשת הפעלה זמנית של המשתמש: חובה להפעיל את navigator.credentials.get({ uiMode: 'immediate' }) בתוך גורם מטפל באירועים שהמשתמש יזם (כמו מאזין click ברכיב <button> או <a>) כדי למנוע בדיקה שקטה של פרטי הכניסה. אין להפעיל אותו באופן אוטומטי בטעינת הדף (DOMContentLoaded). קריאה ל-API לא צורכת את ההפעלה הזמנית.
  • אל תעבירו רשימה לא ריקה של allowCredentials: אל תאכלסו את publicKey.allowCredentials כשמצוין uiMode: 'immediate'. דפדפנים דוחים מיד כל בקשה למצב ממשק משתמש מיידי עם רשימת allowCredentials לא ריקה על ידי הפעלת NotAllowedError, כך שגורמים מסתמכים לא יכולים לחפש מזהי פרטי כניסה ספציפיים כדי להסיק על ביקורים קודמים או לעקוב אחרי משתמשים בסשנים שונים.
  • אל תעבירו AbortSignal (signal): אל תגדירו את המאפיין signal ב-navigator.credentials.get() כשמצוין uiMode: 'immediate', כי דפדפנים לא מאפשרים סגירה פרוגרמטית של תיבת הדו-שיח לכניסה מיידית. אם בדף פועלת גם בקשה ברקע לממשק משתמש מותנה (mediation: 'conditional'), צריך להפעיל את abort() בממשק המשתמש המותנה (AbortController) לפני שמפעילים את מצב ממשק המשתמש המיידי, ולהשמיט את signal מהבקשה המיידית עצמה.
  • סשנים של גלישה פרטית או במצב פרטי: בקשות שנשלחות בחלונות של גלישה פרטית או במצב פרטי תמיד נדחות עם NotAllowedError, כדי שלא יהיה אפשר להבחין בין סשנים פרטיים לבין סשנים ללא פרטי כניסה מקומיים.

הגדרה בצד השרת

נקודת קצה ליצירת אפשרויות

צריך לחשוף נקודת קצה (endpoint) שיוצרת אפשרויות לבקשות WebAuthn שניתן לגלות למבקרים לא מאומתים:

  1. יצירת אתגר שקשור לסשן: יוצרים מאגר אתגרים אקראי מבחינה קריפטוגרפית, מאחסנים אותו בסשן השרת ומקודדים אותו כ-Base64URL.
  2. שמירת העדפת האימות של המשתמש: מגדירים את userVerification: 'preferred' ושומרים את expectedUserVerification: 'preferred' בסשן.
  3. השמטת allowCredentials: אל תכללו רשימת 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. אימות טענת נכוֹנוּת (assertion) של WebAuthn‏ (/api/login/verify): מאמתים את טענת הנכוֹנוּת מול אתגר הסשן והמפתח הציבורי ששמור. אם מזהה פרטי הכניסה לא נמצא במסד הנתונים, צריך להחזיר את סטטוס ה-HTTP‏ 404 כדי שהלקוח יוכל להודיע למנהל הסיסמאות באמצעות PublicKeyCredential.signalUnknownCredential().
  2. אימות סיסמה (/api/login/password): אימות של username (cred.id) ו-password (cred.password) מול מסד הנתונים של המשתמשים ויצירת סשן מאומת.

הטמעה בצד הלקוח

בקשה מאוחדת לפרטי כניסה

  1. בקשת מפתחות גישה וסיסמאות: מפענחים את אפשרויות השרת באמצעות PublicKeyCredential.parseRequestOptionsFromJSON() וקוראים ל-navigator.credentials.get() בתוך handler של קליקים שהמשתמש יזם עם password: true,‏ publicKey ו-uiMode: 'immediate' (בלי להעביר את 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', צריך לשלוח בקשת POST אל { username: cred.id, password: cred.password } לנקודת הקצה לאימות הסיסמה.
  3. טיפול ב-NotAllowedError כשאין פרטי כניסה מקומיים: צריך לתפוס את NotAllowedError (נזרק מיד כשאין מפתחות גישה מקומיים או סיסמאות שמורות במכשיר, או כשהמשתמש סוגר את בורר החשבונות) ולעבור לתהליך הכניסה הרגיל (למשל, פתיחת תיבת דו-שיח מוטמעת לכניסה או ניווט אל /signin עם כתובת URL להחזרה).
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;
  }
});

אסטרטגיות חלופיות

חזרה מיידית למצב ממשק המשתמש

  • חוויית שיפור הדרגתי (progressive enhancement): התייחסו אל uiMode: 'immediate' כשיפור הדרגתי. אל תסיקו שיש תמיכה לפי השם או הגרסה של הדפדפן. האות היחיד שאפשר לסמוך עליו הוא בדיקת זמן הריצה של capabilities.immediateGet באמצעות PublicKeyCredential.getClientCapabilities(). חובה: תמיד צריך לבדוק את capabilities.immediateGet לפני שקוראים ל-navigator.credentials.get({ uiMode: 'immediate' }) – דפדפנים שלא מזהים את 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.getClientCapabilities() יהיה זמין בכל פעם שיש תמיכה ב-PublicKeyCredential:

import 'webauthn-polyfills';

חלופה לפרטי כניסה באמצעות סיסמה

העברת password: true לצד publicKey ב-navigator.credentials.get() מאפשרת לדפדפנים נתמכים לכלול סיסמאות שנשמרו במנהל הסיסמאות של הדפדפן בבורר החשבונות המאוחד והמיידי.

  • חוויית חלופה: דפדפנים שלא תומכים ב-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) הוא שיפור הדרגתי (progressive enhancement) שמסיר מנהלי סיסמאות לא עדכניים ממנהל הסיסמאות של המשתמש כשהשרת מגיב עם HTTP 404.

  • תהליך לגיבוי: הגבלת השיחות באמצעות if (PublicKeyCredential.signalUnknownCredential). אם אין תמיכה, צריך לדלג על קריאת האות בצורה חלקה ולהציג הודעה ברורה שמנחה את המשתמש להיכנס באמצעות שיטה חלופית.