session

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

session

session provides opaque login sessions and the typed cookies an application writes on its own. The browser receives a random 256-bit token; the store key is its SHA-256 hash, so a leaked backend dump cannot be replayed as a cookie. Every authoritative lifetime is decided by the server, even when a cookie survives.

The package contains the Store[T] contract, the payload Codec[T], and the Manager[T] that owns cookies and record lifetime, plus Jar[T] for application cookies and CookieStore for sessions that need no storage. sessionstore/sqlite stores records in a database/sql database, and sessionstore/redis stores them in Redis or Valkey. A backend implements the non-generic RawStore, and Typed[T] adds the payload type back, which is what lets one registry hold every backend and an application link only the one it configured.

raw, _ := rdb.NewStore(db, rdb.Options{})
_ = raw.EnsureSchema(ctx)
manager, _ := session.NewManager(session.Typed[Data](raw, session.JSONCodec[Data]{}), session.Options[Data]{
	TTL:         12 * time.Hour,
	IdleTimeout: time.Hour,
	Cookie:      session.CookieOptions{Secure: true, HTTPOnly: true},
	Subject:     func(data Data) string { return data.AccountID },
})
handler = manager.Middleware(nil)(handler)

Manager.Create issues a session, Rotate revokes the previous record before issuing a replacement, and Delete revokes the record and expires the cookie. Authentication-strength changes must rotate: reusing a token after login leaves a fixated session valid.

The middleware resolves the cookie once per request and publishes a safe view plus the request authentication result. Handlers read it with session.Read[T] and never see the token, the key hash, or the backend client.

A missing, malformed, or expired cookie continues as an explicitly unauthenticated request with the browser cookie cleared. A backend failure is answered by the supplied UnavailableHandler instead of silently downgrading the request to anonymous.

Renewal touches a record only after RenewalInterval and never extends it past the absolute expiry. Version invalidates every record written before an incompatible payload or policy change.

Cookie values, key hashes, and stored payloads must never be logged.

Application cookies

Jar[T] reads and writes one typed cookie in one of three protections: CookiePlain is readable and writable by the client, CookieSigned is readable but tamper-evident, and CookieSealed is encrypted and authenticated. The typed API is identical in all three, so a cookie is promoted without touching the handlers that use it.

keys, _ := session.ParseKeyring(os.Getenv("COOKIE_SECRET")) // openssl rand -base64 32
prefs, _ := session.NewJar[Preferences](nil, session.JarOptions{
	Mode:   session.CookieSigned,
	Keys:   keys,
	Cookie: session.CookieOptions{Name: "pw_prefs", Secure: true, HTTPOnly: true},
	MaxAge: 30 * 24 * time.Hour,
})
handler = prefs.Middleware()(handler)

Inside a request, prefs.Read(ctx) returns the value and prefs.Value(ctx) returns a handle whose Set and Clear write the cookie immediately. A value this jar did not write, or one past its stamped expiry, is cleared from the browser and read as absent.

The cookie name and, for a session record, the token it belongs to are authenticated, so a value cannot be moved between cookies or replayed against another user. The reader also fixes the mode: a sealed cookie downgraded to a plain value is rejected, not decoded. The first keyring secret writes and retired ones keep reading, which is what makes a rotation invisible.

Sessions without storage

CookieStore is a RawStore that seals the record into a second cookie, bound to the hash of its own token. A Manager over Typed[T] of it behaves exactly like one over sessionstore/sqlite — the same options, the same Create, Rotate, and Delete, the same Read[T] — so a deployment moves to a database by setting session.backend and adding that backend's import. This one is built into the framework and needs no import.

It cannot revoke. Delete expires the client's copy, but a copy taken earlier stays valid until its sealed expiry, because no server record exists to remove. Rotating the secret ends every outstanding session at once. A record that outgrows the browser cookie budget is refused at the write rather than dropped silently by the browser. Where either limit matters, run a server-side store.

Documentation

Overview

Package session stores typed per-browser state. It knows nothing about login: what proved a session, how strongly, and for how long belongs to whatever owns authentication, which is normally popcornweb/plugin/auth.

An application declares each piece of state once, as a Go type with a Placement, and reads it back by that type:

session.Register[Cart](registry, "cart", session.Private, nil)
cart, ok := session.Load[Cart](ctx)

The Placement states what the client may do with the value and where its bytes live. Shared is a plain cookie the front end reads and writes, ReadOnly a signed one it may read, Private is sealed and moves from a cookie to the configured backend at the login rotation, ServerOnly is sealed and always on the server because it must stay revocable, and RequestScope lives in process memory for one request and is never persisted at all. A host may deliberately place anonymous Private state on its server too, as the dev memory backend does.

