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

session

package
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package session issues, verifies, and rotates opaque session tokens.

A session is a family of tokens. The first refresh token is the family root and is the session's identity; every later token points back at it. That is what makes "sign me out of this device" one indexed UPDATE, and what gives reuse detection something to revoke.

Rotation is mandatory. Every refresh consumes the token it was given and mints a new pair, so a stolen refresh token is useful exactly until the real client refreshes — at which point the theft becomes visible rather than permanent.

Index

Constants

View Source
const (
	DefaultAccessTTL      = 10 * time.Minute
	DefaultRefreshTTL     = 12 * time.Hour
	DefaultRememberTTL    = 30 * 24 * time.Hour
	DefaultRotationLeeway = 30 * time.Second
)

Default lifetimes.

View Source
const (
	// PrefixRefresh and PrefixAccess make a leaked token identifiable on
	// sight, in a log or a paste, by whoever finds it.
	PrefixRefresh = "rig_rt_"
	PrefixAccess  = "rig_at_"
)

Tokens are opaque, not JWTs.

A JWT saves one indexed lookup per request and costs a signing key to rotate, a JWKS endpoint to serve, a clock-skew policy to argue about, and — the part that matters — a window during which a revoked session keeps working because nothing checks. Here, verification is a row read, so revocation takes effect on the next request and "log out everywhere" means what it says.

The presented value is <prefix><base32(id)>.<base32(secret)>. The identifier half is the lookup key and the secret half is compared against a stored sha256. Splitting them is what keeps the lookup exact: a bare secret would have to be found by scanning, and a scan over hashes is not an index.

View Source
const DefaultIdentityTTL = 30 * time.Minute

DefaultIdentityTTL is how long a session with no tenant lasts.

Short, because of what it is for: somebody signs in, looks at the tenants they belong to and the invitations waiting for them, and picks one. Half an hour covers reading and deciding. It is not a session to work in — there is no tenant, so there is nothing to work on.

View Source
const PrefixIdentity = "rig_it_"

PrefixIdentity marks a token that has proved who somebody is and nothing more.

A third prefix rather than a third Kind: Kind is the account_token_kind column, and an identity session is not a row in that table. Keeping them apart on the wire means a credential presented to the wrong door is refused by shape rather than by a check somebody has to remember to write.

View Source
const TokenTopic = "session"

TokenTopic is the name this package's invalidations travel under.

Exported because a deployment reading the channel with psql to see whether anything is being published needs to know what it is looking at, and because it is half of an agreement — a bus serving "session" under one name and a revocation publishing under another is a cache that never invalidates.

Variables

View Source
var ErrInvalidToken = rigerr.Unauthorized("the session is not valid")

ErrInvalidToken is returned for anything a caller presented that does not resolve to a live token: malformed, unknown, expired, revoked, or replayed.

One error for all of them is deliberate. Telling a caller that a token exists but has expired, as opposed to never existing, is a small oracle, and there is nothing a legitimate client does differently in the two cases.

View Source
var ErrNoIdentitySession = rigerr.Unauthorized("this request needs a sign-in")

ErrNoIdentitySession is what a handler answers when a request that needs one arrived without it.

Functions

This section is empty.

Types

type Client

type Client string

Client is what kind of thing holds the session.

const (
	ClientWeb     Client = "Web"
	ClientMobile  Client = "Mobile"
	ClientMachine Client = "Machine"
)

The clients. The set is closed because the column is a Postgres enum: a value that is not one of these is rejected by the database rather than stored, which is why Manager.Issue defaults an unstated client instead of passing it through. What a session is allowed to do does not depend on this — it is what a person revoking a session from a list is reading to recognise it by.

type Config

