Refresh tokens
This page explains how a client obtains a refresh token and uses it to renew access tokens, and what wicket v0.1.4 actually does and does not do regarding rotation, expiration, and so on. The latter directly affects your security design, so please read the whole page.
Two conditions for getting a refresh token
- The client sets
AllowOfflineAccess = true; - The
scopeof the authorization request includesoffline_access.
web.AllowOfflineAccess = true
web.AllowedScopes = []string{"openid", "profile", "orders.read"} // no need to list offline_access hereoffline_access is a built-in scope. You don't need to define it in an IdentityResource or ApiScope; the discovery document's scopes_supported lists it automatically. Observed results:
| Case | Result |
|---|---|
AllowOfflineAccess = true, request includes offline_access | Token response contains refresh_token |
AllowOfflineAccess = true, request does not include offline_access | refresh_token is an empty string |
AllowOfflineAccess = false, request includes offline_access | Rejected by the authorization endpoint (invalid_scope, redirected to the error page) |
Refresh token issuance has been tested for both the authorization code and CIBA flows. The client credentials flow has no user, so do not enable AllowOfflineAccess for it.
Exchange a refresh token for new tokens
curl -s -u web-app:web-secret \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
http://localhost:5000/connect/tokenObserved response (truncated):
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtp...",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtp...",
"refresh_token": "2AD2B6B67EB04056098359C8...",
"scope": "openid orders.read offline_access",
"token_type": "Bearer"
}- When the original request included
openid, refreshing also returns a newid_token. - The
scopeparameter in refresh requests is ignored in v0.1.4: in testing, refreshing withscope=openidstill returned all of the original scopes. - An invalid or nonexistent refresh token returns
invalid_grant.
Refresh token fields on Client
Fields in storage/models/client.go and their NewClient() defaults:
| Field | NewClient() default | Effective in v0.1.4? |
|---|---|---|
AllowOfflineAccess | false | Yes |
UpdateAccessTokenClaimsOnRefresh | false | Yes (see below) |
RefreshTokenUsage | wicket.TokenUsageOneTimeOnly | No |
RefreshTokenExpiration | wicket.TokenExpirationAbsolute | No |
AbsoluteRefreshTokenLifetime | 2592000 (30 days) | No |
SlidingRefreshTokenLifetime | 1296000 (15 days) | No |
Use NewClient() rather than a literal
The zero values of TokenUsage and TokenExpiration are TokenUsageOneTimeOnly and TokenExpirationSliding respectively. Creating a client with a models.Client{} literal gives you defaults different from NewClient() (and also misses Enabled = true and others). Always start from NewClient().
UpdateAccessTokenClaimsOnRefresh:
false(default): reuses the access token contents saved at the previous issuance, updates only the issue time and lifetime, and re-signs. Changes to user profile data are not reflected in the new token.true: regenerates the access token claims from the user principal saved in the refresh token.
What v0.1.4 implements (important)
v0.1.4 does not yet implement refresh token expiration or rotation policies. Do not rely on the fields marked "No" in the table above. Specifically:
- No rotation: refreshing returns the same refresh token;
RefreshTokenUsage = OneTimeOnlydoes not make it one-time use. - No expiration: the issue time and lifetime are not recorded when the token is saved, nor checked during validation;
AbsoluteRefreshTokenLifetimeandSlidingRefreshTokenLifetimeplay no part. - Not tied to sessions: signing the user out does not invalidate refresh tokens.
Other refresh token security policies are also still being completed. This page will be updated when a fixed version is released.
Recommendations until a fixed version is released
- Enable
AllowOfflineAccessonly for confidential clients that genuinely need it and can keep their secret safe; configure each client'sAllowedScopeswith least privilege. - Add a lifetime to refresh tokens using the method below.
- To revoke, call your
RefreshTokenStore.RemoveRefreshTokendirectly (wicket does not register a/connect/revocationendpoint; requests to that path fall through to the host handler).
Storage port: versioned updates
For custom persistence, inject a storage.RefreshTokenStore with cfg.SetRefreshTokenStore(store). wicket relies on the following semantics (for the full contract, see Storage ports):
| Method | Requirement |
|---|---|
StoreRefreshToken(ctx, handle, token) | Insert only. If handle already exists, return storage.ErrDuplicateHandle without modifying the existing record; the stored copy has version 1, which is written back to token.Version |
GetRefreshToken(ctx, handle) | Returns an independent copy; returns storage.ErrNotFound when not found (the token endpoint responds with invalid_grant based on this) |
UpdateRefreshToken(ctx, handle, token, expectedVersion) | Optimistic concurrency: writes only if the stored version equals expectedVersion; the new version is expectedVersion + 1 and is written back; otherwise returns storage.ErrVersionConflict without modifying anything |
RemoveRefreshToken(ctx, handle) | Delete |
RemoveExpired(ctx, cutoff) | Deletes records where "CreationTime + Lifetime is earlier than cutoff" and returns the number deleted; only reclaims space and plays no part in validity checks |
Every refresh calls UpdateRefreshToken (in testing, the version went from 1 to 2). When two requests refresh the same token concurrently, the one that writes later gets ErrVersionConflict, which the token endpoint converts to invalid_grant. A database implementation is typically UPDATE ... SET version = version + 1 WHERE handle = ? AND version = ?, followed by a check of the affected row count.
Don't schedule RemoveExpired until you add a lifetime
Because v0.1.4 does not record the issue time and lifetime for refresh tokens, RemoveExpired(ctx, time.Now()) treats all of these records as expired and deletes every refresh token. wicket does not start background goroutines of its own; cleanup jobs are scheduled by the host. Until you add a lifetime (next section), do not schedule this cleanup job for refresh tokens.
Adding a lifetime: wrap the store
Wrap any RefreshTokenStore to record the issue time and lifetime on write and reject expired tokens on read. The code below has been compiled and run in the experimental program: with a lifetime of 3 seconds, the first refresh succeeded, and refreshing again 4 seconds later returned invalid_grant, with the record deleted at the same time.
// expiringRefreshTokens adds "issue time + absolute lifetime" to any 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 // the token endpoint returns invalid_grant based on this
}
return t, nil
}Injection (replace inner with your database implementation; for development you can use NewRefreshTokenStore() from the storage/impl package):
cfg.SetRefreshTokenStore(&expiringRefreshTokens{
RefreshTokenStore: inner,
ttl: 30 * 24 * time.Hour,
now: time.Now,
})Once CreationTime and Lifetime are filled in, RemoveExpired also gives correct results, and the host can safely call it on a schedule.
Related reading
- Authorization code + PKCE: where refresh tokens come from
- Storage ports: the contract for all storage interfaces
- Writing a storage adapter: implementation notes for versioned updates
- Production deployment checklist