Storage ports
Wicket abstracts all state that needs to be persisted into Go interfaces (ports), and the host injects implementations through ServerConfig. This page is a quick reference for these interfaces: what each one manages, which methods it has, what it returns on errors, and the semantics adapters must follow. To actually write an adapter, continue with Writing a storage adapter.
The ports fall into three families, each with its own sentinel errors:
| Family | Package | Interfaces |
|---|---|---|
| Authorization data | github.com/gonewx/wicket/storage | 7 authorization state stores + message store + configuration stores |
| Session | github.com/gonewx/wicket/session | session.Store |
| Key records | github.com/gonewx/wicket/keymgmt | keymgmt.RecordStore |
There's also services.DistributedCache, which doesn't belong to any of the three families but must be injected as well; it's covered separately at the end of this page.
Sentinel errors
| Package | Error | Meaning |
|---|---|---|
storage | ErrNotFound | A single-record read found nothing |
storage | ErrDuplicateHandle | An insert-only write hit an existing handle |
storage | ErrVersionConflict | Version mismatch during an optimistic concurrency update |
session | ErrSessionNotFound | The session doesn't exist |
keymgmt | ErrRecordNotFound | The key record doesn't exist |
keymgmt | ErrDuplicateKey | Duplicate handle or duplicate public kid of a non-retired record |
keymgmt | ErrVersionConflict | Key record version conflict |
Note that "not found" is three different values across the three families: a session store returning storage.ErrNotFound won't be recognized. The keymgmt package also defines ErrNoSigningKey, ErrAlgorithmUnavailable, ErrConfiguration, and ErrProtectorUnavailable; these are returned by the key manager and aren't for storage adapters to use. Always check with errors.Is; adapters may wrap them (fmt.Errorf("...: %w", storage.ErrNotFound)).
Contract semantics
The following rules hold for all three families of ports (DistributedCache is an exception; see the end).
"Not found" and "failure" must be distinguishable. When a single-record read finds nothing, return the corresponding sentinel error, never (nil, nil); return other errors for things like a failed database connection. Otherwise a network blip is treated by the core as "invalid authorization code": the user sees a login failure, and operations get no alert at all. List methods with no results return an empty slice rather than a sentinel (the storage family requires a non-nil empty slice; the session family accepts either nil or an empty slice).
Insert-only writes. Writes keyed by a caller-generated handle only insert and never overwrite; if the handle already exists, return a conflict error and leave the original record unchanged. The single-use semantics of refresh tokens and authorization codes depend on this; implementing it as an upsert (for example a plain SET in a key-value store) silently disables replay protection. Only two methods are explicitly declared as upserts: PersistedGrantStore.Store (by grant key) and UserConsentStore.StoreUserConsent (by subject + client).
Version-based concurrency control. Read-modify-write updates carry an expectedVersion: write only if the stored version equals it, increment the version, and write it back to the caller's object; otherwise return ErrVersionConflict and leave the record unchanged. The storage layer doesn't resolve conflicts itself. For refresh tokens, for example, the Wicket core doesn't retry; it turns the conflict into invalid_grant at the token endpoint—of two concurrent refresh requests, only one succeeds.
Timeouts are determined by the caller's ctx. The first parameter of every method is a context.Context, and cancellation and deadlines must be passed all the way down to the underlying driver. Adapters must not create their own context.Background(), nor set timeouts that override the caller's.
Expiry is decided on the core side. Reads return records as usual whether or not they've expired; don't add conditions such as expires_at > now() to queries. Database clocks and application node clocks drift, so expiry is consistent only when the core decides it using the injected clock. Data with a lifetime provides RemoveExpired / DeleteExpired(ctx, cutoff) for reclaiming space; it deletes only records older than cutoff, returns the number deleted, and isn't responsible for correctness.
No self-started background tasks. Adapters must not start background goroutines or timers; the host calls the reclamation methods above on its own schedule. The conformance suite checks this.
Concurrency safety. Store instances are singletons in the container and are called concurrently by all requests.
storage family: authorization state
The model types live in github.com/gonewx/wicket/storage/models.
AuthorizationCodeStore
StoreAuthorizationCode(ctx, handle string, code *models.AuthorizationCode) error // insert-only
GetAuthorizationCode(ctx, handle string) (*models.AuthorizationCode, error) // returns an independent copy
RemoveAuthorizationCode(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error) // creation time + lifetime earlier than cutoffRefreshTokenStore
StoreRefreshToken(ctx, handle string, token *models.RefreshToken) error // insert-only; stored copy gets version 1, written back
GetRefreshToken(ctx, handle string) (*models.RefreshToken, error)
UpdateRefreshToken(ctx, handle string, token *models.RefreshToken, expectedVersion int) error // version-controlled
RemoveRefreshToken(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)This is the only interface in the storage family with a version number.
ReferenceTokenStore
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 // bulk revocation by subject + client
RemoveExpired(ctx, cutoff time.Time) (int, error)UserConsentStore
StoreUserConsent(ctx, consent *models.Consent) error // upsert: by 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) // only records with Expiration setPersistedGrantStore
Store(ctx, grant *models.PersistedGrant) error // upsert: by 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)The fields of PersistedGrantFilter are SubjectId, SessionId, ClientId, ClientIds, Type, Types; GetAll / RemoveAll must match all non-empty conditions exactly.
DeviceFlowStore
StoreDeviceAuthorization(ctx, deviceCode, userCode string, data *models.DeviceCode) error // insert-only; fails if either code conflicts
FindByUserCode(ctx, userCode string) (*models.DeviceCode, error)
FindByDeviceCode(ctx, deviceCode string) (*models.DeviceCode, error)
UpdateByUserCode(ctx, userCode string, data *models.DeviceCode) error // state transition, no version number
RemoveByDeviceCode(ctx, deviceCode string) error // clears the indexes for both codes
RemoveExpired(ctx, cutoff time.Time) (int, error)BackchannelAuthenticationRequestStore
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 // no version number
RemoveBackchannelAuthenticationRequest(ctx, requestID string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)Updates in the device flow and CIBA carry no version number: their single-use semantics are guaranteed by status fields and deletion.
Deleting a record that doesn't exist: Wicket's built-in in-memory implementations return nil (idempotent).
storage family: messages and configuration
| Interface | Methods | Description |
|---|---|---|
AuthorizationParametersMessageStore | Write(ctx, *messages.Message[url.Values]) (string, error), Read(ctx, handle), Delete(ctx, handle) | Preserves authorization parameters across the login redirect. When not injected, the parameters go directly into the callback URL |
MessageStore[T] | Write, Read | Internal messages (logout, errors, etc.); in v0.1.4 handled by built-in implementations, with no injection seam |
ClientStore | FindClientById(ctx, clientId) | Built from the SetClients data; no separate injection seam |
ResourceStore | GetAllEnabledResources, FindResourcesByScope, FindApiResourcesByName, FindApiScopesByName, FindIdentityResourcesByScope | Built from SetIdentityScopes and related data; no separate injection seam |
SigningCredentialStore | GetSigningCredentials(ctx) (*tokens.SigningCredentials, error) | See Signing keys |
ValidationKeysStore | GetValidationKeys(ctx) ([]*models.SecurityKeyInfo, error) | Same as above |
Message and configuration data have no server-side lifetime, so there are no reclamation methods.
session family
type Store interface {
GetSession(ctx, sessionID string) (*session.Record, error) // returns ErrSessionNotFound when not found
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 // atomic append with deduplication
DeleteExpired(ctx, cutoff time.Time) (int, error)
}Differences from the storage family:
- On a duplicate id,
CreateSessionis only required to return a non-nil error and leave the original record unchanged; no specific sentinel is prescribed. UpdateSession,DeleteSession, andAddClientIDare no-ops on a session that doesn't exist; they don't return an error.- There's no version number; the atomicity of
AddClientIDis guaranteed by the store itself (for example a single-statement SQL array append, or a transaction). - Expiry is decided in
session.Coordinator.ValidateSessionusing the injected clock; the store only savesExpires.
Fields of session.Record: SessionID, SubjectID, DisplayName, Created, Renewed, Expires, ClientIDs.
keymgmt family
type RecordStore interface {
List(ctx) ([]*keymgmt.Record, error)
Get(ctx, handle string) (*keymgmt.Record, error) // returns ErrRecordNotFound when not found
Create(ctx, record *keymgmt.Record) error // insert-only; Version is 1 on success
Update(ctx, record *keymgmt.Record, expectedVersion uint64) error
Delete(ctx, handle string) error // returns ErrRecordNotFound when not found
}Key points:
CreatereturnsErrDuplicateKeywhen the handle is a duplicate, or when thePublicID(the kid in the JWKS) duplicates that of a non-retired record; the kid of a retired record may be reused.UpdateandDeleteon a record that doesn't exist returnErrRecordNotFound—the opposite of the session family's "no-op".Versionis auint64incremented on every write, and is unrelated to theFormatfield, which describes the private key protection scheme.- All return values must be deep copies; caller modifications must not affect stored contents.
- The store doesn't filter by lifetime window and has no reclamation method: key retirement is driven by the manager on the core side, and the store keeps records until they're explicitly
Deleted. ProtectedPrivateMaterialis the private key bytes, already encrypted and protected; store and return them as-is.PublicMaterialis the plaintext public key.
DistributedCache (the exception)
type DistributedCache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, opts *services.DistributedCacheEntryOptions) error
Remove(key string) error
}In v0.1.4 it's used only for device flow polling throttling, and its contract differs from the three families above:
- The methods take no
context.Contextparameter; - A miss returns
(nil, nil), not a sentinel error; - The cache decides expiry itself based on
AbsoluteExpiration(the built-in in-memory implementation evicts expired entries on read).
It isn't covered by the three conformance suites. In a multi-replica deployment, you can map it directly to a key-value store with TTLs, such as Redis.