sessions

package
v13.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 30, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

Documentation

Overview

Package sessions keeps session state on the server and gives the client only an identifier.

That division is the whole point. A cookie carrying the state itself has to be signed, has to be encrypted if any of it is private, cannot be revoked before it expires, and grows with whatever anyone thought to put in it. A cookie carrying a 256-bit random identifier can be revoked by deleting one row, tells an attacker who reads it nothing, and never grows.

store, _ := sessions.NewStore(backend, sessions.WithIdleTimeout(30*time.Minute))

session, _ := store.New(ctx, &Principal{UserID: "u_123"})
// hand session.ID to the client; sessions/http puts it in a signed cookie
// — and see below for NewFor, which is the one a sign-in should call

session, err := store.Get(ctx, id)   // ErrNotFound / ErrExpired if it is over

The three layers

A Store is what callers hold: identifiers in, payloads out, expiry enforced. A Backend is where the records physically live — sessions/cache over any cache.Cache, sessions/database over SQL. sessions/http binds a Store to a signed cookie and to net/http.

The split between Store and Backend is not ceremony. The parts of a session store that are easy to get subtly wrong are the same parts in every backend: what Renew preserves, when a session counts as expired, whether a request that read a session just before sign-out can write it back afterwards. Written once in Store, they cannot differ between backends; written per backend, one of them would eventually be wrong, and the wrong one would still pass its tests.

Two timeouts

Idle asks how long a user may walk away and come back. Absolute asks how long a session may exist at all — which is the only bound on a cookie somebody stole, because a thief is not idle. Either may be disabled; both may not.

Session.ExpiresAt is the earlier of the two, and is what a cookie's lifetime should be derived from so that the browser and the store agree on when the session ended.

Touching, and what it costs

An idle timeout means every read is also a write, which at any real request rate is a lot of writes to say the same thing. Policy.Touch is how much of the idle window must elapse before a read bothers: with a thirty-minute idle timeout and a one-minute touch interval, one write per minute per active session instead of one per request.

The precision that buys back is a session whose idle deadline may be up to one interval stale — so it expires up to one interval *early*, never late. Early is the safe direction for a security control, which is the only reason the trade is on offer. Set Touch to zero to refresh on every read.

The store decides expiry, not the backend

A backend is asked to keep each record until its deadline plus a grace period, and the store refuses the record the moment the deadline passes. So the backing store's own expiry is a garbage collector rather than a security control.

That is not incidental. Left to the backend, expiry would be evaluated by a redis server's clock or a row's timestamp instead of by the clock the policy was written against and a test can move; a shortened timeout would not apply to sessions already in flight; and a record already reclaimed cannot be told apart from one that never existed, so "you idled out" and "no such session" would be the same answer. The grace period costs retained bytes for expired sessions and buys all three back. Set it to zero with WithRetentionGrace to give up the distinction and reclaim at the deadline.

Which sessions do I hold, and how do I end the others

A session established through New is held by nobody: it is reachable by its identifier and by nothing else, which is what an anonymous session is. NewFor establishes one held by somebody, and that is what a sign-in should call.

holder := sessions.Holder{Scope: tenancy.Of(accountID), Principal: userID}

session, _ := store.NewFor(ctx, holder, sessions.Metadata{
	DeviceName:  "Jeffrey's laptop",
	IPAddress:   req.RemoteAddr,
	UserAgent:   req.UserAgent(),
	LoginMethod: "passkey",
}, &Principal{UserID: userID})

// the security page
listed, _ := store.List(ctx, holder, session.ID)      // IsCurrent set on one
_, _ = store.RevokeAllExcept(ctx, holder, session.ID) // "sign out my other devices"

The holder is the scope and the principal together, and it is one value because neither half is a key on its own: a revocation keyed on the principal alone reaches into every tenant that spells an identifier the same way, and one keyed on the scope alone signs out everybody in it. Both are working SQL, which is why they are not spellable here.

The principal is an opaque string. This package does not know what a user is, and a session store that could not be used without this module's identity store would be a session store nobody could use with their own.

Revocation lands on the same rows the by-identifier read answers from, because there is only one place a session lives. That is the whole reason this surface is here rather than in a table beside it: a session table maintained alongside the platform's is a second account of which sessions are live, and the moment the two disagree, a revocation has not taken.

Enumeration needs an index on the holder, and only sessions/database has one. sessions/cache reports ErrNoPrincipalIndex from all three — a key-value store answers what is under a key, and the second structure needed to answer which keys are somebody's would be the second source of truth this surface exists to avoid. A deployment that needs "sign out other devices" needs the database backend, and finds that out from an error rather than from an empty list.

Renewal is not optional

Renew rotates a session's identifier and carries the payload across. Call it on every privilege change, sign-in first among them. Without it, an identifier an attacker planted in a victim's browser before sign-in is still valid after it, and the attacker is now signed in as the victim — session fixation, which is a defect in the application rather than in the cookie.

CreatedAt survives renewal, deliberately. If it did not, an application that correctly renewed on every privilege change would thereby give its sessions an unbounded life, and the absolute timeout would quietly stop meaning anything.

