Help us shape the future of Go! Take the 2026 Go Developer Survey

session

package
v1.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 31, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

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. An Impersonator lets an identity act as another one in its own session, for a bounded time; it is the only way to start or stop an impersonation.

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

Examples

Constants

This section is empty.

Variables

View Source
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

func ParseKey

func ParseKey(encoded string) ([]byte, error)

ParseKey decodes a base64 (standard or URL alphabet, padding optional) encryption key and requires exactly 32 bytes, not one byte repeated (all zero included). Generate one with `openssl rand -base64 32`. Errors never repeat the value.

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. An impersonation whose MaximumDuration is up ends on the next request, which then carries the impersonator's identity. 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":[]}
Example (Identify)

ExampleGuard_identify re-validates a long-lived connection by the cookies of its upgrade request, the way a WebSocket does on every message: no response is written.

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})
	if err != nil {
		fmt.Println(err)
		return
	}
	login := httptest.NewRecorder()
	loginHandler(store).ServeHTTP(login, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil))
	upgradeRequest := withCookies("/ws", login)

	identity, err := guard.Identify(context.Background(), upgradeRequest) // later: on every message or a timer tick
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(identity.Subject)
	_, err = guard.Identify(context.Background(), httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ws", nil))
	fmt.Println(err)
}
Output:
user-1
unauthenticated: no valid session cookie

func NewGuard

func NewGuard(options *GuardOptions) (*Guard, error)

NewGuard validates options and returns a Guard working on a copy of them.

func (*Guard) Identify added in v1.3.0

func (g *Guard) Identify(ctx context.Context, request *http.Request) (*auth.Identity, error)

Identify returns the identity of the request's session without writing a response or a cookie: the same lookup RequireAPI does, for a caller that has no response any more, such as a WebSocket re-validating the cookies of its upgrade request for as long as the socket lives. It opens the session from the request's cookies, ends an impersonation whose MaximumDuration is up (the identity is then the impersonator's), runs a due refresh through the Refresher and reports to the Observer, exactly as the middleware does; all of that writes the Store, never a cookie. The errors are the middleware's refusals: an *auth.Error of kind ErrUnauthenticated without a valid session cookie or for an ended or expired session, the Refresher's ErrUnauthenticated or ErrForbidden for a refused refresh, any other error for a failing backend. A refused refresh ends the session in the Store as the middleware would; only the cookie stays with the client, naming a revoked session that every later request is refused for, until a login or logout replaces it or its Max-Age runs out. An identity already in ctx is not consulted: Identify is a lookup, not middleware. The lookup runs on ctx, not on the request's context, which an upgrade request no longer has once its handler returned.

func (*Guard) RequireAPI

func (g *Guard) RequireAPI() func(http.Handler) http.Handler

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.

func (*Guard) RequireUI

func (g *Guard) RequireUI() func(http.Handler) http.Handler

RequireUI redirects unauthenticated requests 302 to LoginURL?return_to=<path> (path omitted when unsafe); forbidden ones get 403 JSON (a redirect would loop). 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 ImpersonationLoader added in v1.1.0

type ImpersonationLoader func(ctx context.Context, subject string) (*auth.Identity, error)

ImpersonationLoader returns the identity of the user subject names, as a login of that user would produce it (OnLogin applied), or nil without an error when there is none. ctx carries the impersonator's identity (auth.IdentityFrom), so a loader can refuse a target with an *auth.Error, e.g. one of another tenant. It must be safe for concurrent use.

type Impersonator added in v1.1.0

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

Impersonator lets an identity act as another one in its own session. It is the only way to start or stop an impersonation: SaveIdentity and SwapIdentity refuse to. An impersonation needs an identity that passes Actor and is not impersonating already, and a target that Load returns, that passes Target, is active and is not the impersonator. It keeps the session (its cookie, lifetime and provider values) and ends when stopped, with the session, or MaximumDuration after its start, whichever comes first. It is safe for concurrent use.

Example
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"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})
}

// 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
}

