session

package
v0.0.0-...-5e3dc0d Latest Latest
Warning

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

Go to latest
Published: Nov 9, 2026 License: MIT Imports: 18 Imported by: 0

README

Opaque browser sessions

Use session.Manager for same-origin HTTPS browsers and apikey.Manager for CLI/CI callers. Inject both into auth.NewChain(sessionManager, keyManager) and wrap protected handlers with server/middleware.HTTPAuth(chain, authctx.Set[auth.Principal]). Missing credentials are rejected by default; WithMissingPolicy(AcceptMissing) dispatches them without storing an identity, so required guards still reject them. Invalid or ambiguous credentials always fail.

Compose the owners

  1. Open a production database.DB. Select database/sqlite for local use; database/postgres is opt-in.
  2. Build cfg := session/database.Migrations(db, sqlite.MigrateDriver()), apply it with cfg.Apply(ctx, database.SchemaVersion) and check cfg.Ready(ctx, database.SchemaVersion). Session migrations track their version in auth_session_schema_migrations (database.VersionTable), separate from the application's schema_migrations, so the sets keep independent versions in one database. On PostgreSQL use postgres.MigrateDriver() and set cfg.Table.Schema to the application schema, or include the set in the postgres.SchemaPlan applied by ApplySchema. Never use AutoMigrate.
  3. Borrow the database with session/database.NewStore(db). An optional util.Clock argument supports deterministic integration tests.
  4. Create security.NewSignedCSRF(csrfSecret, rand.Reader). The CSRF secret and session pepper must be independent, at least 32 bytes each, and shared across instances. Inject util.SystemClock{} and rand.Reader into session.Config.
  5. Call session.NewManager(config), then session.NewLocalBackend(manager, verifier) and session.NewHandler(backend, session.HandlerConfig{Origin: "https://your-host", Errors: errorWriter, Clock: util.SystemClock{}}). LoginVerifier.VerifyLogin(ctx, session.LoginCredentials) returns an application-verified principal with subject, kind and explicit restrictions. It does not encode workspace membership. ErrorWriter is the outer transport's normalizer; expose only approved application failure fields, never causes.
  6. Close the manager before the borrowed database. Close(ctx) synchronously ends local stream lifetimes and waits for its owned lease and cleanup workers.
Remote authority

A gateway implements session.Backend over its own client and injects it into NewHandler. The handler retains origin, JSON, cookie, and logout CSRF checks. Grants must contain a valid auth.Session principal, future expiry, and CSRF token; backend errors require normalization through HandlerConfig.Errors.

LoginRequest.Source uses the connection's RemoteAddr IP, never forwarding headers; absent IPs produce zero. Reuse ParseCSRFToken and CheckOrigin for other unsafe cookie routes before forwarding.

session.Config.Clock is the injected util.Clock owning absolute session expiry and retention dates. Independent util.MonotonicClock runtime timing owns stream leases, polling and cleanup cadence: a frozen domain clock or backward wall-clock adjustment cannot stop them. Tests use testing/synctest for deterministic monotonic timing rather than advancing domain time to drive transport timers.

HTTP contract

Method and route Request Success
POST /auth/login Exact configured Origin; Content-Type: application/json; username/password JSON, at most 4 KiB 200 session response plus session cookie
GET /auth/session Session cookie 200 session response; never Set-Cookie
POST /auth/logout Exact configured Origin; session cookie and X-CSRF-Token 204 after committed family revocation; deletion cookie

Login and status return:

{"status":"authenticated","identity":{"subject":"user-123","kind":"user","restrictions":{"mode":"unrestricted"}},"expiresAt":"2026-01-01T01:00:00Z","csrfToken":"<signed-generation-bound-token>"}

Identity contains no cookie, protected credential reference, IdP token or application-specific membership. The cookie is named __Host-session and always has Secure; HttpOnly; SameSite=Strict; Path=/, no Domain, and one-hour expiry. HTTPS is required locally too: use a locally trusted certificate and trust it explicitly in browser and readiness clients. Never disable certificate verification or weaken cookie flags.

Only unsafe cookie-authenticated requests need one X-CSRF-Token header, including Connect POST and logout. Header API keys need neither cookie nor CSRF. CSRF tokens contain a cryptographic nonce and HMAC signature bound to the protected generation reference; they cannot cross sessions or generations. They are at most 256 bytes. Login has no synchronizer token yet and instead requires exact Origin plus JSON.