Renew reports either a new identifier or an error, never both. A caller that sees an error must assume the old identifier still resolves and refuse the privilege change that prompted the renewal.

Identifiers

Minted by NewID from crypto/rand, 256 bits, base64url. Not identifiers.New: an xid is a timestamp, a machine identifier, a process identifier, and a counter, which is sortable by design and guessable by construction. That is a feature everywhere else in this module and a vulnerability here.

Identifiers are bearer credentials, so nothing in this package puts one on a span or in a log line. What is attached describes a session without naming it.

Choosing a backend

sessions/cache runs on any cache.Cache — redis for a fleet, memory for tests. It is the default answer: sessions are short-lived, read on every request, and a lost session is a sign-in rather than a lost record.

sessions/database survives cache loss, and is the answer when a sign-out has to be enforceable or a flush must not sign everybody out at once. It also enforces one thing the cache backend can only approximate: Update is a single UPDATE that touches nothing if the row is gone, so a request that read a session immediately before it was signed out cannot write it back afterwards. The cache backend checks first and then writes, which narrows that window to two adjacent round trips rather than closing it.

And it is the only backend that can answer which sessions a principal holds. See above: that is a column on the row, which a table has and a keyspace does not.

What T must be

Whatever the chosen backend can round-trip: a concrete struct with exported fields. The cache backend serializes through its provider's codec (CBOR by default, gob available), the database backend through an encoding.Codec.

Every record carries a Version. A record written by a different shape reads as absent rather than being decoded into the current shape, so changing T is a wave of re-logins rather than users holding somebody else's fields. Bump recordVersion when Record itself changes shape; sessions_stale_records counts what that discards.

Record grew a holder, so the current version is 2 and every session written by an earlier build reads as absent. A record from before the change carries no holder at all, and decoding one would produce a session that works, belongs to nobody, and cannot be found by the person trying to end it.

Watching it

sessions_expired          by reason: absolute or idle. A shift toward
                          absolute usually means the idle timeout is longer
                          than anyone thinks.
sessions_touch_failures   idle deadlines that could not be refreshed. The
                          reads still succeeded; the sessions will expire on
                          their old schedule.
sessions_backend_errors   backend health. Absent sessions are not counted
                          here — they are not errors — and neither is a
                          backend that keeps no principal index, which is a
                          wiring decision rather than a store that is
                          unwell.
sessions_stale_records    records discarded for carrying another version;
                          expected to spike once after a shape change.
sessions_created          new sessions.
sessions_renewed          identifier rotations. Should track sign-ins; if it
                          does not, something is not renewing.
sessions_ended            explicit sign-outs.
sessions_revoked          sessions ended through the revocation surface, by
                          reason: one, all, all_but_kept. A deployment where
                          "all" climbs is a deployment where something is
                          scaring people.
sessions_touches          idle deadline refreshes.
sessions_latency_ms       by operation: new, get, save, renew, delete, list,
                          revoke.
Example

The ordinary shape: establish a session after authenticating, read it back on the next request, end it on sign-out.

package main

import (
	"context"
	stderrors "errors"
	"fmt"
	"time"

	"github.com/primandproper/platform-go/v13/cache/memory"
	"github.com/primandproper/platform-go/v13/sessions"

	sessionscache "github.com/primandproper/platform-go/v13/sessions/cache"
)

// Principal is what a session carries: whatever the application needs to know
// about who is making the request.
type Principal struct {
	UserID string
	Admin  bool
}

// newStore builds a store over an in-memory cache, which is what a test wants.
// Production points cachecfg at redis instead.
func newStore(opts ...sessions.Option) sessions.Store[Principal] {
	c, err := memory.NewInMemoryCache[sessions.Record[Principal]](time.Hour)
	if err != nil {
		panic(err)
	}

	backend, err := sessionscache.NewBackend(c)
	if err != nil {
		panic(err)
	}

	store, err := sessions.NewStore(backend, opts...)
	if err != nil {
		panic(err)
	}

	return store
}

func main() {
	ctx := context.Background()
	store := newStore()

	session, err := store.New(ctx, &Principal{UserID: "u_123"})
	if err != nil {
		panic(err)
	}

	// session.ID is what the client gets — nothing else leaves the server.
	read, err := store.Get(ctx, session.ID)
	if err != nil {
		panic(err)
	}

	fmt.Println("user:", read.Data.UserID)

	if err = store.Delete(ctx, session.ID); err != nil {
		panic(err)
	}

	_, err = store.Get(ctx, session.ID)
	fmt.Println("after sign-out:", stderrors.Is(err, sessions.ErrNotFound))

}
Output:
user: u_123
after sign-out: true

Index

Examples

Constants

