session

package
v0.19.2 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 10 Imported by: 0

README

session

Package session declares the SDK server-side session port: the Store that owns a session's lifetime, the Sealer that renders its identifier as a cookie value, the opaque redacting ID, and the immutable SessionValue a store hands back — plus the typed sentinels (NotFound / Expired / InvalidID / InvalidConfig / IdentifierCollision / EntropyFailed / StoreUnavailable / SealInvalid / FixationRefused), and those of the concrete stores and sealer (RecordCorrupt / DirectoryUnsafe / LockFailed / PayloadTooLarge / InvalidPurpose / PathRedirected), declared here so every code of the domain is in one place (ADR 0160).

A session is not a token. A token carries its claims and cannot be revoked; a session is an opaque handle whose facts live on the server, so Destroy takes effect on the next request.

Store.Regenerate is the only call that binds a subject, and it always mints a new identifier — session fixation is prevented by the absence of any other way to spell a login, not by remembering a step.

The SDK ships the store and the sealing. The firewall, the voters, the login conventions and writing the cookie onto an HTTP response belong to the framework.

Concrete stores and the sealer live in internal/service/security/session; facade: pkg/v1/security/session. ADR 0045. See CLAUDE.md.

The ports — Store, Sweeper and Sealer — are generated from design/security/session.yaml into design_gen.go (ADR 0163): a port changes in the design, then kit gen.

Documentation

Overview

Package session — ranges 0.2.14.* (the port's verdicts) and 0.3.46.* (the engines' own refusals) — ADR 0045, declared here since ADR 0160.

Package session — declares the sentinel *errs.Error outcomes: the verdicts of the port, and the refusals specific to the engines in internal/service/security/session (ADR 0160). Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Every Public string here is written on the assumption that a third party reads it: none of them contains an identifier, a subject, a data key, a directory path, or a count. The identifier in particular is a bearer secret, so it never appears in a Public, in a Private, or in a Field.

Package session — the opaque, redacting session identifier.

Package session — the port that renders an identifier as a cookie value.

Package session declares the server-side session domain: the Store port that owns a session's lifetime, the opaque ID that names it, the immutable SessionValue a store hands back, and the Sealer that renders an ID as a tamper-evident string. Concrete stores — in memory and on disk — live in internal/service/security/session; this package owns only the contract.

A session is not a token

The two are routinely confused and the choice has consequences, so this domain states the difference rather than leaving it to the reader.

A TOKEN (internal/service/security/token — JWT, PASETO) is SELF-CONTAINED: the claims travel inside it, signed. Verification needs a key and nothing else — no lookup, no shared state, no round trip. That is why a token scales across processes that share nothing. It is also why a token CANNOT BE REVOKED: the only thing that stops an issued token is its own expiry, so "log out everywhere" means "wait".

A SESSION is an OPAQUE HANDLE: the identifier carries no information at all, and every fact about the session — who it belongs to, when it was created, what the application stored on it — lives on the server, in a Store. Because the server owns the record, Store.Destroy revokes IMMEDIATELY and the next request fails. The price is state: a store, its availability, and a lookup on the request path.

Pick a token when verification must be stateless and a few minutes of residual validity are acceptable. Pick a session when "log out now" has to mean now, when the data is too large or too private to hand to the client, or when the identifier must reveal nothing.

What this domain does NOT do

The SDK ships the STORE and the SEALING. It does not ship a firewall, access voters, a login convention, or anything that writes an HTTP header. Sealer produces the cookie's VALUE; deciding the cookie's name, Path, SameSite, Secure and HttpOnly attributes — and calling http.SetCookie — belongs to the framework, which is the layer that knows what a request is. See the package CLAUDE.md, §The frontier.

There is no registry

Like proc (ADR 0016), resilience (ADR 0026), net (ADR 0029), scheduler (ADR 0041) and token (ADR 0042), this domain has no name->implementation registry. A registry earns its place when an open set of interchangeable schemes is selected by data. Here the set is closed and the choice is a deployment decision made in code: a memory store is per-process and dies with it, a file store survives a restart and is confined to one host. Resolving that from a configuration string would let a typo silently downgrade a persistent store to an ephemeral one.

