sessionx

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 17 Imported by: 0

README

sessionx

Session lifecycle for Go HTTP services: collect a session from a request, enforce absolute and idle timeouts, rotate on sign-in, revoke one or all of a user's sessions, and keep them in the store that fits your deployment.

go get github.com/bakhod1r/sessionx

Requires Go 1.26.

Quick start

store := memory.New() // or redisstore.New, sqlstore.New, cookiestore.New
m := sessionx.NewManager(store, sessionx.Options{
	TTL:         24 * time.Hour,
	IdleTimeout: 30 * time.Minute,
	MaxPerUser:  5,
})

// Sign-in: collect a session for the user and hand the client its cookie.
s, err := m.Collect(r.Context(), m.InputFrom(r, userID))
if err != nil { /* ... */ }
if err := m.Issue(w, s, sessionx.DefaultCookie()); err != nil { /* ... */ }

// Every request: attach the session to the context.
mux := m.Middleware(sessionx.DefaultCookie())(appHandler)

// In a handler.
if s, ok := sessionx.FromContext(r.Context()); ok { /* signed in */ }

// Privilege change: rotate the id (session-fixation defence).
s, err = m.Renew(ctx, s.ID)

// Sign out here, or everywhere.
_ = m.Revoke(ctx, s.ID)
_, _ = m.RevokeUser(ctx, s.UserID)

Lifecycle

active → idle (past IdleTimeout; the next request revives it) → expired (past TTL) or revoked (deliberately ended). Expired and revoked are final: every store refuses to overwrite them, atomically, so a request that loaded a session just before a sign-out cannot save it back to life.

IdleTimeout marks a session idle; it does not end it. To end idle sessions, revoke them or use a short TTL.

Stores

Store Package Enumerate / revoke Notes
Memory sessionx/memory yes One process. Call GC on your own schedule.
Redis github.com/bakhod1r/sessionx/redis yes Single node, Sentinel or Cluster. Expiry is Redis's TTL.
SQL github.com/bakhod1r/sessionx/sqlstore yes PostgreSQL and SQLite (SchemaPostgres, SchemaSQLite). Call GC periodically. Not MySQL.
Cookie sessionx/cookiestore no Stateless: the signed session is the cookie. See below.

Redis and SQL are separate modules so the core pulls in no database client.

cookiestore

The whole session is signed (HMAC-SHA256) and stored in the cookie; any process holding the key serves it, and the middleware reissues the cookie after each request. There is no server state, so:

  • Revoke, RevokeUser, ListUser and MaxPerUser return or ignore ErrUnsupported.
  • Renew issues a new cookie but cannot invalidate the old one; it stays valid until its ExpiresAt. Keep TTL short.
  • The payload is readable by the client. Never put secrets in Data.
  • Keys must be ≥ 32 random bytes. cookiestore.New(newKey, oldKey) rotates: the first key signs, all keys verify.

Cookies

The zero CookieConfig is Secure, HttpOnly and SameSite=Lax. For local HTTP development set Insecure: true. On HTTPS-only sites, consider the __Host- name prefix.

Behind a proxy

Set Options.TrustedProxies. X-Forwarded-For is honoured only when the direct peer is a trusted proxy, and it is read right to left, so entries a client added itself are never used.

Testing a custom store

storetest.Run is the conformance suite every bundled store passes, including the terminal-is-final rule.

License

MIT

Documentation

Index

Constants

View Source
const DefaultCookieName = "sessionx_id"

DefaultCookieName is the cookie a session travels in unless configured otherwise. The __Host- prefix is not used by default because it forbids a Domain and requires HTTPS, which would break local development; an application serving only HTTPS should set it.

View Source
const DefaultTTL = 24 * time.Hour

DefaultTTL is the absolute lifetime applied when Options.TTL is zero.

Variables

View Source
var (
	// ErrNotFound is returned by a Store when no session carries the id.
	ErrNotFound = errors.New("sessionx: session not found")

	// ErrExpired is returned when a session exists but is past its TTL.
	ErrExpired = errors.New("sessionx: session expired")

	// ErrRevoked is returned when a session was deliberately ended.
	ErrRevoked = errors.New("sessionx: session revoked")

	// ErrUnsupported is returned by a Store asked for an operation its
	// design cannot honour, such as enumerating a stateless cookie store.
	ErrUnsupported = errors.New("sessionx: operation unsupported by this store")

	// ErrInvalidTransition is returned when a lifecycle move is refused.
	ErrInvalidTransition = errors.New("sessionx: invalid status transition")

	// ErrTampered is returned when a signed payload fails verification.
	ErrTampered = errors.New("sessionx: payload signature mismatch")
)