View Source
const (
	// DefaultAbsoluteTimeout is how long a session may live from the moment it
	// was established, regardless of activity. It is the ceiling a stolen
	// cookie cannot outlive, so it is the one timeout that has to be set even
	// on a store nobody idles out.
	DefaultAbsoluteTimeout = 24 * time.Hour

	// DefaultIdleTimeout is how long a session survives without being read.
	DefaultIdleTimeout = 30 * time.Minute

	// DefaultTouchInterval is how much of the idle window has to elapse before
	// a read refreshes the session's idle deadline. See Policy for why this is
	// not zero.
	DefaultTouchInterval = time.Minute

	// DefaultRetentionGrace is how long an expired record is kept before the
	// backing store may reclaim it, so that a user who comes back can be told
	// why they were signed out rather than merely that they were. See
	// Policy.Grace for why a backend's own expiry is the wrong thing to end a
	// session with.
	DefaultRetentionGrace = time.Hour

	// DefaultIDByteLength is how many random bytes a session identifier is
	// minted from — 256 bits, which is what makes the cache backend's
	// collision-free Create safe to assume.
	DefaultIDByteLength = 32
)

Variables

View Source
var (
	// ErrNotFound indicates no session is stored under the identifier. It is
	// also what a record written by another shape of this package reads as,
	// deliberately: a stale record is a re-login, and misreading one would hand
	// a user a payload decoded from bytes that meant something else.
	ErrNotFound = platformerrors.New("session not found")
	// ErrExpired indicates a session was found but is past one of its
	// deadlines. It wraps ErrNotFound, so a caller that does not care why the
	// session is unusable checks only that.
	ErrExpired = platformerrors.Wrap(ErrNotFound, "session expired")
	// ErrIdleTimeout indicates the session went unread for longer than the
	// idle timeout. It wraps ErrExpired.
	ErrIdleTimeout = platformerrors.Wrap(ErrExpired, "session idle timeout elapsed")
	// ErrAbsoluteTimeout indicates the session outlived its absolute timeout,
	// which no amount of activity extends. It wraps ErrExpired.
	ErrAbsoluteTimeout = platformerrors.Wrap(ErrExpired, "session absolute timeout elapsed")

	// ErrIDConflict indicates Create was given an identifier that already
	// exists. Identifiers are 256 bits of cryptographic randomness, so this
	// means a backend was handed an identifier it did not mint, not that two
	// sessions collided.
	ErrIDConflict = platformerrors.New("session identifier already in use")

	// ErrIDRequired indicates an empty identifier was supplied. It wraps
	// errors.ErrEmptyInputParameter, so a caller may check either.
	ErrIDRequired = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty session identifier")

	// ErrPrincipalRequired indicates a call that had to name whose sessions it
	// was about and did not. It wraps errors.ErrEmptyInputParameter, so a
	// caller may check either.
	//
	// The empty principal is a session held by nobody — what Store.New
	// establishes — and it is refused here rather than matched, because a list
	// or a revocation over every anonymous session in a scope is not the
	// question anybody meant to ask.
	ErrPrincipalRequired = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty session principal")

	// ErrNoPrincipalIndex indicates a store whose backend cannot answer which
	// sessions a principal holds.
	//
	// It is a property of the backend rather than a misuse: sessions/cache is
	// a key-value store, which answers what is under a key and cannot answer
	// which keys belong to a person without a second structure beside it — and
	// a second structure recording which sessions are live is a second thing
	// that can disagree with the first about a revocation. sessions/database
	// has the index, because a table can carry the column.
	//
	// A deployment that needs "sign out my other devices" therefore needs the
	// database backend, and finds that out from this error rather than from a
	// list that comes back empty.
	ErrNoPrincipalIndex = platformerrors.New("session backend keeps no principal index")

	// ErrNilBackend indicates NewStore was called without a backend. It wraps
	// errors.ErrNilInputParameter, so a caller may check either.
	ErrNilBackend = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil session backend")

	// ErrNoTimeout indicates a Policy with neither an absolute nor an idle
	// timeout. Such a store never releases a session, so it is rejected at
	// construction rather than discovered as unbounded growth.
	ErrNoTimeout = platformerrors.New("session policy sets no timeout")

	// ErrTouchExceedsIdleTimeout indicates a touch interval at least as long as
	// the idle timeout, which would let a session idle out between the reads
	// that were supposed to keep it alive.
	ErrTouchExceedsIdleTimeout = platformerrors.New("session touch interval is not shorter than the idle timeout")

	// ErrNegativeTouchInterval indicates a negative touch interval. Zero is
	// meaningful — refresh on every read — so it cannot stand in for "unset".
	ErrNegativeTouchInterval = platformerrors.New("negative session touch interval")
)

Sentinels. errors/http maps these onto status codes, so that package imports this one. That direction is load-bearing: nothing here may import errors/http or errors/grpc, or the cycle closes.

The four absence errors form a chain — ErrIdleTimeout and ErrAbsoluteTimeout wrap ErrExpired, which wraps ErrNotFound — so a caller picks the resolution it cares about. A middleware deciding whether to redirect to a login page checks ErrNotFound; a page that wants to say "you were signed out for inactivity" checks ErrIdleTimeout.

Functions

func NewID

func NewID(ctx context.Context) (string, error)

NewID mints a session identifier: DefaultIDByteLength bytes from the process's secure random source, base64url-encoded so it travels in a cookie unescaped.

It deliberately does not use identifiers.New. That mints an xid, which is a timestamp, a machine identifier, a process identifier, and a counter — sortable by design and therefore guessable by construction. Everywhere else in this module that is a feature; here it would mean an attacker who holds one identifier can enumerate the ones minted around it. A session identifier is a bearer credential and has to come from crypto/rand.