The chain accepts exactly one __Host-session cookie or one X-API-Key header. Empty, duplicate, mixed and reserved unsupported Authorization credentials fail closed. Session credentials are exactly 43 canonical base64url characters encoding 32 random bytes. API keys are at most 512 bytes and resolve through an indexed HMAC digest, not prefix scans.

The shared credential-protection helper includes a versioned HMAC domain prefix: API keys use "apikey" and sessions use "session". Identical credential material and pepper produce different digests across these domains. API-key validation never performs a usage-metadata write; optional usage observation is an explicit separate store operation.

Lifetimes and rotation

stateDiagram-v2
    [*] --> Active: login commits
    Active --> Active: CAS rotation / same absolute expiry
    Active --> Revoked: logout from any generation
    Active --> Expired: one-hour absolute expiry
    Revoked --> [*]: bounded retention cleanup
    Expired --> [*]: bounded retention cleanup

Every generation points to one transactional family; an old generation can still revoke all its replacements.

Create(ctx, principal) returns Issued{Token, Principal} once. Persist only protected digests. Rotate(ctx, principal.Reference, newPrincipal) changes the identifier and restrictions but preserves subject, kind and absolute expiry. Only one concurrent rotation wins. There is no grace, automatic retry, cookie renewal or refresh endpoint. A committed rotation with a lost response may require another login; cancellation is not proof a mutation did not commit.

Login has two phases around the application's password check: BeginLogin(ctx, presentedCookie) runs before verification and CompleteLogin(ctx, attempt, verifiedPrincipal) runs after it. The HTTP login handler uses both. BeginLogin captures the cookie's state up front, so a logout that commits during verification still wins. It rejects credential ambiguity, malformed cookies, unsupported header credentials, corrupt records and store failures before any password work.

  • Active at begin: the generation is atomically replaced through Store.Relogin. Password verification may change identity or restrictions, but an unexpired family's absolute expiry is preserved. Old-generation local streams are canceled before success, and logout from any retained generation revokes the replacement. If the family was revoked or superseded during verification, login fails and sets no cookie.
  • Already revoked or superseded at begin: the cookie is terminal. Login revokes its whole family, cancels its streams, and only then issues a fresh family. A browser stuck with a stale HttpOnly cookie, for example after a lost rotation or logout response, recovers by logging in, while every generation of the old family stays fenced.
  • Failures: failed authoritative lookups or revocations never recover.

Missing credentials may establish a fresh family after verification. An expired, non-revoked generation may atomically gain a new one-hour lifetime; tombstones remain through expiry plus ten minutes, and committed logout still wins.

Cookie-less logins establish independent families. Clients must serialize login/logout and generation-fence late responses; the server cannot correlate unrelated requests without client identity. A lost response leaves an inaccessible family until expiry, not a recoverable plaintext credential. Status never sets cookies.

Logout(ctx, protectedReference) revokes the family even if the supplied generation is inactive. HTTP logout verifies its generation-bound CSRF token directly before revocation, allowing an old generation's cookie and CSRF token to revoke a replacement. Logout is idempotent while the tombstone exists.

RevokeSubject transactionally revokes matching families and returns an int64 count, not an unbounded ID list. Its local gate covers commit and a subject/kind stream scan even when the count is zero; other instances observe renewal. SQL uses the schema-version-2 subject/kind index.

The identity owner must serialize issuance with password revisions: subject revocation does not fence cookie-less logins verified against an old password. For an application transaction, use database.RevokeSubjectTx, then call Manager.RevokeSubject or Manager.Nudge after commit to end local streams.

Operation Total budget, including admission-gate waits
Lookup/status/stream acquisition 500 ms (LookupBudget)
Create/rotate/logout 2 s (MutationBudget)
Login 5 s (LoginBudget)

Caller cancellation interrupts gate waits. Failures retain application category and semantic reason, including SESSION_INVALID, CSRF_INVALID, AUTH_STORE_UNAVAILABLE, and CREDENTIAL_CEILING. Terminal failures stop protected activity; store errors never certify authenticated success.

Authorization is an intersection

Principal.Allows(resource, scopes...) checks a credential ceiling only. Unrestricted credentials still need application membership. Restricted credentials with empty resource or scope lists grant nothing for those dimensions. Call auth.Authorize(ctx, applicationPolicy, principal, resource, scopes...) to intersect the ceiling with application-owned authorization. Matching an API key resource never creates membership.

Protected streams

Call manager.Acquire(ctx, principal.Reference) immediately before admitting a protected stream. It returns (lifetime context.Context, release func(), err error). Use the authoritative lifetime for SSE and always call the idempotent release callback when the stream ends. Admission's final authoritative read is coordinated with local revocation; transport cancellation is also propagated. context.Cause(lifetime) reports why it ended with an auth/lease cause: ErrRevoked, ErrExpired, ErrEnded, ErrReleased or ErrClosed.