type Config struct {
	Store Store
	Log   authlog.Log

	// AccessTTL is short because an access token is the one that travels. Ten
	// minutes bounds the damage of a leaked one without making refresh a
	// constant background hum.
	AccessTTL time.Duration
	// RefreshTTL is how long an ordinary session lasts.
	RefreshTTL time.Duration
	// RememberTTL is how long a session lasts when the person asked to stay
	// signed in.
	RememberTTL time.Duration

	// RotationLeeway is how long a refresh token stays usable after it has
	// been exchanged.
	//
	// Without one, a dropped response is a logout: the client never received
	// the new pair, retries with the old one, and gets its whole family
	// revoked for a network blip. Thirty seconds covers the retry and leaves a
	// real replay — which turns up minutes or hours later — firmly outside.
	RotationLeeway time.Duration

	// Limiter and RefreshLimit bound how hard one session may refresh.
	//
	// Both or neither. Nil is a manager nobody wired a limiter into, which has
	// to go on working: this package is constructible without the rest of auth,
	// and a refresh limit is a backstop rather than a security boundary — what
	// it catches is a client looping, not somebody with a stolen token, whose
	// second use is caught by reuse detection instead.
	//
	// The limit counts TokenRefreshed against the token family, so it is one
	// session's budget and not one person's: signing in on a phone and a laptop
	// does not make either of them closer to it.
	Limiter      *throttle.Limiter
	RefreshLimit throttle.Limit

	// Cache keeps verified access tokens in memory, and is told to stop.
	//
	// Nil, the default, means every request reads the row. A cache built by
	// [NewTokenCache] means most of them do not, and that a revocation reaches
	// every replica on the transaction that performed it — so the lifetime in
	// [TokenCacheConfig] is the backstop rather than the guarantee.
	//
	// There is no plain time-to-live here any more, and its absence is the
	// decision: a cache over authentication with nothing to withdraw an entry
	// is a revoked session that keeps working, which is not a trade this
	// package offers.
	Cache *TokenCache

	// OnRotate replaces a session's payload when it is refreshed.
	//
	// Nil — the default — carries the previous payload forward unchanged, which is
	// what an application that never sets one wants and what an application that
	// set one at sign-in usually wants too.
	//
	// It is handed the token being exchanged, so the current payload is
	// prev.Payload and everything else about the session is there to decide from.
	// Returning nil clears the payload; returning an error fails the refresh,
	// which means a session cannot be refreshed while this is broken — deliberate,
	// since the alternative is quietly serving a payload the application refused
	// to produce.
	//
	// It runs inside the rotation's transaction, once per refresh, and both new
	// tokens get the result. Keep it quick: it is on the path every client takes
	// when its access token expires.
	OnRotate func(ctx context.Context, prev *Token) (json.RawMessage, error)

	// Now is swappable for tests.
	Now func() time.Time
}

Config builds a manager.

type Family

type Family struct {
	// Root is the token the session started from.
	Root *Token
	// LastUsedAt is the newest descendant's creation time, which is when the
	// session last refreshed. Deriving it beats maintaining a column that
	// every request would have to write.
	LastUsedAt time.Time
	// Tokens is how many tokens the family has produced.
	Tokens int
}

Family is one session, summarized for a "where am I signed in" screen.

type Identity

type Identity struct {
	ID         uuid.UUID
	IdentityID uuid.UUID

	SecretHash []byte

	CreatedAt time.Time
	ExpiresAt time.Time
	// RevokedAt is set by signing out, and by picking a tenant: once there is
	// a real session there is no reason to leave a second credential alive.
	RevokedAt *time.Time

	IPAddress string
	UserAgent string
}

Identity is a person who has signed in but has not chosen a tenant.

Deliberately not a Token, and deliberately not convertible to [tenancy.Claims]. Claims carry a tenant because every generated query scopes by one; claims without a tenant would be claims that cannot scope a read, which is why the type refuses to exist. This is the other state a person can be in, and it is a different credential rather than a weaker version of the same one.

What it opens is small and fixed: the tenants this person belongs to, the invitations addressed to them, accepting one, and creating a tenant where the application allows it. No application data, because there is no tenant to read it in.

