Zitadel 不支持直接用 OIDC 客户端管理 MFA,因其不暴露 LDAP/SCIM 接口且强制通过带 acr_values 的 Authorization Code Flow 触发 MFA,amr 字段仅在成功完成 MFA 后写入 ID Token,需手动校验。

为什么不能直接用 Zitadel 的 OIDC 客户端做 MFA 管理
Zitadel 本身是 OIDC 认证服务,不是传统意义上的“身份源代理”。它不暴露 LDAP 或 SCIM 接口供下游系统拉取用户数据,也不允许你绕过它的登录流程去手动校验 TOTP/Security Key。真正能接入 MFA 的入口,只有它的 OAuth2 Authorization Code Flow + OIDC UserInfo,且 MFA 状态必须在登录后通过 acr_values 和 amr 字段显式声明。
常见错误是试图在 Go 后端用 github.com/coreos/go-oidc 直接验证 token 并忽略 amr,结果发现即使用户已绑定 YubiKey,token.Claims["amr"] 仍是空数组——因为没在授权请求里带上 acr_values=urn:zitadel:iam:org:project:authmethod:mfa。
- 必须在发起 OAuth2 登录时显式设置
acr_values参数,否则 Zitadel 默认走单因素流程 -
amr(Authentication Methods References)字段只在成功完成 MFA 后才写入 ID Token,且值为["totp", "webauthn"]这类字符串数组 - Zitadel 不支持动态切换 MFA 策略;策略由 project-level 的
login_policy控制,Go 服务无法运行时修改
如何用 go-oidc 正确解析带 MFA 的 ID Token
使用 github.com/coreos/go-oidc 时,默认的 verifier.Verify(ctx, rawIDToken) 不会校验 amr,也不会拒绝缺少 MFA 的 token。你需要手动提取并检查 amr 字段。
示例关键片段:
立即学习“go语言免费学习笔记(深入)”;
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return err
}
// 必须显式解包 claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
return err
}
amr, ok := claims["amr"].([]interface{})
if !ok || len(amr) == 0 {
return errors.New("MFA required but not performed")
}
// 检查是否至少含一个认可的认证方式
validAMR := map[string]bool{"totp": true, "webauthn": true, "u2f": true}
hasValidMFA := false
for _, m := range amr {
if method, ok := m.(string); ok && validAMR[method] {
hasValidMFA = true
break
}
}
if !hasValidMFA {
return errors.New("required MFA method not satisfied")
}
- 别依赖
idToken.Subject或idToken.Audience做权限判断——MFA 状态只藏在amr里 -
go-oidcv3+ 对amr类型不做预定义,必须用map[string]interface{}解析,不能强转为 struct - 注意时区:Zitadel 的
auth_time是 Unix 时间戳,但部分前端 SDK 会误传为毫秒,导致Verify失败
如何让 Go HTTP handler 区分 MFA 已完成和待触发状态
Zitadel 不提供“跳过登录页直触 MFA”的 API。所谓“待触发”,实际是指用户已通过密码认证、但尚未完成第二因素——这个中间态只能靠 Zitadel 的 login policy 配置决定,Go 侧唯一能做的,是根据 acr_values 请求参数控制重定向目标。
- 若要强制所有用户走 MFA,OAuth2 登录 URL 必须包含
acr_values=urn:zitadel:iam:org:project:authmethod:mfa - 若想按角色分流(如管理员强制 MFA,普通用户可选),需在 Go 中维护一个 role→acr 映射表,并在生成 auth URL 前查库决定是否拼接
acr_values - Zitadel 无回调钩子通知“用户刚绑定了新设备”,所以 Go 服务无法主动刷新 session;只能等下次登录时新
amr生效
典型 redirect 构造逻辑:
authURL := oauth2Config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("acr_values", "urn:zitadel:iam:org:project:authmethod:mfa"),
oauth2.SetAuthURLParam("prompt", "login"),
)
Zitadel 与 Go 微服务间 session 同步的陷阱
Go 服务自己管理 session 时,容易把 Zitadel 的 ID Token 当作长期凭证缓存。但 Zitadel 的 ID Token 默认有效期仅 15 分钟,且不支持刷新——刷新必须走 /oauth/v2/token 换取新的 access_token + id_token,而 access_token 本身不含 amr。
- 不要把 ID Token 存进 Redis 作为 session 主体;应只存
sub+amr+auth_time,并设 TTL ≤ 15min - 若需延长 MFA 信任窗口,Zitadel 提供
remember_device功能(需在 login policy 中启用),对应 OIDCmax_age参数,Go 侧可在 AuthCodeURL 中加oauth2.SetAuthURLParam("max_age", "3600") - Zitadel 的 logout endpoint(
/oauth/v2/logout)不会广播到其他服务;Go 服务需监听http://zitadel.example.com/oauth/v2/logout?post_logout_redirect_uri=...并主动清理本地 session
最常被忽略的一点:Zitadel 的 amr 只反映本次登录所用的认证方式组合,不承诺用户“永远绑定某设备”。如果用户在另一台设备上用相同账号登录并跳过 MFA(比如用了 remember_device),你的 Go 服务拿到的 amr 就会为空——这不是 bug,是设计使然。


















