package auth import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "errors" "fmt" "time" "github.com/mithral/vellum/internal/core" ) // ErrNoSession means no session exists for the presented token, or it expired. var ErrNoSession = errors.New("auth: no such session") // Session is a logged-in browser or device. // // The token itself is NEVER stored. Only its SHA-256 is, so a database leak // does not hand out live sessions. type Session struct { ID string UserID int64 DeviceLabel string IP string UserAgent string CreatedAt time.Time LastSeen time.Time ExpiresAt time.Time } // newSessionToken returns a fresh opaque token and its storage hash. // // The token is 32 bytes of CSPRNG output, base64url-encoded. Hashing with // SHA-256 (not argon2) is correct here: the token already has full entropy, so // there is nothing to brute-force and lookups must stay cheap. func newSessionToken() (token string, hash string, err error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", fmt.Errorf("auth: read session token: %w", err) } token = base64.RawURLEncoding.EncodeToString(raw) return token, hashToken(token), nil } // hashToken is the deterministic mapping from token to stored value. func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return base64.RawURLEncoding.EncodeToString(sum[:]) } // CreateSession stores a new session for userID and returns the raw token. // The raw token is returned exactly once and never persisted. func CreateSession(ctx context.Context, db *sql.DB, userID int64, ttl time.Duration, deviceLabel, ip, userAgent string) (string, error) { token, hash, err := newSessionToken() if err != nil { return "", err } now := time.Now().UTC() _, err = db.ExecContext(ctx, ` INSERT INTO sessions (id, user_id, token_hash, device_label, ip, user_agent, created_at, last_seen, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash[:16], userID, hash, deviceLabel, ip, userAgent, core.Stamp(now), core.Stamp(now), core.Stamp(now.Add(ttl)), ) if err != nil { return "", fmt.Errorf("auth: create session: %w", err) } return token, nil } // SessionByToken resolves a raw token to its session, or ErrNoSession if the // token is unknown or expired. func SessionByToken(ctx context.Context, db *sql.DB, token string) (Session, error) { var ( s Session created string lastSeen string expiresAt string ) err := db.QueryRowContext(ctx, ` SELECT id, user_id, device_label, ip, user_agent, created_at, last_seen, expires_at FROM sessions WHERE token_hash = ?`, hashToken(token), ).Scan(&s.ID, &s.UserID, &s.DeviceLabel, &s.IP, &s.UserAgent, &created, &lastSeen, &expiresAt) if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrNoSession } if err != nil { return Session{}, fmt.Errorf("auth: load session: %w", err) } if s.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil { return Session{}, fmt.Errorf("auth: parse created_at: %w", err) } if s.LastSeen, err = time.Parse(time.RFC3339Nano, lastSeen); err != nil { return Session{}, fmt.Errorf("auth: parse last_seen: %w", err) } if s.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt); err != nil { return Session{}, fmt.Errorf("auth: parse expires_at: %w", err) } if time.Now().UTC().After(s.ExpiresAt) { return Session{}, ErrNoSession } return s, nil } // TouchSession updates last_seen. Best-effort: a failure here must never fail // the request that triggered it, so callers log rather than propagate. func TouchSession(ctx context.Context, db *sql.DB, sessionID string) error { _, err := db.ExecContext(ctx, `UPDATE sessions SET last_seen = ? WHERE id = ?`, core.Now(), sessionID) return err } // DeleteSession logs out a single session by its raw token. func DeleteSession(ctx context.Context, db *sql.DB, token string) error { _, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token)) return err } // DeleteUserSessions logs a user out everywhere. func DeleteUserSessions(ctx context.Context, db *sql.DB, userID int64) error { _, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) return err } // DeleteExpiredSessions is housekeeping, run periodically. func DeleteExpiredSessions(ctx context.Context, db *sql.DB) (int64, error) { res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, core.Now()) if err != nil { return 0, err } return res.RowsAffected() }