הטמעה של ספק אימייל (מנפיק)

לפרטים נוספים, אפשר לעיין בקוד ההדגמה של ספק האימייל המדומה ובשלבים של המוסד המנפיק בEmail Verification API ובהצעות ל-Email Verification Protocol.

כמנפיקים, אתם לא צריכים להירשם לגרסת מקור לניסיון או לספק טוקן, כי האתר של הצד המסתמך מפעיל את התנהגות הדפדפן. מוודאים שהנקודות הסופיות מוגדרות להגיב לבקשות האלה.

הגדרת גילוי של מנפיק

כדי לאפשר לדפדפנים לגלות באופן אוטומטי את נקודות הקצה של האימות כשבוחרים כתובת אימייל ששייכת לדומיין שלכם, צריך לחשוף את ההגדרה באמצעות DNS ונקודת קצה של .well-known HTTP.

הגדרת רשומת הפניה (delegate) של DNS

מגדירים רשומת TXT ב-DNS בדומיין האימייל, שמקצה סמכות לאימות למזהה המנפיק. המזהים האלה יכולים להשתמש באותו דומיין, בהתאם לתשתית שלכם.

פורמט הרשומה: _email-verification.<email-domain>

קובץ אזור לדוגמה:

_email-verification.example.com IN TXT "iss=accounts.issuer.example"

אירוח נקודת קצה (endpoint) של .well-known/email-verification

מארחים קובץ JSON של מטא-נתונים בדומיין של המנפיק בנתיב /.well-known/. בקובץ הזה מפורטות היכולות שלכם להנפקת אישורים והאלגוריתמים הקריפטוגרפיים לחתימה שהתשתית שלכם תומכת בהם.

נקודת קצה: https://<issuer-domain>/.well-known/email-verification

דוגמה לתגובה:

{
  "issuance_endpoint": "https://accounts.issuer.example/email-verification/issuance",
  "jwks_uri": "https://accounts.issuer.example/.well-known/vc-public-jwks",
  "signing_alg_values_supported": ["EdDSA", "ES256"]
}

אירוח נקודת קצה (endpoint) של .well-known/web-identity

יכול להיות שכבר הטמעתם משאב JSON נוסף .well-known כחלק מ-Federated Credentials (FedCM) API. במקור הזה מופיעים קישורים לנקודת הקצה של החשבונות ולכתובת ה-URL לכניסה.

נקודת קצה: https://<domain>/.well-known/web-identity

דוגמה לתגובה:

{
  "accounts_endpoint": "https://accounts.issuer.example/accounts",
  "login_url": "https://accounts.issuer.example/login"
}

שימוש בנקודת קצה של חשבונות

נקודת הקצה accounts מ-FedCM API מספקת רשימה של חשבונות מחוברים כרגע. בדוגמה הבאה מוצגת תגובה מינימלית. פרטים נוספים זמינים במדריך להטמעה של ספק הזהויות.

נקודת קצה: כפי שמצוין ב-.well-known/web-identity

דוגמה לתגובה:

{
  "accounts": [
    {
      "id": "demo-example",
      "name": "Demo User",
      "email": "demo@example.com",
      "given_name": "Demo"
    }
  ]
}

שילוב עם Login Status API

המשתמש צריך להיות מחובר לספק בסשן פעיל, ואתם צריכים לסמן את זה לדפדפן באמצעות Login Status API.

כשמשתמש נכנס או יוצא מהחשבון בהצלחה, צריך להציג את כותרת התגובה התואמת של HTTP:

Set-Login: logged-in
Set-Login: logged-out

לחלופין, אפשר לעדכן את הסטטוס באמצעות JavaScript בהקשר של אפליקציית האינטרנט:

navigator.login.setStatus("logged-in");
navigator.login.setStatus("logged-out");

טיפול בבקשות להנפקת כרטיסים

שרת issuance_endpoint מקבל בקשת application/json POST שמכילה את מפתח email ואת הכותרות של חתימות הודעות HTTP עבור Signature,‏ Signature-Input ו-Signature-Key.