The browser receives only a random token, issued lazily on the first write, so a visitor who writes nothing receives no cookie and occupies no storage. Handlers never observe the token, the key hash, the placement, or the backend client.

Jar remains for one typed cookie deliberately kept outside the session, such as a sign-in hint that has to survive the logout it describes.

Index

Constants

View Source
const BrowserMax = 400 * 24 * time.Hour

BrowserMax is the longest a browser will keep a cookie.

There is no "never expires" in HTTP: a cookie with no Max-Age dies when the browser closes, and one with a Max-Age is capped. Current browsers cap it at 400 days, so this is what "keep it as long as you can" actually means.

View Source
const (
	// DefaultCookieName is used when Options.Cookie.Name is empty.
	DefaultCookieName = "pw_session"
)
View Source
const DefaultDataCookieName = "pw_session_data"

DefaultDataCookieName holds the sealed record of a CookieStore. It is a second cookie beside the token cookie of the Manager, because the token names the record and this one carries it.

View Source
const (

	// DefaultMaxCookieBytes bounds the encoded value of a Jar or CookieStore
	// cookie. Browsers commonly accept about 4096 bytes per cookie including
	// its name and attributes, so the default leaves room for both.
	DefaultMaxCookieBytes = 3800
)

Variables

View Source
var (
	// ErrCookieMissing reports that the request carries no such cookie.
	ErrCookieMissing = errors.New("session: cookie not present")
	// ErrCookieInvalid reports a value this keyring did not produce: a changed
	// payload, a foreign encoding, a value moved from another cookie name, or
	// one written under a retired key.
	ErrCookieInvalid = errors.New("session: cookie value is not authentic")
	// ErrCookieTooLarge reports an encoded value beyond the configured budget.
	// Browsers drop an oversized cookie silently, so it is refused at the
	// write instead.
	ErrCookieTooLarge = errors.New("session: cookie value exceeds the size limit")
)
View Source
var (
	ErrNotFound       = errors.New("session: record not found")
	ErrExpired        = errors.New("session: record expired")
	ErrCodec          = errors.New("session: codec failure")
	ErrUnavailable    = errors.New("session: backend unavailable")
	ErrInvalidOptions = errors.New("session: invalid options")
	ErrInvalidKey     = errors.New("session: invalid key")
	ErrNoSession      = errors.New("session: no session on request")
)

Functions

func Load

func Load[T any](ctx context.Context) (T, bool)

Load returns the value of the registered slot for T and whether the request carried one.

func ParseSameSite

func ParseSameSite(value string) (http.SameSite, error)

ParseSameSite reads the configured same-site policy.

It lives here rather than beside one caller because CookieOptions is this package's type: a second parser elsewhere would be a second place for the accepted spellings to drift.

func Present

func Present(ctx context.Context) bool

Present reports whether the request carries a session token at all. It is what a logout endpoint checks before doing work, and it is not an authentication claim: an anonymous browser holding a cart has a session.

func Register

func Register[T any](registry *Registry, key string, placement Placement, codec Codec[T], options ...SlotOption) error

Register declares one piece of per-browser state.

key is the browser cookie name for a cookie-placed slot, and the field name inside the session record for a server-placed one. codec may be nil, which uses JSONCodec[T].

Call it from main, after every package init has run, exactly as RegisterConfig requires: the registry must be complete before the first request decodes anything, and an init-time call cannot see the configuration that places it.

A duplicate Go type and a duplicate key are each an error rather than a silent replacement.

func WithValue

func WithValue[T any](ctx context.Context, value T) context.Context

WithValue returns ctx carrying value as the slot for T, exactly as the middleware would have resolved it, without a manager behind it.

It is the seam a test uses to run a handler against a session it did not have to establish. A slot installed this way is read-only: Set and Clear refuse, because nothing here reaches a browser or a store.

Types

type Backend

type Backend struct {
	// Store is required.
	Store RawStore
	// Close releases a client the backend opened. A backend that borrowed a
	// resource from its host leaves it nil: what it did not open, it does not
	// close.
	Close func(context.Context) error
	// Prune is the expiry sweep of a backend that accumulates records. A
	// backend whose server or browser forgets records on its own leaves it
	// nil, and its host schedules nothing.
	Prune func(ctx context.Context, now time.Time, limit int) (int64, error)
}

