← 文章与指南

OIDC 登录成功但没有邮箱:如何排查

从用户信息端点、会话范围、应用声明策略和用户当前授权入手,排查 OIDC 邮箱缺失或返回占位地址的原因。

应用已经完成 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 令牌中的主体。结果只报告邮箱是否存在、是否经过验证、策略和授权状态,不返回邮箱地址。

server/inspect-oidc-email.tsTypeScript
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 通行密钥指南。