From a5f5dde09f72743021ca1f5aa2560aaa227f69b6 Mon Sep 17 00:00:00 2001
From: Andrew Evans <aevans@gitlab.com>
Date: Wed, 3 Apr 2024 08:44:43 +0000
Subject: [PATCH] Add API endpoint to return current SSO session expiry time

This change will add an API endpoint on EE to return the expiry time of
the user's current Group SAML SSO sessions. This way the frontend can be
more responsive if a tab is left open overnight or over the weekend, and
the client no longer has an active SSO session.

Changelog: added
EE: true
---
 .../active_sessions_controller.rb             |  2 +
 .../active_sessions_controller.rb             | 26 +++++++
 ee/config/routes/user_settings.rb             |  9 +++
 ee/lib/gitlab/auth/group_saml/sso_enforcer.rb |  9 +++
 ee/lib/gitlab/auth/group_saml/sso_state.rb    |  4 ++
 .../active_sessions_controller_spec.rb        | 72 +++++++++++++++++++
 .../auth/group_saml/sso_enforcer_spec.rb      | 51 +++++++++++++
 .../gitlab/auth/group_saml/sso_state_spec.rb  | 19 +++++
 ee/spec/routing/user_settings_routing_spec.rb | 23 ++++++
 lib/gitlab/namespaced_session_store.rb        |  8 +++
 .../gitlab/namespaced_session_store_spec.rb   | 72 +++++++++++++++++++
 11 files changed, 295 insertions(+)
 create mode 100644 ee/app/controllers/ee/user_settings/active_sessions_controller.rb
 create mode 100644 ee/config/routes/user_settings.rb
 create mode 100644 ee/spec/controllers/user_settings/active_sessions_controller_spec.rb
 create mode 100644 ee/spec/routing/user_settings_routing_spec.rb

diff --git a/app/controllers/user_settings/active_sessions_controller.rb b/app/controllers/user_settings/active_sessions_controller.rb
index bfc969d0ff8a8..34359eb008084 100644
--- a/app/controllers/user_settings/active_sessions_controller.rb
+++ b/app/controllers/user_settings/active_sessions_controller.rb
@@ -20,3 +20,5 @@ def destroy
     end
   end
 end
+
+UserSettings::ActiveSessionsController.prepend_mod
diff --git a/ee/app/controllers/ee/user_settings/active_sessions_controller.rb b/ee/app/controllers/ee/user_settings/active_sessions_controller.rb
new file mode 100644
index 0000000000000..0b4d3ae4d0075
--- /dev/null
+++ b/ee/app/controllers/ee/user_settings/active_sessions_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module EE
+  module UserSettings
+    module ActiveSessionsController
+      # dotcom-focused endpoint to return time remaining on existing SAML sessions
+      # since we want only session data for current device / browser, this endpoint must be
+      # in a regular app controller, not in the Grape API. client-side JS does not have access to
+      # _gitlab_session_abc123 cookie
+      def saml
+        session_info = ::Gitlab::Auth::GroupSaml::SsoEnforcer.sessions_time_remaining_for_expiry
+
+        session_info = session_info.map do |item|
+          time_remaining = item[:time_remaining].in_milliseconds.to_i
+          time_remaining = 0 if time_remaining <= 0
+
+          {
+            provider_id: item[:provider_id],
+            time_remaining_ms: time_remaining
+          }
+        end
+        render json: session_info
+      end
+    end
+  end
+end
diff --git a/ee/config/routes/user_settings.rb b/ee/config/routes/user_settings.rb
new file mode 100644
index 0000000000000..5152dbd9cc479
--- /dev/null
+++ b/ee/config/routes/user_settings.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+namespace :user_settings do
+  resources :active_sessions, only: [] do
+    collection do
+      get :saml, format: :json
+    end
+  end
+end
diff --git a/ee/lib/gitlab/auth/group_saml/sso_enforcer.rb b/ee/lib/gitlab/auth/group_saml/sso_enforcer.rb
index 4480588a7b054..c43c325fa18d4 100644
--- a/ee/lib/gitlab/auth/group_saml/sso_enforcer.rb
+++ b/ee/lib/gitlab/auth/group_saml/sso_enforcer.rb
@@ -7,6 +7,15 @@ class SsoEnforcer
         DEFAULT_SESSION_TIMEOUT = 1.day
 
         class << self