Streams are held by an auth/lease set of at most 1,024 leases. Committed local logout ends matching streams before success; rotation ends old-generation streams. The single owned renewal worker uses Store.LookupBatch with at most 256 distinct references per query under one 500 ms authority budget. Missing entries are authoritative absence; outages are errors. Batch records undergo the same validation as individual resolution. Timely partial results remain authoritative alongside reported failures; unanswered references expire.

Confirmations extend three-second leases from monotonic check start, never completion. Separate expiry remains active while a driver stalls; replies cannot revive an expired entry even before the expiry worker removes it. Absolute remaining lifetime is separately capped at one hour. Manager.Nudge requests an early coalescing renewal instead of performing synchronous work. Config.ReportError is required and must promptly report safe classifications without blocking; expiry never waits for it. Close accounts for the owned checker/reporting worker. Stream owners react promptly to cancellation and retain their transport admission/write bounds.

Trusted resolution

manager.Reference(token) checks token grammar and returns the protected reference without a lookup. manager.Resolve(ctx, reference) returns the authoritative State (principal, family, generation, absolute expiry, authentication time) or the same typed failures as authentication. State.AuthenticatedAt is when the user last proved a credential for the family: login sets it, relogin renews it and rotation keeps it, so an application can require a recent sign-in for sensitive operations. Zero means unknown, for example a family created before schema version 3, and must be treated as not recent. It skips CSRF, so it is only for trusted callers that already hold a protected reference, such as an identity service validating a delegation against the live session.

Interactive clients serialize login/logout, generation-fence late status responses, and perform one bounded single-flight status check before reconnecting a closed protected stream. Local logout teardown is not proof server revocation committed when the logout request failed.

Retention and bounded cleanup

Family and generation tombstones remain until absolute expiry plus ten minutes. Every ten seconds, the manager calls one 500 ms cleanup batch deleting at most 256 physical rows. The database adapter deletes due generations before orphaned families and retains migration metadata. The 41-second healthy-store bound applies to 1,024 due physical rows, not arbitrary larger backlogs or 1,024 families with additional generations. A cleanup failure ends every leased stream.

Validate

cd auth
go test -race -shuffle=on -count=1 -cover ./...
cd session/database
go test -race -shuffle=on -count=1 -cover ./...
go test -tags=integration -race -shuffle=on -count=1 -run TestPostgresTransactionalFamily

The PostgreSQL integration gate requires reachable Docker and the database owner's digest-pinned image; missing prerequisites are failures, never skipped successes. Behavioral tests cover old-generation logout, lost rotation responses, CAS, expiry, independent stalled-read leases, storage failure, cleanup retention, capacity, JSON/CSRF limits and owned goroutine shutdown. Protocol fixtures live in testdata.

Documentation

Overview

Package session owns opaque, absolutely expiring browser sessions and their stream lifetimes.

Index

Constants

View Source
const (
	Lifetime       = time.Hour
	LookupBudget   = 500 * time.Millisecond
	MutationBudget = 2 * time.Second
	// LoginBudget bounds a POST /auth/login, password verification included; a remote Backend's login call must
	// fit inside it.
	LoginBudget = 5 * time.Second
	Retention   = 10 * time.Minute
	WatchLimit  = 1024
)
View Source
const MaxLookupBatch = 256

MaxLookupBatch bounds the authority query and result payload.

Variables

This section is empty.

Functions

func CheckOrigin

func CheckOrigin(r *http.Request, origin string) error

CheckOrigin requires an unsafe request to carry exactly one Origin header equal to origin, the rule the handler applies to login and logout, so a gateway can apply it to its other unsafe cookie routes. Safe methods pass.

func NewHandler

func NewHandler(backend Backend, cfg HandlerConfig) (http.Handler, error)

NewHandler mounts POST /auth/login, GET /auth/session, and POST /auth/logout only. Login and logout require exactly one Origin header equal to Origin, and login requires JSON; status never emits Set-Cookie. The handler validates every browser-facing input, including the cookie shape and logout CSRF header, before it calls the backend, so a backend may be the local Manager or a remote session authority.

func ParseCSRFToken

func ParseCSRFToken(r *http.Request) (string, error)

ParseCSRFToken returns the synchronizer token an unsafe request presents in exactly one X-CSRF-Token header. Safe methods return an empty token without error.

func ValidateReferences