Backend is one opened storage backend together with the responsibilities it brings. A host reads capabilities from this value instead of type-asserting a plugin type, so adding a backend changes no host.

type Carrier

type Carrier interface {
	// Cookies returns the cookies the request carried.
	Cookies() []*http.Cookie
	// SetCookie adds one Set-Cookie to the response.
	SetCookie(cookie *http.Cookie)
	// Context is the request's context, which is what a store is reached
	// through.
	Context() context.Context
}

Carrier is everything a session needs from the transport it is served over.

It is three methods because that is all a session actually touches: it reads the cookies that arrived, it sets the cookies that leave, and it needs the request's context to reach a store. Everything else this package does — the token, the sealing, the slot map, the rotation rules, the deadlines — is arithmetic over those.

Having found that out, the interface is what lets a second transport carry a session without a second implementation of any of it. The alternative was a parallel copy of the lifecycle, and a session is the last thing that should exist twice: two implementations of when a token rotates, or of which cookie is cleared on a stale record, are two chances to leave a session valid that should have ended.

The cookie type is net/http's. It is a plain data struct with no behaviour and no transport in it — a name, a value, and the attributes a browser is told — so it describes a cookie rather than implementing one, and a transport that spells cookies differently translates at its own edge.

func HTTPCarrier

func HTTPCarrier(w http.ResponseWriter, r *http.Request) Carrier

HTTPCarrier carries a session over net/http.

type Codec

type Codec[T any] interface {
	Encode(value T) ([]byte, error)
	Decode(encoded []byte) (T, error)
}

Codec serializes the typed payload of a record for durable stores. Record timestamps stay in backend fields so renewal never rewrites the payload. Implementations must use a bounded format and must not include payload contents in returned errors.

type CookieMode

type CookieMode int

CookieMode selects how much protection a cookie value carries. A stronger mode never changes the typed API, so a cookie can be promoted from Plain to Sealed without touching the handlers that read it.

const (
	// CookiePlain writes the payload without protection. The client can read
	// it and can replace it, so a handler must treat the decoded value as
	// request input rather than as application state.
	CookiePlain CookieMode = iota + 1
	// CookieSigned appends a message authentication code. The client can still
	// read the payload but cannot change it, and a changed value is rejected
	// instead of being decoded.
	CookieSigned
	// CookieSealed encrypts and authenticates the payload. The client can
	// neither read nor change it.
	CookieSealed
)

func (CookieMode) String

func (m CookieMode) String() string

String names the mode for diagnostics. It never renders a key or a value.

type CookieOptions

type CookieOptions struct {
	Name     string
	Path     string
	Domain   string
	Secure   bool
	HTTPOnly bool
	SameSite http.SameSite
}

CookieOptions is the browser cookie policy of a Manager or a Jar. Secure defaults to true; only an explicit loopback development deployment should disable it.

type CookieStore

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

CookieStore keeps session records in a sealed browser cookie instead of in a backend. It implements RawStore and RequestBinder, so a Manager built over Typed of it behaves like one built over sessionstore/sqlite: the same Options, the same Create, Rotate, and Delete, and the same Read[T] in handlers. Moving a deployment to a persistent backend later replaces the store and nothing else.

The record is sealed under the hash of the session token, so a record cookie is only readable together with the token cookie it was issued with, and a record captured from one browser cannot be presented with another token.

What a browser holds, a browser keeps. This store cannot revoke a record it has already written: Delete expires the client copy, and a client that kept one can replay it until its sealed expiry passes. A deployment that must be able to end a session immediately, or whose payload outgrows a cookie, uses a server-side store instead.

func NewCookieStore

func NewCookieStore(options CookieStoreOptions) (*CookieStore, error)

NewCookieStore validates options and returns a store. Wrap it with Typed to give a Manager the payload type it stores.

func (*CookieStore) BindRequest

func (s *CookieStore) BindRequest(ctx context.Context, carrier Carrier) context.Context

BindRequest implements RequestBinder.

func (*CookieStore) CookieName

func (s *CookieStore) CookieName() string

CookieName reports the record cookie name.

func (*CookieStore) Delete

func (s *CookieStore) Delete(ctx context.Context, keyHash string) error

Delete expires the record cookie. It is idempotent, and it only reaches the browser that made this request: a copy taken earlier stays valid until its sealed expiry.

func (*CookieStore) Get

func (s *CookieStore) Get(ctx context.Context, keyHash string) (RawRecord, error)

Get returns the record the request carries under keyHash. A missing or unauthentic cookie is ErrNotFound: it is stale browser state, not a backend failure, and the request continues unauthenticated.

