Skip to content

存储端口 ​

wicket 把所有需要持久化的状态抽象成 Go 接口(端口),宿主通过 ServerConfig 注入实现。本页是这些接口的速查:每个接口管什么、有哪些方法、出错时返回什么,以及适配器必须遵守的语义。要动手写一个适配器,请接着看编写存储适配器。

端口分三族,各自有独立的哨兵错误:

族包接口
授权数据github.com/gonewx/wicket/storage7 个授权状态存储 + 消息存储 + 配置类存储
会话github.com/gonewx/wicket/sessionsession.Store
密钥记录github.com/gonewx/wicket/keymgmtkeymgmt.RecordStore

另有一个不属于以上三族、但同样需要注入的 services.DistributedCache,放在本页末尾单独说明。

哨兵错误 ​

包错误含义
storageErrNotFound单条读取未命中
storageErrDuplicateHandleinsert-only 写入撞上已存在的句柄
storageErrVersionConflict乐观并发更新时版本号不匹配
sessionErrSessionNotFound会话不存在
keymgmtErrRecordNotFound密钥记录不存在
keymgmtErrDuplicateKey句柄或未退役的公开 kid 重复
keymgmtErrVersionConflict密钥记录版本冲突

注意三族的“未找到”是三个不同的值:会话存储返回 storage.ErrNotFound 不会被识别。keymgmt 包还定义了 ErrNoSigningKey、ErrAlgorithmUnavailable、ErrConfiguration、ErrProtectorUnavailable,它们由密钥管理器返回,不是存储适配器该用的。判断时一律用 errors.Is,适配器可以包装(fmt.Errorf("...: %w", storage.ErrNotFound))。

契约语义 ​

以下规则对三族端口都成立(DistributedCache 例外,见末尾)。

“不存在”与“故障”必须可区分。 单条读取未命中时返回对应哨兵错误,绝不返回 (nil, nil);数据库连接失败等返回其他错误。否则一次网络抖动会被核心当成“授权码无效”,用户看到登录失败而运维侧没有任何报警。列表类方法没有结果时返回空切片而不是哨兵(storage 族要求非 nil 的空切片;会话族 nil 与空切片都接受)。

insert-only 写入。 以调用方生成的句柄为键的写入只插入、不覆盖,句柄已存在即返回冲突错误且不改动原记录。刷新令牌、授权码的一次性语义依赖这一点;把它实现成 upsert(例如键值库里直接 SET)会让重放防线静默失效。显式声明为 upsert 的只有两个:PersistedGrantStore.Store(按 grant key)和 UserConsentStore.StoreUserConsent(按 subject + client)。

版本号并发控制。 读-改-写类更新带 expectedVersion:只有存储中的版本等于它时才写入,并把版本加一、回写到调用方对象上;否则返回 ErrVersionConflict 且不改动记录。存储层自己不解决冲突。以刷新令牌为例,wicket 核心不会重试,而是把冲突转成令牌端点的 invalid_grant——两个并发刷新请求只有一个成功。

超时由调用方的 ctx 决定。 每个方法第一个参数都是 context.Context,取消与截止时间要一路传到底层驱动。适配器不得自己 context.Background(),也不得设置覆盖调用方的超时。

过期判定在核心侧。 读取时不论记录是否过期都照常返回;不要在查询里追加 expires_at > now() 之类的条件。数据库时钟与应用节点时钟会漂移,统一由核心用注入的时钟判断才一致。有生命周期的数据提供 RemoveExpired / DeleteExpired(ctx, cutoff) 用于回收空间,它只删除早于 cutoff 的记录、返回删除条数,不承担正确性。

不自起后台任务。 适配器不得启动后台 goroutine 或定时器;过期回收由宿主按自己的调度调用上述回收方法。契约套件会检查这一点。

并发安全。 存储实例在容器中是单例,会被所有请求并发调用。

storage 族:授权状态 ​

模型类型位于 github.com/gonewx/wicket/storage/models。