func (*Identity) Live

func (i *Identity) Live(now time.Time) bool

Live reports whether the session may still be used.

type IdentityConfig

type IdentityConfig struct {
	Store IdentityStore
	// TTL defaults to [DefaultIdentityTTL].
	TTL time.Duration
	// Now is swappable for tests.
	Now func() time.Time
}

IdentityConfig builds an IdentityManager.

type IdentityIssueInput

type IdentityIssueInput struct {
	IdentityID uuid.UUID
	IPAddress  string
	UserAgent  string
}

IdentityIssueInput starts one.

type IdentityManager

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

IdentityManager issues and verifies identity sessions.

Much smaller than Manager, and the difference is the point. There is no rotation, no family, and no reuse detection: those exist because a refresh token is long-lived and travels, and this is neither. It is exchanged for a real session within minutes and protects nothing but a list of tenant names.

func NewIdentity

func NewIdentity(cfg IdentityConfig) (*IdentityManager, error)

NewIdentity builds a manager.

func (*IdentityManager) Issue

Issue starts an identity session and returns the token for it.

func (*IdentityManager) Revoke

func (m *IdentityManager) Revoke(ctx context.Context, presented string) error

Revoke ends one, by the token that holds it.

Verifying first rather than deleting by identifier: an identifier is half a token and half a token should not be able to end somebody's session.

func (*IdentityManager) RevokeAll

func (m *IdentityManager) RevokeAll(ctx context.Context, identityID uuid.UUID) (int, error)

RevokeAll ends every identity session a person holds.

func (*IdentityManager) Verify

func (m *IdentityManager) Verify(ctx context.Context, presented string) (*Identity, error)

Verify resolves a presented token to the person behind it.

The error is always the same one, whether the prefix was wrong, the identifier unknown, the secret incorrect, or the session expired. Telling a caller which half of their guess was right is telling them how to guess better.

type IdentityStore

type IdentityStore interface {
	InsertIdentitySession(ctx context.Context, s *Identity) error
	FindIdentitySession(ctx context.Context, id uuid.UUID) (*Identity, error)
	RevokeIdentitySession(ctx context.Context, id uuid.UUID, at time.Time) error
	// RevokeIdentitySessionsFor ends every one a person holds, which is what a
	// password change means.
	RevokeIdentitySessionsFor(ctx context.Context, identityID uuid.UUID, at time.Time) (int, error)
}

IdentityStore persists identity sessions.

type IssueInput

type IssueInput struct {
	TenantID  uuid.UUID
	AccountID uuid.UUID
	Client    Client

	IPAddress string
	UserAgent string

	// Remember asks for the long lifetime.
	Remember bool

	// ImpersonatedByAccountID marks a session an administrator opened as
	// somebody else. It propagates through every rotation, so a session that
	// began as impersonation cannot quietly become an ordinary one.
	ImpersonatedByAccountID *uuid.UUID
	// APIKeyID marks a session minted for a machine.
	APIKeyID *uuid.UUID

	// Payload is the application's own context for this session — which device,
	// which sign-in method, when step-up authentication was last satisfied. It is
	// carried forward by every rotation and reaches a handler as
	// [tenancy.Claims.Extra].
	//
	// Never authorization. It is written here and again only when the session is
	// refreshed, so a permission kept in it stays true for as long as the refresh
	// lifetime — twelve hours, or thirty days for "remember me" — after somebody
	// took it away.
	Payload json.RawMessage
}

IssueInput starts a session.

type Issued

type Issued struct {
	// Token is the value to hand the client. It cannot be recovered later:
	// only its hash is stored.
	Token     string
	TokenID   uuid.UUID
	ExpiresAt time.Time
}

Issued is one token, at the only moment its secret exists.

type Kind

type Kind string

Kind is what a token is for.