The generator is package-level rather than injected. There is exactly one correct source of randomness for this value, and an option to replace it would be an option to weaken it.

Types

type Backend

type Backend[T any] interface {
	// Load reads the record stored under id, reporting ErrNotFound when
	// there is none. It does not evaluate expiry — the Store does, from the
	// record's own anchors, so both backends answer the same question the
	// same way.
	Load(ctx context.Context, id string) (*Record[T], error)
	// Create stores a record under an identifier that must not already
	// exist, reporting ErrIDConflict if it does.
	Create(ctx context.Context, id string, record *Record[T], ttl time.Duration) error
	// Update overwrites the record stored under an existing identifier,
	// reporting ErrNotFound when there is none.
	//
	// The existence requirement is not bookkeeping. Without it a request
	// that read a session just before it was signed out would write it back
	// afterwards, and the sign-out would not have happened.
	Update(ctx context.Context, id string, record *Record[T], ttl time.Duration) error
	// Rename moves a record from oldID to newID, reporting ErrNotFound when
	// oldID holds nothing. On a nil return, oldID no longer resolves.
	Rename(ctx context.Context, oldID, newID string, record *Record[T], ttl time.Duration) error
	// Delete removes the record stored under id. An identifier that was
	// already absent is not an error.
	Delete(ctx context.Context, id string) error
	// ListHeld returns every record the holder holds, newest first, each
	// with the identifier it is stored under. It applies no expiry — the
	// Store filters, from the same anchors it refuses a single record by.
	//
	// A backend that keeps no index on the holder reports
	// ErrNoPrincipalIndex, and sessions/cache is one: a key-value store
	// answers "what is under this key" and cannot answer "which keys belong
	// to this person" without a second structure to maintain, which is a
	// second source of truth about which sessions are live.
	//
	// That is why these three are on the interface rather than living only
	// on the backend that has them. Sweep is not here because a caller who
	// chose a cache never needed one; this is here because a caller who
	// chose a cache and does need it has to be told so, in the one place
	// they would otherwise assume it worked.
	ListHeld(ctx context.Context, holder Holder) ([]*Identified[T], error)
	// DeleteHeld removes the record stored under id if — and only if — the
	// holder holds it, reporting how many rows went: one, or none.
	//
	// The holder is part of the statement rather than a check the caller
	// makes first. A revocation authorized in one round trip and executed
	// in another is one that can be got out of step; here the server
	// decides "this session, and it is theirs" at the instant it goes.
	DeleteHeld(ctx context.Context, holder Holder, id string) (int, error)
	// DeleteAllHeld removes every record the holder holds, sparing the one
	// stored under keepID when that is not empty, and reports how many
	// went.
	DeleteAllHeld(ctx context.Context, holder Holder, keepID string) (int, error)
	// Close releases the backend's resources and is safe to call more than
	// once.
	Close() error
}

Backend is where a Store's records physically live. sessions/cache and sessions/database implement it; a Store adds identifiers, expiry, and observability on top.

The split exists because the parts worth getting exactly right — what Renew preserves, when a session is expired, whether a touch may resurrect a signed-out session — are the parts that must not differ between backends. Written once in Store, they cannot.

Every method's ttl is how long the record should remain retrievable, and is always positive: a Store never asks a backend to store something already expired.

type BackendStore

type BackendStore[T any] struct {
	// contains filtered or unexported fields
}

BackendStore is the one Store implementation: a Policy, an identifier mint, and a Backend. It is exported, and returned by NewStore, so a caller can depend on the store it built rather than on the Store seam.

func NewStore

func NewStore[T any](backend Backend[T], opts ...Option) (*BackendStore[T], error)

NewStore builds a Store over a Backend.

The Backend is required and has no default. An implicit in-memory one would work in every test and lose every session on deploy in production, which is the failure mode that looks like intermittent sign-outs for a week before anyone finds it.

func (*BackendStore[T]) Close

func (s *BackendStore[T]) Close() error

Close releases the backend.

func (*BackendStore[T]) Delete

func (s *BackendStore[T]) Delete(ctx context.Context, id string) error

Delete ends a session.

func (*BackendStore[T]) Get

func (s *BackendStore[T]) Get(ctx context.Context, id string) (*Session[T], error)

Get reads a session and refreshes its idle deadline when the touch interval has elapsed.

func (*BackendStore[T]) List

func (s *BackendStore[T]) List(ctx context.Context, holder Holder, currentID string) ([]*Session[T], error)

List enumerates the live sessions a holder holds, newest first.

The expiry filter is the same Policy the by-identifier read applies, run over the records the backend returned rather than pushed into its query. That is deliberate and it is the same decision the schema records: a session's deadlines are computed from its own two anchors against the store's clock, so a predicate on the row's stored deadline would be a second clock deciding which sessions a person is shown — and the two disagreeing means a session that is missing from the list and still answers requests.

Nothing is written. An enumeration does not touch idle deadlines, or opening a security page would keep every session listed on it alive; and it does not delete the expired rows it filters out, which is the sweeper's job and not something a page render should be doing a variable amount of.

