refresh

package
v0.0.0-...-d1dd459 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 22, 2026 License: MIT Imports: 12 Imported by: 0

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

View Source
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).

View Source
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.

View Source
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.

View Source
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

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.

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

func EncodeOpaque(selector, verifier []byte) string

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

func GeneratePair(rand io.Reader) (selector, verifier []byte, err error)

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

func ParseOpaque(s string) (selector, verifier []byte, ok bool)

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)

func (*GCWorker) Start

func (w *GCWorker) Start(ctx context.Context) error

func (*GCWorker) Stop

func (w *GCWorker) Stop(ctx context.Context) error

type GCWorkerConfig

type GCWorkerConfig struct {
	Store     Store
	Clock     clock.Clock
	Interval  time.Duration
	Retention time.Duration
	Logger    *slog.Logger
	Metrics   GCCollector
}

type NoopGCCollector

type NoopGCCollector struct{}

NoopGCCollector drops all GC observations.

func (NoopGCCollector) ObserveRefreshGC

func (NoopGCCollector) ObserveRefreshGC(context.Context, string, int, time.Duration)

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.

func (Policy) Validate

func (p Policy) Validate() error

Validate returns an error if the Policy contains invalid field values.

MaxAge must be positive. ReuseInterval must not be negative. MaxIdle must be positive. GraceMaxReuses must be positive.

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

func (c *ProviderGCCollector) ObserveRefreshGC(_ context.Context, result string, removed int, duration time.Duration)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL