电子邮件提供方(发卡机构)实现

如需了解更多详情,您可以浏览电子邮件提供商模拟演示代码,并参阅电子邮件验证 API 和电子邮件验证协议提案中的签发方步骤。

作为签发者,您无需注册源试用或提供令牌,因为依赖方网站会触发浏览器行为。确保您的端点已配置为响应这些请求。

配置发行方发现

如需允许浏览器在选择属于您网域的电子邮件地址时自动发现您的验证端点,请使用 DNS 和 .well-known HTTP 端点公开您的配置。

配置 DNS 委托记录

在您的电子邮件网域中配置 DNS TXT 记录,以将验证权限委托给您的签发者标识符。这些标识符可以使用相同的网域,具体取决于您的基础架构。

记录格式:_email-verification.<email-domain>

区域文件示例:

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

托管 .well-known/email-verification 端点

在发布者网域的 /.well-known/ 路径下托管 JSON 元数据文件。此文件概述了您的签发功能以及您的基础架构支持的加密签名算法。

端点: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"]
}

托管 .well-known/web-identity 端点

您可能已经实现了额外的 .well-known JSON 资源,作为联合凭据 (FedCM) API 的一部分。此资源提供指向您的账号端点和登录网址的链接。

端点:https://<domain>/.well-known/web-identity

示例响应:

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

使用账号端点

FedCM API 中的账号端点会提供当前已登录账号的列表。以下示例展示了一个最简响应。如需了解详情,请参阅身份提供方实施指南。

端点:如 .well-known/web-identity 中所指定

以下是一个示例响应:

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

与登录状态 API 集成

用户必须与提供方建立有效的会话,并且您必须使用 Login Status API 向浏览器发出信号。

当用户成功登录或退出时,提供匹配的 HTTP 响应标头:

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

或者,在 Web 应用上下文中使用 JavaScript 更新状态:

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

处理签发请求

您的 issuance_endpoint 收到包含 email 密钥和 Signature、Signature-Input 和 Signature-Key 的 HTTP 消息签名标头的 application/json POST 请求。

使用支持结构化标头和 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 标头:设置为 email-verification。
  • HTTP 消息签名:使用 Signature-Key 中的临时公钥验证请求签名,并验证 Content-Digest。
  • 载荷:JSON 正文包含请求验证的 email 字符串。

签发响应

成功验证会话和请求令牌后,生成已签名的选择性披露 JWT (SD-JWT),并使用适合您平台的库以 JSON 格式返回。例如,对于 Node,您可以使用 @sd-jwt/core 和 jose。

原始载荷格式应如下所示:

{
  "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。在同网站验证器上进行测试时,SameSite=Lax Cookie 可以正常运行,但在跨网站请求中会被省略。
  • 发现或账号不匹配:验证 _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) 不匹配:验证 RFC 9421 签名时,@authority 组件反映的是面向公众的主机。如果您的服务器位于反向代理或负载均衡器后面,请使用 X-Forwarded-Host(或您的公共来源)而非内部主机名来重建验证网址,并在 JSON 解析之前计算原始请求正文字节的 Content-Digest。

浏览器拒绝返回的 issuance_token

  • 经过修改或规范化的 email 声明:从 Chrome 156 开始,浏览器会检查 EVT email 声明是否与所请求的 email 完全一致。如果后端将地址规范化为标准账号格式(例如,在请求 first.LAST@example.com 时返回 First.Last@example.com),Chrome 会舍弃令牌。将请求与用户账号进行匹配,但返回在请求正文中收到的确切 email 字符串。
  • 缺少末尾的波浪号 (~):即使披露内容为零,issuance_token 也必须是有效的 SD-JWT,并且以末尾的波浪号 (<Issuer-signed-JWT>~) 结尾,以便浏览器可以附加 <KB-JWT>。
  • iss 或 cnf.jwk 不匹配:确保 EVT iss 声明是与 .well-known/email-verification 元数据完全匹配的 HTTPS 源(https://<issuer-domain>,不含尾部斜杠),并且 cnf.jwk 会嵌入来自 Signature-Key 标头的浏览器临时公钥。