session

package
v1.32.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package session issues and resolves rotating session tokens for first-party apps.

A session is one opaque token "<prefix>s_<body>" (see package token). The token may arrive in a cookie (web) or in the Authorization header (mobile keychain). Resolve is transport-agnostic: it takes the bare token value and never looks at the HTTP request. Only the cookie path runs the CSRF rule in requests.Authenticate, which AuthenticatedRequest.FromCookie distinguishes. When Resolve rotates the token, SetRotated hands the new value back: as a fresh cookie when the request came with one, else in the Session-Token response header (TokenHeader) for clients that keep the token themselves.

A session expires when unused for IdleTTL or MaxTTL after creation. Resolve deletes an expired session it meets; Sweep deletes the rest, and nothing calls it automatically. On the first use after RotateAfter the token is replaced and the old one becomes the previous token. The previous token keeps working for Grace after the rotation while the new token is unused, and for Grace after the new token's first use once it is used, so requests already in flight still succeed; a rotation due before that waits for Grace to end. Presenting the previous token later is treated as theft: the session is deleted. A rotation response lost on the wire therefore costs the user a login: the client still holds only the previous token, and its next request after Grace deletes the session. Resolve rotates when a handler starts and the new token reaches the client only with the response, so Grace must exceed the slowest authenticated handler of the application, and a client that aborts a rotating request before the response arrives costs the user a login the same way.

LastUsedAt is written at most once per TouchEvery, so a busy session does not update its row on every request; idle expiry can fire up to TouchEvery early as a result.

Every write Resolve makes, a delete included, is one conditional statement on the revision the request read (WHERE id = ? AND revision = ?), so concurrent requests on one session cannot overwrite or delete each other's work. A request whose write matches nothing reads the session again and re-runs the rules on what is stored now: of many requests racing past RotateAfter one rotates and the others find their token as the previous one inside Grace, and an expiry or reuse decided on a stale read cannot delete a session in use: a previous token presented after Grace of the rotation, retried after a concurrent first use of the current token, is inside Grace of that first use.

Index

Constants

View Source
const Never = time.Duration(-1)

Never disables the rule of IdleTTL, MaxTTL or RotateAfter. Grace does not accept it.

View Source
const TokenHeader = "Session-Token"

TokenHeader is the response header SetRotated writes the rotated token to for clients that do not use cookies.

Variables

This section is empty.

Functions

func CreateTable

func CreateTable(ctx context.Context, db *bun.DB, table string) error

CreateTable creates the table of NewBunStore when it does not exist, with a unique index on token_hash and plain indexes on previous_hash, subject, expires_at and last_used_at, named after the table with "_token_hash", "_previous_hash", "_subject", "_expires_at" and "_last_used_at" suffixes in the schema of the table. The last two serve Sweep.

Types

type Config

type Config struct {
	// Prefix is the app prefix; tokens are "<Prefix>s_<body>".
	Prefix string
	// Store persists sessions.
	Store Store
	// IdleTTL expires a session unused for this long. Default 30 days. Never
	// means no idle expiry.
	IdleTTL time.Duration
	// MaxTTL expires a session this long after creation regardless of use.
	// Default 90 days. Never means no max expiry.
	MaxTTL time.Duration
	// RotateAfter replaces the token on the first use after this age.
	// Default 1 hour. Never means never rotate.
	RotateAfter time.Duration
	// Grace keeps the previous token valid this long after the rotation
	// while the new token is unused, and this long after the new token's
	// first use once it is used, so requests already in flight from other
	// tabs still succeed. Default 60s. Must be bounded: Never is rejected.
	// The new token reaches the client only with the response of the request
	// that rotated, so Grace must exceed the slowest authenticated handler of
	// the application; a client that aborts a rotating request before the
	// response arrives costs the user a login.
	Grace time.Duration
	// TouchEvery bounds how often LastUsedAt is written: a request that
	// changes nothing else skips the write while the stored value is younger
	// than this. Default 1 minute. Never is rejected, and so is a value not
	// shorter than IdleTTL, which would expire sessions in active use, or
	// shorter than one second, which would write on every request and make
	// concurrent requests on one session fail on the optimistic lock.
	TouchEvery time.Duration
}

Config configures a Manager. A zero duration selects its default.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager issues, resolves and revokes sessions.

func New

func New(cfg Config) (*Manager, error)

New returns a Manager. Prefix must be such that both Prefix and Prefix+"s" pass token.ValidPrefix, Store is required, zero durations take their defaults and negative durations other than Never are rejected, as is Grace set to Never and a TouchEvery under one second or not shorter than a bounded IdleTTL. Returns ez.EINVALID for a bad config.

func (*Manager) ClearCookie

func (m *Manager) ClearCookie(name string) *http.Cookie

ClearCookie returns the cookie that deletes the one Cookie sets: same attributes, empty value, MaxAge -1.

func (*Manager) Cookie

func (m *Manager) Cookie(name, value string) *http.Cookie

Cookie returns an http.Cookie for value: HttpOnly, Secure, SameSite=Lax, Path "/", MaxAge from MaxTTL, or from IdleTTL when MaxTTL is Never. When both are Never it is a session cookie (MaxAge 0, no Expires).

MaxAge follows MaxTTL rather than IdleTTL because the cookie is only sent again when the token rotates: with RotateAfter Never an IdleTTL cookie would log an active user out IdleTTL after login. Resolve still enforces IdleTTL, so a cookie that outlives it is harmless. The remaining limit: with MaxTTL and RotateAfter both Never the cookie expires IdleTTL after login unless the application sends it again.

