Skip to content

刷新令牌 ​

本页说明客户端如何获得刷新令牌(refresh token)并用它续期访问令牌,以及 wicket v0.1.4 在轮换、过期等方面实际做了什么、没做什么。后者直接影响你的安全设计,请完整读完。

拿到刷新令牌的两个条件 ​

  1. 客户端设置 AllowOfflineAccess = true;
  2. 授权请求的 scope 中包含 offline_access。
go
web.AllowOfflineAccess = true
web.AllowedScopes = []string{"openid", "profile", "orders.read"} // 不需要把 offline_access 写进来

offline_access 是内置作用域,不需要在 IdentityResource 或 ApiScope 中定义,发现文档的 scopes_supported 会自动列出它。实测:

情况结果
AllowOfflineAccess = true,请求含 offline_access令牌响应含 refresh_token
AllowOfflineAccess = true,请求不含 offline_accessrefresh_token 为空字符串
AllowOfflineAccess = false,请求含 offline_access授权端点拒绝(invalid_scope,跳转到错误页)

授权码与 CIBA 流程签发刷新令牌均已实测。客户端凭据流没有用户,不应为其开启 AllowOfflineAccess。

用刷新令牌换取新令牌 ​

bash
curl -s -u web-app:web-secret \
  -d grant_type=refresh_token \
  -d refresh_token=$REFRESH_TOKEN \
  http://localhost:5000/connect/token

实测响应(截断):

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtp...",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJSUzI1NiIsImtp...",
  "refresh_token": "2AD2B6B67EB04056098359C8...",
  "scope": "openid orders.read offline_access",
  "token_type": "Bearer"
}
  • 原始请求含 openid 时,刷新也会返回新的 id_token。
  • 刷新请求中的 scope 参数在 v0.1.4 中被忽略:实测带 scope=openid 刷新,返回的仍是原始的全部作用域。
  • 无效或不存在的刷新令牌返回 invalid_grant。

Client 上的刷新令牌字段 ​

storage/models/client.go 中的字段及 NewClient() 默认值:

字段NewClient() 默认值v0.1.4 中是否生效
AllowOfflineAccessfalse生效
UpdateAccessTokenClaimsOnRefreshfalse生效(见下文)
RefreshTokenUsagewicket.TokenUsageOneTimeOnly未生效
RefreshTokenExpirationwicket.TokenExpirationAbsolute未生效
AbsoluteRefreshTokenLifetime2592000(30 天)未生效
SlidingRefreshTokenLifetime1296000(15 天)未生效

用 NewClient() 而不是字面量

TokenUsage、TokenExpiration 的零值分别是 TokenUsageOneTimeOnly 和 TokenExpirationSliding。用 models.Client{} 字面量创建客户端会得到与 NewClient() 不同的默认值(还会漏掉 Enabled = true 等),请始终从 NewClient() 开始。

UpdateAccessTokenClaimsOnRefresh:

  • false(默认):复用上次签发时保存的访问令牌内容,只更新签发时间与有效期后重新签名。用户资料变化不会反映到新令牌中。
  • true:根据刷新令牌中保存的用户主体重新生成访问令牌声明。

v0.1.4 的实现程度(务必了解) ​

v0.1.4 尚未实现刷新令牌的过期与轮换策略,上表中标为“未生效”的字段请勿依赖。具体表现为:

  1. 不轮换:刷新后返回的仍是同一个刷新令牌,RefreshTokenUsage = OneTimeOnly 不会让它变成一次性的。
  2. 不过期:保存时不记录签发时间与有效期,校验时也不检查;AbsoluteRefreshTokenLifetime、SlidingRefreshTokenLifetime 不参与计算。
  3. 不关联会话:用户登出不会使刷新令牌失效。

刷新令牌相关的其余安全策略也尚在完善中,修复版本发布后本页会同步更新。

