Skip to content

oidcverify ​

github.com/gonewx/wicket/oidcverify 用提供者公布的 JWKS 公钥在本地验证 ID Token,返回其中的用户标识。适用于“App 已经完成 Apple / Google 登录,把 ID Token 发给后端”的场景。

快速示例 ​

go
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.comhttps://appleid.apple.com/auth/keys单个:你的 App 的 bundle id 或 Services ID
GoogleConfig(audiences ...string)https://accounts.google.com 和 accounts.google.comhttps://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 ​

go
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) 按以下顺序检查,任一步失败即返回对应哨兵错误:

  1. 解析 JWS,算法只接受 RS256(alg 为 none、HS256、ES256 等一律 ErrInvalidAlgorithm);
  2. 按头部 kid 在 JWKS 中找公钥(见下节);
  3. 验签,并用注入的时钟和 ClockSkew 校验 exp / nbf / iat;
  4. iss 必须在配置的签发方列表中;
  5. aud 必须与配置的受众有交集;
  6. nonce:NonceRequired 为 true 或调用了 VerifyWithNonce 且传入非空 nonce 时,令牌必须带 nonce;传入了期望值时还必须相等;
  7. sub 不能为空。

返回值:

go
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验签失败(以及无法归入其他类别的解析错误)
ErrTokenExpiredexp / nbf / iat 校验失败
ErrInvalidIssuer签发方不在允许列表中
ErrInvalidAudience受众无交集
ErrMissingNonce要求 nonce 但令牌没有
ErrInvalidNoncenonce 与期望值不符

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();测试中可以注入固定时间的时钟,让过期判定可重复。

完整可运行示例 ​

go
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 自身也不读取它。直接在你的初始化代码里构造验证器即可,无需经过容器。

相关阅读 ​

以 Apache License 2.0 发布 · 文档内容 © 2026 Wicket 作者