应用已经完成 OIDC 登录,但响应中没有 email,或者返回了 proxy.sudomimus.email 地址。登录成功证明了身份,并不保证应用能够读取用户的真实邮箱。
要让 Sudomimus OIDC 用户信息响应返回真实邮箱,会话必须包含 email 范围(scope),应用当前的邮箱策略必须允许真实数据,用户必须同意向这个应用共享邮箱,而且账户当前必须有可用的主要邮箱。分别检查这几个条件。
本文讨论授权码登录流程。接入概览见 OpenID Connect,完整共享规则见身份声明与共享。
1. 使用正确的令牌读取正确的响应
在授权码流程中,Sudomimus /token 返回的 ID 令牌包含身份和会话声明,不包含邮箱等个人资料字段。刷新得到的 ID 令牌也不包含这些字段。因此,这两类 ID 令牌没有 email 是预期行为。
从 OIDC 发现文档读取 userinfo_endpoint,然后通过 Authorization: Bearer 请求头提交 OIDC 访问令牌。不要提交 ID 令牌,也不要将令牌放入查询参数。确认用户信息响应中的 sub 与 OIDC 库校验过的 ID 令牌主体一致。
Connect 会话使用 Session API 的用户信息端点,没有 OIDC 范围。混用端点或令牌类型会导致令牌错误,而不是只缺少邮箱。如果响应是 invalid_token,先排查令牌过期、会话是否仍有效,以及令牌属于哪一种登录流程。
用户信息响应默认使用 JSON。如果应用注册配置选择了签名响应,应先用 OIDC 库校验 JWT 的签名、签发方、受众和主体,再读取其中的声明。
2. 检查当前会话是否包含邮箱范围
需要邮箱时,授权请求应包含 scope=openid email。应用的 OIDC 返回规则必须允许这两个范围。profile 请求姓名和头像,不包含邮箱。请求未获允许的范围会返回 invalid_scope。
Sudomimus 的发现文档还提供 claim_state_endpoint。这是提供方扩展端点,返回当前策略和声明授权状态,不返回个人资料值。只有会话包含对应范围时,响应才会有 email 项。只包含 openid 的会话会得到空的 claims 对象。
如果没有邮箱状态项,检查最初的请求,再发起包含所需范围的交互式授权。修改客户端配置不会给已有会话增加范围。刷新也不能恢复此前已经从该会话移除的范围。
3. 检查应用的邮箱策略
在 With 门户打开应用的声明共享设置。新应用默认只提供邮箱占位值。
下表假定会话已经包含 email 范围:
| 邮箱策略 | 预期行为 |
|---|---|
OFF |
不返回邮箱,即使用户以前同意共享。 |
SYNTHETIC_ONLY |
返回生成的代理地址,并设置 email_verified: false。应用不会请求真实邮箱。 |
OPTIONAL |
只有用户授权且当前真实邮箱可用时才返回,否则省略。 |
REQUIRED |
完成登录或刷新需要用户授权和真实邮箱。只有应用确实需要该信息时才选择此策略。 |
SYNTHETIC_FALLBACK |
用户授权且真实邮箱可用时返回真实值,否则返回代理地址,并设置 email_verified: false。 |
代理地址不是经过验证的真实邮箱,不能直接用作已验证的账户恢复或通知地址。用校验后的签发方和 sub 识别用户,不要用可能变化的邮箱作为用户身份。
4. 检查用户当前的授权和账户资料
声明授权属于一个账户和一个应用。用户同意向另一个应用共享邮箱,不代表你的应用也获得了许可。
| 授权状态 | 含义 |
|---|---|
UNKNOWN |
当前没有已保存的决定。策略请求共享时,交互式登录可以询问用户。 |
DENIED |
用户拒绝共享。可选声明不会在每次登录时自动重复询问。 |
GRANTED |
用户同意共享。会话范围、当前应用策略和当前账户资料仍需满足要求。 |
请用户在 With 账户的「数据共享」中检查该应用的记录。明确发起带有 prompt=consent 的交互式请求会强制显示授权步骤,但不会覆盖用户的选择,也不会改变只提供占位值的策略。不要在静默循环中反复请求授权。
用户信息端点读取当前策略、授权和主要邮箱。撤销可选声明的授权后,后续响应不再返回真实邮箱。如果策略允许真实邮箱,但当前没有可用值,可选的真实邮箱声明会被省略;允许回退的策略会提供代理地址。必需资料需要补全后,新的登录或刷新才能成功。
5. 在服务端检查状态,避免记录令牌或邮箱
下面的诊断函数适用于默认的 JSON 用户信息响应。传入服务端会话的访问令牌,以及已校验 ID 令牌中的主体。结果只报告邮箱是否存在、是否经过验证、策略和授权状态,不返回邮箱地址。
export async function inspectOidcEmail( accessToken: string, expectedSubject: string,) { const issuer = "https://oidc.sudomimus.com"; const discoveryResponse = await fetch( `${issuer}/.well-known/openid-configuration`, ); if (!discoveryResponse.ok) { throw new Error(`Discovery failed (${discoveryResponse.status})`); } const metadata = await discoveryResponse.json(); if (metadata.issuer !== issuer || !metadata.claim_state_endpoint) { throw new Error("Unexpected issuer or missing claim-state endpoint"); }
const headers = { Authorization: `Bearer ${accessToken}` }; const userInfoResponse = await fetch(metadata.userinfo_endpoint, { headers, cache: "no-store", }); if (!userInfoResponse.ok) { throw new Error(`UserInfo failed (${userInfoResponse.status})`); } if (!userInfoResponse.headers.get("content-type")?.includes("application/json")) { throw new Error("Use an OIDC library to validate signed UserInfo"); } const userInfo = await userInfoResponse.json();
const stateResponse = await fetch(metadata.claim_state_endpoint, { headers, cache: "no-store", }); if (!stateResponse.ok) { throw new Error(`Claim state failed (${stateResponse.status})`); } const state = await stateResponse.json(); if (userInfo.sub !== expectedSubject || state.sub !== expectedSubject) { throw new Error("UserInfo or claim-state subject mismatch"); }
return { emailPresent: typeof userInfo.email === "string", emailVerified: userInfo.email_verified === true, requirement: state.claims.email?.requirement ?? null, grant: state.claims.email?.state ?? null, };}两次响应分别反映各自读取时的状态,不是一个原子快照。如果用户在两次请求之间修改了设置,应等修改完成后重新检查。策略返回 null 不等于 OFF:当前会话的范围可能让邮箱状态项根本没有出现。
分别测试以下情况:只请求 openid、用户拒绝可选邮箱、用户同意可选邮箱、只提供占位邮箱,以及登录后撤销可选授权。确认应用能处理每种结果,不会把可选字段缺失变成原因不明的登录失败。
协议错误的排查见 OIDC 故障排查。如果你准备通过框架接入 Connect,可以阅读 React Router 通行密钥指南。