+          def sessions_time_remaining_for_expiry
+            SsoState.active_saml_sessions.map do |id, last_sign_in_at|
+              expires_at = last_sign_in_at + DEFAULT_SESSION_TIMEOUT
+              # expires_at is DateTime; convert to Time; Time - Time yields a Float
+              time_remaining_for_expiry = expires_at.to_time - Time.current
+              { provider_id: id, time_remaining: time_remaining_for_expiry }
+            end
+          end
+
           def access_restricted?(user:, resource:, session_timeout: DEFAULT_SESSION_TIMEOUT)
             group = resource.is_a?(::Group) ? resource : resource.group
 
diff --git a/ee/lib/gitlab/auth/group_saml/sso_state.rb b/ee/lib/gitlab/auth/group_saml/sso_state.rb
index 2a8e410dfd68f..a33f73b0e564a 100644
--- a/ee/lib/gitlab/auth/group_saml/sso_state.rb
+++ b/ee/lib/gitlab/auth/group_saml/sso_state.rb
@@ -6,6 +6,10 @@ module GroupSaml
       class SsoState
         SESSION_STORE_KEY = :active_group_sso_sign_ins
 
+        def self.active_saml_sessions
+          Gitlab::NamespacedSessionStore.new(SESSION_STORE_KEY).to_h
+        end
+
         attr_reader :saml_provider_id
 
         def initialize(saml_provider_id)
diff --git a/ee/spec/controllers/user_settings/active_sessions_controller_spec.rb b/ee/spec/controllers/user_settings/active_sessions_controller_spec.rb
new file mode 100644
index 0000000000000..e2d3d6262ccba
--- /dev/null
+++ b/ee/spec/controllers/user_settings/active_sessions_controller_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require('spec_helper')
+
+RSpec.describe UserSettings::ActiveSessionsController, feature_category: :system_access do
+  let_it_be(:user) { create(:user) }
+
+  describe '/saml' do
+    subject(:get_saml) { get :saml }
+
+    let(:saml_provider) { create(:saml_provider) }
+    let(:other_saml_provider) { create(:saml_provider) }
+    let(:first_saml_sign_in) { Time.utc(2024, 2, 2, 1, 44) }
+    let(:json_response) { Gitlab::Json.parse(response.body).map(&:with_indifferent_access) }
+
+    around do |ex|
+      travel_to(first_saml_sign_in + 6.hours) { ex.run }
+    end
+
+    it 'responds with 404' do
+      get_saml
+
+      expect(response).to redirect_to(new_user_session_path)
+    end
+
+    context 'with signed-in user' do
+      before do
+        sign_in(user)
+      end
+
+      context 'with no current SAML sessions' do
+        it 'responds with empty array' do
+          get_saml
+
+          expect(json_response).to eq([])
+          expect(response).to have_gitlab_http_status(:ok)
+        end
+      end
+
+      context 'with SAML sign-in session data' do
+        # mimic the actions of ::Gitlab::Auth::GroupSaml::SsoState.update_active, but
+        # ensure it is hooked into the controller test session rather than initializing its
+        # own thread-local session
+        before do
+          key = ::Gitlab::Auth::GroupSaml::SsoState::SESSION_STORE_KEY
+          store = Gitlab::NamespacedSessionStore.new(key, session)
+          store[saml_provider.id] = first_saml_sign_in
+          store[other_saml_provider.id] = (first_saml_sign_in + 4.hours)
+        end
+
+        it 'responds with JSON of current SAML sessions' do
+          get_saml
+
+          expect(json_response).to match_array(
+            [
+              {
+                provider_id: saml_provider.id,
+                time_remaining_ms: 18.hours.in_milliseconds
+              },
+              {
+                provider_id: other_saml_provider.id,
+                time_remaining_ms: 22.hours.in_milliseconds
+              }
+            ]
+          )
+
+          expect(response).to have_gitlab_http_status(:ok)
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/lib/gitlab/auth/group_saml/sso_enforcer_spec.rb b/ee/spec/lib/gitlab/auth/group_saml/sso_enforcer_spec.rb
index 984598649bfe2..95be2308a0ad8 100644
--- a/ee/spec/lib/gitlab/auth/group_saml/sso_enforcer_spec.rb
+++ b/ee/spec/lib/gitlab/auth/group_saml/sso_enforcer_spec.rb
@@ -504,4 +504,55 @@
       expect { described_class.access_restricted_groups(groups) }.not_to exceed_all_query_limit(control)
     end
   end