Package session — the immutable session a Store hands back.

Package session — the description a Store fills in to build a SessionValue.

Index

Constants

View Source
const CodeDirectoryUnsafe errs.Code = 0x00_03_2E_02 // 0.3.46.2

CodeDirectoryUnsafe identifies a location that cannot hold a session record safely — a directory readable beyond its owner, or a filesystem that accepts a 0600 request and does not enforce it.

View Source
const CodeEntropyFailed errs.Code = 0x00_02_0E_06 // 0.2.14.6

CodeEntropyFailed identifies a failure to read the random bytes an identifier is made of. No identifier is minted from a partial read.

View Source
const CodeExpired errs.Code = 0x00_02_0E_02 // 0.2.14.2

CodeExpired identifies a session that was found but whose deadline had already passed. The record is dropped as it is reported, so the same identifier answers CodeNotFound on the next call.

View Source
const CodeFixationRefused errs.Code = 0x00_02_0E_09 // 0.2.14.9

CodeFixationRefused identifies a Save that would have bound a different subject to an existing identifier. Changing principal without rotating the identifier is session fixation, so the write is refused rather than applied or silently reduced to a data-only write.

View Source
const CodeIdentifierCollision errs.Code = 0x00_02_0E_05 // 0.2.14.5

CodeIdentifierCollision identifies a freshly minted identifier that already names a live session. It means the entropy source is broken, and it is refused rather than reused: reusing it would hand one caller's session to another, and would silently disable the rotation Regenerate exists for.

View Source
const CodeInvalidConfig errs.Code = 0x00_02_0E_04 // 0.2.14.4

CodeInvalidConfig identifies a store configuration that could never work — a non-positive timeout, an idle window that the absolute ceiling makes unreachable, a missing directory or key (ADR 0031).

View Source
const CodeInvalidID errs.Code = 0x00_02_0E_03 // 0.2.14.3

CodeInvalidID identifies an identifier that is not well formed: the wrong length, not unpadded base64url, or the zero value.

View Source
const CodeInvalidPurpose errs.Code = 0x00_03_2E_05 // 0.3.46.5

CodeInvalidPurpose identifies a sealer built without a purpose string, which would silently drop the domain separation between two things sealed under one key.

View Source
const CodeLockFailed errs.Code = 0x00_03_2E_03 // 0.3.46.3

CodeLockFailed identifies a failure to take the store-wide exclusive lock. The operation is refused rather than attempted unserialised.

View Source
const CodeNotFound errs.Code = 0x00_02_0E_01 // 0.2.14.1

CodeNotFound identifies a lookup for an identifier that names no live session — never minted, already destroyed, or dropped after expiring.

View Source
const CodePathRedirected errs.Code = 0x00_03_2E_06 // 0.3.46.6

CodePathRedirected identifies a store location reached through an indirection the file store refuses to follow: a link at the lock file's name, a link at a component of Dir planted where any account could have planted it, or a Dir that stopped naming the directory the store opened.

View Source
const CodePayloadTooLarge errs.Code = 0x00_03_2E_04 // 0.3.46.4

CodePayloadTooLarge identifies a session payload above the store's key-count or per-string caps, or a subject above the per-string cap. The bound is checked before the write it would fund.

View Source
const CodeRecordCorrupt errs.Code = 0x00_03_2E_01 // 0.3.46.1

CodeRecordCorrupt identifies a stored record that could not be turned back into a session: the seal did not open, the frame did not parse, or the record named a digest other than the one it was filed under.

View Source
const CodeSealInvalid errs.Code = 0x00_02_0E_08 // 0.2.14.8

CodeSealInvalid identifies a sealed value that did not open. It is the single non-oracle verdict for every cause — tampering, truncation, the wrong key, the wrong purpose — so a forger learns nothing from which one they hit.