משתמשים בספרייה שתומכת בכותרות מובנות ובחתימות של הודעות HTTP בסביבה שלכם. ב-Node.js אפשר להשתמש ב-structured-headers וב-http-message-sig.

הפורמט המלא של הבקשה:

POST /email-verification/issuance HTTP/1.1
Host: provider.example
Accept: application/json
Content-Digest: sha-256=:aBc123aBc123aBc123aBc123aBc123=:
Content-Type: application/json
Sec-Fetch-Dest: email-verification
Signature: sig=:+dEf567dEf567/dEf567dEf567dEf567/dEf567==:
Signature-Input: sig=("@method" "@authority" "@path" "content-digest" "signature-key");created=1786455840
Signature-Key: sig=hwk;crv="Ed25519";kty="OKP";x="gHi890_gHi890_gHi890"

{email: "demo@example.com"}

מנתחים ומאמתים את הבקשה:

  • אימות סשן: אימות קובצי ה-Cookie של הסשן מהדומיין הנוכחי שנשלחים עם הבקשה. צריך לאמת את המשתמש.
  • ‫Sec-Fetch-Dest header: מגדירים את הערך email-verification.
  • חתימות של הודעות HTTP: מאמתים את חתימת הבקשה באמצעות המפתח הציבורי הזמני ב-Signature-Key ומאמתים את Content-Digest.
  • המטען הייעודי (Payload): גוף ה-JSON מכיל את המחרוזת email שנדרשת לאימות.

תשובה להנפקה

אחרי אימות מוצלח של הסשן ושל אסימון הבקשה, צריך ליצור SD-JWT חתום (טוקן JWT עם חשיפה סלקטיבית) שמוחזר כ-JSON באמצעות ספריות מתאימות לפלטפורמה שלכם. לדוגמה, ב-Node אפשר להשתמש ב-@sd-jwt/core וב-jose.

הפורמט של מטען הייעודי (payload) הגולמי צריך להיראות כך:

{
  "iss": "https://accounts.issuer.example",
  "iat": 1780272000,
  "exp": 1780272300,
  "cnf": {
    "jwk": {
      "kty": "EC",
      "crv": "P-256",
      "x": "pUbLiCKeY123pUbLiCKeY123pUbLiCKeY123",
      "y": "pUbLiCKeY456pUbLiCKeY456pUbLiCKeY456"
    }
  },
  "email": "demo@example.com",
  "email_verified": true
}

יוצרים את הטוקן, חותמים עליו ומחזירים אותו:

import { importJWK, CompactSign } from "jose";
import { SDJwtInstance } from "@sd-jwt/core";
import crypto from "node:crypto";
// Issuer private key from secure storage
const privateKey = await importJWK(PRIVATE_KEY_JWK, "EdDSA");
const origin = url.origin;
const currentTime = Math.floor(Date.now() / 1000);
const evtPayload = {
  iss: origin,
  iat: currentTime,
  exp: currentTime + 300, // 5 minutes
  cnf: {
    jwk: browserJwk,
  },
  email: payload.email, // exactly as received in payload
  email_verified: true,
};
const sdJwt = new SDJwtInstance({
  signer: async (data) => {
    const [headerB64, payloadB64] = data.split(".");
    const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
    const payload = Buffer.from(payloadB64, "base64url");
    const signed = await new CompactSign(payload).setProtectedHeader(header).sign(privateKey);
    return signed.split(".").pop()!;
  },
  signAlg: "EdDSA",
  hasher: async (data, alg) => {
    const nodeAlg = alg.replace("-", "");
    return new Uint8Array(crypto.createHash(nodeAlg).update(data).digest());
  },
  hashAlg: "sha-256",
  saltGenerator: async () => crypto.randomBytes(16).toString("base64url"),
});
const issuanceToken = await sdJwt.issue(evtPayload, undefined, {
  header: {
    alg: "EdDSA",
    kid: PRIVATE_KEY_JWK.kid,
    typ: "evt+jwt",
  },
});
return sendResponse({ issuance_token: issuanceToken, }, 200);

