Documentation
¶
Overview ¶
Package session keeps server-side sessions for every provider of the module and authenticates requests with them.
A Store issues sessions: the cookie carries 32 random bytes only, the Backend sees the SHA-256 of them and AES-256-GCM sealed values. Providers save the authenticated auth.Identity in the session; a Guard turns a request with such a session into a request with the identity in its context, or refuses it (401 JSON for APIs, 302 to a login URL for UIs), and re-validates identities through Refreshers at an interval.
The package works on net/http and context.Context only, never logs and never starts goroutines; expiry is lazy. Every component is safe for concurrent use after construction.
Index ¶
- Variables
- func ParseKey(encoded string) ([]byte, error)
- type Backend
- type Guard
- type GuardOptions
- type MemoryBackend
- func (b *MemoryBackend) CompareAndSwap(_ context.Context, sessionID, key string, previous, value []byte) (bool, error)
- func (b *MemoryBackend) Get(_ context.Context, sessionID, key string) ([]byte, bool, error)
- func (b *MemoryBackend) Pop(_ context.Context, sessionID, key string) ([]byte, bool, error)
- func (b *MemoryBackend) Put(_ context.Context, sessionID, key string, value []byte, ttl time.Duration) error
- func (b *MemoryBackend) Revoke(_ context.Context, sessionID string, ttl time.Duration) error
- type MemoryBackendOptions
- type Refresher
- type Session
- func (s Session) Get(ctx context.Context, key string) ([]byte, bool, error)
- func (s Session) Identity(ctx context.Context) (*auth.Identity, error)
- func (s Session) Pop(ctx context.Context, key string) ([]byte, bool, error)
- func (s Session) Put(ctx context.Context, key string, value []byte, ttl time.Duration) error
- func (s Session) SaveIdentity(ctx context.Context, identity *auth.Identity) error
- func (s Session) SwapIdentity(ctx context.Context, previous, identity *auth.Identity) (bool, error)
- type Store
- func (s *Store) End(ctx context.Context, writer http.ResponseWriter, request *http.Request) error
- func (s *Store) MaximumAge() time.Duration
- func (s *Store) Open(request *http.Request) (Session, error)
- func (s *Store) SameSite() http.SameSite
- func (s *Store) Start(ctx context.Context, writer http.ResponseWriter, request *http.Request) (Session, error)
- type StoreOptions
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrRevoked = errors.New("session revoked")
ErrRevoked is returned by Backend.Put for a session that carries a revocation marker. Store and Session translate it into an auth.ErrUnauthenticated refusal.
Functions ¶
Types ¶
type Backend ¶
type Backend interface {
// Put stores value under (sessionID, key) for ttl, replacing an existing value; ErrRevoked when revoked.
Put(ctx context.Context, sessionID, key string, value []byte, ttl time.Duration) error
// Get returns the live value under (sessionID, key); found is false when there is none or it expired.
Get(ctx context.Context, sessionID, key string) (value []byte, found bool, err error)
// Pop returns and deletes the live value in one step: of two concurrent Pops at most one finds the value.
Pop(ctx context.Context, sessionID, key string) (value []byte, found bool, err error)
// CompareAndSwap replaces the live value under (sessionID, key) with value in one step, keeping its expiry,
// only when it still equals previous; swapped is false when it changed, expired or was revoked meanwhile.
CompareAndSwap(ctx context.Context, sessionID, key string, previous, value []byte) (swapped bool, err error)
// Revoke deletes every key of sessionID. When there was any, it writes a marker living ttl, or until the
// latest of the deleted values would have expired when that is sooner.
Revoke(ctx context.Context, sessionID string, ttl time.Duration) error
}
Backend stores the server-side half of sessions: one opaque, already encrypted value per (sessionID, key). sessionID is a non-secret identifier derived from the cookie (SHA-256), never the cookie value. Every operation is atomic per key; Put is serialized against Revoke of the same sessionID, so after Revoke returned no Put for that sessionID succeeds until the marker expires or a full backend evicts it (MemoryBackend; Redis under its own eviction policy). A marker never outlives the values it replaced, and a sessionID without values gets none: revoking unknown IDs stores nothing. Values expire after their own ttl, which is always positive. Applications may implement it (e.g. on Postgres); share one backend between replicas. Implementations must be safe for concurrent use.
type Guard ¶
type Guard struct {
// contains filtered or unexported fields
}
Guard authenticates requests by their session: it puts the stored identity into the request context or refuses the request. One Guard serves every provider. It is safe for concurrent use.
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/govengers/auth"
"github.com/govengers/auth/session"
)
// newExampleStore builds a store on the in-process backend; production code shares a Redis backend
// between replicas and reads the key from its configuration.
func newExampleStore() (*session.Store, error) {
backend, err := session.NewMemoryBackend(nil)
if err != nil {
return nil, err
}
key, err := session.ParseKey("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
if err != nil {
return nil, err
}
return session.NewStore(&session.StoreOptions{Backend: backend, EncryptionKey: key})
}
// loginHandler stands in for a provider: it starts a fresh session and saves the identity in it.
func loginHandler(store *session.Store) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
current, err := store.Start(request.Context(), writer, request)
if err != nil {
auth.WriteError(writer, err)
return
}
identity := &auth.Identity{Provider: "password", Subject: "user-1", Username: "ada", Active: true}
if err := current.SaveIdentity(request.Context(), identity); err != nil {
auth.WriteError(writer, err)
return
}
writer.WriteHeader(http.StatusNoContent)
})
}
// withCookies returns a GET request to target carrying the cookies response set, as a browser would.
func withCookies(target string, response *httptest.ResponseRecorder) *http.Request {
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil)
for _, line := range response.Header().Values("Set-Cookie") {
if cookie, err := http.ParseSetCookie(line); err == nil {
request.AddCookie(cookie)
}
}
return request
}
func main() {
store, err := newExampleStore()
if err != nil {
fmt.Println(err)
return
}
guard, err := session.NewGuard(&session.GuardOptions{Store: store, LoginURL: "/login"})
if err != nil {
fmt.Println(err)
return
}
serveMux := http.NewServeMux()
serveMux.Handle("POST /login", loginHandler(store))
serveMux.Handle("GET /api/me", guard.RequireAPI()(auth.IdentityHandler()))
serveMux.Handle("GET /reports", guard.RequireUI()(http.NotFoundHandler()))
anonymousAPI := httptest.NewRecorder()
serveMux.ServeHTTP(anonymousAPI, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/me", nil))
anonymousUI := httptest.NewRecorder()
serveMux.ServeHTTP(anonymousUI, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/reports?year=2026", nil))
login := httptest.NewRecorder()
serveMux.ServeHTTP(login, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil))
authenticated := httptest.NewRecorder()
serveMux.ServeHTTP(authenticated, withCookies("/api/me", login))
fmt.Print(anonymousAPI.Code, " ", anonymousAPI.Body.String())
fmt.Println(anonymousUI.Code, anonymousUI.Header().Get("Location"))
fmt.Print(authenticated.Code, " ", authenticated.Body.String())
}
Output: 401 {"code":"unauthenticated","message":"authentication required"} 302 /login?return_to=%2Freports%3Fyear%3D2026 200 {"provider":"password","subject":"user-1","username":"ada","active":true,"roles":[]}
func NewGuard ¶
func NewGuard(options *GuardOptions) (*Guard, error)
NewGuard validates options and returns a Guard working on a copy of them.
func (*Guard) RequireAPI ¶
RequireAPI refuses unauthenticated requests with 401 JSON, forbidden ones with 403 JSON. A request that already carries an identity (e.g. from an API key) passes untouched.
type GuardOptions ¶
type GuardOptions struct {
Store *Store // required
LoginURL string // RequireUI target; absolute http(s) URL or path starting with "/", without a fragment; default "/"
ReturnPathHeader string // header carrying the original request URI (forward auth: "X-Forwarded-Uri"); default: request.URL.RequestURI()
Refreshers []Refresher // optional; names must be unique
RefreshInterval time.Duration // default 1 min
// MaximumUnverifiedAge bounds a degraded refresh: a refresh failing for longer than this since the login
// or the last successful refresh ends the session. Default 1 h (RefreshInterval when that is longer); at
// least RefreshInterval.
MaximumUnverifiedAge time.Duration
Observer auth.Observer // optional
}
GuardOptions configures NewGuard.
type MemoryBackend ¶
type MemoryBackend struct {
// contains filtered or unexported fields
}
MemoryBackend implements Backend with a map and one mutex; expiry is lazy (no goroutine). It is correct for exactly one replica and safe for concurrent use. A full backend evicts revocation markers like sessions, so a flood of login starts cannot block every new session; the cost is that an evicted marker no longer refuses a Put for its revoked session. Nothing can give that session an identity again (SaveIdentity and the Guard swap against a live identity, and only the first save of a Start handle writes without one), but a provider or application value may land in it. Meant for one replica at non-production scale; use Redis in production. ponytail: O(n) eviction scan when full; shard or use Redis if that ever shows in profiles.
func NewMemoryBackend ¶
func NewMemoryBackend(options *MemoryBackendOptions) (*MemoryBackend, error)
NewMemoryBackend returns the in-process backend: correct for exactly one replica. nil options allowed.
func (*MemoryBackend) CompareAndSwap ¶
func (b *MemoryBackend) CompareAndSwap(_ context.Context, sessionID, key string, previous, value []byte) (bool, error)
CompareAndSwap replaces the live value under (sessionID, key) with a copy of value, keeping its expiry, when it equals previous.
func (*MemoryBackend) Put ¶
func (b *MemoryBackend) Put(_ context.Context, sessionID, key string, value []byte, ttl time.Duration) error
Put stores a copy of value under (sessionID, key) for ttl; ErrRevoked when sessionID is revoked.
type MemoryBackendOptions ¶
type MemoryBackendOptions struct {
// MaximumSessions defaults to 100000, MaximumBytes (session IDs, keys and values held) to 64 MiB. When a
// write would pass either (a replacement counts only what it adds), expired entries are dropped, then the entries expiring soonest are evicted, a
// live revocation marker included. A single value that cannot fit is refused.
MaximumSessions int
MaximumBytes int
}
MemoryBackendOptions configures NewMemoryBackend.
type Refresher ¶
type Refresher interface {
Name() string // the Identity.Provider it refreshes
Refresh(ctx context.Context, current Session, identity *auth.Identity) (*auth.Identity, error)
}
Refresher re-validates identities of one provider. The Guard calls it for identities whose last refresh is older than RefreshInterval, always with the real identity (identity.Real()). Return a copy with provider-owned fields updated; keep every other field (roles set by OnLogin etc.). ErrUnauthenticated or ErrForbidden end the session; any other error keeps the identity (degraded) until the last successful verification is older than GuardOptions.MaximumUnverifiedAge. Refresh runs without the request's cancellation, bounded by 30 s. Implementations must be safe for concurrent use.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is a handle on one server-side session (value type, unexported fields: the backend ID and the store). Values are AES-256-GCM sealed under a key derived per session from the store's key (HKDF-SHA256), with a random 12-byte nonce and additional data "<sessionID>\x00<key>"; a value that does not decrypt (moved, tampered, other key) counts as absent. A Session is safe for concurrent use; the zero value is not usable. The name repeats the package on purpose (NAM-003): it is the package's domain noun, like time.Time, and session.Handle would hide what it is a handle on.
func (Session) Get ¶
Get returns the value under key; false when there is none, it expired or it does not decrypt.
func (Session) Identity ¶
Identity returns the stored identity; none, expired (now > authenticatedAt + MaximumAge) or revoked: *auth.Error ErrUnauthenticated.
func (Session) Pop ¶
Pop returns the value under key and deletes it; of two concurrent Pops at most one finds it. The key "identity" is reserved, as for Put.
func (Session) Put ¶
Put seals value and stores it under key for ttl (positive). A revoked session refuses with ErrUnauthenticated. The key "identity" is reserved: only SaveIdentity and SwapIdentity write it, with their lifetime and swap rules.
func (Session) SaveIdentity ¶
SaveIdentity stores identity under key "identity". The first save of a session sets authenticatedAt to the time Start issued it; later saves (refresh, TOTP enable, impersonation) keep it. refreshedAt=now. TTL = authenticatedAt + MaximumAge - now. Only a session returned by Start may have a first save, and only one: without a live identity (never authenticated, expired, revoked) any other save refuses with ErrUnauthenticated, so neither a planted cookie, a save racing the expiry nor a retained Start handle gets a fresh lifetime. A later save swaps against the identity it read, so a save racing a logout cannot write the identity back even where the backend forgot the revocation (a full MemoryBackend).
func (Session) SwapIdentity ¶
SwapIdentity saves identity in place of previous (an identity Identity returned) only while the session still holds an identity equal to it (same JSON); swapped is false when another request saved a different one meanwhile or changed the session between the read and the swap. It keeps authenticatedAt and the expiry, refreshedAt=now. A session without a live identity refuses with ErrUnauthenticated, like Identity.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store issues and ends server-side sessions. The cookie carries 32 random bytes; the backend sees their SHA-256 and sealed values only. It is safe for concurrent use.
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/govengers/auth"
"github.com/govengers/auth/session"
)
// newExampleStore builds a store on the in-process backend; production code shares a Redis backend
// between replicas and reads the key from its configuration.
func newExampleStore() (*session.Store, error) {
backend, err := session.NewMemoryBackend(nil)
if err != nil {
return nil, err
}
key, err := session.ParseKey("MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
if err != nil {
return nil, err
}
return session.NewStore(&session.StoreOptions{Backend: backend, EncryptionKey: key})
}
// loginHandler stands in for a provider: it starts a fresh session and saves the identity in it.
func loginHandler(store *session.Store) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
current, err := store.Start(request.Context(), writer, request)
if err != nil {
auth.WriteError(writer, err)
return
}
identity := &auth.Identity{Provider: "password", Subject: "user-1", Username: "ada", Active: true}
if err := current.SaveIdentity(request.Context(), identity); err != nil {
auth.WriteError(writer, err)
return
}
writer.WriteHeader(http.StatusNoContent)
})
}
// withCookies returns a GET request to target carrying the cookies response set, as a browser would.
func withCookies(target string, response *httptest.ResponseRecorder) *http.Request {
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, target, nil)
for _, line := range response.Header().Values("Set-Cookie") {
if cookie, err := http.ParseSetCookie(line); err == nil {
request.AddCookie(cookie)
}
}
return request
}
func main() {
store, err := newExampleStore()
if err != nil {
fmt.Println(err)
return
}
login := httptest.NewRecorder()
loginHandler(store).ServeHTTP(login, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil))
current, err := store.Open(withCookies("/", login))
if err != nil {
fmt.Println(err)
return
}
identity, err := current.Identity(context.Background())
if err != nil {
fmt.Println(err)
return
}
fmt.Println(identity.Provider, identity.Username)
}
Output: password ada
func NewStore ¶
func NewStore(options *StoreOptions) (*Store, error)
NewStore validates options and returns a Store working on a copy of them. A Secure, host-only cookie on path "/" is named "__Host-" + CookieName, unless CookieName has its own "__Host-" or "__Secure-" prefix: browsers accept such a cookie only from this host over https, so a sibling subdomain cannot toss a cookie of the same name that the store would read first. A "__Host-" CookieName needs exactly those attributes, a "__Secure-" one a Secure cookie.
func (*Store) End ¶
End revokes the request's session (with context.WithoutCancel and its own 10 s timeout, so a disconnecting client cannot keep it and a hung backend cannot hold the request) and expires the cookie. No cookie: only expires it.
func (*Store) MaximumAge ¶
MaximumAge returns the configured absolute session lifetime.
func (*Store) Open ¶
Open returns the handle of the request's session from its cookie (no I/O). No or malformed cookie: *auth.Error ErrUnauthenticated.
func (*Store) SameSite ¶
SameSite returns the cookie's SameSite mode. The OIDC and GitHub providers refuse a store with http.SameSiteStrictMode: the browser withholds a Strict cookie on the redirect back from the provider, and the login state with it.
func (*Store) Start ¶
func (s *Store) Start(ctx context.Context, writer http.ResponseWriter, request *http.Request) (Session, error)
Start issues a new session: revokes the request's current session (if any), sets a fresh cookie, returns the handle. Used at login start and again after authentication (session ID rotation, fixation defense).
type StoreOptions ¶
type StoreOptions struct {
Backend Backend // required
EncryptionKey []byte // required, exactly 32 bytes (AES-256-GCM); see ParseKey
CookieName string // default "auth_session"; "__Host-" is prepended when Secure, host-only and on "/" (see NewStore)
CookieDomain string // default "" (host-only cookie)
CookiePath string // default "/"
SameSite http.SameSite // Lax (default), Strict or None; None requires InsecureCookie false (front-channel logout needs None); Strict only for password-only stores (oidc and github refuse it); SameSiteDefaultMode would send no attribute and is refused
InsecureCookie bool // drops the Secure attribute; local HTTP development only
MaximumAge time.Duration // default 24h, at least 1s; absolute session lifetime, also the cookie Max-Age
}
StoreOptions configures NewStore.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package redisbackend implements session.Backend on Redis, so that sessions work across replicas.
|
Package redisbackend implements session.Backend on Redis, so that sessions work across replicas. |