View Source
const CodeStoreUnavailable errs.Code = 0x00_02_0E_07 // 0.2.14.7

CodeStoreUnavailable identifies a backend that could not be read or written: a permission error, a full disk, a vanished directory.

View Source
const IDLen int = 32

IDLen is the required identifier length in bytes. 32 bytes is 256 bits of entropy from crypto/rand: guessing one is not a threat model, it is a rounding error. The length is fixed rather than configurable because every value a caller could choose below it is a weaker session and every value above it only lengthens the cookie.

Variables

View Source
var (
	// NotFound is returned by Load, and by Regenerate, for an identifier that
	// names no live session.
	NotFound = errs.Define(CodeNotFound, "NOT_FOUND",
		"No session matches that identifier",
		"core/security/session: no live record under the presented identifier's digest — never minted, destroyed, or swept",
		errs.WithHTTPStatus(httpUnauthorized))

	// Expired is returned when the record existed but its deadline had passed.
	// It is distinct from NotFound because the two need different operator
	// responses — a spike in Expired is a timeout that is too short, a spike in
	// NotFound is a store that is losing records. Telling them apart leaks
	// nothing usable: an identifier is 256 random bits, so an attacker cannot
	// reach either answer by guessing.
	Expired = errs.Define(CodeExpired, "EXPIRED",
		"That session has expired",
		"core/security/session: the record's effective deadline — the earlier of the idle and absolute deadlines — is in the past; the record is dropped as this is reported",
		errs.WithHTTPStatus(httpUnauthorized))

	// InvalidID is returned by ParseID and NewID for a malformed identifier,
	// and by any Store method handed the zero ID.
	InvalidID = errs.Define(CodeInvalidID, "INVALID_ID",
		"That is not a valid session identifier",
		"core/security/session: identifier must be exactly IDLen bytes in unpadded base64url; the decoder's own message is dropped because it can echo attacker input",
		errs.WithHTTPStatus(httpUnauthorized))

	// InvalidConfig is returned by every store constructor for a configuration
	// it cannot honour. ADR 0031: a zero timeout is refused at construction,
	// never accepted as "expires immediately" — a store where every session is
	// already dead is the inert-policy failure, and it would present as a
	// login loop nobody can debug.
	InvalidConfig = errs.Define(CodeInvalidConfig, "INVALID_CONFIG",
		"The session store configuration is not usable",
		"core/security/session: a timeout is non-positive, the idle window is not shorter than the absolute ceiling, or a required directory or key is missing; the fields name which",
		errs.WithExitCode(exitConfig))

	// IdentifierCollision is returned by New and Regenerate when the minted
	// identifier already names a live session. At 256 bits this cannot happen
	// by chance, so it means the entropy source is broken — and the honest
	// answer to a broken entropy source is to refuse, not to carry on with a
	// predictable identifier.
	IdentifierCollision = errs.Define(CodeIdentifierCollision, "IDENTIFIER_COLLISION",
		"The session store could not mint a unique identifier",
		"core/security/session: a freshly minted identifier already names a live record — at 256 bits this is a broken random source, not chance; nothing is overwritten")

	// EntropyFailed is returned when the random source could not be read.
	EntropyFailed = errs.Define(CodeEntropyFailed, "ENTROPY_FAILED",
		"The session store could not generate a secure identifier",
		"core/security/session: the random source returned an error or a short read; no identifier is minted from partial entropy")

	// StoreUnavailable is returned when the backend itself failed. It carries
	// EX_TEMPFAIL and 503 because retrying is meaningful, which is exactly what
	// separates it from InvalidConfig.
	StoreUnavailable = errs.Define(CodeStoreUnavailable, "STORE_UNAVAILABLE",
		"The session store is unavailable",
		"core/security/session: the backend could not be read or written; the fields name the operation, never the path or the identifier",
		errs.WithExitCode(exitTempFail),
		errs.WithHTTPStatus(httpUnavailable))

	// SealInvalid is returned by Sealer.Open for every failure, without
	// distinguishing them. One verdict for tampering, truncation, a wrong key
	// and a wrong purpose is what keeps Open from becoming an oracle — the same
	// posture crypto.DecryptionFailed takes for the AEAD it is built on.
	SealInvalid = errs.Define(CodeSealInvalid, "SEAL_INVALID",
		"That session value could not be opened",
		"core/security/session: sealed value failed to open — tampered, truncated, wrong key or wrong purpose, deliberately not distinguished",
		errs.WithHTTPStatus(httpUnauthorized))

	// FixationRefused is returned by Save when the value's subject differs from
	// the stored record's. Rebinding a principal onto an identifier that is
	// already in circulation IS session fixation, so the write is refused
	// loudly. Regenerate is the operation that was wanted: it mints a new
	// identifier, carries the data across, and destroys the old record.
	FixationRefused = errs.Define(CodeFixationRefused, "FIXATION_REFUSED",
		"A session's subject cannot be changed without a new identifier",
		"core/security/session: Save was given a subject the stored record does not have; call Regenerate, which rotates the identifier as it rebinds")

	// RecordCorrupt is returned when a stored record cannot be read back. It
	// is deliberately ONE verdict for four causes — a tampered file, a
	// truncated file, the wrong store key, and a file moved onto another
	// session's digest — because distinguishing them would tell whoever
	// arranged the tampering which half of the attempt already worked.
	//
	// It maps to 401, not 500: whatever happened to the file, the caller's
	// session is not usable, and that is what the request needs to know.
	RecordCorrupt = errs.Define(CodeRecordCorrupt, "RECORD_CORRUPT",
		"That session could not be read",
		"service/security/session: record failed to open or decode — tampered, truncated, wrong key, or filed under another digest; deliberately not distinguished",
		errs.WithExitCode(exitDataErr),
		errs.WithHTTPStatus(httpUnauthorized))

	// DirectoryUnsafe is returned by NewFileStore for a location that cannot
	// hold a session record safely, and during a write for a filesystem that
	// accepted a 0600 request without enforcing it.
	//
	// It refuses rather than repairing. A chmod would hide how long the records
	// were exposed, and on the filesystems this check exists to catch it would
	// report success while changing nothing — a repair that cannot be verified
	// is not a repair.
	DirectoryUnsafe = errs.Define(CodeDirectoryUnsafe, "DIRECTORY_UNSAFE",
		"The session store location is not private enough to use",
		"service/security/session: the directory or a record carries a group or world permission bit; refused rather than chmod'ed, and the fields carry the mode that was required",
		errs.WithExitCode(exitConfig))

	// LockFailed is returned when the store-wide exclusive lock could not be
	// taken. The operation is refused rather than run unserialised: without the
	// lock the store cannot promise that a read-modify-write is indivisible,
	// and quietly dropping that promise is worse than failing the request.
	LockFailed = errs.Define(CodeLockFailed, "LOCK_FAILED",
		"The session store is busy",
		"service/security/session: flock on the store-wide lock descriptor failed; the operation was refused rather than run without serialisation",
		errs.WithExitCode(exitTempFail))

	// PayloadTooLarge is returned by Save for a payload above the store's caps,
	// and by Regenerate — in both stores, before anything is minted — for a
	// subject longer than the per-string cap, which the file store's frame
	// could not read back. A session store holds per-user state on the request
	// path; an unbounded payload there is a memory-exhaustion vector with a
	// very cheap trigger, so the bound is a refusal rather than a truncation —
	// silently dropping keys would report success for a write that did not
	// happen, and a truncated subject would name a different principal.
	PayloadTooLarge = errs.Define(CodePayloadTooLarge, "PAYLOAD_TOO_LARGE",
		"That session holds more data than the store accepts",
		"service/security/session: payload or subject exceeds the key-count or per-string cap; the offending key or subject is NOT named because it is caller data",
		errs.WithHTTPStatus(httpPayloadTooLarge))

	// InvalidPurpose is returned by NewSealer for an empty purpose. The purpose
	// is the domain separator between two things sealed under one key; reading
	// "" as "no separation needed" would be an inert policy in ADR 0031's exact
	// sense, and there is no default the SDK could invent that would mean
	// anything.
	InvalidPurpose = errs.Define(CodeInvalidPurpose, "INVALID_PURPOSE",
		"A sealer needs a non-empty purpose",
		"service/security/session: NewSealer needs a purpose string to bind as additional authenticated data; an empty one would drop domain separation silently",
		errs.WithExitCode(exitConfig))

	// PathRedirected is returned by NewFileStore when the store's location is
	// reached through an indirection it refuses to follow.
	//
	// Three shapes, one remedy — a human looks at the directory, and no retry
	// helps, which is why it is not StoreUnavailable: a symbolic link at the
	// lock file's name, which would put the store-wide lock — and, when it
	// dangles, a file the store creates — wherever the link points; a link at
	// a component of Dir planted in a directory any account can write, which
	// would move every record into a tree its planter chose; and a Dir that no
	// longer names the directory the store opened, because it was swapped
	// between the check and the open.
	//
	// A link at a RECORD's name is not this: that one is RecordCorrupt, the
	// single verdict for every record the store did not write, so a planted
	// link reads exactly like any other tampering.
	PathRedirected = errs.Define(CodePathRedirected, "PATH_REDIRECTED",
		"The session store location is reached through a link",
		"service/security/session: an indirection the store refuses — a link at the lock file, a link at a component of Dir planted where any account could, or Dir swapped while opening; the fields name the component, the kind and the target",
		errs.WithExitCode(exitConfig))
)