func ValidateReferences(refs []string) error

ValidateReferences validates a batch before persistence access.

func ValidateToken

func ValidateToken(token string) error

ValidateToken checks the exact canonical 32-byte base64url session grammar.

Types

type Backend

type Backend interface {
	Login(context.Context, LoginRequest) (Grant, error)
	Status(ctx context.Context, token string) (Grant, error)
	Logout(context.Context, LogoutRequest) error
}

Backend owns session state behind the browser handler. Implementations return typed failures. The handler passes backend errors, causes included, to HandlerConfig.Errors, which must normalize them so causes never reach the browser.

func NewLocalBackend

func NewLocalBackend(manager *Manager, verifier LoginVerifier) (Backend, error)

NewLocalBackend serves sessions from manager, verifying passwords with the application-owned verifier.

type Config

type Config struct {
	Store  Store
	Clock  util.Clock
	Random io.Reader
	Pepper string
	CSRF   *security.SignedCSRF
	// ReportError receives worker failures that have no caller: lease renewal and retained-generation cleanup.
	ReportError func(context.Context, error)
}

Config injects persistence, time, cryptographic randomness and independent secrets.

type ErrorWriter

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

ErrorWriter is the outer transport's normalizer/problem response boundary.

type Grant

type Grant struct {
	Token     string
	Principal auth.Principal
	CSRFToken string
}

Grant is an authoritative session view. Token is set only by Login.

type HandlerConfig

type HandlerConfig struct {
	// Origin is the exact HTTPS origin accepted for login and logout.
	Origin string
	// Errors writes every failure through the outer transport's problem boundary.
	Errors ErrorWriter
	// Clock derives cookie Max-Age from each grant's absolute expiry.
	Clock util.Clock
}

HandlerConfig configures the browser session endpoints.

type Issued

type Issued struct {
	Token     string
	Principal auth.Principal
}

Issued contains the one-time plaintext credential, which callers must not log or persist.

type LoginAttempt

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

LoginAttempt is the presented cookie's state captured by BeginLogin before password verification. The zero value is invalid; an attempt is bound to the Manager that began it.

type LoginCredentials

type LoginCredentials struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

LoginCredentials is the bounded password login input. Credentials must never be logged.

type LoginRequest

type LoginRequest struct {
	Credentials LoginCredentials
	Presented   string
	Source      netip.Addr
}

LoginRequest is a validated login plus the session cookie the browser presented, which is empty for a fresh login. Source is the address of the connection that sent the login, for per-source admission; it is the zero Addr when the connection has no IP address, such as a Unix socket. Forwarding headers are never trusted for it.

type LoginVerifier

type LoginVerifier interface {
	VerifyLogin(context.Context, LoginCredentials) (auth.Principal, error)
}

LoginVerifier supplies application-owned verification without embedding membership in sessions.

type LoginVerifierFunc

type LoginVerifierFunc func(context.Context, LoginCredentials) (auth.Principal, error)

func (LoginVerifierFunc) VerifyLogin

type LogoutRequest

type LogoutRequest struct {
	Token     string
	CSRFToken string
}

LogoutRequest is a validated session cookie plus the synchronizer CSRF token presented with it.

type Manager

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

Manager owns its watch/cleanup workers. Composition must Close it before closing its store.

func NewManager

func NewManager(cfg Config) (*Manager, error)

NewManager starts explicitly owned stream lease and cleanup workers.

func (*Manager) Acquire

func (m *Manager) Acquire(ctx context.Context, ref string) (context.Context, func(), error)

Acquire performs a final authoritative lookup under the local revocation gate and leases the stream to it. The returned lifetime ends on expiry, logout, rotation, an unrenewed lease or manager shutdown; context.Cause reports a lease cause. Release is idempotent and must be called by the stream owner.

func (*Manager) Authenticate

func (m *Manager) Authenticate(r *http.Request) (auth.Principal, error)

Authenticate resolves a cookie and enforces synchronizer CSRF on every unsafe cookie request.

func (*Manager) BeginLogin

func (m *Manager) BeginLogin(ctx context.Context, token string) (LoginAttempt, error)

BeginLogin classifies the presented cookie before the application verifies the password, so that a logout committed during verification still wins. An empty token begins a fresh login. Malformed tokens, corrupt records and store failures are rejected before any password work.

func (*Manager) CSRFToken

func (m *Manager) CSRFToken(ctx context.Context, ref string) (string, error)

CSRFToken issues a token bound to a currently authoritative session generation.

func (*Manager) Cleanup

func (m *Manager) Cleanup(ctx context.Context) (int64, error)