+
+  describe '.sessions_time_remaining_for_expiry' do
+    subject(:sessions_time_remaining_for_expiry) { described_class.sessions_time_remaining_for_expiry }
+
+    it 'returns data for existing sessions' do
+      freeze_time do
+        described_class.new(saml_provider).update_session
+
+        expect(sessions_time_remaining_for_expiry).to match_array(
+          [
+            {
+              provider_id: saml_provider.id,
+              time_remaining: described_class::DEFAULT_SESSION_TIMEOUT
+            }
+          ]
+        )
+      end
+    end
+
+    it 'returns empty array when no session data exists' do
+      expect(sessions_time_remaining_for_expiry).to eq([])
+    end
+
+    it 'returns calculated data when sessions have been around for some time' do
+      other_saml_provider = build_stubbed(:saml_provider)
+      frozen_time = Time.utc(2024, 2, 2, 1, 44)
+
+      travel_to(frozen_time) do
+        described_class.new(saml_provider).update_session
+      end
+
+      travel_to(frozen_time + 4.hours) do
+        described_class.new(other_saml_provider).update_session
+      end
+
+      travel_to(frozen_time + 6.hours) do
+        expect(sessions_time_remaining_for_expiry).to match_array(
+          [
+            {
+              provider_id: saml_provider.id,
+              time_remaining: 18.hours.to_f
+            },
+            {
+              provider_id: other_saml_provider.id,
+              time_remaining: 22.hours.to_f
+            }
+          ]
+        )
+      end
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/auth/group_saml/sso_state_spec.rb b/ee/spec/lib/gitlab/auth/group_saml/sso_state_spec.rb
index f22e50c68f866..d80ce672aca2c 100644
--- a/ee/spec/lib/gitlab/auth/group_saml/sso_state_spec.rb
+++ b/ee/spec/lib/gitlab/auth/group_saml/sso_state_spec.rb
@@ -7,6 +7,25 @@
 
   subject(:sso_state) { described_class.new(saml_provider_id) }
 
+  describe '.active_saml_sessions' do
+    subject(:active_saml_sessions) { described_class.active_saml_sessions }
+
+    context 'when session data is stored' do
+      let(:session_data) do
+        {
+          27 => (Time.current - 1.day),
+          99 => (Time.current - 12.hours)
+        }
+      end
+
+      around do |ex|
+        Gitlab::Session.with_session(described_class::SESSION_STORE_KEY => session_data) { ex.run }
+      end
+
+      it { is_expected.to match(session_data) }
+    end
+  end
+
   describe '#update_active' do
     it 'updates the current sign in state' do
       Gitlab::Session.with_session({}) do