Functions

This section is empty.

Types

type ID

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

ID is an opaque session identifier. It IS a bearer secret — whoever holds it is the session — so this type is built to make leaking it hard:

  • String and GoString render "<redacted>", so an accidental %v, %s or %#v in a log line, an error message or a struct dump prints nothing usable. The public/private split of errs is the same idea applied to errors: a Public string is read by third parties, so no sentinel in this domain ever carries an identifier in one.
  • ID.Reveal is the only way to the canonical string, and it is spelled to read like a mistake at a call site that is not building a cookie.
  • ID.Equal compares in constant time. Comparing identifiers with == on the byte slice would not compile; comparing their Reveal strings would, and would leak the length of the shared prefix.
  • ID.Digest is the value a store indexes and names files by, so the secret itself is never a map key, never a filename, and never at rest.

func NewID

func NewID(raw []byte) (id ID, err error)

NewID builds an ID from raw, which MUST be exactly IDLen bytes. A wrong length returns InvalidID rather than padding or truncating — a truncated identifier is a weaker secret that would still work. The bytes are copied so a later mutation of raw cannot reach the ID.

func ParseID

func ParseID(encoded string) (id ID, err error)

ParseID decodes the canonical form produced by ID.Reveal — unpadded base64url over exactly IDLen bytes. It is the inbound path: what arrives from a cookie, a header or a query string reaches the domain through here, and anything that is not exactly that shape is refused with InvalidID before it can be used as a lookup key.