func (*CookieStore) Put

func (s *CookieStore) Put(ctx context.Context, keyHash string, record RawRecord) error

Put seals record under keyHash and writes the record cookie.

func (*CookieStore) Touch

func (s *CookieStore) Touch(ctx context.Context, keyHash string, lastSeenAt, idleExpiresAt time.Time) error

Touch renews the record cookie. Like a backend store it never revives a missing or expired record and never renews past the absolute expiry.

type CookieStoreOptions

type CookieStoreOptions struct {
	// Keys seals every record. Its first secret writes; the rest keep records
	// written before a rotation readable.
	Keys *Keyring
	// Cookie is the policy of the record cookie. Name defaults to
	// DefaultDataCookieName; the remaining fields normally repeat the policy
	// of the session cookie so both expire under the same rules.
	Cookie CookieOptions
	// MaxBytes bounds the cookie name and encoded record together. It defaults
	// to DefaultMaxCookieBytes.
	MaxBytes int
	Now      func() time.Time
	Random   io.Reader
}

CookieStoreOptions configures a CookieStore. Keys is required.

type JSONCodec

type JSONCodec[T any] struct{}

JSONCodec is the default payload codec for durable stores. Record timestamps stay in backend columns or fields, so only the typed application payload is serialized here.

func (JSONCodec[T]) Decode

func (JSONCodec[T]) Decode(encoded []byte) (T, error)

func (JSONCodec[T]) Encode

func (JSONCodec[T]) Encode(value T) ([]byte, error)

type Jar

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

Jar reads and writes one typed browser cookie under one protection mode.

The typed API is the same in all three modes, so a cookie that starts as CookiePlain during development can be promoted to CookieSigned or CookieSealed later without changing the handlers that use it. A value written under another name, another mode, or another keyring is rejected rather than decoded.

A Jar is safe for concurrent use.

func NewJar

func NewJar[T any](codec Codec[T], options JarOptions) (*Jar[T], error)

NewJar validates options and returns a Jar over codec. A nil codec uses JSONCodec[T].

func (*Jar[T]) Clear

func (j *Jar[T]) Clear(w http.ResponseWriter)

Clear expires the cookie in the browser.

func (*Jar[T]) ClearFrom

func (j *Jar[T]) ClearFrom(carrier Carrier)

ClearFrom is Clear over a carrier.

func (*Jar[T]) Load

func (j *Jar[T]) Load(r *http.Request) (T, error)

Load decodes the cookie of r.

It reports ErrCookieMissing when the request carries no such cookie, ErrCookieInvalid for a value this jar did not write, ErrExpired for a protected value past its embedded expiry, and ErrCodec for a payload the codec cannot decode.

func (*Jar[T]) LoadFrom

func (j *Jar[T]) LoadFrom(carrier Carrier) (T, error)

LoadFrom is Load over a carrier, so a transport that spells cookies differently reads the same jar.

func (*Jar[T]) Middleware

func (j *Jar[T]) Middleware() func(http.Handler) http.Handler

Middleware decodes the jar cookie once per request and publishes the handle returned by Value.

A missing cookie continues with an absent value. A value this jar did not write, or one past its expiry, is cleared from the browser and continues as absent: stale client state is not an error the application has to handle.

func (*Jar[T]) Mode

func (j *Jar[T]) Mode() CookieMode

Mode reports the protection of the value.

func (*Jar[T]) Name

func (j *Jar[T]) Name() string

Name reports the browser cookie name.

func (*Jar[T]) Read

func (j *Jar[T]) Read(ctx context.Context) (T, bool)

Read returns the decoded value of the current request. It reports false when the request carried no usable value or when the jar's middleware did not run.

func (*Jar[T]) Save

func (j *Jar[T]) Save(w http.ResponseWriter, value T) error

Save encodes value and writes the cookie. It must run before the response body is committed, like any other header write.

func (*Jar[T]) SaveTo

func (j *Jar[T]) SaveTo(carrier Carrier, value T) error

SaveTo is Save over a carrier, for a transport whose response is not an http.ResponseWriter. Save keeps the net/http shape because it is the one an application already calls.

func (*Jar[T]) Value

func (j *Jar[T]) Value(ctx context.Context) (*JarValue[T], bool)

Value returns the request-scoped handle installed by Middleware. It reports false when the jar's middleware did not run.

type JarOptions

