Documentation
¶
Index ¶
- Constants
- Variables
- func DeviceFromRequest(r *http.Request) (Device, Client)
- func DeviceFromUA(ua string) (Device, Client)
- func EqualID(a, b string) bool
- func NewContext(ctx context.Context, s *Session) context.Context
- func NewID() (string, error)
- type Client
- type CookieConfig
- type Device
- type Input
- type Manager
- func (m *Manager) Clear(w http.ResponseWriter, c CookieConfig)
- func (m *Manager) Collect(ctx context.Context, in Input) (*Session, error)
- func (m *Manager) Get(ctx context.Context, id string) (*Session, error)
- func (m *Manager) InputFrom(r *http.Request, userID string) Input
- func (m *Manager) Issue(w http.ResponseWriter, s *Session, c CookieConfig) error
- func (m *Manager) ListUser(ctx context.Context, userID string) ([]*Session, error)
- func (m *Manager) Middleware(c CookieConfig) func(http.Handler) http.Handler
- func (m *Manager) Renew(ctx context.Context, id string) (*Session, error)
- func (m *Manager) Revoke(ctx context.Context, id string) error
- func (m *Manager) RevokeUser(ctx context.Context, userID string) (int, error)
- func (m *Manager) Touch(ctx context.Context, id string) (*Session, error)
- type Network
- type Options
- type Session
- type Status
- type Store
- type Tokenizer
Constants ¶
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.
const DefaultTTL = 24 * time.Hour
DefaultTTL is the absolute lifetime applied when Options.TTL is zero.
Variables ¶
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 ¶
DeviceFromRequest collects from the whole request: the User-Agent, the Client Hints, and the fetch metadata around them.
func DeviceFromUA ¶
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 ¶
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 ¶
NewContext returns a context carrying s. Framework adapters use it to attach a session to their own request context.
func NewID ¶
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
ListUser returns the user's live sessions, most recently seen first. It is what an "active devices" screen renders.
func (*Manager) Middleware ¶
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 ¶
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 ¶
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 ¶
RevokeUser ends every live session a user holds — "sign out everywhere" — and returns how many were ended.
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 ¶
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 ¶
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 ¶
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.
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 ¶
CanMoveTo reports whether the lifecycle permits s -> next. Terminal states permit nothing, which is what makes them terminal.
func (Status) Err ¶
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.
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 ¶
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.
Source Files
¶
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. |