func (ID) Digest

func (i ID) Digest() string

Digest returns the lowercase hex SHA-256 of the identifier: 64 characters, deterministic, and safe to write down.

It is what a Store uses as its lookup key and what the file store uses as a filename, so the identifier itself is never a map key, never a directory entry, and never on disk. A stolen backup therefore yields digests, not usable cookies. The digest is a LOOKUP KEY, not an authenticator: it is unkeyed, so anyone holding an identifier can compute it — which is exactly the point, and exactly why it is not treated as a secret. A zero-value ID digests to "".

func (ID) Equal

func (i ID) Equal(other ID) bool

Equal reports whether i and other are the same identifier, in time that does not depend on how many leading bytes they share. Every comparison of a session identifier in this SDK goes through here or through crypto/subtle directly; none of them uses == or string equality.

func (ID) GoString

func (i ID) GoString() string

GoString implements fmt.GoStringer so %#v stays redacted: fmt bypasses String for Go-syntax formatting and would otherwise dump the backing slice.

func (ID) IsZero

func (i ID) IsZero() bool

IsZero reports whether the ID names nothing — the zero value, which no constructor produces. A store refuses it with InvalidID.

func (ID) Reveal

func (i ID) Reveal() string

Reveal returns the canonical unpadded base64url form — 43 characters, all of them in the RFC 6265 cookie-octet set, so the result needs no further escaping to be a cookie value.