Functions

func DeviceFromRequest

func DeviceFromRequest(r *http.Request) (Device, Client)

DeviceFromRequest collects from the whole request: the User-Agent, the Client Hints, and the fetch metadata around them.

func DeviceFromUA

func DeviceFromUA(ua string) (Device, Client)

DeviceFromUA collects what a bare User-Agent string discloses.

Prefer DeviceFromRequest where a request is in hand: Client Hints are structured, are not subject to the User-Agent reduction that is steadily emptying the legacy string, and are what a modern Chrome actually carries the platform and model in.

func EqualID

func EqualID(a, b string) bool

EqualID compares two ids in constant time. Length is compared first and leaks only the length, which is fixed for ids this package issues.

func NewContext

func NewContext(ctx context.Context, s *Session) context.Context

NewContext returns a context carrying s. Framework adapters use it to attach a session to their own request context.

func NewID

func NewID() (string, error)

NewID returns a fresh session identifier: 32 crypto/rand bytes in unpadded base64url, safe in a cookie, a URL and a database key.

It returns an error rather than panicking when the system entropy source fails, because a process that cannot generate a secure id must refuse the login, not continue with a weak one.

Types

type Client

type Client struct {
	// Kind is uax's classification: Human, Bot, AICrawler, AIAgent, Tool,
	// Automation, or Unknown.
	Kind string

	// BotName is the crawler's name when one identified itself. It is a
	// claim, not a verified fact — a User-Agent saying "Googlebot" is not
	// proof that Google sent it.
	BotName string

	// AppName is the native application hosting an embedded WebView, for
	// example "Instagram".
	AppName string

	// AutomationName is the headless browser or automation framework that
	// declared itself. Its absence is never evidence of a human: a driver
	// configured to hide leaves no marker.
	AutomationName string

	// SpoofScore rises from 0 towards 1 as the client's claims stop fitting
	// together. It is recorded rather than acted on: what to do about a
	// suspicious session is the application's policy, not this library's.
	SpoofScore float64
}

Client is what kind of thing opened the session, and how well its own claims hold together.

It exists because "a session" and "a person" are not the same thing. A crawler, a CLI tool and a headless browser all open sessions, and an application that cannot tell them apart cannot rate-limit them apart either.

func (Client) Human

func (c Client) Human() bool

Human reports whether the client looks like a person's browser — not a bot, not a tool, not declared automation. It refuses to guess: a client uax could not classify, and a request that carried no User-Agent at all, are both reported as not human rather than assumed to be one.

type CookieConfig

type CookieConfig struct {
	// Name is the cookie name.
	Name string

	// Path scopes the cookie. Defaults to "/".
	Path string

	// Domain scopes the cookie to a host. Empty means the origin host only,
	// which is the safer default.
	Domain string

	// Insecure lets the cookie travel over plain HTTP. The zero value keeps
	// it HTTPS-only (Secure); set this only for local development.
	Insecure bool

	// AllowScript makes the cookie readable by JavaScript. The zero value
	// keeps it HttpOnly, and there is no good reason to change that: a
	// session id readable by JavaScript is a session id stealable by one XSS.
	AllowScript bool

	// SameSite controls cross-site sending. Zero means Lax.
	SameSite http.SameSite
}

CookieConfig describes the cookie a session id travels in.

func DefaultCookie

func DefaultCookie() CookieConfig

DefaultCookie returns the secure defaults: HttpOnly, Secure, SameSite=Lax, path "/". The zero CookieConfig means the same thing; this names it.

type Device

type Device struct {
	// Raw is the User-Agent exactly as it arrived.
	Raw string

	// Code is the model code, from Sec-CH-UA-Model when the client sent it
	// and from the User-Agent otherwise, with any build suffix stripped.
	Code string

	// Brand is the manufacturer, normalised by devicex.
	Brand string

	// Name is the marketing name, for example "Galaxy S10".
	Name string

	// Family is the product line, when a rule identifies one.
	Family string

	// Type is the form factor: Mobile, Tablet, Desktop, TV, Console, Watch,
	// Car, XR, EInk, Embedded, or empty.
	Type string

	// OS is the operating system name.
	OS string

	// OSVersion is the operating system version.
	OSVersion string

	// Browser is the browser name, for example "Chrome".
	Browser string

	// BrowserVersion is the browser version.
	BrowserVersion string

	// Confidence is the device identification's own 0..1 score, 0 when
	// nothing matched.
	Confidence float64
}

Device is the hardware and software a session was opened from, as far as the request can be trusted to say.

