oidcverify
github.com/gonewx/wicket/oidcverify 用提供者公布的 JWKS 公钥在本地验证 ID Token,返回其中的用户标识。适用于“App 已经完成 Apple / Google 登录,把 ID Token 发给后端”的场景。
快速示例
import (
"context"
"errors"
"github.com/gonewx/wicket/oidcverify"
"github.com/gonewx/wicket/services/impl"
)
// 启动时构造一次,进程内复用;appCtx 取消时 JWKS 缓存随之停止
verifier, err := oidcverify.NewVerifier(appCtx, oidcverify.AppleConfig("com.example.app"), impl.NewSystemClock())
if err != nil {
return err // 只有签发方或受众为空等配置错误才会走到这里
}
// 每次登录请求
tok, err := verifier.VerifyWithNonce(r.Context(), idToken, expectedNonce)
switch {
case err == nil:
user, err := findOrCreateUser("apple", tok.Subject, tok.Email, tok.EmailVerified)
// ...
case errors.Is(err, oidcverify.ErrTokenExpired):
// 提示客户端重新登录
case errors.Is(err, oidcverify.ErrKeyNotFound):
// 可能是 JWKS 拉取失败(网络、超时),不一定是用户的错:记录并告警
default:
// 按 401 处理
}上面是嵌在业务代码里的片段(示意,appCtx、findOrCreateUser 等为你自己的代码)。页末的完整示例在本地起了一个 JWKS 服务并签发测试令牌,已编译运行,输出见注释。
提供者预设
| 函数 | 签发方(iss) | JWKS 地址 | 受众 |
|---|---|---|---|
AppleConfig(audience string) | https://appleid.apple.com | https://appleid.apple.com/auth/keys | 单个:你的 App 的 bundle id 或 Services ID |
GoogleConfig(audiences ...string) | https://accounts.google.com 和 accounts.google.com | https://www.googleapis.com/oauth2/v3/certs | 可变参数,可同时接受 Android、iOS、Web 的多个 client id |
两个预设的 ClockSkew 都是 5 分钟。Google 同时接受带与不带 https:// 的两种签发方写法,因为它的 ID Token 两种形式都出现过。签发方、JWKS 地址也以常量导出:AppleIssuer、AppleJWKSURI、GoogleIssuerHTTPS、GoogleIssuerPlain、GoogleJWKSURI。
别漏掉受众
GoogleConfig() 不传参数时没有受众,NewVerifier 会返回 oidcverify: at least one audience must be configured。受众校验是防止“别的应用拿到的令牌被拿来登录你的应用”的关键,不能省略。
自定义提供者:ProviderConfig
type ProviderConfig struct {
Issuer string // 单个签发方
Issuers []string // 多个签发方,任一相等即通过;非空时优先于 Issuer
JWKSURI string // JWKS 地址
Audience string // 单个受众
Audiences []string // 多个受众,与令牌 aud 有交集即通过;非空时优先于 Audience
ClockSkew time.Duration // 时间容差;为 0 时取 5 分钟
NonceRequired bool // 为 true 时令牌必须带 nonce
}单数与复数字段二选一即可;同时设置时以复数字段为准,单数字段被忽略。签发方比较是完全相等,没有前缀或大小写归一化。
验证做了什么
Verify(ctx, idToken) 与 VerifyWithNonce(ctx, idToken, nonce) 按以下顺序检查,任一步失败即返回对应哨兵错误:
- 解析 JWS,算法只接受 RS256(
alg为none、HS256、ES256等一律ErrInvalidAlgorithm); - 按头部
kid在 JWKS 中找公钥(见下节); - 验签,并用注入的时钟和
ClockSkew校验exp/nbf/iat; iss必须在配置的签发方列表中;aud必须与配置的受众有交集;- nonce:
NonceRequired为 true 或调用了VerifyWithNonce且传入非空 nonce 时,令牌必须带nonce;传入了期望值时还必须相等; sub不能为空。
返回值:
type VerifiedToken struct {
Subject string // sub,用户在该提供者下的唯一标识
Email string // email,可能为空
EmailVerified bool // email_verified,兼容布尔与字符串 "true" 两种写法
Issuer string
Audience []string
}只有这些字段被投影出来;需要其他 claim 时请另行解析。用户主键请用“签发方 + Subject”,不要用邮箱:邮箱可能变化、可能被隐藏、也可能未经验证。
错误
全部为包级哨兵,用 errors.Is 判断:
| 哨兵 | 含义 |
|---|---|
ErrMalformedToken | 不是合法 JWS,或缺少 sub |
ErrInvalidAlgorithm | 签名算法不是 RS256 |
ErrKeyNotFound | 取 JWKS 失败(含网络错误、调用方 ctx 取消或超时),或刷新后仍找不到对应 kid |
ErrInvalidSignature | 验签失败(以及无法归入其他类别的解析错误) |
ErrTokenExpired | exp / nbf / iat 校验失败 |
ErrInvalidIssuer | 签发方不在允许列表中 |
ErrInvalidAudience | 受众无交集 |
ErrMissingNonce | 要求 nonce 但令牌没有 |
ErrInvalidNonce | nonce 与期望值不符 |
NewVerifier 自身的错误不是上述哨兵,而是普通的配置错误。
JWKS 缓存
- 构造时只登记 JWKS 地址,不发网络请求;第一次
Verify时才同步拉取。因此NewVerifier在离线环境也能成功,网络问题会在首次验证时以ErrKeyNotFound表现出来。 - 拉取后由 jwx 的
jwk.Cache托管,最小刷新间隔为 15 分钟。 - 令牌的
kid在缓存中找不到时,立即强制刷新一次(应对提供者轮换密钥),刷新后仍找不到才返回ErrKeyNotFound。 - 令牌没有
kid时,只有 JWKS 恰好只有一把钥才会使用它,否则ErrKeyNotFound。 - 缓存与传给
NewVerifier的ctx绑定。请传入一个与进程同寿命的上下文,只在关闭时取消;不要传请求级的ctx。
构造用的 ctx 取消后,验证器不可再用
v0.1.4 实测:构造用的 ctx 一旦取消,之后每一次 Verify 都会失败——即使公钥此前已缓存成功;而且调用会一直阻塞到本次调用自己的 ctx 结束才返回 ErrKeyNotFound。若本次调用的 ctx 没有 deadline,调用将永久阻塞。因此:构造用的 ctx 只能在进程退出时取消;每次 Verify 都传入带 deadline 的请求 ctx。
时钟
第三个参数是 services.SystemClock(只有一个方法 Now() time.Time)。生产环境用 impl.NewSystemClock();测试中可以注入固定时间的时钟,让过期判定可重复。
完整可运行示例
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"time"
"github.com/gonewx/wicket/oidcverify"
"github.com/gonewx/wicket/services/impl"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
// 模拟一个提供者:生成 RS256 密钥并通过 HTTP 公布 JWKS
raw, _ := rsa.GenerateKey(rand.Reader, 2048)
priv, _ := jwk.Import(raw)
_ = priv.Set(jwk.KeyIDKey, "k1")
_ = priv.Set(jwk.AlgorithmKey, jwa.RS256())
pub, _ := priv.PublicKey()
set := jwk.NewSet()
_ = set.AddKey(pub)
jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(set)
}))
defer jwks.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
v, err := oidcverify.NewVerifier(ctx, oidcverify.ProviderConfig{
Issuer: "https://idp.example.com",
JWKSURI: jwks.URL,
Audiences: []string{"mobile-app", "web-app"},
ClockSkew: 2 * time.Minute,
}, impl.NewSystemClock())
if err != nil {
log.Fatal(err)
}
sign := func(iss, aud, nonce string, ttl time.Duration) string {
t := jwt.New()
_ = t.Set(jwt.IssuerKey, iss)
_ = t.Set(jwt.AudienceKey, aud)
_ = t.Set(jwt.SubjectKey, "user-1")
_ = t.Set(jwt.ExpirationKey, time.Now().Add(ttl))
_ = t.Set("email", "u@example.com")
_ = t.Set("email_verified", "true")
if nonce != "" {
_ = t.Set("nonce", nonce)
}
b, _ := jwt.Sign(t, jwt.WithKey(jwa.RS256(), priv))
return string(b)
}
tok, err := v.Verify(ctx, sign("https://idp.example.com", "web-app", "", time.Hour))
fmt.Printf("%+v %v\n", tok, err)
// &{Subject:user-1 Email:u@example.com EmailVerified:true Issuer:https://idp.example.com Audience:[web-app]} <nil>
_, err = v.VerifyWithNonce(ctx, sign("https://idp.example.com", "web-app", "n-1", time.Hour), "n-2")
fmt.Println(errors.Is(err, oidcverify.ErrInvalidNonce)) // true
_, err = v.Verify(ctx, sign("https://evil.example.com", "web-app", "", time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrInvalidIssuer)) // true
_, err = v.Verify(ctx, sign("https://idp.example.com", "other", "", time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrInvalidAudience)) // true
_, err = v.Verify(ctx, sign("https://idp.example.com", "web-app", "", -time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrTokenExpired)) // true
}与容器的关系
容器中注册了一个空的 *oidcverify.ProviderConfig 单例(root.Ptr_ProviderConfig()),但没有注册 Verifier,wicket 自身也不读取它。直接在你的初始化代码里构造验证器即可,无需经过容器。