// adminLoginHandler stands in for a provider whose OnLogin grants the admin role.
func adminLoginHandler(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
		}
		admin := &auth.Identity{Provider: "password", Subject: "admin-1", Username: "grace", Active: true, Roles: []string{auth.RoleAdmin}}
		if err := current.SaveIdentity(request.Context(), admin); err != nil {
			auth.WriteError(writer, err)
			return
		}
		writer.WriteHeader(http.StatusNoContent)
	})
}

func main() {
	store, err := newExampleStore()
	if err != nil {
		fmt.Println(err)
		return
	}
	guard, err := session.NewGuard(&session.GuardOptions{Store: store})
	if err != nil {
		fmt.Println(err)
		return
	}
	users := map[string]*auth.Identity{"user-2": {Provider: "password", Subject: "user-2", Username: "ada", Active: true}}
	impersonator, err := session.NewImpersonator(&session.ImpersonatorOptions{
		Guard: guard,
		// Actor defaults to authorization.IsAdmin(), Target to authorization.Not(authorization.IsAdmin()).
		Load: func(_ context.Context, subject string) (*auth.Identity, error) {
			return users[subject].Clone(), nil // your user table and OnLogin; nil: no such user
		},
	})
	if err != nil {
		fmt.Println(err)
		return
	}
	serveMux := http.NewServeMux()
	serveMux.Handle("POST /login", adminLoginHandler(store))
	serveMux.Handle("GET /api/me", guard.RequireAPI()(auth.IdentityHandler()))
	auth.RegisterRoutes(serveMux, impersonator.Routes()) // POST and DELETE /auth/impersonation

	login := httptest.NewRecorder()
	serveMux.ServeHTTP(login, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil))
	start := withCookies("/auth/impersonation", login)
	start.Method, start.Body = http.MethodPost, io.NopCloser(strings.NewReader(`{"subject":"user-2"}`))
	start.Header.Set("Content-Type", "application/json")
	serveMux.ServeHTTP(httptest.NewRecorder(), start)
	impersonating := httptest.NewRecorder()
	serveMux.ServeHTTP(impersonating, withCookies("/api/me", login))

	fmt.Print(impersonating.Body.String())
}
Output:
{"provider":"password","subject":"user-2","username":"ada","active":true,"roles":[],"impersonator":{"provider":"password","subject":"admin-1","username":"grace","active":true,"roles":["admin"]}}

func NewImpersonator added in v1.1.0

func NewImpersonator(options *ImpersonatorOptions) (*Impersonator, error)

NewImpersonator validates options and returns an Impersonator working on a copy of them.

func (*Impersonator) Routes added in v1.1.0

func (i *Impersonator) Routes() []auth.Route

Routes returns POST {PathPrefix} (JSON body {"subject": "..."}: Start, answers 200 with the impersonating identity's auth.IdentityResponse) and DELETE {PathPrefix} (Stop, answers 200 with the impersonator's), both behind the Guard's RequireAPI. Register them with auth.RegisterRoutes or ginadapter.RegisterRoutes. The POST requires Content-Type application/json and DELETE is no simple request, so a cross-site page cannot send either without a CORS preflight.

func (*Impersonator) Start added in v1.1.0

func (i *Impersonator) Start(request *http.Request, subject string) (*auth.Identity, error)

Start makes the request's identity (auth.IdentityFrom, put there by the Guard) impersonate the identity Load returns for subject, in the request's session, and returns the impersonating identity. Refusals: no identity 401; already impersonating 409 already_impersonating; inactive 403; an empty subject 400; Actor's or Load's refusal; no such target 400 unknown_impersonation_target; the impersonator itself 400 self_impersonation; an inactive target 403; Target's refusal; a session that no longer holds the request's identity 409. It reports the outcome (Action impersonation_start) under the impersonator's provider.

func (*Impersonator) Stop added in v1.1.0

func (i *Impersonator) Stop(request *http.Request) (*auth.Identity, error)

Stop ends the impersonation of the request's identity (auth.IdentityFrom) in the request's session and returns the impersonator's identity as the session now holds it. Refusals: no identity 401; not impersonating 409 not_impersonating; a session that no longer holds the request's identity 409. It reports the outcome (Action impersonation_stop) under the impersonator's provider.

