כשמשתמש לא מאומת מועבר לדף כניסה ייעודי אחרי שהוא לוחץ על קישור "כניסה", מתחיל תהליך תשלום, שומר פריט במועדפים או מגיע לחומת תשלום של מאמר, הוא מאבד את ההקשר וזה מגדיל את שיעור הנטישה של המשתמשים. מצב ממשק משתמש מיידי (uiMode: 'immediate' ב-navigator.credentials.get()) מאפשר כניסות מהירות בהתאם להקשר על ידי הצגת Account Chooser מאוחד בדפדפן באופן מיידי, אם קיימים במכשיר מפתחות גישה מקומיים (publicKey) או סיסמאות שמורות (password: true).
בניגוד להנחיות מודאליות רגילות של WebAuthn – שתמיד מציגות את ממשק המשתמש של הדפדפן וחוזרות לקודי QR חוצי-מכשירים או למפתחות אבטחה חיצוניים – מצב ממשק משתמש מיידי נכשל במהירות עם NotAllowedError אם אין פרטי כניסה כשירים שזמינים באופן מקומי במכשיר. כך האפליקציה יכולה לאמת משתמשים חוזרים במקום בהקשה אחת, ובמקביל להעביר בצורה חלקה משתמשים ללא פרטי כניסה מקומיים לדף הכניסה הרגיל, לתשלום כאורח או לטופס התחברות מוטבע.
דפוסי UX מומלצים ודפוסי נגד
מפעילים את מצב "ממשק משתמש מיידי" בתוך גורם מטפל באירועים שהמשתמש יזם (שמחייב הפעלה זמנית על ידי המשתמש) ברגעים שבהם כוונת המשתמש ברורה, ממש לפני שהוא עובר מהדף הנוכחי. אם האימות המיידי מצליח, צריך להשלים את הפעולה שהמשתמש התכוון לבצע באופן מיידי (על ידי עדכון הדף הנוכחי או מעבר ישירות ליעד המאומת). אם הפעולה נכשלת עם 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 שניתן לגלות למבקרים לא מאומתים:
- יצירת אתגר שקשור לסשן: יוצרים מאגר אתגרים אקראי מבחינה קריפטוגרפית, מאחסנים אותו בסשן השרת ומקודדים אותו כ-Base64URL.
- שמירת העדפת האימות של המשתמש: מגדירים את
userVerification: 'preferred'ושומרים אתexpectedUserVerification: 'preferred'בסשן. - השמטת
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), צריך לספק פונקציות טיפול באימות לשני סוגי פרטי הכניסה:
- אימות טענת נכוֹנוּת (assertion) של WebAuthn (
/api/login/verify): מאמתים את טענת הנכוֹנוּת מול אתגר הסשן והמפתח הציבורי ששמור. אם מזהה פרטי הכניסה לא נמצא במסד הנתונים, צריך להחזיר את סטטוס ה-HTTP404כדי שהלקוח יוכל להודיע למנהל הסיסמאות באמצעותPublicKeyCredential.signalUnknownCredential(). - אימות סיסמה (
/api/login/password): אימות שלusername(cred.id) ו-password(cred.password) מול מסד הנתונים של המשתמשים ויצירת סשן מאומת.
הטמעה בצד הלקוח
בקשה מאוחדת לפרטי כניסה
- בקשת מפתחות גישה וסיסמאות: מפענחים את אפשרויות השרת באמצעות
PublicKeyCredential.parseRequestOptionsFromJSON()וקוראים ל-navigator.credentials.get()בתוך handler של קליקים שהמשתמש יזם עםpassword: true,publicKeyו-uiMode: 'immediate'(בלי להעביר אתsignal). - אימות הסניף על ידי
cred.type:- אם
cred.type === 'public-key', צריך להפוך את ההצהרה לסדרה באמצעותcred.toJSON()ולשלוח אותה ב-POST לנקודת הקצה של אימות WebAuthn. כשהשרת מגיב עם HTTP404, צריך להפעיל אתPublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id }). - אם
cred.type === 'password', צריך לשלוח בקשת POST אל{ username: cred.id, password: cred.password }לנקודת הקצה לאימות הסיסמה.
- אם
- טיפול ב-
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). אם אין תמיכה, צריך לדלג על קריאת האות בצורה חלקה ולהציג הודעה ברורה שמנחה את המשתמש להיכנס באמצעות שיטה חלופית.