Documentation
¶
Overview ¶
This file declares the two public error sentinels for refresh.Store.
Do NOT add ErrTokenExpired, ErrTokenRevoked, etc. — enumeration of rejection causes beyond the reuse / non-reuse split is a timing / side-channel oracle. All other diagnostic reasons surface through the structured slog field "reason".
ErrReused is intentionally NOT a subtype of ErrRejected (no wrapping); callers must use errors.Is(err, ErrReused) or errors.Is(err, ErrRejected) as separate, mutually-exclusive branches. See godoc below.
Package refresh provides the server-side opaque refresh token store interface and in-memory implementation used by the accesscore session slices.
Design: append-only lineage with selector/verifier split. Every Issue and every Rotate inserts a new row; nothing is ever updated in place except the one-way flips rotated_at and revoked_at. The wire token returned to clients is base64url(selector_16B) + "." + base64url(verifier_32B) — 66 chars deterministic. Only the selector is indexed; only SHA-256(verifier) is persisted. A DB snapshot therefore contains no credential-equivalent data.
ref: ory/fosite token/hmac/hmacsha.go (base64url nopad, 32 B entropy, hmac.Equal) ref: ory/hydra persistence/sql/persister_oauth2.go (CAS chain + reuse detection) ref: zitadel/zitadel internal/api/oidc/token_refresh.go (revoke-on-use baseline)
Index ¶
- Constants
- Variables
- func EncodeOpaque(selector, verifier []byte) string
- func GeneratePair(rand io.Reader) (selector, verifier []byte, err error)
- func ParseOpaque(s string) (selector, verifier []byte, ok bool)
- type GCCollector
- type GCWorker
- type GCWorkerConfig
- type NoopGCCollector
- type Policy
- type ProviderGCCollector
- type Store
- type Token
Constants ¶
const ( // DefaultMaxIdle is the standard idle-expiry window (30 days). // Matches Zitadel auth-token default retention period. // ref: zitadel/zitadel internal/repository/session expire.go DefaultMaxIdle = 30 * 24 * time.Hour // DefaultGraceMaxReuses is the standard grace reuse counter cap (3 re-uses). // Tolerates SPA double-submit + network retry within a grace window without // treating them as reuse attacks. Exceeding the cap triggers cascade revoke. // ref: ory/fosite handler/oauth2/refresh.go (COALESCE reuse guard) DefaultGraceMaxReuses = 3 // CascadeRevokeTimeout is the maximum time allowed for a cascade-revoke DB // write that runs detached from the caller's cancellation context. // // Cascade revoke is a security response (reuse-attack or subject-mismatch) // that MUST persist even when the HTTP request that triggered it is canceled // or times out. The detached context is constructed via // pkg/ctxutil.WithDetachedTimeout so the write gets its own 5-second budget // independent of the caller's deadline. // // ref: golang/go context.WithoutCancel (proposal#40221) // ref: hashicorp/vault vault/token_store.go quitContext (detached critical write) // ref: ADR docs/architecture/202605051800-adr-refresh-store-ambient-tx-and-idle-grace.md CascadeRevokeTimeout = 5 * time.Second )
Default policy values. Callers must set these explicitly — Validate does not apply implicit defaults (must be set explicitly by callers; no implicit defaults applied by Validate).
const SelectorLen = 16
SelectorLen is the raw byte length of the selector half.
16 bytes = 128 bits of search space. Collision probability across a population of N live tokens ≈ N² / 2^129 — for any realistic N this is negligible. Matches the OWASP selector+verifier pattern recommendation.
const VerifierLen = 32
VerifierLen is the raw byte length of the verifier half.
32 bytes = 256 bits of entropy; preimage resistance of SHA-256(verifier) against a DB snapshot is 2^256. Matches ory/fosite minimum entropy.
const WireLen = 22 + 1 + 43
WireLen is the deterministic length of the encoded wire token: base64url_nopad(16B) = 22 chars, "." = 1, base64url_nopad(32B) = 43 chars.
Variables ¶
var ErrRejected = errcode.New( errcode.KindUnauthenticated, errcode.ErrRefreshTokenRejected, "refresh token rejected", errcode.WithCategory(errcode.CategoryAuth), )
ErrRejected is the sentinel returned by Store implementations for every unhappy Peek/Rotate path that is NOT a reuse attack (malformed wire, unknown selector, verifier mismatch, expired, revoked). Callers map it to HTTP 401 via pkg/httputil and must not distinguish between causes — the distinction is observable through the structured slog field "reason", never through the error shape.
Rationale: collapsing NotFound/Expired/Revoked into one public sentinel eliminates enumeration and timing side-channels in the refresh endpoint. Operations retain full diagnostic fidelity through the slog "reason" attribute (malformed | selector_miss | verifier_miss | expired | revoked | rotated_beyond_grace | idle_expired).
CategoryAuth — the HTTP 401 mapping is enforced by pkg/errcode/classify.go.
errors.Is chain: ErrRejected does NOT wrap ErrReused. The two sentinels are independent; errors.Is(ErrRejected, ErrReused) == false.
var ErrReused = errcode.New( errcode.KindUnauthenticated, errcode.ErrRefreshTokenReused, "refresh token reused", errcode.WithCategory(errcode.CategoryAuth), )
ErrReused is returned by Store implementations when a refresh token that has already been consumed (rotated_at IS NOT NULL, i.e. it was previously rotated) is re-presented beyond the Policy.ReuseInterval grace window. This is a confirmed token-reuse attack signal.
Callers MUST use errors.Is(err, ErrReused) to detect the reuse case and trigger cascade revoke + epoch bump. Other rejection causes (malformed, expired, revoked by logout) return ErrRejected and must NOT trigger the cascade-revoke side-effect.
errors.Is chain: ErrReused does NOT wrap ErrRejected. The two sentinels are independent:
errors.Is(err, ErrReused) == true → confirmed reuse attack errors.Is(err, ErrRejected) == false → not a plain rejection
CategoryAuth — maps to HTTP 401 like ErrRejected.
Functions ¶
func EncodeOpaque ¶
EncodeOpaque renders selector || separator || verifier as a URL-safe, base64 no-padding string. Callers hand this to clients verbatim.
Panics if either half is nil — callers must have both halves from crypto/rand before reaching this point.
func GeneratePair ¶
GeneratePair reads SelectorLen + VerifierLen random bytes from rand. Returns distinct buffers; never returns the same buffer twice.
Both memstore and the postgres adapter delegate to this helper (F10) so the generation logic lives in exactly one place.
func ParseOpaque ¶
ParseOpaque is the strict inverse of EncodeOpaque. Returns ok=false for any deviation — wrong number of dots, wrong base64, wrong length for either half. Callers map !ok to ErrRejected with slog reason "malformed".
Rationale: a single uniform rejection path for every shape defect makes parse failures indistinguishable from unknown-selector DB misses at both the error-shape level (both return ErrRejected) and the code-path level (both perform no DB traffic before deciding).
Types ¶
type GCCollector ¶
type GCCollector interface {
ObserveRefreshGC(ctx context.Context, result string, removed int, duration time.Duration)
}
GCCollector records refresh-token GC outcomes. Alert on sustained auth_refresh_gc_runs_total{result="failure"} growth.
type GCWorker ¶
type GCWorker struct {
// contains filtered or unexported fields
}
func NewGCWorker ¶
func NewGCWorker(cfg GCWorkerConfig) (*GCWorker, error)
type GCWorkerConfig ¶
type NoopGCCollector ¶
type NoopGCCollector struct{}
NoopGCCollector drops all GC observations.
func (NoopGCCollector) ObserveRefreshGC ¶
type Policy ¶
type Policy struct {
ReuseInterval time.Duration
MaxAge time.Duration
MaxIdle time.Duration
GraceMaxReuses int
}
Policy controls Rotate semantics and token lifetime.
ReuseInterval is the grace window: if a parent token is presented a second time within this duration after it was rotated, the store issues another child rather than flagging a reuse attack. This absorbs legitimate double-submissions from SPAs and at-least-once client retries.
MaxAge bounds Token.ExpiresAt - Token.CreatedAt.
MaxIdle is the sliding-window idle-expiry duration. Issue and Rotate write the newly issued row's idle deadline as now + MaxIdle. A token row that is not rotated before its idle deadline is rejected as idle-expired. Must be positive; use DefaultMaxIdle for the standard 30-day window.
GraceMaxReuses caps how many times a parent token may be re-presented within the ReuseInterval grace window. Once the cap is reached, the next re-present triggers a cascade revoke (same as an out-of-window reuse attack). Must be positive; use DefaultGraceMaxReuses for the standard cap of 3.
All four fields are required; Validate returns an error for any non-positive or negative value.
type ProviderGCCollector ¶
type ProviderGCCollector struct {
// contains filtered or unexported fields
}
ProviderGCCollector records refresh GC metrics through the kernel metrics API.
func NewProviderGCCollector ¶
func NewProviderGCCollector(p metrics.Provider) (*ProviderGCCollector, error)
func (*ProviderGCCollector) ObserveRefreshGC ¶
type Store ¶
type Store interface {
// Issue creates a new refresh chain for (sessionID, subjectID). The
// Store generates an opaque wire token of the form
// base64url(selector_16B) "." base64url(verifier_32B) and persists only
// (selector, sha256(verifier)). Token.ExpiresAt = now + Policy.MaxAge.
//
// authzEpochAtIssue is the snapshot of users.authz_epoch at issue time;
// it is persisted on the row and returned to validate paths so that
// sessionrefresh can reject stale grants (S4d). Zero is invalid:
// implementations return ErrValidationFailed (storetest conformance
// T-S4D-2 enforces).
//
// Consistency: L1 LocalTx — single INSERT, no outbox event.
Issue(ctx context.Context, sessionID, subjectID string, authzEpochAtIssue int64) (wire string, tok *Token, err error)
// Peek validates the presented wire token and returns the metadata for the
// currently-presented row without issuing a child and without flipping
// rotated_at. Callers use this for no-side-effect preflight checks before
// deciding whether to commit a rotation.
//
// Return shape matches the package-level vocabulary above:
// - valid token → (*Token{SubjectID, SessionID, ...}, nil)
// - reuse detected → (*Token{SubjectID, SessionID, ...}, ErrReused)
// - any other rejection → (nil, ErrRejected)
//
// Implementations MUST still commit the per-session cascade-revoke before
// returning ErrReused — that is a security response and persists
// regardless of caller transaction outcome. The token returned alongside
// ErrReused conveys *only* the row identity (SubjectID, SessionID, ID,
// CreatedAt, ExpiresAt); it is not a usable refresh credential.
//
// Consistency: L1 LocalTx — read-only for valid tokens; reuse-detection
// cascade revoke is committed before returning ErrReused.
Peek(ctx context.Context, presentedWire string) (tok *Token, err error)
// Rotate consumes the presented wire token and advances the chain by
// issuing a new child. Returns the new wire token alongside its
// metadata on success.
//
// Branches (return shape matches the package-level vocabulary):
//
// - active happy path: presented token is the current live row →
// INSERT child, flip parent rotated_at, return new wire +
// (*Token{...}, nil).
// - grace retry: parent's rotated_at was already set but the retry
// arrived within Policy.ReuseInterval → INSERT another child,
// return a distinct new wire + (*Token{...}, nil). Preserves
// idempotency for SPA double-submit without weakening reuse
// detection.
// - reuse detection: parent's rotated_at was set beyond
// Policy.ReuseInterval, OR grace counter cap exhausted →
// cascade-revoke the entire session_id lineage, emit slog Error
// "reuse_detected", return ("", *Token{SubjectID, SessionID, ...},
// ErrReused). The token conveys row identity for the service-layer
// user-wide invalidation cascade.
// - malformed / unknown selector / verifier mismatch / expired /
// revoked: return ("", nil, ErrRejected) with corresponding slog
// "reason".
//
// Consistency: L1 LocalTx — INSERT child + UPDATE parent within one
// transaction; reuse-detection cascade revoke is committed before
// returning ErrReused.
Rotate(ctx context.Context, presentedWire string) (wire string, tok *Token, err error)
// RevokeSession marks every row in the session_id lineage as revoked.
// Idempotent — 0 rows affected is not an error. Called by business flows
// such as logout, where refresh-chain revoke must share the caller's
// transaction boundary with session state and outbox writes.
//
// Consistency: L1 LocalTx — single UPDATE.
RevokeSession(ctx context.Context, sessionID string) error
// RevokeSessionDetached marks every row in the session_id lineage as
// revoked outside the caller's ambient transaction/cancellation boundary.
// It is reserved for security/compensation paths where the revoke must
// persist even if the triggering request is canceled or the surrounding
// business transaction rolls back.
//
// Consistency: L1 LocalTx — single UPDATE committed independently by
// durable implementations. In-memory implementations may share the same
// critical section as RevokeSession.
RevokeSessionDetached(ctx context.Context, sessionID string) error
// RevokeUser marks every row belonging to subjectID as revoked.
// Called by user-delete, user-lock, and change-password flows to
// invalidate every refresh chain owned by the subject in one atomic
// statement. Idempotent. There is intentionally no detached RevokeUser:
// these user-level revokes are business state transitions that must remain
// atomic with user/session mutations and related outbox writes. Session-
// level cascade revoke is split because it also serves security responses
// to token reuse and compensating cleanup.
//
// Consistency: L1 LocalTx — single UPDATE.
RevokeUser(ctx context.Context, subjectID string) error
// GC removes rows whose effective expiry has passed olderThan. Effective
// expiry is the earlier of expires_at (hard cap) and idle_expires_at
// (sliding window driven by Policy.MaxIdle). Implementations MUST sweep on
// the LEAST(expires_at, idle_expires_at) so an idle-abandoned chain is
// reclaimed without waiting for the hard MaxAge horizon.
//
// Safe to run from a background worker; batched with SKIP LOCKED to avoid
// contending with active Rotate traffic.
//
// Consistency: L0 LocalOnly — best-effort cleanup.
GC(ctx context.Context, olderThan time.Time) (removed int, err error)
}
Store persists refresh token chains with CAS-protected Rotate and reuse detection. Implementations MUST honor the append-only lineage model: Issue and Rotate only INSERT rows; rotated_at and revoked_at are one-way timestamp flips; verifier_hash is never updated in place.
Error vocabulary (Peek / Rotate):
- happy / grace-retry → (non-nil *Token, nil)
- reuse detected → (non-nil *Token, ErrReused) — Token MUST carry SubjectID and SessionID so callers can drive a user-wide credential-invalidation cascade (epoch bump + revoke other sessions + revoke other refresh chains). The single-session cascade-revoke is committed by the store before return; user-wide invalidation is the service layer's responsibility and depends on this metadata.
- any other rejection (malformed / selector_miss / verifier_miss / revoked / expired / idle_expired) → (nil, ErrRejected). Internal diagnostic reasons surface through the slog structured field "reason", not through error shape (enumeration / timing side-channel defense).
Returning (nil, ErrReused) is a contract violation — the service layer would silently lose the cross-session cascade. The runtime/auth/refresh/ storetest conformance suite enforces this invariant against every implementation.
ref: ory/fosite token/hmac/hmacsha.go (base64url nopad + hmac.Equal) ref: ory/hydra persistence/sql/persister_oauth2.go (CAS + grace + chain revoke) ref: keycloak TokenManager — reuse → full session-scoped revocation
type Token ¶
type Token struct {
ID uuid.UUID
SessionID string
SubjectID string
CreatedAt time.Time
ExpiresAt time.Time
// AuthzEpochAtIssue snapshots users.authz_epoch at the moment this
// refresh row was inserted (Issue / Rotate child). sessionrefresh rejects
// a presented token whose AuthzEpochAtIssue != current users.authz_epoch
// via an independent stale-epoch code path: the service's
// rejectIfStaleEpoch runs in refreshInTx **before** calling Rotate,
// detects the mismatch, and invokes `cascadeRevoke("stale-epoch")`
// directly for a session-scoped revocation. This is separate from
// handleReuseDetected, which is the reuse-attack cascade entry triggered
// from Peek and Rotate (user-wide Invalidator.Apply). Without this
// column, refresh re-mints access tokens with live user.epoch and stale
// grants "upgrade" to current epoch (PR #490 review P1-#2).
//
// ADR-credential §A6 (stale-epoch path) + §A8 (row-level credential
// provenance).
AuthzEpochAtIssue int64
}
Token is the persisted refresh token metadata returned by Issue and Rotate.
The wire token (the opaque string clients present) is returned separately from Issue/Rotate as a string; Token itself carries only server-side identity and lifetime, never the verifier or any credential-equivalent value.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore provides an in-memory implementation of refresh.Store.
|
Package memstore provides an in-memory implementation of refresh.Store. |
|
Package storetest provides a reusable contract test suite for refresh.Store implementations.
|
Package storetest provides a reusable contract test suite for refresh.Store implementations. |