func (*BackendStore[T]) New

func (s *BackendStore[T]) New(ctx context.Context, data *T) (*Session[T], error)

New establishes a session around data, attributed to nobody.

func (*BackendStore[T]) NewFor

func (s *BackendStore[T]) NewFor(
	ctx context.Context,
	holder Holder,
	metadata Metadata,
	data *T,
) (*Session[T], error)

NewFor establishes a session held by somebody.

The holder is validated before anything is written, because the failure it guards against is not a failed write: a session established under an unset scope or an empty principal is a session that stores fine, reads fine by its identifier, and appears in no list — so the sign-out control the holder was recorded for silently does not cover it.

func (*BackendStore[T]) Policy

func (s *BackendStore[T]) Policy() Policy

Policy reports the expiry rule this store enforces.

func (*BackendStore[T]) Renew

func (s *BackendStore[T]) Renew(ctx context.Context, oldID string) (string, error)

Renew rotates a session's identifier.

CreatedAt is carried across untouched, which is what keeps the absolute timeout absolute: a caller renewing on every privilege change — the correct thing to do — cannot thereby give a session an unbounded life.

func (*BackendStore[T]) Revoke

func (s *BackendStore[T]) Revoke(ctx context.Context, holder Holder, id string) error

Revoke ends one of a holder's sessions.

A count of zero is ErrNotFound rather than a distinct refusal, so a caller naming a session that is not theirs learns nothing about whether it exists. The alternative — a permission error — is a lookup oracle over every session identifier anybody cares to try, on the one endpoint whose whole purpose is to be reachable by an authenticated stranger.

func (*BackendStore[T]) RevokeAll

func (s *BackendStore[T]) RevokeAll(ctx context.Context, holder Holder) (int, error)

RevokeAll ends every session a holder holds, the caller's own included.

func (*BackendStore[T]) RevokeAllExcept

func (s *BackendStore[T]) RevokeAllExcept(ctx context.Context, holder Holder, keepID string) (int, error)

RevokeAllExcept ends every session a holder holds but one.

An empty keepID spares nothing, which makes this exactly RevokeAll. That is the honest reading rather than an error: the identifier a caller passes here is their own current session, and a caller that has none is signing out of everywhere.

func (*BackendStore[T]) Save

func (s *BackendStore[T]) Save(ctx context.Context, id string, data *T) error

Save replaces a session's payload.

type Expiry

type Expiry uint8

Expiry names which of a Policy's two deadlines a session has passed.

const (
	// ExpiryNone means the session is still live.
	ExpiryNone Expiry = iota
	// ExpiryAbsolute means the session outlived its absolute timeout, measured
	// from when it was established. No activity extends this one.
	ExpiryAbsolute
	// ExpiryIdle means the session went unread for longer than the idle
	// timeout.
	ExpiryIdle
)

func (Expiry) Err

func (e Expiry) Err() error

Err returns the sentinel for this reason, or nil for ExpiryNone.

func (Expiry) String

func (e Expiry) String() string

String renders the reason as it appears on the sessions_expired counter.

type Holder

type Holder struct {
	// Principal is who holds the session within that scope.
	Principal string
	// Scope is the tenant whose data the session is. It must name
	// something — see tenancy.Scope, whose zero value names nobody and
	// which no query here accepts.
	Scope tenancy.Scope
}

Holder names whose sessions a call is about: the tenancy scope, and the principal inside it.

It is one value rather than two arguments because neither half is a key on its own, and both failures are the kind nobody notices in review. A revocation keyed on the principal alone reaches into every tenant that happens to spell an identifier the same way; one keyed on the scope alone signs out everybody in it. Passed together, the pair is what a statement binds and what a caller has to have decided.

Principal is an opaque identifier, deliberately not a type from this module's identity package: a session store that could not be used without an identity store would be a session store nobody could use with their own. What the string means is the consumer's business — a user ID, a service account, an API client.

The empty principal is a session attributed to nobody, which is what Store.New establishes. It is not enumerable: a list of every anonymous session in a scope answers nobody's question, and would be a way to reach sessions that have not yet been claimed.

type Identified

type Identified[T any] struct {
	// Record is the stored record.
	Record *Record[T]
	// ID is the identifier it is stored under.
	ID string
}

Identified is one stored record together with the identifier it is stored under.

It exists for the one read that answers with identifiers rather than taking them: a Record does not carry its own, because everywhere else the identifier is the key the record was fetched by. An enumeration is the caller asking which identifiers those are, and the answer is unusable without them — they are what a revocation is then aimed at.

type Metadata

type Metadata struct {
	// DeviceName is what the client called itself, in whatever vocabulary
	// the consumer chose.
	DeviceName string
	// IPAddress is the address the session was established from. It is
	// rendered, never trusted: deriving it from a forwarded-for header is
	// the caller's decision, and so is whether to believe it.
	IPAddress string
	// UserAgent is the client's self-description at establishment.
	UserAgent string
	// LoginMethod is how the principal proved themselves — a password, a
	// passkey, an OAuth provider's name. The vocabulary is the consumer's.
	LoginMethod string
}