const (
	// KindRefresh is exchanged for a new pair and never sent to an API.
	KindRefresh Kind = "Refresh"
	// KindAccess authenticates a request and is short-lived.
	KindAccess Kind = "Access"
)

type Manager

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

Manager issues and verifies sessions.

func New

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

New builds a manager. Zero-valued durations take their defaults.

func (*Manager) FindSession

func (m *Manager) FindSession(ctx context.Context, tenantID, rootID uuid.UUID) (*Token, error)

FindSession returns the root token of a live session in a tenant, or nil when there is none.

The tenant is an argument rather than something the caller checks afterwards, because "does this session exist" and "does this session exist *here*" are questions with different answers and only the second one is ever safe to answer. A session that has been revoked or has expired is nil too: it is not there to be acted on, and saying otherwise would let an old identifier be told apart from an invented one.

func (*Manager) Issue

func (m *Manager) Issue(ctx context.Context, in IssueInput) (Pair, error)

Issue starts a new session and returns its first pair.

func (*Manager) List

func (m *Manager) List(ctx context.Context, tenantID, accountID uuid.UUID) ([]Family, error)

List returns an account's live sessions, newest first.

func (*Manager) ListTenant

func (m *Manager) ListTenant(ctx context.Context, tenantID uuid.UUID) ([]Family, error)

ListTenant returns every live session in a tenant, newest first.

This is the wide answer, and holding it is a permission somebody was granted: it says who is signed in across the whole tenant, which is what makes ending the session of somebody who left possible at all.

func (*Manager) Revoke

func (m *Manager) Revoke(ctx context.Context, rootID uuid.UUID) error

Revoke ends one session.

func (*Manager) RevokeAll

func (m *Manager) RevokeAll(ctx context.Context, tenantID, accountID uuid.UUID) error

RevokeAll ends every session an account has.

A password change and a password reset both call it. Changing a password while the person who stole it stays signed in elsewhere is not a recovery.

func (*Manager) RevokeBy

func (m *Manager) RevokeBy(ctx context.Context, rootID, by uuid.UUID) error

RevokeBy ends one session and records who ended it.

The administrative half of Manager.Revoke, for a session somebody else is holding: a lost phone, or a departure. `by` is the account that asked, and uuid.Nil means the holder ended their own — which is what Revoke passes and what a logout is.

It reads the root token before revoking, and not only for the identifier. The entry needs the tenant and the account, and an entry without a tenant is invisible to every reader of the trail: `rig_auth_log.tenant_id` is nullable for the attempts that resolved to nobody, and the readers filter on it. A logout stamped with no tenant is a logout the tenant's own audit screen can never show, which is how this went unnoticed for as long as nothing read the table.

func (*Manager) Rotate

func (m *Manager) Rotate(ctx context.Context, presented string) (Pair, error)

Rotate exchanges a refresh token for a new pair.

The whole exchange runs in one transaction under a row lock, because the decision it makes — is this the first use, a retry, or a replay — is only correct if nobody else can be making it at the same time.

func (*Manager) Verify

func (m *Manager) Verify(ctx context.Context, presented string) (*Token, error)

Verify resolves a presented access token.

It refuses a refresh token outright. The two are interchangeable strings and a client that sends the wrong one should be told so by failing, not by being let in with a credential that was never meant to leave its keychain.

type MemoryStore

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

MemoryStore is an in-process Store.

It is for tests. Rotation and reuse detection are decided in Go, not in SQL, which is what makes them testable without a database.

Two things it cannot show, both of which have hidden a real bug: it takes no locks, so a concurrent rotation here proves nothing; and its InTx is a passthrough, so a write that a real transaction would roll back appears to stick. Those cases belong in the Docker suite, and there are tests for both of them there.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore builds an empty store.

func (*MemoryStore) Families

func (s *MemoryStore) Families(_ context.Context, tenantID, accountID uuid.UUID) ([]Family, error)

