package auth import ( "context" "errors" "github.com/google/uuid" "go.uber.org/zap" ) // GetSession returns the active session keyed by deviceSessionID. The // lookup is cache-only: the cache is the write-through projection of // `device_sessions WHERE status='active'`, so a miss means the session // is either revoked or absent. Either way the gateway sees // ErrSessionNotFound and treats the calling client as unauthenticated. func (s *Service) GetSession(_ context.Context, deviceSessionID uuid.UUID) (Session, error) { if deviceSessionID == uuid.Nil { return Session{}, ErrSessionNotFound } sess, ok := s.deps.Cache.Get(deviceSessionID) if !ok { return Session{}, ErrSessionNotFound } return sess, nil } // RevokeSession marks deviceSessionID revoked, evicts it from the cache, // and emits a session_invalidation push event. The call is idempotent: // a second revoke on an already-revoked session returns the existing // row with status='revoked' (HTTP 200), not ErrSessionNotFound. An // unknown device_session_id yields ErrSessionNotFound. // // Cache eviction and the push emission run after the database UPDATE // commits so a failed UPDATE leaves both cache and gateway view intact. func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) { if deviceSessionID == uuid.Nil { return Session{}, ErrSessionNotFound } revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID) if err != nil { return Session{}, err } if ok { s.deps.Cache.Remove(deviceSessionID) s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, "auth.revoke_session") s.deps.Logger.Info("auth session revoked", zap.String("device_session_id", deviceSessionID.String()), zap.String("user_id", revoked.UserID.String()), ) return revoked, nil } // UPDATE matched no rows: the session is either already revoked or // never existed. Distinguish by reading the row directly so we can // return the idempotent revoked-shape rather than a 404 when the // session simply was revoked earlier. existing, err := s.deps.Store.LoadSession(ctx, deviceSessionID) if err != nil { if errors.Is(err, ErrSessionNotFound) { return Session{}, ErrSessionNotFound } return Session{}, err } return existing, nil } // RevokeAllForUser marks every active session for userID revoked, // evicts each from the cache, and emits one session_invalidation push // event per revoked row. Returns the list of revoked sessions in the // order Postgres returned them. An empty result is a successful // idempotent call (handler reports revoked_count=0). func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) { if userID == uuid.Nil { return nil, nil } revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID) if err != nil { return nil, err } for _, sess := range revoked { s.deps.Cache.Remove(sess.DeviceSessionID) s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, "auth.revoke_all_for_user") } if len(revoked) > 0 { s.deps.Logger.Info("auth sessions revoked (bulk)", zap.String("user_id", userID.String()), zap.Int("count", len(revoked)), ) } return revoked, nil }