Metadata describes the client a session was established from, for the security page that lists a principal's sessions back to them.

Every field is the client's own account of itself, so none of it is evidence and none of it is ever compared against anything. It is there so that a person scanning their own sessions can recognize the ones that are theirs and notice one that is not — which is a judgment only they can make, and only if they are shown enough to make it.

It is stamped once, when the session is established, and no later write moves it. A session whose recorded device changed under a user would be worse than one that recorded nothing.

type Option

type Option func(*storeOptions)

Option configures a Store at construction.

It is deliberately not parameterized on the Store's T. None of these settings depend on it, and Go cannot infer a type argument from a call's result type — so an Option[T] would force every call site to spell the payload type out by hand, WithIdleTimeout[Principal](time.Hour), forever.

func WithAbsoluteTimeout

func WithAbsoluteTimeout(timeout time.Duration) Option

WithAbsoluteTimeout bounds a session's total lifetime, measured from when it was established and unaffected by activity or by Renew.

A non-positive value disables it, which is only safe when the idle timeout is not also disabled — a store with neither never releases a session, and is rejected at construction.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock the store stamps and expires against, so timeout behavior is deterministic in tests.

func WithIdleTimeout

func WithIdleTimeout(timeout time.Duration) Option

WithIdleTimeout bounds how long a session may go unread. A non-positive value disables it, and also disables touching: there is then no idle deadline for a read to refresh.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger. An absent logger logs nowhere.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider. An absent one records nothing.

func WithRetentionGrace

func WithRetentionGrace(grace time.Duration) Option

WithRetentionGrace sets how long an expired record is kept before the backing store may reclaim it. See Policy.Grace for what it buys; a non-positive value lets the backend reclaim the record at the deadline, so an expired session then reads as merely absent.

func WithTouchInterval

func WithTouchInterval(interval time.Duration) Option

WithTouchInterval sets how much of the idle window must elapse before a read refreshes the idle deadline. Zero refreshes on every read; see Policy for what the interval buys and what it costs.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider. An absent one traces nowhere.

type Policy

type Policy struct {
	// Absolute bounds a session's total lifetime from CreatedAt. Non-positive
	// disables it.
	Absolute time.Duration
	// Idle bounds how long a session may go unread. Non-positive disables it.
	Idle time.Duration
	// Touch is how much of the idle window must elapse before a read refreshes
	// the idle deadline.
	//
	// It exists because an idle timeout is otherwise a write on every read. At
	// a hundred requests a second against one session that is a hundred writes
	// a second to say the same thing; with a touch interval it is one write per
	// interval. What it costs is precision: a session's idle deadline can be up
	// to one interval stale, so a session expires up to Touch early rather than
	// late. Early is the safe direction for a security control, which is why
	// the trade is available at all.
	//
	// Zero refreshes on every read. It must be shorter than Idle, and is
	// irrelevant when Idle is disabled — there is no idle deadline to refresh,
	// so nothing is ever touched.
	Touch time.Duration

	// Grace is how long an expired record is kept before the backing store is
	// allowed to reclaim it.
	//
	// It exists because a backend's own expiry would otherwise decide when
	// sessions end, and it is the wrong thing to decide it. A record a cache
	// has already dropped cannot be told apart from one that never existed, so
	// a user who idled out and a client presenting a forged identifier would
	// get the same answer; worse, expiry would then be evaluated by the cache
	// server's clock rather than by the store's, which is neither the clock the
	// policy was written against nor one a test can move.
	//
	// So every write asks the backend to keep the record for its deadline plus
	// this, and the store refuses it the moment the deadline passes. The
	// backend's expiry becomes a garbage collector rather than a security
	// control. What it costs is retained bytes for expired sessions; what it
	// buys is a deterministic timeout and a returning user who can be told why
	// they were signed out.
	//
	// Non-positive lets the backend reclaim the record exactly at the deadline,
	// which is the cheaper setting and the one that gives up the distinction.
	Grace time.Duration
}

Policy is the expiry rule a Store enforces, and the reason both backends cannot disagree about when a session ends: they never evaluate it, the Store does, once.

Two timeouts, because they answer different questions. Idle asks how long a user may walk away and come back; Absolute asks how long a session may exist at all, which is the only bound on a cookie somebody stole. Either may be disabled by setting it non-positive, but not both — see ErrNoTimeout.

func (Policy) Deadline

func (p Policy) Deadline(createdAt, lastSeenAt time.Time) time.Time

Deadline is the instant a session stops being usable if nothing touches it again: the earlier of the two deadlines, or the only one that is enabled.

func (Policy) Expiry

func (p Policy) Expiry(createdAt, lastSeenAt, now time.Time) Expiry

Expiry reports which deadline, if either, now has passed.

Absolute is checked first so that a session past both is reported as the one nothing could have prevented. Telling a user they were signed out for inactivity when they were in fact signed out on schedule is a worse answer than the reverse.

func (Policy) RetentionTTL

func (p Policy) RetentionTTL(createdAt, now time.Time) time.Duration

RetentionTTL is how long a backend should keep a record written now: its remaining life, plus the grace that lets an expired session still be diagnosed rather than merely missed. See Policy.Grace.

