package auth import ( "context" "crypto/sha256" "encoding/hex" "errors" "net/http" "sync" "time" ) type session struct { principal Principal issuedAt time.Time expiresAt time.Time absoluteExpiresAt time.Time context context.Context cancel context.CancelFunc } // SessionAuthentication carries the principal and the revocable lifetime of // the authenticated browser session. Long-lived transports must derive their // lifecycle from Context so logout and the absolute deadline remain effective // after an HTTP upgrade. type SessionAuthentication struct { Principal Principal Context context.Context } type SessionManager struct { mu sync.Mutex sessions map[string]session CookieName string TTL time.Duration AbsoluteTTL time.Duration RenewBefore time.Duration Secure bool MaxSessions int MaxSessionsPerSubject int } func NewSessionManager(cookieName string, ttl time.Duration, secure bool) *SessionManager { if cookieName == "" { cookieName = "pulse_session" } if ttl <= 0 { ttl = 8 * time.Hour } return &SessionManager{sessions: make(map[string]session), CookieName: cookieName, TTL: ttl, AbsoluteTTL: ttl, Secure: secure, MaxSessions: 4096, MaxSessionsPerSubject: 8} } // NewSlidingSessionManager creates an idle-expiring browser session with a // separate absolute lifetime. Successful authenticated requests renew the idle // deadline once half of the idle lifetime has elapsed, but never beyond the // absolute deadline. Both lifetimes remain finite and the opaque token stays in // an HttpOnly cookie. func NewSlidingSessionManager(cookieName string, idleTTL, absoluteTTL time.Duration, secure bool) *SessionManager { manager := NewSessionManager(cookieName, idleTTL, secure) if absoluteTTL < idleTTL { absoluteTTL = idleTTL } manager.AbsoluteTTL = absoluteTTL manager.RenewBefore = idleTTL / 2 return manager } func (manager *SessionManager) Issue(response http.ResponseWriter, principal Principal, now time.Time) error { if principal.Subject == "" || !Allows(principal.Role, PermissionView) { return errors.New("session principal is invalid") } token, err := randomToken() if err != nil { return err } absoluteExpiresAt := now.Add(manager.AbsoluteTTL) expiresAt := earliest(now.Add(manager.TTL), absoluteExpiresAt) sessionContext, cancel := context.WithDeadline(context.Background(), absoluteExpiresAt) manager.mu.Lock() manager.purgeExpiredLocked(now) manager.enforceSubjectLimitLocked(principal.Subject) if manager.MaxSessions > 0 && len(manager.sessions) >= manager.MaxSessions { manager.mu.Unlock() cancel() return errors.New("session capacity reached") } manager.sessions[hashToken(token)] = session{principal: principal, issuedAt: now, expiresAt: expiresAt, absoluteExpiresAt: absoluteExpiresAt, context: sessionContext, cancel: cancel} manager.mu.Unlock() manager.setCookie(response, token, now, expiresAt) return nil } func (manager *SessionManager) Principal(request *http.Request, now time.Time) (Principal, bool) { authentication, ok := manager.authenticate(nil, request, now) return authentication.Principal, ok } // Authenticate validates the session and renews an active sliding session when // it enters its renewal window. The token is deliberately stable: concurrent // API requests cannot invalidate each other, while Clear still revokes it // immediately server-side. func (manager *SessionManager) Authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (Principal, bool) { authentication, ok := manager.authenticate(response, request, now) return authentication.Principal, ok } // AuthenticateSession validates and renews the cookie while exposing the // revocable session context to middleware that serves long-lived transports. func (manager *SessionManager) AuthenticateSession(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) { return manager.authenticate(response, request, now) } func (manager *SessionManager) authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) { cookie, err := request.Cookie(manager.CookieName) if err != nil || cookie.Value == "" { return SessionAuthentication{}, false } manager.mu.Lock() defer manager.mu.Unlock() manager.purgeExpiredLocked(now) stored, ok := manager.sessions[hashToken(cookie.Value)] if !ok { return SessionAuthentication{}, false } if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) { stored.cancel() delete(manager.sessions, hashToken(cookie.Value)) return SessionAuthentication{}, false } if response != nil && manager.RenewBefore > 0 && stored.expiresAt.Sub(now) <= manager.RenewBefore { renewed := earliest(now.Add(manager.TTL), stored.absoluteExpiresAt) if renewed.After(stored.expiresAt) { stored.expiresAt = renewed manager.sessions[hashToken(cookie.Value)] = stored manager.setCookie(response, cookie.Value, now, renewed) } } return SessionAuthentication{Principal: stored.principal, Context: stored.context}, true } func (manager *SessionManager) Clear(response http.ResponseWriter, request *http.Request) { if cookie, err := request.Cookie(manager.CookieName); err == nil { manager.mu.Lock() key := hashToken(cookie.Value) if stored, ok := manager.sessions[key]; ok { stored.cancel() delete(manager.sessions, key) } manager.mu.Unlock() } http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode}) } func (manager *SessionManager) purgeExpiredLocked(now time.Time) { for key, stored := range manager.sessions { if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) { stored.cancel() delete(manager.sessions, key) } } } func (manager *SessionManager) enforceSubjectLimitLocked(subject string) { if manager.MaxSessionsPerSubject <= 0 { return } for { count := 0 oldestKey := "" var oldest time.Time for key, stored := range manager.sessions { if stored.principal.Subject != subject { continue } count++ if oldestKey == "" || stored.issuedAt.Before(oldest) { oldestKey = key oldest = stored.issuedAt } } if count < manager.MaxSessionsPerSubject || oldestKey == "" { return } stored := manager.sessions[oldestKey] stored.cancel() delete(manager.sessions, oldestKey) } } func hashToken(token string) string { digest := sha256.Sum256([]byte(token)) return hex.EncodeToString(digest[:]) } func (manager *SessionManager) setCookie(response http.ResponseWriter, token string, now, expiresAt time.Time) { maxAge := int(expiresAt.Sub(now).Seconds()) if maxAge < 1 { maxAge = 1 } http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: token, Path: "/", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode}) } func earliest(first, second time.Time) time.Time { if first.Before(second) { return first } return second }