Documentation
¶
Overview ¶
Package session @notice Opaque server-side sessions in Postgres — kal's primary credential.
@dev Sessions in the application's own database is the design position of the whole library: revocation is an UPDATE, "log out everywhere" is one statement, a user can see their own devices, and because the session cookie is the long-lived credential there is no refresh-token subsystem — no rotating families, no reuse detection, no two-tab race. The honest cost is one indexed SELECT per authenticated request, and it is worth paying; if it ever measurably is not, the upgrade is a short-TTL in-process cache keyed on the token hash, at which point the cache TTL becomes the revocation SLA. Measure first.
There is deliberately no Store interface. Postgres is the premise — the lookup JOINs the users and roles tables, which is exactly what an abstraction over "some backend" would forbid. All SQL sits in sql.go; that file is the seam if a driver swap ever becomes real.
Index ¶
- Constants
- func ClearCookie(name string) *http.Cookie
- func Cookie(name, token string) *http.Cookie
- func HashToken(token string) []byte
- func NewToken() (token string, hash []byte, err error)
- func SetCookie(ctx context.Context, c *http.Cookie) error
- type Claims
- type Info
- type JWT
- type Meta
- type MiddlewareOptions
- type Options
- type Sessions
- func (s *Sessions) Issue(ctx context.Context, db orm.DB, userID string, meta Meta) (string, error)
- func (s *Sessions) List(ctx context.Context, db orm.DB, userID string) ([]Info, error)
- func (s *Sessions) Lookup(ctx context.Context, db orm.DB, token string) (*authz.Principal, error)
- func (s *Sessions) Middleware(db orm.DB, opts MiddlewareOptions) func(http.Handler) http.Handler
- func (s *Sessions) RecordMFA(ctx context.Context, db orm.DB, sessionID, userID string) error
- func (s *Sessions) Revoke(ctx context.Context, db orm.DB, sessionID string) error
- func (s *Sessions) RevokeAllForUser(ctx context.Context, db orm.DB, userID string) error
- func (s *Sessions) Rotate(ctx context.Context, db orm.DB, sessionID string) (string, error)
Constants ¶
const ( // DefaultIdle @notice The rolling window a session survives without a request. DefaultIdle = 12 * time.Hour // DefaultAbsolute @notice The hard lifetime, anchored at authentication, never extended. DefaultAbsolute = 14 * 24 * time.Hour )
Defaults for the two expiries. Both are enforced on every lookup (ASVS 7.3.1, 7.3.2): idle alone lets an active session live forever; absolute alone logs a working user out mid-task.
const DefaultCookieName = "__Host-kal_session"
DefaultCookieName @notice The session cookie's name, __Host- prefixed.
@dev The prefix is the important part: a browser rejects a __Host- cookie unless it has Secure, Path=/ and no Domain attribute — which means a compromised or attacker-controlled sibling subdomain cannot overwrite it. That property is what makes session fixation via cookie injection impossible (ASVS 3.3.1, 3.3.3). One layer owns each cookie name: this one is kal's, and nothing else — no Fiber session middleware, no CSRF middleware — may set it.
const MaxTokenTTL = 5 * time.Minute
MaxTokenTTL @notice The hard ceiling on a minted token's lifetime.
@dev A constant in code, not a configuration field. These tokens are not revocable, so the TTL *is* the revocation window — and a config field inviting "24h" is a config field someone eventually sets. Five minutes is short enough that a stolen token is a narrow problem and long enough for any request a service makes on a caller's behalf.
Variables ¶
This section is empty.
Functions ¶
func ClearCookie ¶
ClearCookie @notice The deletion twin of Cookie.
@dev It carries the same Secure/Path/SameSite attributes, because a browser will not let a non-compliant cookie overwrite a __Host- one — a bare "name=; Max-Age=0" would be silently ignored and the stale token would ride along forever.
@param name the cookie name to delete @return *http.Cookie with MaxAge -1
func Cookie ¶
Cookie @notice The session cookie, with every attribute the way it must be.
@dev Each attribute is load-bearing:
- Secure always, and there is deliberately no knob to turn it off. Chrome and Firefox accept Secure cookies from http://localhost, so local development works unmodified; Safari's answer is a local certificate (mkcert), not a flag that will be found and set in production.
- HttpOnly always — nothing in the token is useful to client-side script.
- SameSite=Lax, not Strict: Strict drops the cookie on top-level navigation from another site, which breaks every link in every email — including kal's own verification links, so the user clicks "verify" and lands logged out. Lax is not sufficient alone; the middleware's transport guard covers the rest.
- No Max-Age and no Expires: a browser-session cookie. Server-side idle and absolute expiry are the real lifetime, and they cannot be extended by a client-held attribute.
@param name the cookie name; use DefaultCookieName unless there are two kal instances @param token the raw session token from Issue or Rotate @return *http.Cookie ready for SetCookie
func HashToken ¶
HashToken @notice SHA-256 of the token exactly as presented on the wire.
@dev Hashing the string form, not decoded bytes, keeps the lookup path free of a decode branch: a malformed token simply hashes to something that matches no row. Lookup is then a single indexed equality — not constant-time, and it does not need to be, because a B-tree probe's timing reveals nothing about a 256-bit preimage the attacker cannot enumerate.
@param token the wire form @return []byte the 32-byte digest for the *_sha256 column
func NewToken ¶
NewToken @notice Mints a fresh credential: 32 bytes from crypto/rand, base64url-encoded to 43 characters, plus the SHA-256 the database stores.
@dev Only the hash is ever stored. A read-only SQL injection, a leaked backup or a query logger then yields hashes, not live sessions — and unlike a password, a 256-bit CSPRNG token has no preimage to brute-force, so a fast hash is the whole job and a KDF would burn ~100 ms of CPU per authenticated request buying nothing. The same pair of functions serves the emailed tokens in authn: one design, one set of tests.
No selector/verifier split: that pattern exists to get an indexable lookup key that is not the secret, and the hash already is exactly that — derived, non-secret, indexable.
@return token the credential for the client; never stored @return hash SHA-256 of the token string, for the *_sha256 column @return err only a CSPRNG failure
func SetCookie ¶
SetCookie @notice Queues c on the response from anywhere that has the request context — which is how a login resolver, holding only a ctx, sets the session cookie.
@dev gqlgen's transports write headers after resolvers return in the common POST path, but that is a transport implementation detail that differs on the streaming paths — the jar works regardless of who writes first.
The error for a missing jar is a plain error, not a *kalerr.Error: it means the middleware is not mounted, which is server misconfiguration — the presenter redacts it to "internal server error", which is exactly right.
@param ctx the resolver context, as passed through Middleware @param c the cookie to send @return error nil, or "middleware not mounted"
Types ¶
type Claims ¶
type Claims struct {
UserID string // the sub claim
SessionID string // the sid claim
Audience string
ExpiresAt time.Time
}
Claims @notice What a verified token asserts.
type Info ¶
type Info struct {
ID string `pg:"id"`
CreatedAt time.Time `pg:"created_at"`
LastSeenAt time.Time `pg:"last_seen_at"`
UserAgent string `pg:"user_agent"`
IP string `pg:"ip"`
MFAAt time.Time `pg:"mfa_at"` // zero when MFA was never satisfied on this session
}
Info @notice One row of List: a live session as shown to its owner.
@dev The pg tags are explicit rather than left to name inference, because go-pg's camel-to-snake guess for initialisms (MFAAt) is exactly the kind of silent mismatch that scans a column into nothing.
type JWT ¶
type JWT struct {
// contains filtered or unexported fields
}
JWT @notice Mints and verifies short-lived bearer tokens derived from a live session.
@dev This exists for exactly one reason: a service that cannot reach the session table needs to verify a caller. If it can reach the table, it should — the session is strictly better, because it is revocable.
Being derived from a session rather than replacing one is what lets kal ship no refresh token at all. The cookie remains the long-lived credential, so there is nothing to rotate, no reuse-detection family to track, and no two-tab race: the entire largest subsystem of every JWT-first auth library simply does not exist here.
Separate from Sessions on purpose. Key material is optional configuration that most deployments never need, and folding it into Sessions would make every consumer think about Ed25519 keys to get a session cookie.
func NewJWT ¶
func NewJWT(issuer string, keys ...ed25519.PrivateKey) (*JWT, error)
NewJWT @notice Builds the minter from an issuer and one or more Ed25519 keys.
@dev EdDSA only, and the algorithm is fixed here rather than read from a token header — that kills the algorithm-confusion class structurally instead of by discipline. Not HS256: a shared secret means every verifier can also mint, so any service handed the key can impersonate any user. Not RS256: larger, slower, and more parameters to get wrong.
The first key signs; every key verifies. That is what makes rotation a deploy rather than an outage — publish the new key alongside the old, let tokens signed by the old one drain, then drop it.
@param issuer the iss claim, and what verifiers must require @param keys signing keys, newest first; at least one @return *JWT ready for concurrent use @return error a missing issuer, no keys, or a malformed key
func (*JWT) JWKS ¶
JWKS @notice An http.Handler publishing the public keys as a JWKS document.
@dev Twenty-five lines, and worth every one: a non-Go verifier has no other way to learn these keys. Every active key is published, which is what makes rotation an overlap rather than a cutover.
@return http.Handler serving application/json at whatever path you mount it on
func (*JWT) Token ¶
Token @notice Mints a bearer token for the caller in ctx.
@dev The sid claim carries the session id, so a verifier that *can* reach the database may check liveness and close the revocation gap entirely. Include it always; it costs 36 bytes and it is the only thing that makes these tokens revocable by anyone.
@param ctx the resolver context; must carry an authenticated principal @param ttl requested lifetime, silently capped at MaxTokenTTL; zero means the cap @param audience the service this token is for; required, and the verifier must check it @return string the signed compact token @return error UNAUTHENTICATED with no caller, INVALID_INPUT with no audience
func (*JWT) Verify ¶
Verify @notice Checks a token's signature and every mandatory claim, and returns what it asserts.
@dev Three things here are the difference between this and a vulnerability:
- The keyfunc receives the *parsed but unverified* token, so alg and kid are attacker-controlled at that point. It type-asserts the method rather than trusting the header, and WithValidMethods says the same thing again at the parser level. Belt and braces on purpose: this is the algorithm-confusion attack, and it has bitten every JWT library at least once.
- aud, iss and exp are all required. v5 does *not* require exp by default, so without WithExpirationRequired a token with no expiry validates forever. Skipping aud is worse than it sounds: against a shared multi-tenant issuer it means a token minted for any other tenant authenticates here.
- One error out, never a joined one. This is the CVE-2024-51744 lesson: v5's validator collects errors and joins them, so errors.Is(err, ErrTokenExpired) can be true even when the signature is invalid — and the near-universal "expired, let me refresh" branch then happily processes a forgery. Fatal conditions are checked before benign ones and exactly one flat error is returned.
@param token the compact token @param audience the audience this verifier answers to @return *Claims what the token asserts, once every check has passed @return error a single *kalerr.Error; never a joined one, never a typed JWT error
type Meta ¶
type Meta struct {
UserAgent string
// IP @notice Best-effort client address. Parsed here; anything net.ParseIP rejects is
// stored as NULL rather than failing the login over a mangled forwarding header.
IP string
}
Meta @notice What Issue records about the request that created the session — the raw material of a "your devices" page and of suspicious-login notices.
func MetaFromContext ¶
MetaFromContext @notice The request attribution the middleware recorded — user agent and client address — for anything issuing sessions or counting failures per IP.
@param ctx the resolver context @return Meta zero when the middleware is not mounted
type MiddlewareOptions ¶
type MiddlewareOptions struct {
// CookieName @notice Which cookie carries the session. Default DefaultCookieName.
CookieName string
// ClientIP @notice How to attribute a request to a client address, for rate limiting and
// session metadata. Default: the host part of RemoteAddr.
//
// @dev Deliberately not X-Forwarded-For by default — that header is client-supplied unless
// a trusted proxy overwrites it, and a spoofable address turns per-IP rate limiting into a
// bypass. Deployments behind a proxy set this to read the header their proxy guarantees.
ClientIP func(*http.Request) string
}
MiddlewareOptions @notice Configuration for Middleware. The zero value is the production posture.
type Options ¶
type Options struct {
// Idle @notice Rolling inactivity timeout. Zero means DefaultIdle.
Idle time.Duration
// Absolute @notice Hard session lifetime. Zero means DefaultAbsolute. Must not be shorter
// than Idle.
Absolute time.Duration
// Schema @notice Optional Postgres schema holding the auth_* tables. Empty means the
// connection's search_path decides, which is the default install.
Schema string
// Audit @notice Called for every security-relevant event. Nil discards them.
//
// @dev Emitted beside the existing log line rather than instead of it: the log is the
// operator's channel and the hook is the consumer's.
Audit authz.Audit
}
Options @notice Configuration for NewSessions. The zero value is the production posture.
type Sessions ¶
type Sessions struct {
// contains filtered or unexported fields
}
Sessions @notice Issues, resolves, rotates and revokes sessions.
@dev Carries no *pg.DB: every method takes orm.DB, so *pg.DB, *pg.Conn and *pg.Tx all work and a caller can put session changes inside the same transaction as the change that motivated them — "create user and issue session" or "reset password and revoke everything" commit or roll back as one.
func NewSessions ¶
NewSessions @notice Validates opts and prepares the statements.
@dev The schema name is validated against identRe and then quoted with go-pg's own ident writer — belt and braces, because it is the single string interpolated into SQL text rather than bound as a parameter. An absolute lifetime shorter than the idle timeout is a configuration that silently disables the idle refresh, so it is rejected loudly instead.
@param opts zero-value fields take the defaults above @return *Sessions ready for concurrent use @return error an invalid schema name, or Absolute < Idle
func (*Sessions) Issue ¶
Issue @notice Creates a session for userID and returns the raw token — the only moment it exists outside the client.
@param userID the auth_users id @param meta request attribution; zero value is fine @return token the credential to put in the cookie; only its hash is stored @return err any driver error
func (*Sessions) List ¶
List @notice The user's live sessions, most recently active first.
@param userID the auth_users id @return []Info one row per live session; empty, never nil @return error any driver error
func (*Sessions) Lookup ¶
Lookup @notice Resolves a wire token to the caller, or to anonymous — never to an error for a merely bad token.
@dev One statement, one round trip: session liveness, the user (dropped the moment deleted_at is set), and the roles — read fresh here rather than frozen at login, so revoking a role takes effect on the holder's next request at no extra cost. The same statement refreshes the idle window, at most once per 60 seconds (see lookupSQL).
@param token the cookie value; arbitrary bytes are safe @return *authz.Principal the caller, or nil for unknown, expired, revoked or disabled @return error only a driver failure — absence is (nil, nil)
func (*Sessions) Middleware ¶
Middleware @notice Resolves the session cookie into an authz.Principal on the request context, carries the cookie jar for resolvers, and enforces the cross-site transport guard. Plug it into luima's Config.HTTPMiddleware (luima ≥ 0.2.0) or any net/http stack.
@dev The middleware never returns 401. It resolves a session or it does not, populates the context accordingly, and calls next — the graph decides what anonymous may see, because one GraphQL endpoint serves public and private fields in the same document and a transport-level 401 would make every public field unreachable while logged out. An invalid or expired token is not an error either: clear the cookie, proceed anonymously. The only outright rejection is the transport guard below.
A lookup that fails on the driver (database down) also proceeds anonymously, after logging: no identity is granted (fail closed), and the resolvers' own queries will surface the outage with a proper error.
@param db the pool Lookup runs on; orm.DB so a test can hand in anything @param opts zero value for the defaults @return func(http.Handler) http.Handler the outermost-first middleware
func (*Sessions) RecordMFA ¶ added in v0.4.0
RecordMFA @notice Marks the session as having satisfied a second factor, now.
@dev The seam the @auth(mfa:) directive was always documented as having. kal ships no TOTP and no WebAuthn — deliberately, see README "Deliberately not here" — but until this existed the mfa_at column had exactly one writer, inside zkauthn, so the directive denied every caller of every deployment that did not ship Groth16.
Deliberately does not rotate: a privilege change never rides on the old credential, so a caller that wants rotation calls Sessions.Rotate explicitly and can see both in one place. Deliberately takes no time.Time: now() is the database clock, the one time authority, exactly as issueSQL argues — N replicas with drifting clocks must not disagree about when a factor was satisfied.
The returned error wraps pg.ErrNoRows rather than replacing it, so a caller that must not distinguish "session not live" from any other refusal — zkauthn, where the difference is a timing-and-message oracle (gotcha 63) — can still classify it with errors.Is.
@param sessionID the id from the caller's Principal @param userID the caller's id; both are matched, so a session id alone cannot elevate
another user's session
@return error UNAUTHENTICATED when the session is not live, or a driver error
func (*Sessions) Revoke ¶
Revoke @notice Terminates one session. Idempotent.
@param sessionID the session to kill; unknown or already-revoked is not an error @return error any driver error
func (*Sessions) RevokeAllForUser ¶
RevokeAllForUser @notice Terminates every live session of one user — password reset, account disable, "log out everywhere" (ASVS 7.4.2).
@param userID the auth_users id @return error any driver error
func (*Sessions) Rotate ¶
Rotate @notice Swaps the session's credential and returns the new token. Call it on every privilege change: login over an existing cookie, password change, MFA satisfaction, impersonation start or stop.
@dev This is the session-fixation fix (ASVS 7.2.4): a token an attacker planted or observed before the privilege change must not survive it. Identity, created_at and the absolute deadline stay — rotation refreshes the credential, not the session's age.
@param sessionID the id from the caller's Principal @return token the replacement credential @return err UNAUTHENTICATED when the session is no longer live, or a driver error