It is what a Store hands a Backend. TTL is the deadline the Store itself enforces, and the two are deliberately different numbers.

func (Policy) ShouldTouch

func (p Policy) ShouldTouch(lastSeenAt, now time.Time) bool

ShouldTouch reports whether a read should refresh the idle deadline.

It is false whenever the idle timeout is disabled: with no idle deadline there is nothing for a touch to extend, and writing on every read to update a field nobody expires against is pure cost.

func (Policy) TTL

func (p Policy) TTL(createdAt, now time.Time) time.Duration

TTL is how much longer a record written now should remain retrievable: the idle window, clipped to whatever is left of the absolute one.

It is what a Store hands a Backend, so the backing store's own expiry lands on the same instant Deadline reports. A non-positive result means the session is already over and must not be written at all — callers reach Expiry first, which is where that is decided.

func (Policy) Validate

func (p Policy) Validate() error

Validate reports whether a Policy is one a store can enforce, rejecting the combinations that would leave it either unbounded or unable to keep a session alive.

type Record

type Record[T any] struct {
	// CreatedAt is when the session was established. It survives Renew, so
	// rotating an identifier does not extend the absolute deadline — which
	// is the whole reason rotation is safe to do on every privilege change.
	CreatedAt time.Time
	// LastSeenAt is when the session was last read or written. It is the
	// idle deadline's anchor, and it is refreshed no more often than the
	// Policy's touch interval — see Policy.
	LastSeenAt time.Time
	// Data is the payload. It may be nil: a session that only needs to
	// exist is a legitimate session.
	Data *T
	// Metadata is what the session was established from. Like Holder it
	// survives Renew, and unlike the two anchors above it is never
	// reassigned: it describes an establishment rather than a state.
	Metadata Metadata
	// Holder is whose session this is. It survives Renew, so rotating an
	// identifier does not hand the session to somebody else — and it is
	// what makes a principal's sessions enumerable at all.
	Holder Holder
	// Version is the record shape this was written with.
	Version int
}

Record is what a Backend holds for a session identifier. It carries the payload and the two anchors expiry is measured from, and nothing else — the identifier is the key it is stored under, and the deadlines are derived from the Policy rather than frozen into the record.

T must round-trip through whichever backend stores it. The cache backend serializes with its provider's codec (CBOR by default), the database backend with an encoding.Codec; both want a concrete struct with exported fields.

type Session

type Session[T any] struct {
	// CreatedAt is when the session was established, unchanged by Renew.
	CreatedAt time.Time
	// LastSeenAt is the idle deadline's anchor as of this read.
	LastSeenAt time.Time
	// ExpiresAt is the earlier of the absolute and idle deadlines: the
	// instant this session stops being usable if nothing touches it again.
	// It is what a cookie's MaxAge should be derived from, so the browser
	// and the store agree on when the session ended.
	ExpiresAt time.Time
	// Data is the payload, as stored.
	Data *T
	// Metadata is what this session was established from.
	Metadata Metadata
	// ID is the identifier this session was read under. It is the value the
	// cookie carries, and the only part of a session that ever leaves the
	// server.
	ID string
	// Holder is whose session this is.
	Holder Holder
	// IsCurrent reports whether this is the session the caller asked with.
	//
	// It is derived at read time from the identifier passed to Store.List
	// rather than stored, because "current" is a fact about a request and
	// not about a session: the same row is current to one browser and not
	// to the other four. A stored flag would have to be moved on every
	// read, and would be wrong for everybody but the last writer.
	//
	// It is always false outside List — the by-identifier reads answer
	// about the session the caller already named.
	IsCurrent bool
}

Session is a live session as a Store hands it back.

It is a snapshot, not a handle: mutating it changes nothing server-side. Store.Save is how a payload is written back.

type Store