This is the ONLY way the secret leaves the type. Hand it to a Set-Cookie value or to Sealer.Seal; never to a logger, a metric label, a URL, or an error's Public string. A zero-value ID reveals "".

func (ID) String

func (i ID) String() string

String implements fmt.Stringer and always redacts, so an identifier never reaches a log line through %v or %s. Use ID.Reveal when the destination is a cookie, and ID.Digest when it is a log line that needs correlation.

type Sealer

type Sealer interface {
	// Seal renders id as an opaque string safe to use verbatim as a cookie
	// value. A zero id is refused with [InvalidID].
	Seal(id ID) (sealed string, err error)
	// Open reverses Seal. Every failure returns [SealInvalid] and a zero ID.
	Open(sealed string) (id ID, err error)
}

Sealer turns an ID into a tamper-evident, confidential string and back. Implementations MUST be safe for concurrent use.

What it is for, and where it stops

A session identifier can be handed to a client as-is: it is already 256 bits of opaque randomness. Sealing adds two things a raw identifier cannot have. It BINDS the value to a key and a purpose, so a value minted for one application — or for one purpose within it — is refused by another rather than being looked up and missed. And it makes the value UNREADABLE in transit and at rest on the client, so an identifier does not sit in plain text in a browser's cookie jar, a proxy log, or a crash report.

It produces the cookie's VALUE. It does not write a cookie. Nothing in this domain knows what an HTTP response is, and that boundary is deliberate: the name, Path, Domain, Max-Age, Secure, HttpOnly and SameSite attributes are decisions about a request, and the layer that understands requests is the framework.

sealed, err := sealer.Seal(session.ID())
if err != nil {
    return err
}
http.SetCookie(w, &http.Cookie{ // the framework's line, not the SDK's
    Name: "sid", Value: sealed,
    HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})

Open is not an oracle

Every failure — a truncated value, a flipped bit, the right ciphertext under the wrong key, the right key with the wrong purpose — returns the SAME SealInvalid verdict with no detail. Distinguishing them would tell an attacker which half of a forgery attempt was already correct.

The seal is not a token

Nothing but the identifier goes inside. No subject, no expiry, no claims: a sealed identifier that carried its own expiry would be a token, and would have a token's revocation problem. Every fact about the session is read from the Store, which is what makes Store.Destroy effective immediately.

IFACE-PLUGIN: the concrete sealer stays unexported behind its constructor in internal/service/security/session.

type SessionValue

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

SessionValue is one session as a Store sees it: an identifier, the subject it is bound to, its two deadlines, and the caller's data. Every field is unexported and every mutator returns a COPY, so a value handed to two goroutines cannot be changed under either of them.

The absence here is load-bearing. There is no WithSubject, no SetSubject and no Elevate: the subject is written exactly once, by Store.Regenerate, and that call always mints a new identifier. A caller therefore cannot express "keep this identifier and log this user in" — the sentence has no method.

func NewSessionValue

func NewSessionValue(state StateValue) (session SessionValue, err error)

NewSessionValue builds an immutable SessionValue from state. It is the only producer of the value type outside this package's own accessors.

It refuses a zero StateValue.ID with InvalidID — a session that names nothing could be Loaded by nobody and Saved over anything — and it clamps StateValue.IdleExpiry to StateValue.AbsoluteExpiry so the "earlier deadline wins" rule holds for every value in existence, not only for the ones this SDK's own stores built.