type JarOptions struct {
	// Mode selects the protection of the value. It defaults to CookieSealed,
	// so a jar declared without a mode does not silently hand application
	// state to the client.
	Mode CookieMode
	// Keys protects signed and sealed values. Its first secret writes; the
	// rest keep a rotation readable.
	Keys *Keyring
	// Cookie is the browser cookie policy. Name has no default.
	Cookie CookieOptions
	// MaxAge bounds how long the browser keeps the value and, in a protected
	// mode, how long this process accepts it. Zero writes a session cookie
	// that the browser drops when it closes.
	MaxAge time.Duration
	// MaxBytes bounds the cookie name and encoded value together. It defaults
	// to DefaultMaxCookieBytes.
	MaxBytes int
	Now      func() time.Time
	Random   io.Reader
}

JarOptions configures a Jar. Name is required; every other field has a safe default. Signed and Sealed additionally require Keys.

type JarValue

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

JarValue is the request-scoped handle to one jar cookie. The middleware decodes the cookie once and installs it, so repeated reads in one request cost nothing and a write is visible to later handlers immediately.

func (*JarValue[T]) Clear

func (v *JarValue[T]) Clear()

Clear expires the cookie and makes the request look like it carried none.

func (*JarValue[T]) Get

func (v *JarValue[T]) Get() (T, bool)

Get returns the current value and whether the request carried a usable one.

func (*JarValue[T]) Set

func (v *JarValue[T]) Set(value T) error

Set writes value to the browser and makes it the value later handlers read.

type Keyring

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

Keyring holds the secret that protects signed and sealed cookies. The first secret writes every new value; the remaining ones only read, which is what makes a key rotation invisible to a browser that still holds a value written under the previous secret.

A secret is 32 or more random bytes and is never logged.

func NewKeyring

func NewKeyring(secrets ...[]byte) (*Keyring, error)

NewKeyring returns a keyring over raw secrets. The first secret is the writing key and the rest are accepted for reading during a rotation.

func ParseKeyring

func ParseKeyring(secrets ...string) (*Keyring, error)

ParseKeyring is NewKeyring over base64 secrets, which is the form a configuration file or environment variable carries. Standard and URL alphabets are both accepted, with or without padding.

Generate one with `openssl rand -base64 32`.

type Manager

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

Manager owns the opaque token that names one browser and applies the lifetime it is handed, without knowing what any slot means.

Handlers read and write state through Load and Value and never call the manager. Rotate and Destroy are called by whatever owns the login, which is normally popcornweb/plugin/auth.

func NewManager

func NewManager(registry *Registry, backend RawStore, options Options) (*Manager, error)

NewManager validates options and returns a Manager over registry.

backend is the configured server store. A nil backend selects the cookie backend, where nothing is kept on the server and promotion has nowhere to go. Registering further slots after this call is refused.

func (*Manager) Attach

func (m *Manager) Attach(w http.ResponseWriter, r *http.Request) (*http.Request, error)

Attach resolves the session of r and returns the request carrying it, for a caller that reaches the manager outside its middleware.

Middleware is the normal path and does this for every request. Attach exists for the callers that legitimately have no middleware above them, such as a test seam that establishes a session against a bare request. Attaching twice is a no-op: the request keeps the session it already has.

func (*Manager) AttachTo

func (m *Manager) AttachTo(carrier Carrier) (Resolved, error)

AttachTo is Attach over a carrier: it returns the session already attached to the carrier's context, and otherwise resolves one.

The caller publishes the result, through Attach for a transport that derives a context and through StoreOn for one that writes into its request value. Which of those it is, is the only part that differs.

func (*Manager) CookieName

func (m *Manager) CookieName() string

CookieName reports the configured token cookie name.

func (*Manager) Destroy

func (m *Manager) Destroy(w http.ResponseWriter, r *http.Request) error

Destroy ends the whole session: every record is revoked and every cookie the session owns is expired, whatever placement each slot carries.

A cookie a deployment wants to survive this is a Jar cookie and not a registered slot, which is why a sign-in hint lives outside the session.

func (*Manager) DestroyOn

func (m *Manager) DestroyOn(ctx context.Context) error

DestroyOn is Destroy over the context the session was attached to, on the same terms as RotateOn.

func (*Manager) Middleware

func (m *Manager) Middleware(unavailable UnavailableHandler) func(http.Handler) http.Handler

Middleware resolves the session token into the registered slots.

A missing token is a browser with no session yet, not a failure. A malformed or expired one is cleared and the request continues with no session. A backend failure is answered by unavailable without calling the next handler.

func (*Manager) Resolve

func (m *Manager) Resolve(carrier Carrier) (Resolved, error)

Resolve reads the session a carrier presents, and reports the failure that should be answered rather than continued through.