type Store[T any] interface {
	// New establishes a session around data and returns it, identifier
	// included. data may be nil.
	//
	// The session it establishes is attributed to nobody: the global scope
	// and the empty principal. It is therefore reachable only by its
	// identifier and appears in no List — which is what an anonymous
	// session is. Call NewFor once somebody has signed in.
	New(ctx context.Context, data *T) (*Session[T], error)
	// NewFor establishes a session held by somebody, with the metadata a
	// security page will render beside it.
	//
	// This is the call a sign-in makes. What separates it from New is that
	// the resulting session is enumerable and revocable as one of that
	// principal's — so a holder with no scope, or no principal, is refused
	// rather than quietly establishing a session nobody can find: see
	// tenancy.ErrNoScope and ErrPrincipalRequired.
	//
	// The holder and the metadata are stamped once. Renew carries both
	// across, and no other write moves either.
	NewFor(ctx context.Context, holder Holder, metadata Metadata, data *T) (*Session[T], error)
	// Get reads a session, refreshing its idle deadline when the Policy's
	// touch interval has elapsed.
	//
	// A session past either deadline is reported as ErrExpired and removed;
	// one that was never there, or whose record was written by another
	// shape of this package, is reported as ErrNotFound.
	Get(ctx context.Context, id string) (*Session[T], error)
	// Save replaces a session's payload. It refreshes the idle deadline and
	// leaves the absolute one alone.
	Save(ctx context.Context, id string, data *T) error
	// Renew rotates a session's identifier, carrying the payload and the
	// original CreatedAt across, and returns the new identifier.
	//
	// Call it on every privilege change — sign-in above all. Without it, an
	// identifier an attacker planted before sign-in is still valid after
	// it, which is session fixation. Because CreatedAt survives, rotating
	// on every privilege change cannot be used to extend a session forever.
	//
	// The old identifier stops working the moment this returns nil. If it
	// returns an error, assume it still works and refuse the privilege
	// change.
	Renew(ctx context.Context, oldID string) (newID string, err error)
	// Delete ends a session. An identifier that was already gone is not an
	// error: sign-out is not the place to surface a race.
	Delete(ctx context.Context, id string) error
	// List enumerates the live sessions holder holds, newest first.
	//
	// currentID is the identifier the caller is asking with, and decides
	// which of the returned sessions has IsCurrent set; the empty string
	// marks none of them. It is not a filter — the session it names is
	// still listed, since a security page that hid the reader's own session
	// would be listing the wrong set.
	//
	// Expired sessions are left out, decided by the same Policy the
	// by-identifier read decides with, so a session this omits is one Get
	// would refuse. Nothing is written: an enumeration does not touch idle
	// deadlines, or reading a security page would keep every session on it
	// alive.
	//
	// A holder with an empty principal is ErrPrincipalRequired. A backend
	// that keeps no principal index is ErrNoPrincipalIndex — see Backend.
	List(ctx context.Context, holder Holder, currentID string) ([]*Session[T], error)
	// Revoke ends one of a holder's sessions.
	//
	// The holder is part of the question rather than checked beforehand: the
	// session ends only if it is the named principal's within the named
	// scope, decided where the row is removed. A caller naming a session
	// that is not theirs is answered ErrNotFound, not a refusal, so the
	// answer does not confirm that the identifier names anything.
	//
	// Unlike Delete this is not idempotent: revoking a session that is
	// already gone is ErrNotFound. Delete ends the caller's own session,
	// where a race is a sign-out that already happened; this ends a session
	// somebody is looking at a list of, where "there was nothing there" is
	// the answer they need to see.
	Revoke(ctx context.Context, holder Holder, id string) error
	// RevokeAll ends every session a holder holds, including the caller's
	// own, and reports how many ended.
	//
	// It is the "sign out everywhere" a password change should trigger.
	RevokeAll(ctx context.Context, holder Holder) (int, error)
	// RevokeAllExcept ends every session a holder holds but one, and
	// reports how many ended.
	//
	// keepID is normally the caller's current session, which is what makes
	// this "sign out my other devices". An empty keepID spares nothing and
	// is exactly RevokeAll; an identifier the holder does not hold spares
	// nothing either, because sparing is by the same key everything else
	// here is.
	RevokeAllExcept(ctx context.Context, holder Holder, keepID string) (int, error)
	// Policy reports the expiry rule this store enforces.
	//
	// It is on the interface because the cookie has to agree with it:
	// sessions/http derives how long a browser should keep a session
	// cookie from the absolute timeout rather than from a second setting
	// that could drift from this one.
	Policy() Policy
	// Close releases what the store holds — the backend's connection pool,
	// a background sweep — and is safe to call more than once.
	Close() error
}

Store is the server-side session store: identifiers in, payloads out.

Every method takes or returns an identifier rather than a cookie. What the identifier travels in is the caller's business — sessions/http binds it to a signed cookie, which is what nearly everyone wants.

A Store enforces the expiry Policy and mints identifiers; where the records physically live is the Backend's business. Absence and expiry are reported as ErrNotFound and ErrExpired, and ErrExpired wraps ErrNotFound, so a caller that does not care about the difference checks one thing.

Directories

Path Synopsis
Package cache stores session records in a cache.Cache.
Package cache stores session records in a cache.Cache.
Package sessionscfg assembles a session store, and optionally a cookie-bound manager, from environment configuration.
Package sessionscfg assembles a session store, and optionally a cookie-bound manager, from environment configuration.
Package database stores session records in a SQL table.
Package database stores session records in a SQL table.
internal/queries
Package queries is the session schema described as data: the table's name, its columns in the order every statement lists them, the subset a write may assign, and the one column that may be NULL.
Package queries is the session schema described as data: the table's name, its columns in the order every statement lists them, the subset a write may assign, and the one column that may be NULL.
internal/queriesgen command
Command queriesgen writes the canonical sqlc input for the session schema, one file per dialect, from sessions/database/internal/queries.
Command queriesgen writes the canonical sqlc input for the session schema, one file per dialect, from sessions/database/internal/queries.
migrations
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix.
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix.
Package http binds a sessions.Store to a signed cookie and to net/http.
Package http binds a sessions.Store to a signed cookie and to net/http.
Package sessionsmock provides moq-generated mock implementations of interfaces in the sessions package.
Package sessionsmock provides moq-generated mock implementations of interfaces in the sessions package.

Jump to

Keyboard shortcuts

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