package session import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/netip" "slices" "strings" "sync" "time" "tug/internal/service/identity" "tug/internal/storage" ) const ( SudoWindow = 5 * time.Minute secretBytes = 32 defaultPurgeInterval = time.Hour ) var ( ErrNoSession = errors.New("session: no live session") ErrUnavailable = errors.New("session: storage is unavailable") ) type Config struct { Store *storage.Store Identity *identity.Service AdminGroup string TTL time.Duration Now func() time.Time PurgeInterval time.Duration HostTTL map[string]time.Duration SeenFlush time.Duration } type Principal struct { SessionID, UserID, Username, Host string DisplayName string Admin bool Groups []string CreatedAt, ExpiresAt, SudoUntil time.Time } type OpenInput struct { UserID, Host, Via, ClientID string SignIn bool Epoch *int64 Browser string Address netip.Addr UserAgent string } type Service struct { store *storage.Store identity *identity.Service adminGroup string ttlMu sync.RWMutex ttl time.Duration now func() time.Time purgeInterval time.Duration seenFlush time.Duration hostTTL map[string]time.Duration seen seenGate } type openDetail struct { Host string `json:"host"` Via string `json:"via"` Client string `json:"client"` } type signinDetail struct { Host string `json:"host"` Client string `json:"client"` } type hostDetail struct { Host string `json:"host"` } type keptDetail struct { Kept string `json:"kept"` } func New(cfg Config) (*Service, error) { switch { case cfg.Store == nil: return nil, errors.New("session: store is nil") case cfg.Identity == nil: return nil, errors.New("session: identity service is nil") case strings.TrimSpace(cfg.AdminGroup) == "": return nil, errors.New("session: admin group is empty") case cfg.PurgeInterval < 0: return nil, fmt.Errorf("session: purge interval %s is negative", cfg.PurgeInterval) } hostTTL, err := checkTTLs(cfg.TTL, cfg.HostTTL) if err != nil { return nil, err } now := cfg.Now if now == nil { now = time.Now } purgeInterval := cfg.PurgeInterval if purgeInterval == 0 { purgeInterval = defaultPurgeInterval } seenFlush := cfg.SeenFlush if seenFlush == 0 { seenFlush = SeenFlushEvery } return &Service{ store: cfg.Store, identity: cfg.Identity, adminGroup: cfg.AdminGroup, ttl: cfg.TTL, now: now, purgeInterval: purgeInterval, seenFlush: seenFlush, hostTTL: hostTTL, seen: seenGate{interval: SeenInterval}, }, nil } func checkTTLs(ttl time.Duration, hostTTL map[string]time.Duration) (map[string]time.Duration, error) { if ttl <= 0 { return nil, fmt.Errorf("session: TTL %s is not positive", ttl) } checked := make(map[string]time.Duration, len(hostTTL)) for host, d := range hostTTL { if strings.TrimSpace(host) == "" || d <= 0 { return nil, fmt.Errorf("session: host %q TTL %s is not positive", host, d) } checked[host] = d } return checked, nil } func (s *Service) SetTTLs(ttl time.Duration, hostTTL map[string]time.Duration) error { checked, err := checkTTLs(ttl, hostTTL) if err != nil { return err } s.ttlMu.Lock() defer s.ttlMu.Unlock() s.ttl, s.hostTTL = ttl, checked return nil } func (s *Service) Open(ctx context.Context, in OpenInput) ([]byte, Principal, error) { claims, err := s.identity.Claims(ctx, in.UserID, true) if err != nil { return nil, Principal{}, failure("open", err) } secret := make([]byte, secretBytes) rand.Read(secret) now := s.clock() address := in.Address.Unmap().WithZone("") id := rowID(secret) opened := auditInput(claims.UserID, "session.open", id, openDetail{Host: in.Host, Via: in.Via, Client: in.ClientID}) opened.Address = address audits := []storage.AppendAuditInput{opened} if in.SignIn { signin := auditInput(claims.UserID, "session.signin", id, signinDetail{Host: in.Host, Client: in.ClientID}) signin.Address = address audits = append(audits, signin) } row, err := s.store.CreateForwardSessionAudited(ctx, storage.CreateForwardSessionInput{ ID: id, UserID: claims.UserID, AppDomain: in.Host, CreatedAt: now, ExpiresAt: now.Add(s.ttlFor(in.Host)), Epoch: in.Epoch, Browser: in.Browser, ClientID: in.ClientID, Address: address, UserAgent: storage.CleanUserAgent(in.UserAgent), }, audits...) if err != nil { return nil, Principal{}, failure("open", err) } return secret, s.principal(row, claims), nil } func (s *Service) Resolve(ctx context.Context, secret []byte, host string) (Principal, error) { if len(secret) != secretBytes { return Principal{}, fmt.Errorf("session: resolve: %w: secret is %d bytes", ErrNoSession, len(secret)) } row, err := s.store.ForwardSessionByID(ctx, rowID(secret)) if err != nil { return Principal{}, failure("resolve", err) } now := s.clock() switch { case row.Revoked: return Principal{}, fmt.Errorf("session: resolve: %w: revoked", ErrNoSession) case !now.Before(row.ExpiresAt): return Principal{}, fmt.Errorf("session: resolve: %w: expired", ErrNoSession) case row.AppDomain != host: return Principal{}, fmt.Errorf("session: resolve: %w: another host's session", ErrNoSession) } claims, err := s.identity.Claims(ctx, row.UserID, true) if err != nil { return Principal{}, failure("resolve", err) } s.touch(row, now) return s.principal(row, claims), nil } func (s *Service) Authenticated(ctx context.Context, p Principal, clientID string) error { return s.audit(ctx, auditInput(p.UserID, "session.signin", p.SessionID, signinDetail{Host: p.Host, Client: clientID})) } func (s *Service) Reauthenticate(ctx context.Context, p Principal) (Principal, error) { now := s.clock() if err := s.store.MarkReauthenticatedAudited(ctx, p.SessionID, now, auditInput(p.UserID, "sudo.grant", p.SessionID, hostDetail{Host: p.Host})); err != nil { if errors.Is(err, storage.ErrInvalid) { return Principal{}, fmt.Errorf("session: reauthenticate: %w: %w", ErrNoSession, err) } return Principal{}, failure("reauthenticate", err) } p.SudoUntil = now.Add(SudoWindow) return p, nil } func (s *Service) ReauthenticationRefused(ctx context.Context, p Principal) error { return s.audit(ctx, auditInput(p.UserID, "sudo.refuse", p.SessionID, hostDetail{Host: p.Host})) } func (s *Service) Revoke(ctx context.Context, p Principal) error { if err := s.store.RevokeForwardSessionAuditedAt(ctx, p.SessionID, auditInput(p.UserID, "session.revoke", p.SessionID, hostDetail{Host: p.Host}), s.clock()); err != nil { return failure("revoke", err) } return nil } func (s *Service) RevokeAll(ctx context.Context, userID, actor string) (int64, error) { revoked, err := s.store.RevokeUserCredentialsAuditedAt(ctx, userID, "", storage.AppendAuditInput{Actor: actor, Action: "session.revoke_all", Subject: userID}, s.clock()) if err != nil { return 0, failure("revoke all", err) } return revoked.Sessions, nil } func (s *Service) RevokeOthers(ctx context.Context, p Principal) (int64, error) { if p.SessionID == "" { return 0, fmt.Errorf("session: revoke others: %w: no current session to keep", ErrNoSession) } revoked, err := s.store.RevokeUserCredentialsAuditedAt(ctx, p.UserID, p.SessionID, auditInput(p.UserID, "session.revoke_others", p.UserID, keptDetail{Kept: p.SessionID}), s.clock()) if errors.Is(err, storage.ErrInvalid) { return 0, fmt.Errorf("session: revoke others: %w: %w", ErrNoSession, err) } if err != nil { return 0, failure("revoke others", err) } return revoked.Sessions, nil } func (s *Service) List(ctx context.Context, userID string) ([]storage.ForwardSession, error) { sessions, err := s.store.LiveForwardSessionsByUser(ctx, userID, s.clock()) if err != nil { return nil, failure("list", err) } return sessions, nil } func (s *Service) Purge(ctx context.Context) (int64, error) { count, err := s.store.DeleteExpiredForwardSessions(ctx, s.clock()) if err != nil { return 0, failure("purge", err) } return count, nil } func (s *Service) Run(ctx context.Context) { s.Purge(ctx) ticker := time.NewTicker(s.purgeInterval) defer ticker.Stop() seen := time.NewTicker(s.seenFlush) defer seen.Stop() for { select { case <-ctx.Done(): final, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second) s.flushSeen(final) cancel() return case <-ticker.C: s.Purge(ctx) case <-seen.C: s.flushSeen(ctx) } } } func (s *Service) clock() time.Time { return time.UnixMicro(s.now().UnixMicro()).UTC() } func (s *Service) ttlFor(host string) time.Duration { s.ttlMu.RLock() defer s.ttlMu.RUnlock() if d, ok := s.hostTTL[host]; ok { return d } return s.ttl } func (s *Service) principal(row storage.ForwardSession, claims identity.Claims) Principal { p := Principal{ SessionID: row.ID, UserID: row.UserID, Username: claims.Username, DisplayName: claims.DisplayName, Host: row.AppDomain, Admin: slices.Contains(claims.Groups, s.adminGroup), Groups: claims.Groups, CreatedAt: row.CreatedAt, ExpiresAt: row.ExpiresAt, } if row.ReauthenticatedAt != nil { p.SudoUntil = row.ReauthenticatedAt.Add(SudoWindow) } return p } func (s *Service) audit(ctx context.Context, input storage.AppendAuditInput) error { if _, err := s.store.AppendAudit(ctx, input); err != nil { return failure(input.Action, err) } return nil } func auditInput(actor, action, subject string, detail any) storage.AppendAuditInput { encoded, _ := json.Marshal(detail) return storage.AppendAuditInput{Actor: actor, Action: action, Subject: subject, Detail: string(encoded)} } func rowID(secret []byte) string { sum := sha256.Sum256(secret) return base64.RawURLEncoding.EncodeToString(sum[:]) } func failure(operation string, err error) error { switch { case errors.Is(err, identity.ErrNoUser), errors.Is(err, storage.ErrNotFound): return fmt.Errorf("session: %s: %w: %w", operation, ErrNoSession, err) case errors.Is(err, storage.ErrBusy), errors.Is(err, storage.ErrLocked), errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): return fmt.Errorf("session: %s: %w: %w", operation, ErrUnavailable, err) } return fmt.Errorf("session: %s: %w", operation, err) }