גוף התגובה שיתקבל ייראה כך:

{
  "issuance_token": "tOkEn123tOkEn123tOkEn123...~"
}

אחרי שיוצרים את טוקן האימות של כתובת האימייל וחותמים עליו, הדפדפן מעביר אותו לאתר המאמת לצורך אימות.

פתרון בעיות

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

הדפדפן אף פעם לא קורא ל-accounts_endpoint או ל-issuance_endpoint

  • סטטוס הכניסה לא מוגדר: Chrome שולח שאילתות לנקודות הקצה רק אם הוא יודע שהמשתמש מחובר. מוודאים שתהליך הכניסה מגדיר את Set-Login: logged-in כותרת ה-HTTP או קורא ל-navigator.login.setStatus("logged-in").
  • קובצי Cookie של סשן נחסמו (SameSite=None): הדפדפן מאחזר את accounts_endpoint וissuance_endpoint מצד שלישי באתר אחר. מכיוון שמדובר בבקשות חוצות אתרים, קובץ ה-Cookie של הסשן חייב לכלול את SameSite=None; Secure. קובץ Cookie עם מאפיין SameSite=Lax פועל כשבודקים אותו באמצעות כלי אימות של אותו אתר, אבל הוא מושמט בבקשות בין אתרים.
  • גילוי או אי התאמה של החשבון: מוודאים שהפקודה _email-verification.<email-domain> מחזירה רשומת TXT אחת (iss=<issuer-domain>, ללא https://), ששתי נקודות הקצה .well-known מחזירות Content-Type: application/json, ושבתגובה accounts_endpoint מופיע חשבון שערך email שלו תואם לכתובת שהוזנה.

האימות של בקשת ההנפקה נכשל

  • איות הכותרת Sec-Fetch-Dest: ב-Chrome 154 ואילך נשלח Sec-Fetch-Dest: email-verification (עם מקף), וב-Chrome 153 נשלח emailverification (בלי מקף). צריך לאפשר את שני הערכים במהלך ההשקה.
  • אי התאמה של חתימת HTTP על הודעה (@authority) מאחורי שרת proxy: כשמאמתים את החתימה של RFC 9421, הרכיב @authority משקף את המארח שפונה לציבור. אם השרת שלכם נמצא מאחורי שרת proxy הפוך או מאזן עומסים, צריך לשחזר את כתובת ה-URL לאימות באמצעות X-Forwarded-Host (או המקור הציבורי שלכם) ולא באמצעות שם המארח הפנימי, ולחשב את Content-Digest על בסיס הבייטים של גוף הבקשה הגולמי לפני ניתוח ה-JSON.

הדפדפן דוחה את הערך המוחזר issuance_token

  • טענה email ששונתה או שעברה קנוניזציה: החל מגרסה Chrome 156, הדפדפן בודק שהטענה email של EVT זהה לבקשת email בבייט אחר בייט. אם ה-Backend שלכם מבצע נורמליזציה של הכתובת לפורמט קנוני של חשבון (למשל, מחזיר First.Last@example.com כשמתבצעת בקשה ל-first.LAST@example.com), Chrome משמיט את האסימון. להתאים את הבקשה לחשבון של המשתמש, אבל להחזיר את המחרוזת email המדויקת שהתקבלה בגוף הבקשה.
  • חסר סימן טילדה (~) בסוף: גם אם אין גילוי נאות, המחרוזת issuance_token צריכה להיות SD-JWT תקין שמסתיים בסימן טילדה (<Issuer-signed-JWT>~) כדי שהדפדפן יוכל לצרף את <KB-JWT>.
  • יש אי התאמה בין iss לבין cnf.jwk: מוודאים שהטענה של EVT‏ iss היא המקור המדויק של HTTPS (https://<issuer-domain>, ללא לוכסן בסוף) שתואם למטא-נתונים של .well-known/email-verification, ושה-EVT‏ cnf.jwk מטמיע את המפתח הציבורי הזמני של הדפדפן מהכותרת Signature-Key.