It is the whole of what Middleware does apart from wiring, and it exists separately so a second transport carries a session without a second copy of any of this. The rules a copy would have had to reproduce are the ones worth not reproducing: when a token rotates, which cookie is cleared on a stale record, and the difference between browser state that is merely stale and a backend that is down.

Stale or unreadable browser state is cleared and the request continues with no session, because a client holding an expired cookie has not done anything wrong. A backend failure returns an error, because continuing would serve the request as anonymous and a reader could not tell that apart from a signed-out visitor.

func (*Manager) Rotate

func (m *Manager) Rotate(w http.ResponseWriter, r *http.Request) error

Rotate revokes the record the request carries and issues a replacement under a new token, keeping every slot value.

It is the fixation defense, so it changes the token and never the state, which is what lets a login keep what the anonymous browser accumulated. It is also the promotion: the replacement is written to the configured server backend, so a Private slot that rode a sealed cookie while the session was anonymous lands on the server and its cookie is expired in the same response.

A deployment running the cookie backend promotes nothing, because the destination is where the value already is.

func (*Manager) RotateOn

func (m *Manager) RotateOn(ctx context.Context) error

RotateOn is Rotate over the context the session was attached to.

The response writer Rotate takes is not read: the cookies of a rotation go to the carrier the session was resolved with, which is fixed when the session is attached rather than when it turns. So the context is the whole input, and a transport whose request value is its context passes that.

type MemoryStore

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

MemoryStore is a process-local RawStore. It is intended for development and tests: records are concurrency-safe and expiry-aware, but disappear when the process stops and are never shared with another process.

Environment policy does not belong to the storage package. The built-in pw backend that exposes this store rejects it outside development.

func NewMemoryStore

func NewMemoryStore(now func() time.Time) *MemoryStore

NewMemoryStore returns an empty process-local store.

func (*MemoryStore) Delete

func (s *MemoryStore) Delete(ctx context.Context, keyHash string) error

func (*MemoryStore) Get

func (s *MemoryStore) Get(ctx context.Context, keyHash string) (RawRecord, error)

func (*MemoryStore) Put

func (s *MemoryStore) Put(ctx context.Context, keyHash string, record RawRecord) error

func (*MemoryStore) Touch

func (s *MemoryStore) Touch(ctx context.Context, keyHash string, lastSeenAt, idleExpiresAt time.Time) error

type Options

type Options struct {
	// TTL is the absolute session lifetime. Zero writes a browser-session
	// cookie and stamps no absolute deadline.
	TTL time.Duration
	// IdleTimeout optionally expires a session that stops being used. It must
	// not exceed TTL.
	IdleTimeout time.Duration
	// RenewalInterval bounds how often an active request renews idle expiry.
	// It defaults to one tenth of IdleTimeout.
	RenewalInterval time.Duration
	// Cookie is the policy of the token cookie.
	Cookie CookieOptions
	// RecordCookie is the policy of the sealed cookie that carries the record
	// of an anonymous session. Its name defaults to DefaultDataCookieName and
	// its remaining fields default to Cookie, so both expire under one policy.
	RecordCookie CookieOptions
	// Keys protects every slot that is not Shared. It is required unless every
	// registered slot is Shared, which is the only placement protecting
	// nothing.
	Keys *Keyring
	// ServerSideAnonymous places Private records in the configured backend
	// before authentication as well as after it. It is used by the development
	// memory backend to avoid persisting an unstable sealed-cookie format.
	ServerSideAnonymous bool
	// Version invalidates records written before an incompatible change.
	Version int
	// MaxBytes bounds a cookie name and encoded value together. It defaults to
	// DefaultMaxCookieBytes.
	MaxBytes int
	Now      func() time.Time
	Random   io.Reader
}

Options configures a Manager.

Every duration here is supplied by whatever owns the session lifetime, which is normally popcornweb/plugin/auth: an expiry states how long a proof of identity stays good, and the store holding the bytes has no basis to make that statement. A zero TTL is allowed and means the session is bounded by the browser alone.

type Placement

type Placement int

Placement states what the client may do with a registered slot and where its bytes live. It is declared where the type is registered, because what a client may do with a value is a property of the value rather than of the deployment. The deployment is left with one choice, which server backend a server-placed slot uses.

