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
- Variables
- type Client
- type Config
- type Family
- type Identity
- type IdentityConfig
- type IdentityIssueInput
- type IdentityManager
- func (m *IdentityManager) Issue(ctx context.Context, in IdentityIssueInput) (Issued, error)
- func (m *IdentityManager) Revoke(ctx context.Context, presented string) error
- func (m *IdentityManager) RevokeAll(ctx context.Context, identityID uuid.UUID) (int, error)
- func (m *IdentityManager) Verify(ctx context.Context, presented string) (*Identity, error)
- type IdentityStore
- type IssueInput
- type Issued
- type Kind
- type Manager
- func (m *Manager) FindSession(ctx context.Context, tenantID, rootID uuid.UUID) (*Token, error)
- func (m *Manager) Issue(ctx context.Context, in IssueInput) (Pair, error)
- func (m *Manager) List(ctx context.Context, tenantID, accountID uuid.UUID) ([]Family, error)
- func (m *Manager) ListTenant(ctx context.Context, tenantID uuid.UUID) ([]Family, error)
- func (m *Manager) Revoke(ctx context.Context, rootID uuid.UUID) error
- func (m *Manager) RevokeAll(ctx context.Context, tenantID, accountID uuid.UUID) error
- func (m *Manager) RevokeBy(ctx context.Context, rootID, by uuid.UUID) error
- func (m *Manager) Rotate(ctx context.Context, presented string) (Pair, error)
- func (m *Manager) Verify(ctx context.Context, presented string) (*Token, error)
- type MemoryStore
- func (s *MemoryStore) Families(_ context.Context, tenantID, accountID uuid.UUID) ([]Family, error)
- func (s *MemoryStore) Find(_ context.Context, id uuid.UUID) (*Token, error)
- func (s *MemoryStore) FindIdentitySession(_ context.Context, id uuid.UUID) (*Identity, error)
- func (s *MemoryStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error
- func (s *MemoryStore) Insert(_ context.Context, t *Token) error
- func (s *MemoryStore) InsertIdentitySession(_ context.Context, in *Identity) error
- func (s *MemoryStore) Lock(ctx context.Context, id uuid.UUID) (*Token, error)
- func (s *MemoryStore) MarkRotated(_ context.Context, id uuid.UUID, at time.Time) error
- func (s *MemoryStore) RevokeFamily(_ context.Context, rootID uuid.UUID, at time.Time) ([]uuid.UUID, error)
- func (s *MemoryStore) RevokeIdentitySession(_ context.Context, id uuid.UUID, at time.Time) error
- func (s *MemoryStore) RevokeIdentitySessionsFor(_ context.Context, identityID uuid.UUID, at time.Time) (int, error)
- func (s *MemoryStore) TenantFamilies(_ context.Context, tenantID uuid.UUID) ([]Family, error)
- type Pair
- type Store
- type Token
- type TokenCache
- type TokenCacheConfig
Constants ¶
const ( DefaultAccessTTL = 10 * time.Minute DefaultRefreshTTL = 12 * time.Hour DefaultRememberTTL = 30 * 24 * time.Hour DefaultRotationLeeway = 30 * time.Second )
Default lifetimes.
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.
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.
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.
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 ¶
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.
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.
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.
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 ¶
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 ¶
func (m *IdentityManager) Issue(ctx context.Context, in IdentityIssueInput) (Issued, error)
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) Verify ¶
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 Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager issues and verifies sessions.
func (*Manager) FindSession ¶
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) ListTenant ¶
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) RevokeAll ¶
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 ¶
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 ¶
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.
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 (*MemoryStore) FindIdentitySession ¶
FindIdentitySession implements IdentityStore.
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) MarkRotated ¶
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 ¶
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 ¶
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.
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.