type ImpersonatorOptions added in v1.1.0

type ImpersonatorOptions struct {
	Guard *Guard              // required; its Store holds the sessions, and Routes run behind its RequireAPI
	Load  ImpersonationLoader // required
	// Actor decides who may impersonate, evaluated against the impersonator's identity; default
	// authorization.IsAdmin().
	Actor authorization.Check
	// Target decides who may be impersonated, evaluated against the target's identity; default
	// authorization.Not(authorization.IsAdmin()), so nobody gains an admin's rights by impersonating one.
	Target          authorization.Check
	MaximumDuration time.Duration // default 1 h, at least 1 s; the Guard ends an impersonation that long after its start
	PathPrefix      string        // default "/auth/impersonation"; segments of A-Z a-z 0-9 . _ ~ -, no trailing "/"
	Observer        auth.Observer // optional
}

ImpersonatorOptions configures NewImpersonator.

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.

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) Get

func (b *MemoryBackend) Get(_ context.Context, sessionID, key string) ([]byte, bool, error)

Get returns a copy of the live value under (sessionID, key).

func (*MemoryBackend) Pop

func (b *MemoryBackend) Pop(_ context.Context, sessionID, key string) ([]byte, bool, error)

Pop returns the live value under (sessionID, key) and deletes it.

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.

func (*MemoryBackend) Revoke

func (b *MemoryBackend) Revoke(_ context.Context, sessionID string, ttl time.Duration) error

Revoke deletes every value of sessionID. When there was any, the entry becomes a marker that refuses Puts for ttl, or until the latest of those values would have expired when that is sooner; it never takes a new slot.

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 (a Refresher that is also a Revoker is called first); 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 Revoker added in v1.1.0

type Revoker interface {
	RevokeSession(ctx context.Context, current Session) error
}

Revoker is optionally implemented by a Refresher whose provider holds state for a session at the identity provider, such as GitHub's OAuth token. Before the Guard ends a session whose refresh it refused (a refusal of the Refresher, or a failure lasting longer than MaximumUnverifiedAge), it calls RevokeSession with the session, which still holds the provider's values, on a context without the request's cancellation and with its own 10 s timeout. An error only degrades the outcome: the session ends either way. A session that expires (MaximumAge) or ends otherwise is not seen by the Guard, so it is not called then. 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

func (s Session) Get(ctx context.Context, key string) ([]byte, bool, error)

Get returns the value under key; false when there is none, it expired or it does not decrypt.

func (Session) Identity

func (s Session) Identity(ctx context.Context) (*auth.Identity, error)

Identity returns the stored identity; none, expired (now > authenticatedAt + MaximumAge) or revoked: *auth.Error ErrUnauthenticated.

func (Session) Pop

func (s Session) Pop(ctx context.Context, key string) ([]byte, bool, error)

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

func (s Session) Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

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

func (s Session) SaveIdentity(ctx context.Context, identity *auth.Identity) error

SaveIdentity stores identity under key "identity". The first save of a session sets authenticatedAt to the time Start issued it; later saves (TOTP enable, application changes) keep it and refreshedAt: they verify nothing, so only the login and a Guard refresh may postpone the next refresh. 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). A save never changes who the session belongs to: the first one refuses an impersonating identity, a later one an identity or impersonator of another provider or subject than the stored ones (see Impersonator).

func (Session) SwapIdentity

func (s Session) SwapIdentity(ctx context.Context, previous, identity *auth.Identity) (bool, error)

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, the expiry and refreshedAt, like SaveIdentity. A session without a live identity refuses with ErrUnauthenticated, like Identity. Like SaveIdentity it never changes who the session belongs to: an identity or impersonator of another provider or subject than the stored ones is an error (see Impersonator).

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

func (s *Store) End(ctx context.Context, writer http.ResponseWriter, request *http.Request) error

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

func (s *Store) MaximumAge() time.Duration

MaximumAge returns the configured absolute session lifetime.

func (*Store) Open

func (s *Store) Open(request *http.Request) (Session, error)

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

func (s *Store) SameSite() http.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), not one byte repeated; 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.

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.

Jump to

Keyboard shortcuts

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