func (*Manager) Issue

func (m *Manager) Issue(ctx context.Context, subject string) (string, Session, error)

Issue creates a session and returns the token to set in the cookie or hand to the client.

func (*Manager) Resolve

func (m *Manager) Resolve(ctx context.Context, value string) (Session, string, error)

Resolve authenticates a token and returns its session. rotated is the new token when this call replaced it, empty otherwise; hand it to the client with SetRotated. Returns ez.ENOTAUTHENTICATED for a malformed, unknown, expired or reused token.

func (*Manager) Revoke

func (m *Manager) Revoke(ctx context.Context, value string) error

Revoke deletes the session value belongs to. Returns ez.ENOTAUTHENTICATED for a malformed or unknown token.

func (*Manager) RevokeSubject

func (m *Manager) RevokeSubject(ctx context.Context, subject string) error

RevokeSubject deletes every session of subject.

func (*Manager) SetRotated

func (m *Manager) SetRotated(w http.ResponseWriter, cookieName, rotated string, fromCookie bool)

SetRotated hands a rotated token back to the client: as a Set-Cookie of cookieName when the request authenticated with a cookie, else in the Session-Token response header for clients that keep the token themselves, such as a mobile keychain. It does nothing when rotated is "".

func (*Manager) Sweep

func (m *Manager) Sweep(ctx context.Context) (int64, error)

Sweep deletes every session past MaxTTL or unused for IdleTTL and returns how many it deleted. Resolve deletes an expired session it meets, but a session whose token is never presented again (cookies cleared, device lost) stays in the Store until Sweep removes it. Nothing calls Sweep automatically, so run it periodically, for example from a goroutine started with the application:

go func() {
	ticker := time.NewTicker(time.Hour)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			_, err := manager.Sweep(ctx)
			if err != nil {
				logger.Error("sweeping sessions failed", "error", err)
			}
		}
	}
}()

LastUsedAt is written at most once per TouchEvery, so like Resolve, Sweep can delete a session up to TouchEvery before its idle expiry.

type Record

type Record struct {
	Session
	TokenHash    []byte
	PreviousHash []byte
	Revision     int64
}

Record is the stored row: Session plus TokenHash, PreviousHash and Revision. PreviousHash is nil until the first rotation. Revision is the optimistic lock: 0 on insert, incremented by every Update.

type Session

type Session struct {
	ID         string
	Subject    string
	CreatedAt  time.Time
	LastUsedAt time.Time
	RotatedAt  time.Time
	// FirstUsedAt is when the current token was first presented, zero until
	// then.
	FirstUsedAt time.Time
	// ExpiresAt is CreatedAt plus MaxTTL, zero when MaxTTL is Never.
	ExpiresAt time.Time
}

Session is what a resolved token authenticates.

type Store

type Store interface {
	Insert(ctx context.Context, r Record) error
	// FindByHash returns the record whose current token hash or previous
	// token hash equals hash: the Manager decides whether a previous hash is
	// still accepted. ez.ENOTFOUND when there is none.
	FindByHash(ctx context.Context, hash []byte) (Record, error)
	// Update writes every mutable column of r (token hash, previous hash,
	// first used, last used, rotated and expires at) to the
	// session r.ID whose revision is still r.Revision, and sets its revision
	// to r.Revision+1, in one conditional write (UPDATE ... SET revision =
	// r.Revision+1 WHERE id = ? AND revision = ?). A request whose read is
	// stale therefore cannot overwrite a concurrent write, whether that
	// write rotated the token or only recorded its first use. Returns
	// ez.ENOTFOUND when no row matched: the session changed or is gone.
	Update(ctx context.Context, r Record) error
	// Delete removes the session id whatever its revision; a session that is
	// already gone is not an error. Revoke uses it: an explicit revocation
	// wins over any concurrent use.
	Delete(ctx context.Context, id string) error
	// DeleteRevision removes the session id only while its revision is still
	// revision, in one conditional write (DELETE ... WHERE id = ? AND
	// revision = ?). Resolve uses it to delete an expired or reused
	// session, so a request whose read is stale cannot delete a session that a
	// concurrent request has just used. A custom Store must implement it as
	// one atomic compare-and-delete, never as a read followed by Delete.
	// Returns ez.ENOTFOUND when no row matched: the session changed or is
	// gone.
	DeleteRevision(ctx context.Context, id string, revision int64) error
	DeleteBySubject(ctx context.Context, subject string) error
	// DeleteExpired removes every session whose ExpiresAt is set and not
	// after now and, when idleBefore is not zero, every session whose
	// LastUsedAt is not after idleBefore, whatever its revision. A session
	// with a zero ExpiresAt (MaxTTL Never) never matches the first rule.
	// Sweep uses it. Returns how many sessions it removed.
	DeleteExpired(ctx context.Context, now, idleBefore time.Time) (int64, error)
}

Store persists session records.

func NewBunStore

func NewBunStore(db *bun.DB, table string) Store

NewBunStore returns a Store backed by one table with the columns id, subject, token_hash, previous_hash, created_at, last_used_at, rotated_at, first_used_at, expires_at and revision. Call CreateTable with the same table at startup to create it. Expired rows stay in the table until Resolve meets them, Sweep deletes them or the subject is revoked. Nothing calls Sweep automatically; run it periodically as the Manager.Sweep doc shows.

func NewMemoryStore

func NewMemoryStore() Store

NewMemoryStore returns a Store kept in process memory, for tests and single-process deployments. Expired sessions stay until Resolve meets them or Sweep deletes them.

Jump to

Keyboard shortcuts

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