const (
	// Shared is a plain cookie the client reads and writes. A value the client
	// writes cannot live on the server, so this placement is a cookie by
	// definition. A decoded value is request input and is validated like a
	// query parameter.
	Shared Placement = iota + 1

	// ReadOnly is a signed cookie the client reads but cannot change. The
	// payload stays readable, so it carries no secret.
	ReadOnly

	// Private is sealed and unreadable by the client. It rides a sealed cookie
	// while the session is anonymous and moves to the configured server backend
	// at the login rotation, so a visitor who never logs in costs the server
	// nothing. Its anonymous phase is bounded by the browser cookie budget; a
	// value that can grow past it is declared ServerOnly instead.
	Private

	// ServerOnly is sealed and always server-placed, including while the
	// session is anonymous. The argument is revocation rather than
	// confidentiality: sealing already hides a value from the client, but a
	// cookie-placed record cannot be taken back. An anonymous write creates a
	// server record, which is what this placement asks for.
	ServerOnly

	// RequestScope lives in process memory for one request and is never
	// persisted: no cookie, no record, no backend. A middleware or handler
	// derives it from an authoritative source and later handlers in the same
	// request read it; the next request starts empty. It is for a value whose
	// freshness matters more than its cost to rebuild — the scope set a bearer
	// token resolves to against the authentication database is the standing
	// example — so staleness is prevented by reconstruction rather than
	// chased by invalidation.
	RequestScope
)

func (Placement) String

func (p Placement) String() string

String implements fmt.Stringer.

type RawRecord

type RawRecord struct {
	// Payload is the encoded application payload.
	Payload []byte
	// The remaining fields carry the same meaning as in Record.
	CreatedAt       time.Time
	AuthenticatedAt time.Time
	LastSeenAt      time.Time
	ExpiresAt       time.Time
	IdleExpiresAt   time.Time
	Method          string
	Version         int
}

RawRecord is a Record whose payload is already encoded. It is what a storage backend writes and reads, so a backend never sees the application payload type and never needs a type parameter.

func (RawRecord) Deadline

func (r RawRecord) Deadline() time.Time

Deadline is the earliest authoritative expiry of the record. A backend enforces it on read, whatever its own expiry mechanism does.

A zero field means "no such bound" rather than "the epoch", so this is the earliest of those that are set. See Record.deadline for what reading the absolute bound alone used to cost.

type RawStore

type RawStore interface {
	Put(ctx context.Context, keyHash string, record RawRecord) error
	Get(ctx context.Context, keyHash string) (RawRecord, error)
	Touch(ctx context.Context, keyHash string, lastSeenAt, idleExpiresAt time.Time) error
	Delete(ctx context.Context, keyHash string) error
}

RawStore is the contract a storage backend implements. It is Store without the type parameter, which is what lets one registry hold every backend: api:session-backend-plugin resolves a backend by name, and the host adds the payload type back with Typed.

Every rule of Store applies here unchanged: honor cancellation, never accept the raw cookie token, replace one key atomically, never revive an expired record, and treat stored expiry as authoritative.

type Record

type Record[T any] struct {
	// Data is the typed application payload.
	Data T
	// CreatedAt is when this record was written.
	CreatedAt time.Time
	// AuthenticatedAt is when the current authentication strength was
	// established. Rotation after login refreshes it.
	AuthenticatedAt time.Time
	// LastSeenAt is the last renewal timestamp.
	LastSeenAt time.Time
	// ExpiresAt is the authoritative absolute expiry.
	ExpiresAt time.Time
	// IdleExpiresAt is the optional inactivity expiry. It is never later than
	// ExpiresAt.
	IdleExpiresAt time.Time
	// Method records how the session was authenticated, such as oidc.
	Method string
	// Version invalidates records after an incompatible schema or policy
	// change.
	Version int
}

Record is the stored session state. Stores persist it under a key hash and must treat every field as immutable once written.

type Registry

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

Registry holds every piece of per-browser state an application declared.

A slot is declared once, as a Go type with a Placement, and read back by that type. The type is the key, so a misspelled name is a compile error rather than a missing value, and a package reads its own state without importing the package that owns the layout. Two packages wanting one slot share the type, which makes the sharing visible in the import graph.

Registration happens at startup, before any request decodes anything. A Registry is safe for concurrent use once a Manager has been built over it; registering after that point is refused.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty registry.

type RequestBinder

type RequestBinder interface {
	BindRequest(ctx context.Context, carrier Carrier) context.Context
}

RequestBinder is the optional contract of a Store whose records live in the browser instead of a backend, such as CookieStore. The Manager calls BindRequest before every store call it makes on behalf of a request, so the store can reach the cookie it reads and the response it writes.

A backend store implements nothing here and is unaffected, which is what keeps one Manager, one Options, and one handler working over a cookie, an RDB, or a Redis store.