Families implements Store.

func (*MemoryStore) Find

func (s *MemoryStore) Find(_ context.Context, id uuid.UUID) (*Token, error)

Find implements Store.

func (*MemoryStore) FindIdentitySession

func (s *MemoryStore) FindIdentitySession(_ context.Context, id uuid.UUID) (*Identity, error)

FindIdentitySession implements IdentityStore.

func (*MemoryStore) InTx

func (s *MemoryStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error

InTx implements Store. There is no transaction to open in one map.

func (*MemoryStore) Insert

func (s *MemoryStore) Insert(_ context.Context, t *Token) error

Insert implements Store.

func (*MemoryStore) InsertIdentitySession

func (s *MemoryStore) InsertIdentitySession(_ context.Context, in *Identity) error

InsertIdentitySession implements IdentityStore.

func (*MemoryStore) Lock

func (s *MemoryStore) Lock(ctx context.Context, id uuid.UUID) (*Token, error)

Lock implements Store. There is no lock to take in one process.

func (*MemoryStore) MarkRotated

func (s *MemoryStore) MarkRotated(_ context.Context, id uuid.UUID, at time.Time) error

MarkRotated implements Store.

func (*MemoryStore) RevokeFamily

func (s *MemoryStore) RevokeFamily(_ context.Context, rootID uuid.UUID, at time.Time) ([]uuid.UUID, error)

RevokeFamily implements Store.

func (*MemoryStore) RevokeIdentitySession

func (s *MemoryStore) RevokeIdentitySession(_ context.Context, id uuid.UUID, at time.Time) error

RevokeIdentitySession implements IdentityStore.

func (*MemoryStore) RevokeIdentitySessionsFor

func (s *MemoryStore) RevokeIdentitySessionsFor(_ context.Context, identityID uuid.UUID, at time.Time) (int, error)

RevokeIdentitySessionsFor implements IdentityStore.

func (*MemoryStore) TenantFamilies

func (s *MemoryStore) TenantFamilies(_ context.Context, tenantID uuid.UUID) ([]Family, error)

TenantFamilies implements Store.

type Pair

type Pair struct {
	Access  Issued
	Refresh Issued
	// RootTokenID identifies the session the pair belongs to. It is what a
	// client shows on a "signed in devices" screen and what Revoke takes.
	RootTokenID uuid.UUID
}

Pair is what every issue and every rotation returns.

type Store

type Store interface {
	// Insert writes a token.
	Insert(ctx context.Context, t *Token) error

	// Find reads a token by identifier, or returns nil when there is none.
	//
	// A missing token is not an error: it is the ordinary answer to a
	// presented value that was made up.
	Find(ctx context.Context, id uuid.UUID) (*Token, error)

	// Lock reads a token for update inside the caller's transaction.
	//
	// Rotation reads, decides, and writes, and two requests doing that at once
	// would both see an unconsumed token. The lock is what makes the second
	// one see the first one's work.
	Lock(ctx context.Context, id uuid.UUID) (*Token, error)

	// MarkRotated records the first exchange of a token.
	//
	// Implementations must leave an existing value alone. If a replay could
	// push the timestamp forward, an attacker replaying every twenty seconds
	// would keep a thirty-second leeway open indefinitely.
	MarkRotated(ctx context.Context, id uuid.UUID, at time.Time) error

	// RevokeFamily kills every live token descending from a root and returns
	// the identifiers of the ones it killed.
	//
	// The identifiers rather than a count, because a cache over verification is
	// keyed by the access token a request presents and not by the session it
	// belongs to — so "this session is over" has to be spelled out as the
	// tokens it was made of before anything can be told to forget them. A
	// caller that only wants the count takes len.
	RevokeFamily(ctx context.Context, rootID uuid.UUID, at time.Time) ([]uuid.UUID, error)

	// Families lists an account's sessions, newest first.
	Families(ctx context.Context, tenantID, accountID uuid.UUID) ([]Family, error)

	// TenantFamilies lists every session open in a tenant, newest first.
	//
	// A second method rather than a nullable account on the first, for the reason
	// [Store] is small: "every session in the tenant" and "this person's
	// sessions" are different questions with different authority behind them, and
	// a nil argument that silently widens a read is the kind of parameter that
	// gets passed a zero value by accident.
	TenantFamilies(ctx context.Context, tenantID uuid.UUID) ([]Family, error)

	// InTx runs fn inside one transaction, joining one already in progress.
	InTx(ctx context.Context, fn func(ctx context.Context) error) error
}

Store is the persistence the manager needs.

It is small on purpose. Everything interesting — rotation, reuse detection, expiry — is decided here rather than in SQL, so the rules can be read in one place and tested without a database.

type Token

type Token struct {
	ID        uuid.UUID
	TenantID  uuid.UUID
	AccountID uuid.UUID
	Kind      Kind

	// RootTokenID is the family. The first token of a session is its own root,
	// which is what lets "revoke this session" be one indexed UPDATE.
	RootTokenID uuid.UUID
	// ParentTokenID is the token this one was minted from, nil for a root.
	ParentTokenID *uuid.UUID

	SecretHash []byte

	CreatedAt time.Time
	ExpiresAt time.Time
	// RotatedAt is when this token was first exchanged. It is a soft consume:
	// the row stays, because a replay of a consumed token is the signal that
	// matters and a deleted row cannot signal anything.
	RotatedAt *time.Time
	// RevokedAt is a hard kill.
	RevokedAt *time.Time

	IPAddress string
	UserAgent string
	Client    Client

	ImpersonatedByAccountID *uuid.UUID
	APIKeyID                *uuid.UUID

	// Payload is whatever the application wants to remember about this session.
	//
	// Opaque here on purpose: this package stores and copies it and never looks
	// inside. It survives every rotation, so a fact recorded at sign-in is still
	// there twelve hours later — and is only as fresh as the last refresh, which
	// is why nothing deciding what somebody may do belongs in it. Grants are
	// resolved per request for exactly that reason.
	Payload json.RawMessage
}

Token is one row of the token table.

The family root is the session. There is no separate session table because there is nothing a session would hold that its root token does not already: who, when, from where, and whether it is still alive.

func (*Token) Live

func (t *Token) Live(now time.Time) bool

Live reports whether a token may still be used at the given time.

type TokenCache

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

TokenCache holds verified access tokens, and is how a revocation reaches the replicas holding them.

A nil *TokenCache is usable and caches nothing, which is what a manager built without one has: every Manager.Verify reads the row, exactly as it did before this existed.

It is one type rather than a map and a topic side by side because the two are halves of one agreement. NewTokenCache registers the map and returns the publisher for the same registration, so the name is typed once and there is no second place for it to drift.

func NewTokenCache

func NewTokenCache(bus *cache.Bus, cfg TokenCacheConfig) *TokenCache

NewTokenCache registers a cache on a bus and returns it.

The bus must not already serve TokenTopic; cache.Bus.Serve panics if it does, which is the wiring mistake of building two managers over one bus.

type TokenCacheConfig

type TokenCacheConfig struct {
	// TTL is how long a verified token may be reused with no invalidation
	// arriving, and the whole cost of the cache stated as time.
	//
	// It is the backstop rather than the guarantee: a revocation is published on
	// the transaction that performed it, so the ordinary case is that every
	// replica has forgotten before the next request arrives. This is what
	// remains if one of them was not listening.
	//
	// Zero or less caches nothing.
	TTL time.Duration

	// MaxEntries bounds the map. Zero takes the package default.
	MaxEntries int

	// Now is the clock, for tests.
	Now func() time.Time
}

TokenCacheConfig is what a TokenCache needs beyond its bus.

Jump to

Keyboard shortcuts

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