在修复版本发布前的建议

  • 只给确有需要、且能保管好密钥的机密客户端开启 AllowOfflineAccess;各客户端的 AllowedScopes 按最小权限配置。
  • 按下文方法给刷新令牌补上有效期。
  • 需要吊销时,直接调用你的 RefreshTokenStore.RemoveRefreshToken(wicket 没有注册 /connect/revocation 端点,该路径的请求会落到宿主 handler)。

存储端口:版本化更新 ​

自定义持久化时,通过 cfg.SetRefreshTokenStore(store) 注入 storage.RefreshTokenStore。wicket 依赖以下语义(完整约定见 存储端口):

方法要求
StoreRefreshToken(ctx, handle, token)只插入。handle 已存在时返回 storage.ErrDuplicateHandle 且不改动原记录;存入的副本版本号为 1,并回写到 token.Version
GetRefreshToken(ctx, handle)返回独立副本;不存在时返回 storage.ErrNotFound(令牌端点据此回 invalid_grant)
UpdateRefreshToken(ctx, handle, token, expectedVersion)乐观并发:仅当存储中的版本等于 expectedVersion 时写入,新版本为 expectedVersion + 1 并回写;否则返回 storage.ErrVersionConflict 且不改动
RemoveRefreshToken(ctx, handle)删除
RemoveExpired(ctx, cutoff)删除“CreationTime + Lifetime 早于 cutoff”的记录,返回删除数量;只回收空间,不参与有效性判断

每次刷新都会调用 UpdateRefreshToken(实测版本号从 1 变为 2)。两个请求并发刷新同一个令牌时,后写入的一方会遇到 ErrVersionConflict,令牌端点把它转换为 invalid_grant。数据库实现通常是 UPDATE ... SET version = version + 1 WHERE handle = ? AND version = ?,再检查受影响行数。

补上有效期之前不要调度 RemoveExpired

由于 v0.1.4 不为刷新令牌记录签发时间与有效期,RemoveExpired(ctx, time.Now()) 会把这些记录全部视为已过期而删除全部刷新令牌。wicket 不自起后台 goroutine,清理任务由宿主调度——在补上有效期(下一节)之前,不要为刷新令牌安排这个清理任务。

补上有效期:包装存储 ​

在任何 RefreshTokenStore 外面包一层,就能在写入时记录签发时间与有效期、在读取时拒绝过期令牌。下面的代码已在实验程序中编译运行:有效期设为 3 秒时,第一次刷新成功,4 秒后再刷新返回 invalid_grant,记录同时被删除。

go
// expiringRefreshTokens 为任意 RefreshTokenStore 补上“签发时间 + 绝对有效期”。
type expiringRefreshTokens struct {
	storage.RefreshTokenStore
	ttl time.Duration
	now func() time.Time
}

func (s *expiringRefreshTokens) StoreRefreshToken(ctx context.Context, handle string, t *models.RefreshToken) error {
	if t.CreationTime.IsZero() {
		t.CreationTime = s.now().UTC()
	}
	if t.Lifetime == 0 {
		t.Lifetime = int(s.ttl / time.Second)
	}
	return s.RefreshTokenStore.StoreRefreshToken(ctx, handle, t)
}

func (s *expiringRefreshTokens) GetRefreshToken(ctx context.Context, handle string) (*models.RefreshToken, error) {
	t, err := s.RefreshTokenStore.GetRefreshToken(ctx, handle)
	if err != nil {
		return nil, err
	}
	if s.now().After(t.CreationTime.Add(time.Duration(t.Lifetime) * time.Second)) {
		_ = s.RefreshTokenStore.RemoveRefreshToken(ctx, handle)
		return nil, storage.ErrNotFound // 令牌端点据此返回 invalid_grant
	}
	return t, nil
}

注入(inner 换成你的数据库实现;开发时可用 storage/impl 包的 NewRefreshTokenStore()):

go
cfg.SetRefreshTokenStore(&expiringRefreshTokens{
	RefreshTokenStore: inner,
	ttl:               30 * 24 * time.Hour,
	now:               time.Now,
})

补上 CreationTime 与 Lifetime 之后,RemoveExpired 的结果也就正确了,可以放心由宿主定时调用。

相关阅读 ​

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