diff --git a/ee/spec/routing/user_settings_routing_spec.rb b/ee/spec/routing/user_settings_routing_spec.rb
new file mode 100644
index 0000000000000..261f861fec716
--- /dev/null
+++ b/ee/spec/routing/user_settings_routing_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'EE-specific user_settings routing', feature_category: :system_access do
+  describe '/-/user_settings/active_sessions/saml.json' do
+    subject { get('/-/user_settings/active_sessions/saml.json') }
+
+    it { is_expected.to route_to(controller: 'user_settings/active_sessions', action: 'saml', format: 'json') }
+  end
+
+  describe '/-/user_settings/active_sessions/saml' do
+    subject { get('/-/user_settings/active_sessions/saml') }
+
+    it { is_expected.to route_to('user_settings/active_sessions#saml') }
+  end
+
+  describe '/-/user_settings/active_sessions/saml.html' do
+    subject { get('/-/user_settings/active_sessions/saml.html') }
+
+    it { is_expected.not_to route_to('user_settings/active_sessions#saml') }
+  end
+end
diff --git a/lib/gitlab/namespaced_session_store.rb b/lib/gitlab/namespaced_session_store.rb
index 957e8fe9b9f58..5f1f684f81bae 100644
--- a/lib/gitlab/namespaced_session_store.rb
+++ b/lib/gitlab/namespaced_session_store.rb
@@ -2,6 +2,8 @@
 
 module Gitlab
   class NamespacedSessionStore
+    include Enumerable
+
     def initialize(key, session = Session.current)
       @namespace_key = key
       @session = session
@@ -11,6 +13,12 @@ def initiated?
       !session.nil?
     end
 
+    def each(&block)
+      return unless session
+
+      session.fetch(@namespace_key, {}).each(&block)
+    end
+
     def [](key)
       return unless session
 
diff --git a/spec/lib/gitlab/namespaced_session_store_spec.rb b/spec/lib/gitlab/namespaced_session_store_spec.rb
index 4e9b35e68593b..07e2b9c0cea3b 100644
--- a/spec/lib/gitlab/namespaced_session_store_spec.rb
+++ b/spec/lib/gitlab/namespaced_session_store_spec.rb
@@ -5,6 +5,78 @@
 RSpec.describe Gitlab::NamespacedSessionStore do
   let(:key) { :some_key }
 
+  describe 'Enumerable methods' do
+    subject(:instance) { described_class.new(key) }
+
+    context 'with data in the session' do
+      around do |ex|
+        Gitlab::Session.with_session(key => { a: 1, b: 2 }) { ex.run }
+      end
+
+      it 'passes .each call to storage hash' do
+        keys = []
+        values = []
+        # rubocop:disable Lint/UnreachableLoop -- false positive
+        instance.each do |key, val|
+          keys << key
+          values << val
+        end
+        # rubocop:enable Lint/UnreachableLoop
+
+        expect(keys).to match_array([:a, :b])
+        expect(values).to match_array([1, 2])
+      end
+
+      it 'passes .map to storage hash' do
+        expect(instance.map { |item| item }).to match_array([[:a, 1], [:b, 2]])
+      end
+
+      it 'converts into a basic hash upon request' do
+        expect(instance.to_h).to match(a: 1, b: 2)
+      end
+    end
+
+    context 'with no data in session' do
+      subject(:iterator) do
+        instance.each do # rubocop:disable Lint/UnreachableLoop -- no clearer way to write this
+          raise 'This code should not be reachable'
+        end
+      end
+
+      around do |ex|
+        Gitlab::Session.with_session(another_key: { a: 1, b: 2 }) { ex.run }
+      end
+
+      it 'does not iterate when session is not initialized' do
+        expect { iterator }.not_to raise_error
+      end
+
+      it 'converts to empty hash with .to_h' do
+        expect(instance.to_h).to eq({})
+      end
+    end
+
+    context 'with empty data in session' do
+      subject(:iterator) do
+        instance.each do # rubocop:disable Lint/UnreachableLoop -- no clearer way to write this
+          raise 'This code should not be reachable'
+        end
+      end
+
+      around do |ex|
+        Gitlab::Session.with_session(key => {}) { ex.run }
+      end
+
+      it 'does not raise error' do
+        expect { iterator }.not_to raise_error
+      end
+
+      it 'converts to empty hash with .to_h' do
+        expect(instance.to_h).to eq({})
+      end
+    end
+  end
+
   context 'current session' do
     subject { described_class.new(key) }
 
-- 
GitLab