Fields are empty when nothing recognised them. This package never substitutes "Unknown" or "Other": an empty Brand means no rule and no catalogue entry answered, which a caller can act on, while a placeholder cannot be told apart from a real value.

func (Device) Known

func (d Device) Known() bool

Known reports whether anything at all was identified.

type Input

type Input struct {
	// UserID is the subject the session belongs to. Empty is allowed and
	// means an anonymous session.
	UserID string

	// UserAgent is the raw User-Agent header.
	UserAgent string

	// RemoteAddr is the socket peer, with or without a port.
	RemoteAddr string

	// Forwarded is the X-Forwarded-For header, acted on only when the peer
	// is a trusted proxy.
	Forwarded string

	// Device and Client are the already-collected identification, filled by
	// InputFrom when a full *http.Request was available so that Client
	// Hints are not thrown away. Left zero, Collect derives both from
	// UserAgent alone.
	Device Device
	Client Client

	// Locale and LocaleSource are the resolved language and its provenance,
	// filled by InputFrom when Options.Locale is set.
	Locale       string
	LocaleSource string

	// Data is the caller's payload, stored untouched.
	Data map[string]any
}

Input is what a transport hands over about one request. Every framework adapter fills this same struct, which is why the adapters stay thin.

type Manager

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

Manager collects sessions from request facts and enforces their lifecycle. It is safe for concurrent use as long as the Store is.

func NewManager

func NewManager(store Store, opts Options) *Manager

NewManager returns a Manager over the given store.

func (*Manager) Clear

func (m *Manager) Clear(w http.ResponseWriter, c CookieConfig)

Clear removes the session cookie from the client.

func (*Manager) Collect

func (m *Manager) Collect(ctx context.Context, in Input) (*Session, error)

Collect opens a session from the facts of a request: it issues an id, resolves the device and the network, sets the deadlines, enforces the per-user cap and saves the result.

func (*Manager) Get

func (m *Manager) Get(ctx context.Context, id string) (*Session, error)

Get loads a session and refuses it if the lifecycle says it is over.

An expired session is recorded as expired in the store rather than deleted: "this session ended because it timed out" is information an audit log wants, and a deleted row cannot carry it.

func (*Manager) InputFrom

func (m *Manager) InputFrom(r *http.Request, userID string) Input

InputFrom reads the request facts a session is collected from.

The device is resolved here rather than in Collect because only here is the whole request available: Client Hints live in headers Collect would never see, and they are where a modern Chrome actually states its platform and model.

func (*Manager) Issue

func (m *Manager) Issue(w http.ResponseWriter, s *Session, c CookieConfig) error

Issue writes the session id to the response as a cookie whose lifetime matches the session's own deadline. There are three cases: a zero ExpiresAt means the session has no absolute deadline (see session.go), so Issue omits both Expires and Max-Age and lets the browser treat it as a session cookie, kept until the browser closes — the honest transport-level reading of "no deadline", rather than the huge negative Max-Age a naive subtraction would produce, which a browser reads as "delete immediately". A deadline in the future gets Expires set and a Max-Age of at least one second, so a deadline a fraction of a second away does not truncate to zero and get misread as "no deadline" by net/http, which omits a zero Max-Age. A deadline already in the past gets Expires set and a negative Max-Age, which does mean "delete this" and is left alone.

With a Tokenizer store (cookiestore) the cookie carries the signed session itself, and Issue fails if it cannot be encoded or does not fit.

func (*Manager) ListUser

func (m *Manager) ListUser(ctx context.Context, userID string) ([]*Session, error)

ListUser returns the user's live sessions, most recently seen first. It is what an "active devices" screen renders.

func (*Manager) Middleware

func (m *Manager) Middleware(c CookieConfig) func(http.Handler) http.Handler

Middleware reads the session cookie, touches the session, and attaches it to the request context.

It never rejects a request. A missing, unknown, expired or revoked session means the handler simply sees no session — deciding what an anonymous request may do is the application's business, not this package's. A cookie naming an unknown, expired or revoked session is cleared on the way out so the browser stops sending it; one that failed on a store error is kept.

This covers net/http, chi, gorilla/mux and httprouter alike: all four speak http.Handler.

func (*Manager) Renew

func (m *Manager) Renew(ctx context.Context, id string) (*Session, error)

Renew rotates the session id and restarts the absolute lifetime, carrying the payload, the device and the network across.

Rotating on privilege change — sign-in above all — is the defence against session fixation: an id an attacker planted before the login is not the id that carries the session after it.