func (SessionValue) CreatedAt

func (s SessionValue) CreatedAt() time.Time

CreatedAt reports when this identifier was minted. Regeneration mints a new identifier, so it also restarts this clock — and with it the absolute deadline. That is intended: a session that just changed principal is a new session in every sense that matters.

func (SessionValue) Data

func (s SessionValue) Data() map[string]string

Data returns a copy of the whole payload. It is a copy so a caller cannot reach behind the value type and mutate a session another goroutine holds.

func (SessionValue) Delete

func (s SessionValue) Delete(key string) SessionValue

Delete returns a copy without key. Deleting an absent key is not an error — the caller asked for the key to be gone and it is.

func (SessionValue) ExpiresAt

func (s SessionValue) ExpiresAt() time.Time

ExpiresAt reports the EFFECTIVE deadline: the earlier of the absolute ceiling and the sliding idle window. The two are never reconciled by averaging or by preferring the later one — the earlier deadline always wins, which is what makes the absolute timeout a ceiling rather than a suggestion.

func (SessionValue) Get

func (s SessionValue) Get(key string) (value string, ok bool)

Get reads one datum. The second return distinguishes "absent" from "present and empty", which a bare "" cannot.

func (SessionValue) GoString

func (s SessionValue) GoString() string

GoString implements fmt.GoStringer so %#v stays redacted too — fmt bypasses String for Go-syntax formatting and would otherwise dump every field.

func (SessionValue) ID

func (s SessionValue) ID() ID

ID reports the session's identifier. It is a bearer secret: see ID.

func (SessionValue) IsZero

func (s SessionValue) IsZero() bool

IsZero reports whether this is the zero SessionValue — what every failing Store method returns alongside its error. There is no "invalid, but here are the claims anyway" path.

func (SessionValue) Keys

func (s SessionValue) Keys() []string

Keys reports the data keys in sorted order, so a caller iterating them gets the same order on every run and in every process.

func (SessionValue) LastSeen

func (s SessionValue) LastSeen() time.Time

LastSeen reports the instant the idle window last slid, i.e. the most recent successful Store.Load.

func (SessionValue) LiveAt

func (s SessionValue) LiveAt(now time.Time) bool

LiveAt reports whether the session is still usable at now. The boundary is exclusive: a session is dead AT its deadline, not one tick after it.

func (SessionValue) Set

func (s SessionValue) Set(key, value string) SessionValue

Set returns a copy carrying key=value. The receiver is untouched, so threading the result is mandatory:

session = session.Set("locale", "fr")
err = store.Save(ctx, session)

func (SessionValue) String

func (s SessionValue) String() string

String implements fmt.Stringer and renders the session's SHAPE, never its contents: no identifier, no subject, no data keys, no values.

A session's data is whatever the application decided to keep about a logged-in user, and its subject names that user. Rendering either would put both into whichever log line happened to use %v — which is how identifying data ends up in an aggregator that was never scoped to hold it. The counts are enough to tell "the session was empty" from "the session had five keys", which is what a %v is usually reaching for.

func (SessionValue) Subject

func (s SessionValue) Subject() string

Subject reports the authenticated principal, or "" when the session is anonymous. It changes only through Store.Regenerate.

type StateValue

type StateValue struct {
	// ID names the session. NewSessionValue refuses a zero one.
	ID ID
	// Subject is the authenticated principal, or "" for anonymous.
	Subject string
	// CreatedAt anchors the absolute deadline.
	CreatedAt time.Time
	// LastSeen anchors the idle deadline.
	LastSeen time.Time
	// AbsoluteExpiry is the ceiling — CreatedAt + the store's absolute
	// timeout. Nothing extends it.
	AbsoluteExpiry time.Time
	// IdleExpiry is LastSeen + the store's idle timeout. NewSessionValue clamps
	// to AbsoluteExpiry, so a store cannot accidentally publish a sliding
	// window that outlives the ceiling.
	IdleExpiry time.Time
	// Data is the caller's payload; it is copied, so the caller keeps
	// ownership of the map it passed.
	Data map[string]string
}