type Resolved

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

Resolved is a session resolved against one request, ready to be published wherever that transport publishes request state.

func (Resolved) Attach

func (r Resolved) Attach(ctx context.Context) context.Context

Attach returns ctx carrying this session.

func (Resolved) StoreOn

func (r Resolved) StoreOn(store ValueStore)

StoreOn records this session on a request value that carries its own state.

It exists because the key is this package's and should stay that way: a transport publishing the session would otherwise need the key exported, and an exported key is one any code can write, which for a session means any code can present one.

type Slot

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

Slot is the request-scoped handle to one registered piece of state.

func Value

func Value[T any](ctx context.Context) (*Slot[T], bool)

Value returns the request-scoped handle for T. It reports false when the session middleware did not run or T is not registered.

func (*Slot[T]) Clear

func (s *Slot[T]) Clear() error

Clear removes the value and makes the request look like it carried none.

func (*Slot[T]) Get

func (s *Slot[T]) Get() (T, bool)

Get returns the current value and whether the request carried a usable one.

func (*Slot[T]) Set

func (s *Slot[T]) Set(value T) error

Set writes value and makes it what later handlers in this request read.

A cookie-placed slot writes Set-Cookie immediately and therefore precedes response commitment. A record-placed slot is flushed once, before the response is committed. The first Set on any slot issues the session token, so a visitor who writes nothing receives no cookie and occupies no storage.

type SlotOption

type SlotOption func(*slot) error

SlotOption states how long one registered slot lives, and what ends it.

The two axes are independent, with one constraint: a value may always die before its session, but only a value the browser holds can outlive it, because a session-placed value is destroyed with the record that holds it.

func ExpiresAfter

func ExpiresAfter(d time.Duration) SlotOption

ExpiresAfter bounds a slot to d, whatever its placement.

It is how a value dies before the session that carries it does: a CSRF secret rotated on a schedule, a step-up admission good for seconds, a cached decision good for minutes. A record-placed slot carries its own deadline inside the record and is dropped on read once it passes; a cookie-placed one carries it as the cookie lifetime.

The slot still dies with the session. Use OutlivesSession for a value that should not.

func OutlivesSession

func OutlivesSession(d time.Duration) SlotOption

OutlivesSession keeps a slot for d and exempts it from the destruction of the session, so a sign-out leaves it alone.

It is what a display language or a density preference wants: state that belongs to the browser rather than to whoever is signed in. Pass BrowserMax to keep it as long as a browser will.

It is refused for session.Private and session.ServerOnly. Those live in the session record, and a record cannot outlive its own destruction; the refusal is a registration error rather than a surprise at logout.

func ResetOnRotate

func ResetOnRotate() SlotOption

ResetOnRotate drops the slot at a rotation instead of carrying it forward.

A rotation normally preserves every value, which is what lets a login keep what the anonymous browser accumulated. A few values must not survive it: a CSRF secret is the standing example, because policy:session-security requires it to change with the session, so a token minted before a sign-in cannot be presented after one.

It states what a rotation does to the value, where ExpiresAfter states what time does and OutlivesSession states what a destroy does.

type Store

type Store[T any] interface {
	// Put replaces one key atomically.
	Put(ctx context.Context, keyHash string, record Record[T]) error
	// Get returns ErrNotFound for a missing key and ErrExpired for a record
	// past its absolute or idle expiry.
	Get(ctx context.Context, keyHash string) (Record[T], error)
	// Touch renews an existing record. It never revives a missing or expired
	// one.
	Touch(ctx context.Context, keyHash string, lastSeenAt, idleExpiresAt time.Time) error
	// Delete is idempotent.
	Delete(ctx context.Context, keyHash string) error
}

Store persists session records behind one context-aware contract. Stores never accept or return the raw cookie token.

func Typed

func Typed[T any](raw RawStore, codec Codec[T]) Store[T]

Typed adds the payload type back to a RawStore, producing the Store a Manager takes. The codec belongs to the host, which is why a backend can be selected by configuration without knowing what an application stores.

type UnavailableHandler

type UnavailableHandler func(http.ResponseWriter, *http.Request, error)

UnavailableHandler responds to a request whose session backend could not be reached. The lifecycle fails closed here instead of silently downgrading the request to unauthenticated.

type ValueStore

type ValueStore interface {
	SetUserValue(key, value any)
}

ValueStore is a request value that carries its own state instead of being replaced by a derived copy, which is how the second transport publishes request state.

Jump to

Keyboard shortcuts

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