Files
pad/internal/store/sessions.go
T
xarmian d76f06d199 feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2026-10-21 06:49:54 -04:00

443 lines
17 KiB
Go

package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// SessionInfo holds the result of session validation, including the
// authenticated user and session binding metadata for IP/UA verification.
type SessionInfo struct {
// ID is the session row's id (sessions.id), what rows that live and die
// with the session (push_devices) reference. Never the token.
ID string
User *models.User
IPAddress string
UAHash string
// Kind is how the session was issued: SessionKindCLI or SessionKindWeb
// (BUG-3350). Only a web session is accepted as a browser cookie.
Kind string
// CreatedAt is when the sign-in this session descends from happened: a
// rotation (CreateSessionIssuedAt) carries it over rather than restarting
// it (BUG-3336).
CreatedAt time.Time
// RenewTTL is the session's sliding window (renew_ttl_seconds); zero for a
// legacy row that never slides. A rotation keeps it (BUG-3978).
RenewTTL time.Duration
}
// Session kinds (BUG-3350). A CLI session is a bearer credential only; a web
// session is the browser's cookie (and may also be sent as a bearer).
const (
SessionKindWeb = "web"
SessionKindCLI = "cli"
)
// SessionKindFor maps a session's device label to its kind. The CLI's two
// sign-in paths label their sessions "cli-browser-auth" (browser approval)
// and "cli" (`pad auth login -i`).
func SessionKindFor(deviceInfo string) string {
if deviceInfo == "cli" || deviceInfo == "cli-browser-auth" {
return SessionKindCLI
}
return SessionKindWeb
}
// ErrUserDisabled is returned when a session would be created for an account
// that is disabled, or does not exist.
var ErrUserDisabled = errors.New("user is disabled")
// CreateSession generates a random session token, stores its SHA-256 hash,
// and returns the plaintext token (prefixed with "padsess_"). The plaintext
// is returned exactly once and never stored. IP address and User-Agent hash
// are stored for session binding validation.
func (s *Store) CreateSession(userID, deviceInfo, ipAddress, userAgent string, ttl time.Duration) (string, error) {
return s.CreateSessionIssuedAt(userID, deviceInfo, ipAddress, userAgent, ttl, time.Time{})
}
// CreateSessionIssuedAt is CreateSession for a session that replaces an
// existing one (credential-change rotation): created_at is the REPLACED
// session's, so the sign-in it descends from stays dated where it happened.
// That keeps rotation from making a session look freshly signed in
// (BUG-3336's re-auth window) or from extending SessionMaxLifetime, which is
// measured from created_at. The expiry is capped at that lifetime too. A
// zero issuedAt means a new sign-in, now.
func (s *Store) CreateSessionIssuedAt(userID, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) {
return s.createSession(userID, -1, deviceInfo, ipAddress, userAgent, ttl, issuedAt)
}
// ErrCredentialsChanged refuses a session mint whose credential check is
// stale: the account's credential_epoch moved between the check and the mint
// (a password reset or change, a disable, an account claim). The caller
// answers as for a failed sign-in (BUG-3382).
var ErrCredentialsChanged = errors.New("credentials changed since the sign-in was checked")
// CreateSessionFenced is CreateSessionIssuedAt for a sign-in: it mints only if
// the account's credential_epoch still equals epoch, the value read when the
// credential was checked (models.User.CredentialEpoch on the user the check
// loaded), else ErrCredentialsChanged. Every production sign-in uses it
// (BUG-3382); CreateSession / CreateSessionIssuedAt carry no fence and are
// for tests and tooling.
func (s *Store) CreateSessionFenced(userID string, epoch int64, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) {
if epoch < 0 {
return "", fmt.Errorf("insert session: negative credential epoch %d", epoch)
}
return s.createSession(userID, epoch, deviceInfo, ipAddress, userAgent, ttl, issuedAt)
}
func (s *Store) createSession(userID string, epoch int64, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) {
// Only for an account that is not disabled, decided under the users row
// (BUG-3349, requireActiveUserTx): a request admitted before a disable,
// minting after it, would otherwise leave a session its revoke never saw.
tx, err := s.db.Begin()
if err != nil {
return "", fmt.Errorf("insert session: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
plaintext, _, err := s.createSessionTx(tx, userID, epoch, deviceInfo, ipAddress, userAgent, ttl, issuedAt)
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("insert session: commit: %w", err)
}
return plaintext, nil
}
// createSessionTx is createSession's body on a caller-owned transaction: it
// returns the plaintext token and the new row's id, and commits nothing.
func (s *Store) createSessionTx(tx *sql.Tx, userID string, epoch int64, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, string, error) {
// Generate 32 random bytes → hex → prefix
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", fmt.Errorf("generate session token: %w", err)
}
plaintext := "padsess_" + hex.EncodeToString(raw)
hash := sha256.Sum256([]byte(plaintext))
tokenHash := hex.EncodeToString(hash[:])
// Hash the User-Agent for storage (we don't need the original)
uaHash := ""
if userAgent != "" {
h := sha256.Sum256([]byte(userAgent))
uaHash = hex.EncodeToString(h[:])
}
id := newID()
ts := now()
expires := time.Now().UTC().Add(ttl)
if !issuedAt.IsZero() {
ts = issuedAt.UTC().Format(time.RFC3339)
if limit := issuedAt.UTC().Add(SessionMaxLifetime); expires.After(limit) {
expires = limit
}
}
expiresAt := expires.Format(time.RFC3339)
if err := s.requireActiveUserTx(tx, userID); err != nil {
if errors.Is(err, ErrUserDisabled) {
return "", "", err
}
return "", "", fmt.Errorf("insert session: %w", err)
}
// The epoch is read under requireActiveUserTx's lock (FOR SHARE on
// Postgres; SQLite serializes writers), which a credential change's
// UPDATE of the row conflicts with, so the two serialize.
if epoch >= 0 {
var current int64
if err := tx.QueryRow(s.q(`SELECT credential_epoch FROM users WHERE id = ?`), userID).Scan(&current); err != nil {
return "", "", fmt.Errorf("insert session: read credential epoch: %w", err)
}
if current != epoch {
return "", "", ErrCredentialsChanged
}
}
if _, err := tx.Exec(s.q(`
INSERT INTO sessions (id, user_id, token_hash, device_info, ip_address, ua_hash, expires_at, created_at, renew_ttl_seconds, kind)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, userID, tokenHash, deviceInfo, ipAddress, uaHash, expiresAt, ts, int64(ttl/time.Second), SessionKindFor(deviceInfo)); err != nil {
return "", "", fmt.Errorf("insert session: %w", err)
}
return plaintext, id, nil
}
// RotateUserSessions is a credential change's session rotation (password
// change, 2FA disable, provider unlink) as ONE transaction: it mints the
// replacement session (the same epoch fence and active-user check as
// CreateSessionFenced), moves the push devices bound to oldSessionID, the
// caller's own session, onto it, then deletes every other session of the
// user. A device on any OTHER session dies with that session (the cascade):
// the change exists to cut off stolen sessions, and their push tokens go with
// them (TASK-3938). An empty oldSessionID rebinds nothing.
//
// It fails closed. When the transaction fails for any reason, every session
// of the user is revoked as DeleteUserSessions always did, and the original
// error is returned: the caller answers 500 and the user signs in again,
// where a half-rotated state would leave the sessions the change meant to cut
// off. ErrUserDisabled and ErrCredentialsChanged get the same revoke-all.
func (s *Store) RotateUserSessions(userID string, epoch int64, oldSessionID, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) {
token, err := s.rotateUserSessions(userID, epoch, oldSessionID, deviceInfo, ipAddress, userAgent, ttl, issuedAt)
if err == nil {
return token, nil
}
if delErr := s.DeleteUserSessions(userID); delErr != nil {
slog.Error("failed to revoke sessions after a failed rotation",
"user_id", userID, "rotation_error", err, "error", delErr)
}
return "", err
}
func (s *Store) rotateUserSessions(userID string, epoch int64, oldSessionID, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) {
if epoch < 0 {
return "", fmt.Errorf("rotate sessions: negative credential epoch %d", epoch)
}
tx, err := s.db.Begin()
if err != nil {
return "", fmt.Errorf("rotate sessions: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
token, replacementID, err := s.createSessionTx(tx, userID, epoch, deviceInfo, ipAddress, userAgent, ttl, issuedAt)
if err != nil {
return "", err
}
if oldSessionID != "" {
if _, err := tx.Exec(s.q(`UPDATE push_devices SET session_id = ? WHERE session_id = ? AND user_id = ?`),
replacementID, oldSessionID, userID); err != nil {
return "", fmt.Errorf("rotate sessions: rebind push devices: %w", err)
}
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE user_id = ? AND id <> ?`), userID, replacementID); err != nil {
return "", fmt.Errorf("rotate sessions: delete other sessions: %w", err)
}
if s.rotateFault != nil {
if err := s.rotateFault(); err != nil {
return "", fmt.Errorf("rotate sessions: %w", err)
}
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("rotate sessions: commit: %w", err)
}
return token, nil
}
// ValidateSession hashes the provided token, looks it up, checks expiry,
// and returns the session info including the associated user and binding
// metadata (IP/UA). Returns nil if invalid or expired.
func (s *Store) ValidateSession(token string) (*SessionInfo, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
var sessionID, userID, expiresAt, ipAddress, uaHash, createdAt, kind string
var renewSeconds int64
err := s.db.QueryRow(s.q(`
SELECT id, user_id, expires_at, ip_address, ua_hash, created_at, kind, renew_ttl_seconds FROM sessions WHERE token_hash = ?
`), tokenHash).Scan(&sessionID, &userID, &expiresAt, &ipAddress, &uaHash, &createdAt, &kind, &renewSeconds)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("validate session: %w", err)
}
// Check expiry
if parseTime(expiresAt).Before(time.Now().UTC()) {
return nil, nil
}
user, err := s.GetUser(userID)
if err != nil {
return nil, err
}
// A bot's session, however it came to exist, resolves to nothing
// (TASK-3392).
if user == nil || user.IsApp() {
return nil, nil
}
return &SessionInfo{
ID: sessionID,
User: user,
IPAddress: ipAddress,
UAHash: uaHash,
CreatedAt: parseTime(createdAt),
Kind: kind,
RenewTTL: time.Duration(renewSeconds) * time.Second,
}, nil
}
// SessionMaxLifetime caps the total sliding lifetime of a session measured
// from its creation. Sliding renewal can push expires_at forward repeatedly
// while a session is active, but never past created_at + SessionMaxLifetime —
// so an indefinitely-active client still has to re-authenticate eventually.
const SessionMaxLifetime = 90 * 24 * time.Hour
// RenewSessionIfStale implements sliding-window expiration. When a session is
// past its renewal threshold (less than half its renewal window remaining) it
// extends expires_at to now + the session's renewal window, capped at
// created_at + SessionMaxLifetime. It returns the resulting expiry and whether
// a renewal was written.
//
// Sessions with renew_ttl_seconds <= 0 (legacy rows from before sliding
// renewal, and rows created with a non-positive TTL) are never renewed — their
// current expiry is returned with renewed=false.
//
// The threshold gate keeps writes rare: a 7-day window renews at most once
// every ~3.5 days of activity, not on every request. The UPDATE is a
// compare-and-set on the old expiry so concurrent requests don't double-write.
//
// Callers should treat this as best-effort: a renewal error must not fail an
// otherwise-valid request (the session is still valid until its current
// expiry).
func (s *Store) RenewSessionIfStale(token string) (time.Time, bool, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
var expiresAtStr, createdAtStr string
var renewSeconds int64
err := s.db.QueryRow(s.q(`
SELECT expires_at, created_at, renew_ttl_seconds FROM sessions WHERE token_hash = ?
`), tokenHash).Scan(&expiresAtStr, &createdAtStr, &renewSeconds)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, fmt.Errorf("read session for renewal: %w", err)
}
expiresAt := parseTime(expiresAtStr)
if renewSeconds <= 0 {
// Sliding renewal disabled for this session (legacy row).
return expiresAt, false, nil
}
renewWindow := time.Duration(renewSeconds) * time.Second
nowT := time.Now().UTC()
// Only renew once we're past the threshold (< half the window remaining).
if expiresAt.Sub(nowT) >= renewWindow/2 {
return expiresAt, false, nil
}
newExpiry := nowT.Add(renewWindow)
// Never let sliding renewal exceed the absolute lifetime cap.
if createdAt := parseTime(createdAtStr); !createdAt.IsZero() {
if cap := createdAt.Add(SessionMaxLifetime); newExpiry.After(cap) {
newExpiry = cap
}
}
// Never shorten an existing expiry (e.g. once the cap is reached).
if !newExpiry.After(expiresAt) {
return expiresAt, false, nil
}
newExpiryStr := newExpiry.Format(time.RFC3339)
res, err := s.db.Exec(s.q(`
UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at = ?
`), newExpiryStr, tokenHash, expiresAtStr)
if err != nil {
return expiresAt, false, fmt.Errorf("renew session: %w", err)
}
// Only report a renewal when this request's CAS actually wrote the row.
// A zero-row update means we lost the race to a concurrent renewal (the
// session is still renewed, just not by us) or the session was deleted
// between the read and the write — in either case the caller must NOT
// reissue a cookie advertising an expiry it didn't set. Treat an
// unreliable RowsAffected as "not renewed" so we fail safe.
n, err := res.RowsAffected()
if err != nil || n == 0 {
return expiresAt, false, nil
}
return newExpiry, true, nil
}
// UpdateSessionIPIfEquals atomically rotates the stored IP on a session
// from expectedOldIP to newIP. Returns true if a row was updated (i.e. the
// caller is the race winner and should write the audit row); false when
// some other concurrent request already rotated the IP ahead of us.
//
// This compare-and-set behavior is what lets the auth middleware emit
// exactly one ActionSessionIPChanged audit row per transition even when
// multiple requests race after the client's IP flips.
func (s *Store) UpdateSessionIPIfEquals(token, expectedOldIP, newIP string) (bool, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
res, err := s.db.Exec(s.q(`
UPDATE sessions
SET ip_address = ?
WHERE token_hash = ? AND ip_address = ?
`), newIP, tokenHash, expectedOldIP)
if err != nil {
return false, fmt.Errorf("update session ip: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
// Some drivers don't report RowsAffected reliably. Err on the safe
// side: treat as "not the winner" so we don't log when unsure.
return false, nil
}
return n > 0, nil
}
// DeleteSession destroys a session by its plaintext token.
func (s *Store) DeleteSession(token string) error {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE token_hash = ?"), tokenHash)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// DeleteSessionIfExists destroys a session by plaintext token and reports
// whether a row was actually deleted. Used by the IP-change strict-mode
// path as a compare-and-set primitive: only the caller that actually
// deletes the row emits the ActionSessionIPChanged audit entry so
// concurrent requests don't write duplicate audit rows, and only the
// caller whose DELETE succeeded on the DB gets a clean "session is now
// gone" guarantee.
func (s *Store) DeleteSessionIfExists(token string) (bool, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
res, err := s.db.Exec(s.q("DELETE FROM sessions WHERE token_hash = ?"), tokenHash)
if err != nil {
return false, fmt.Errorf("delete session: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
// Drivers that don't report affected rows → treat as "existed" so
// callers don't skip their post-delete work.
return true, nil
}
return n > 0, nil
}
// DeleteUserSessions destroys all sessions for a user (logout everywhere).
func (s *Store) DeleteUserSessions(userID string) error {
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE user_id = ?"), userID)
if err != nil {
return fmt.Errorf("delete user sessions: %w", err)
}
return nil
}
// CleanExpiredSessions removes all sessions past their expiry time.
func (s *Store) CleanExpiredSessions() error {
_, err := s.db.Exec(s.q("DELETE FROM sessions WHERE expires_at < ?"), now())
if err != nil {
return fmt.Errorf("clean expired sessions: %w", err)
}
return nil
}