StateValue is the full description of a session, with exported fields, from which NewSessionValue builds an immutable SessionValue. It exists because Store is a PUBLISHED port: a framework writing its own store — Redis, Postgres, whatever the deployment already runs — has to be able to construct the value type it is contracted to return, and every field of SessionValue is unexported.

It is not a back door around the fixation rule. A caller can certainly build a StateValue naming any subject; what they cannot do is persist it, because Store.Save compares the subject against the stored record and refuses a mismatch with FixationRefused. The forgery is possible and useless, which is the property worth having: the refusal is a runtime verdict a test can reach, not a comment asking people to behave.

type Store

type Store interface {
	// New mints an anonymous session with a fresh identifier and no subject.
	// The identifier is never supplied by the caller: adopting one is the
	// other half of session fixation.
	New(ctx context.Context) (SessionValue, error)
	// Load resolves id to a live session and SLIDES its idle window — an idle
	// timeout that is not refreshed on use is not an idle timeout. It returns
	// [NotFound] for an identifier that names nothing and [Expired] for one
	// whose deadline has passed; an expired record is dropped, so the second
	// call reports [NotFound].
	Load(ctx context.Context, id ID) (SessionValue, error)
	// Save persists the session's DATA. It never writes the subject: a value
	// whose subject differs from the stored record is refused with
	// [FixationRefused] rather than being written or silently ignored.
	Save(ctx context.Context, session SessionValue) error
	// Regenerate mints a new identifier for the session currently named by
	// current, carries its data across, binds subject to it, and destroys the
	// old record. Pass the same subject to rotate without changing principal;
	// pass "" to drop back to anonymous. It refuses with
	// [IdentifierCollision] if the freshly minted identifier already names a
	// live session — a broken entropy source must not silently disable the
	// rotation this call exists to perform.
	Regenerate(ctx context.Context, current ID, subject string) (SessionValue, error)
	// Destroy removes the session named by id. It is idempotent: destroying an
	// identifier that names nothing reports nil, because a caller logging out
	// twice has got what they asked for.
	Destroy(ctx context.Context, id ID) error
}

Store owns the lifetime of every session it minted. Implementations MUST be safe for concurrent use.

The method set is deliberately five operations wide and is FROZEN: pkg/v1 aliases this interface, so under ADR 0039 a sixth method would break every downstream implementer at compile time with no deprecation window. New capabilities arrive as SIBLING interfaces discovered by type assertion — Sweeper is the first, and the model is codec's Appender (ADR 0037).

Regenerate is the only way to bind a subject

There is no SetSubject, no Store.Elevate, and no SessionValue mutator that touches the subject. The single call that associates a session with an authenticated principal is Store.Regenerate, and it mints a NEW identifier every time. Session fixation — an attacker plants an identifier, the victim authenticates, the identifier keeps working — is therefore not a mistake a caller can make by forgetting a step: the step IS the login.

IFACE-PLUGIN: the concrete stores stay unexported behind their constructors in internal/service/security/session.

type Sweeper

type Sweeper interface {
	// Sweep drops every record whose deadline has passed and reports how many
	// it removed. It is safe to call concurrently with any [Store] method.
	Sweep(ctx context.Context) (removed int, err error)
}

Sweeper is the first SIBLING of Store (ADR 0039): a store that can drop its expired records on demand implements it, and a caller reaches it by type assertion rather than by a widened Store.

if sweeper, ok := store.(session.Sweeper); ok {
    removed, err := sweeper.Sweep(ctx)
}

Sweeping is not scheduled here. A store that decided its own cadence would own a goroutine the caller never asked for; driving Sweep on a cron or an interval is what the scheduler domain (ADR 0041) is for.

Jump to

Keyboard shortcuts

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