AuthorizationCodeStore ​

go
StoreAuthorizationCode(ctx, handle string, code *models.AuthorizationCode) error // insert-only
GetAuthorizationCode(ctx, handle string) (*models.AuthorizationCode, error)      // 返回独立副本
RemoveAuthorizationCode(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error) // 创建时间 + 有效期 早于 cutoff

RefreshTokenStore ​

go
StoreRefreshToken(ctx, handle string, token *models.RefreshToken) error // insert-only,存储副本版本为 1 并回写
GetRefreshToken(ctx, handle string) (*models.RefreshToken, error)
UpdateRefreshToken(ctx, handle string, token *models.RefreshToken, expectedVersion int) error // 版本控制
RemoveRefreshToken(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)

这是 storage 族中唯一带版本号的接口。

ReferenceTokenStore ​

go
StoreReferenceToken(ctx, handle string, token *models.Token) error // insert-only
GetReferenceToken(ctx, handle string) (*models.Token, error)
RemoveReferenceToken(ctx, handle string) error
RemoveReferenceTokens(ctx, subjectId, clientId string) error // 按 subject + client 批量吊销
RemoveExpired(ctx, cutoff time.Time) (int, error)

UserConsentStore ​

go
StoreUserConsent(ctx, consent *models.Consent) error // upsert:按 subject + client
GetUserConsent(ctx, subjectId, clientId string) (*models.Consent, error)
GetAllUserConsents(ctx, subjectId string) ([]*models.Consent, error)
RemoveUserConsent(ctx, subjectId, clientId string) error
RemoveExpired(ctx, cutoff time.Time) (int, error) // 仅 Expiration 已设置的记录

PersistedGrantStore ​

go
Store(ctx, grant *models.PersistedGrant) error // upsert:按 grant.Key
Get(ctx, key string) (*models.PersistedGrant, error)
GetAll(ctx, filter *storage.PersistedGrantFilter) ([]*models.PersistedGrant, error)
Remove(ctx, key string) error
RemoveAll(ctx, filter *storage.PersistedGrantFilter) error
RemoveExpired(ctx, cutoff time.Time) (int, error)

PersistedGrantFilter 的字段为 SubjectId、SessionId、ClientId、ClientIds、Type、Types,GetAll / RemoveAll 必须精确匹配所有非空条件。

DeviceFlowStore ​

go
StoreDeviceAuthorization(ctx, deviceCode, userCode string, data *models.DeviceCode) error // insert-only,任一 code 冲突即失败
FindByUserCode(ctx, userCode string) (*models.DeviceCode, error)
FindByDeviceCode(ctx, deviceCode string) (*models.DeviceCode, error)
UpdateByUserCode(ctx, userCode string, data *models.DeviceCode) error // 状态迁移,无版本号
RemoveByDeviceCode(ctx, deviceCode string) error // 两个 code 的索引一并清除
RemoveExpired(ctx, cutoff time.Time) (int, error)

BackchannelAuthenticationRequestStore ​

go
StoreBackchannelAuthenticationRequest(ctx, requestID string, data *models.BackchannelAuthenticationRequest) error // insert-only
FindBackchannelAuthenticationRequest(ctx, requestID string) (*models.BackchannelAuthenticationRequest, error)
UpdateBackchannelAuthenticationRequest(ctx, requestID string, data *models.BackchannelAuthenticationRequest) error // 无版本号
RemoveBackchannelAuthenticationRequest(ctx, requestID string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)

设备流与 CIBA 的更新不带版本号:它们的一次性语义由状态字段和删除保证。

删除不存在的记录:wicket 自带的内存实现返回 nil(幂等)。

storage 族:消息与配置 ​