The old session is revoked and that revocation is persisted before the replacement is ever saved. That ordering is deliberate, not incidental: if the revoke-save fails, nothing has changed yet and the caller is told, which is fine because no rotation was claimed. If the replacement's save then fails, the old id is already revoked, so the caller ends up signed out rather than left holding a still-live pre-rotation id.

The old row is kept, revoked, rather than deleted. A revoked row is what stops a request that loaded the old id before the rotation from saving it back to life afterwards: the store refuses to overwrite a terminal session, but it cannot refuse to recreate a deleted one. The row goes when the store's own expiry or GC removes it.

func (*Manager) Revoke

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

Revoke ends a session deliberately. Revoking an already-terminal session is a no-op, not an error: a double sign-out is not a failure.

func (*Manager) RevokeUser

func (m *Manager) RevokeUser(ctx context.Context, userID string) (int, error)

RevokeUser ends every live session a user holds — "sign out everywhere" — and returns how many were ended.

func (*Manager) Touch

func (m *Manager) Touch(ctx context.Context, id string) (*Session, error)

Touch marks a session as seen now, reviving it from idle. It is what a middleware calls on every request.

type Network

type Network struct {
	// IP is the client address this package is willing to attribute the
	// session to.
	IP string

	// Forwarded is the X-Forwarded-For chain exactly as it arrived, kept
	// whether or not it was trusted, because an audit log wants to see a
	// spoof attempt as much as a legitimate hop.
	Forwarded string
}

Network is where a session was opened from.

func NetworkFrom

func NetworkFrom(remoteAddr, forwarded string, trusted []netip.Prefix) Network

NetworkFrom resolves the client address.

The socket peer is the default answer, and the only answer when trusted is empty: X-Forwarded-For is attacker-controlled on a direct connection, so honouring it unconditionally would let any client claim any address. The header is consulted only when the peer itself falls inside one of the trusted prefixes. It is then walked right to left — proxies append, so the left end is whatever the client sent — and the first hop that is not a trusted proxy is the client. An unparsable hop stops the walk: nothing to its left can be vouched for.

type Options

type Options struct {
	// TTL is the absolute lifetime of a session, measured from creation or
	// from the last Renew. Activity does not extend it. Zero means
	// DefaultTTL.
	TTL time.Duration

	// IdleTimeout is how long a session may go untouched before it is
	// treated as idle. Zero disables the idle check.
	IdleTimeout time.Duration

	// MaxPerUser caps how many live sessions one user may hold. When the cap
	// is reached, collecting a new session revokes the least recently seen.
	// Zero means no cap.
	MaxPerUser int

	// TrustedProxies are the networks whose X-Forwarded-For header this
	// Manager will believe. Empty means the header is recorded but never
	// acted on. See NetworkFrom.
	TrustedProxies []netip.Prefix

	// Locale resolves the session's language across URL, query, cookie and
	// Accept-Language. Nil means no locale is recorded — Session.Locale
	// stays empty rather than being guessed from the header alone.
	//
	// It is an *alx.Resolver because deciding the language is alx's whole
	// subject: quality values, wildcards, fallback chains and provenance
	// are already solved there and would be re-implemented badly here.
	Locale *alx.Resolver

	// Now is the clock. Nil means time.Now. It exists so tests can move
	// time without sleeping.
	Now func() time.Time
}

Options configures a Manager. The zero value is usable: it yields a 24-hour absolute lifetime, no idle timeout, no per-user cap and no trusted proxies.

type Session

type Session struct {
	// ID is the opaque identifier the client presents. See NewID.
	ID string

	// UserID is the caller's subject. Empty for an anonymous session.
	UserID string

	// Status is the lifecycle state. See Status.
	Status Status

	// Device is the hardware and software that opened the session.
	Device Device

	// Client is what kind of thing opened it — human, bot, tool — and how
	// well its claims held together. See Client.
	Client Client

	// Network is where it was opened from.
	Network Network

	// Locale is the language tag resolved for this session, for example
	// "uz". Empty when no resolver was configured.
	Locale string

	// LocaleSource records where the locale came from: "url", "query",
	// "cookie", "header" or "default". It is what makes a "why is the site
	// in the wrong language" report answerable.
	LocaleSource string

	// Data is the caller's own payload. sessionx neither reads nor
	// interprets it.
	Data map[string]any

	// CreatedAt is when the session began.
	CreatedAt time.Time

	// LastSeen is the last request served on it, which is what the idle
	// window is measured from.
	LastSeen time.Time

	// ExpiresAt is the absolute deadline. It is not extended by activity;
	// only Renew moves it.
	ExpiresAt time.Time

	// RenewedAt is when the id was last rotated, zero if never.
	RenewedAt time.Time
}