Cleanup performs exactly one indexed batch of at most 256 retained generations.

func (*Manager) Close

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

Close ends local stream lifetimes synchronously and waits for all owned workers.

func (*Manager) CompleteLogin

func (m *Manager) CompleteLogin(ctx context.Context, attempt LoginAttempt, p auth.Principal) (Issued, error)

CompleteLogin issues a credential for a principal the application verified after BeginLogin. A fresh attempt creates a family. An active attempt atomically replaces its generation, preserving an unexpired family's absolute expiry, and fails if the family was revoked or superseded meanwhile. A terminal attempt revokes the stale cookie's whole family before issuing a fresh family, so a browser stuck with a stale HttpOnly cookie recovers through strong authentication while every generation of the old family stays fenced.

func (*Manager) Create

func (m *Manager) Create(ctx context.Context, p auth.Principal) (Issued, error)

Create establishes a fresh one-hour family, without binding it to an application resource.

func (*Manager) Logout

func (m *Manager) Logout(ctx context.Context, ref string) error

Logout revokes the family even when ref belongs to a rotated-away generation.

func (*Manager) Nudge

func (m *Manager) Nudge()

Nudge requests early revalidation through the owned renewal worker. Requests coalesce.

func (*Manager) Reference

func (m *Manager) Reference(token string) (string, error)

Reference checks the session token grammar and returns its protected reference. It performs no lookup.

func (*Manager) Resolve

func (m *Manager) Resolve(ctx context.Context, ref string) (State, error)

Resolve validates a protected reference against authoritative storage and returns its current generation, family, absolute expiry and authentication time. It applies the same rules as Authenticate without CSRF, so only trusted callers that already hold a protected reference, such as an identity service checking a delegation, may use it.

func (*Manager) RevokeSubject

func (m *Manager) RevokeSubject(ctx context.Context, kind auth.Kind, subject string) (int64, error)

RevokeSubject revokes every session of one subject, for example after a password reset, and ends their streams. It returns how many session families it revoked; zero means the subject had none left to revoke.

func (*Manager) Rotate

func (m *Manager) Rotate(ctx context.Context, ref string, p auth.Principal) (Issued, error)

Rotate atomically replaces the active generation; no grace, retry or expiry extension is allowed.

type Record

type Record struct {
	Reference   string
	Family      string
	Generation  uint64
	Principal   auth.Principal
	ExpiresAt   time.Time
	RetainUntil time.Time
	// AuthenticatedAt is when the family's user last proved a credential: set by create and relogin, kept by
	// rotation. Zero means unknown, which callers must treat as not recent.
	AuthenticatedAt time.Time
	Active          bool
	Revoked         bool
}

Record is protected persistence material. Never persist plaintext session credentials.

type Response

type Response struct {
	Status    string         `json:"status"`
	Identity  auth.Principal `json:"identity"`
	ExpiresAt time.Time      `json:"expiresAt"`
	CSRFToken string         `json:"csrfToken"`
}

Response is the browser session contract. Identity omits credential references and tokens.

type State

type State struct {
	Principal  auth.Principal
	Family     string
	Generation uint64
	ExpiresAt  time.Time
	// AuthenticatedAt is when the user last proved a credential for this family; zero means unknown.
	AuthenticatedAt time.Time
}

State is the authoritative record behind a protected session reference.

type Store

type Store interface {
	Create(context.Context, Record) error
	Lookup(context.Context, string) (Record, error)
	LookupBatch(context.Context, []string) (map[string]Record, error)
	Rotate(context.Context, string, Record) error
	Relogin(context.Context, string, Record) error
	Revoke(context.Context, string) (string, error)
	RevokeSubject(context.Context, auth.Kind, string) (int64, error)
	Cleanup(context.Context, time.Time, int) (int64, error)
}

Store operations are transactional and cancellation-aware. Lookup must read authoritative writer state. Rotate atomically compares the active generation and inserts its successor without extending expiry or changing the authentication time. Relogin compares the active, non-revoked generation and replaces its identity after password verification. It preserves unexpired absolute expiry; an expired active generation may start a new one-hour lifetime. It records the new authentication time. Lookup reports a missing protected reference with NOT_FOUND, never an unavailable-store error. LookupBatch accepts at most MaxLookupBatch distinct references; omitted entries are authoritative absence, not a store outage. RevokeSubject atomically revokes matching families and returns the newly transitioned family count. Revoke resolves even an inactive generation and revokes its entire family.

Directories

Path Synopsis
database module

Jump to

Keyboard shortcuts

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