接口方法说明
AuthorizationParametersMessageStoreWrite(ctx, *messages.Message[url.Values]) (string, error)、Read(ctx, handle)、Delete(ctx, handle)跨登录重定向保存授权参数。未注入时参数直接放在回调 URL 中
MessageStore[T]Write、Read内部消息(登出、错误等),v0.1.4 由内置实现承担,无注入缝
ClientStoreFindClientById(ctx, clientId)由 SetClients 数据构造,无独立注入缝
ResourceStoreGetAllEnabledResources、FindResourcesByScope、FindApiResourcesByName、FindApiScopesByName、FindIdentityResourcesByScope由 SetIdentityScopes 等数据构造,无独立注入缝
SigningCredentialStoreGetSigningCredentials(ctx) (*tokens.SigningCredentials, error)见签名密钥
ValidationKeysStoreGetValidationKeys(ctx) ([]*models.SecurityKeyInfo, error)同上

消息与配置类数据没有服务端生命周期,因此没有回收方法。

session 族 ​

go
type Store interface {
    GetSession(ctx, sessionID string) (*session.Record, error) // 未命中返回 ErrSessionNotFound
    CreateSession(ctx, s *session.Record) error                // insert-only
    UpdateSession(ctx, s *session.Record) error
    DeleteSession(ctx, sessionID string) error
    GetSessionsBySubjectID(ctx, subjectID string) ([]*session.Record, error)
    DeleteSessionsBySubjectID(ctx, subjectID string) (int, error)
    AddClientID(ctx, sessionID, clientID string) error // 原子追加并去重
    DeleteExpired(ctx, cutoff time.Time) (int, error)
}

与 storage 族的差异:

  • CreateSession 遇到重复 id 只要求返回非 nil 错误且不改动原记录,没有规定具体哨兵。
  • UpdateSession、DeleteSession、AddClientID 作用于不存在的会话时是无操作,不报错。
  • 没有版本号;AddClientID 的原子性由存储自身保证(例如单条 SQL 的数组追加或事务)。
  • 过期判定在 session.Coordinator.ValidateSession 中用注入的时钟完成,存储只负责保存 Expires。

session.Record 的字段:SessionID、SubjectID、DisplayName、Created、Renewed、Expires、ClientIDs。

keymgmt 族 ​

go
type RecordStore interface {
    List(ctx) ([]*keymgmt.Record, error)
    Get(ctx, handle string) (*keymgmt.Record, error)       // 未命中返回 ErrRecordNotFound
    Create(ctx, record *keymgmt.Record) error               // insert-only,成功后 Version 为 1
    Update(ctx, record *keymgmt.Record, expectedVersion uint64) error
    Delete(ctx, handle string) error                        // 未命中返回 ErrRecordNotFound
}

要点:

  • Create 在句柄重复,或 PublicID(JWKS 中的 kid)与一条未退役记录重复时返回 ErrDuplicateKey;已退役记录的 kid 可以复用。
  • Update、Delete 作用于不存在的记录返回 ErrRecordNotFound——这一点与会话族的“无操作”相反。
  • Version 是 uint64,每次写入加一,与表示私钥保护方案的 Format 字段无关。
  • 所有返回值必须是深拷贝,调用方修改不能影响存储内容。
  • 存储不按生命周期窗口过滤,也没有回收方法:密钥退役由管理器在核心侧推进,存储保留记录直到被显式 Delete。
  • ProtectedPrivateMaterial 是已加密保护的私钥字节,按原样存取即可;PublicMaterial 是明文公钥。

DistributedCache(例外) ​

go
type DistributedCache interface {
    Get(key string) ([]byte, error)
    Set(key string, value []byte, opts *services.DistributedCacheEntryOptions) error
    Remove(key string) error
}

v0.1.4 中它只被设备流的轮询节流使用,契约与上面三族不同:

  • 方法没有 context.Context 参数;
  • 未命中返回 (nil, nil),不是哨兵错误;
  • 缓存自己按 AbsoluteExpiration 判定过期(内置内存实现在读取时淘汰过期项)。

它不在三套契约套件的覆盖范围内。多副本部署时可直接映射到 Redis 一类带 TTL 的键值存储。

相关阅读 ​

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