Session is one authenticated or anonymous visit: who, from what, from where, and for how much longer.

func FromContext

func FromContext(ctx context.Context) (*Session, bool)

FromContext returns the session the middleware attached, if any. A false second return is the honest answer for an anonymous request: this package never fabricates a session to keep a handler's code shorter.

func (*Session) Clone

func (s *Session) Clone() *Session

Clone returns a copy that shares nothing mutable with the original. A Store that hands out its own memory must clone, or a caller's write to Data silently mutates the stored session.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the absolute deadline has passed at now.

func (*Session) IdleSince

func (s *Session) IdleSince(now time.Time, idle time.Duration) bool

IdleSince reports whether the session has gone untouched for longer than idle. A zero idle disables the check: some applications want only an absolute lifetime.

func (*Session) MoveTo

func (s *Session) MoveTo(next Status) error

MoveTo advances the lifecycle, refusing any move the FSM does not permit and leaving the session untouched when it refuses.

type Status

type Status string

Status is where a session is in its life. The set is closed: a value outside these four is not a state this package will act on.

const (
	// StatusActive is a session in use. Requests on it are served.
	StatusActive Status = "active"

	// StatusIdle is a session past its idle window but inside its absolute
	// TTL. It is dormant, not dead: the next request revives it.
	StatusIdle Status = "idle"

	// StatusExpired is a session past its absolute TTL. Terminal.
	StatusExpired Status = "expired"

	// StatusRevoked is a session ended deliberately — a sign-out, an
	// administrative kill. Terminal, and distinct from expired so an audit
	// log can tell the two apart.
	StatusRevoked Status = "revoked"
)

func (Status) CanMoveTo

func (s Status) CanMoveTo(next Status) bool

CanMoveTo reports whether the lifecycle permits s -> next. Terminal states permit nothing, which is what makes them terminal.

func (Status) Err

func (s Status) Err() error

Err is the error that reports a session in state s as over: ErrRevoked, ErrExpired, or nil for a live state. A Store returns it from Save when the stored session is already terminal.

func (Status) String

func (s Status) String() string

String returns the state as it is stored and serialised.

func (Status) Terminal

func (s Status) Terminal() bool

Terminal reports whether s admits no further transition.

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is one of the four known states.

type Store

type Store interface {
	// Save writes the session, creating or overwriting by ID — unless the
	// stored session is already terminal (revoked or expired). A terminal
	// session is final: Save then leaves it untouched and returns the stored
	// status's Err (ErrRevoked or ErrExpired). The check and the write must
	// be atomic, or a request that loaded the session just before a sign-out
	// can save it back as active and undo the sign-out.
	Save(ctx context.Context, s *Session) error

	// Load reads a session by id, returning ErrNotFound if there is none.
	Load(ctx context.Context, id string) (*Session, error)

	// Delete removes a session. Deleting an absent session is not an error.
	Delete(ctx context.Context, id string) error

	// ListByUser returns every session belonging to userID.
	ListByUser(ctx context.Context, userID string) ([]*Session, error)

	// DeleteByUser removes every session belonging to userID and returns
	// how many were removed.
	DeleteByUser(ctx context.Context, userID string) (int, error)
}

Store is where sessions live between requests.

Implementations must be safe for concurrent use. Load returns ErrNotFound and nothing else when the id is unknown — an empty session with a nil error is never a valid answer.

ListByUser and DeleteByUser are in the interface rather than in an optional extension because an application that cannot enumerate a user's sessions cannot offer "sign out everywhere", and that is table stakes. A backend that genuinely cannot honour them returns ErrUnsupported.

type Tokenizer

type Tokenizer interface {
	Token(s *Session) (string, error)
}

Tokenizer is implemented by a store that keeps the session in the client rather than on the server (cookiestore). Manager.Issue writes the token as the cookie value instead of the bare ID, and the middleware reissues it after every touch so the cookie carries the current state. Load then receives the token as its id.

Directories

Path Synopsis
Package cookiestore is a sessionx Store that keeps no server state: the whole session travels in the client's cookie, signed with HMAC-SHA256.
Package cookiestore is a sessionx Store that keeps no server state: the whole session travels in the client's cookie, signed with HMAC-SHA256.
Package memory is a sessionx Store that keeps sessions in process memory.
Package memory is a sessionx Store that keeps sessions in process memory.
redis module
sqlstore module
Package storetest holds the conformance suite every sessionx Store must pass.
Package storetest holds the conformance suite every sessionx Store must pass.

Jump to

Keyboard shortcuts

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