diff --git a/app/controllers/api/internal/sessions_controller.rb b/app/controllers/api/internal/sessions_controller.rb new file mode 100644 index 00000000000..8a1d81a1767 --- /dev/null +++ b/app/controllers/api/internal/sessions_controller.rb @@ -0,0 +1,62 @@ +module Api + module Internal + class SessionsController < ApplicationController + include CsrfTokenConcern + + prepend_before_action :skip_session_expiration + prepend_before_action :skip_devise_hooks + + after_action :add_csrf_token_header_to_response, only: [:update] + + respond_to :json + + def show + render json: { live: live?, timeout: timeout } + end + + def update + analytics.session_kept_alive if live? + update_last_request_at + render json: { live: live?, timeout: timeout } + end + + def destroy + analytics.session_timed_out + request_id = sp_session[:request_id] + sign_out + render json: { redirect: root_url(request_id:, timeout: :session) } + end + + private + + def skip_devise_hooks + request.env['devise.skip_timeout'] = true + request.env['devise.skip_trackable'] = true + end + + def live? + timeout.future? + end + + def timeout + if last_request_at.present? + Time.zone.at(last_request_at + User.timeout_in) + else + Time.current + end + end + + def last_request_at + warden_session['last_request_at'] if warden_session + end + + def update_last_request_at + warden_session['last_request_at'] = Time.zone.now.to_i if warden_session + end + + def warden_session + session['warden.user.user.session'] + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 58912bce1ca..f1c8935f50a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,14 @@ post '/api/risc/security_events' => 'risc/security_events#create' post '/api/irs_attempts_api/security_events' => 'api/irs_attempts_api#create' + namespace :api do + namespace :internal do + get '/sessions' => 'sessions#show' + put '/sessions' => 'sessions#update' + delete '/sessions' => 'sessions#destroy' + end + end + # SAML secret rotation paths SamlEndpoint.suffixes.each do |suffix| get "/api/saml/metadata#{suffix}" => 'saml_idp#metadata', format: false diff --git a/spec/controllers/api/internal/sessions_controller_spec.rb b/spec/controllers/api/internal/sessions_controller_spec.rb new file mode 100644 index 00000000000..b933cb866e3 --- /dev/null +++ b/spec/controllers/api/internal/sessions_controller_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +RSpec.describe Api::Internal::SessionsController do + let(:user) { nil } + + around do |example| + freeze_time { example.run } + end + + before do + establish_warden_session if user + end + + describe '#show' do + subject(:response) { JSON.parse(get(:show).body, symbolize_names: true) } + + it 'responds with live and timeout properties' do + expect(response).to eq(live: false, timeout: Time.zone.now.as_json) + end + + context 'signed in' do + let(:user) { create(:user, :signed_up) } + + it 'responds with live and timeout properties' do + expect(response).to eq(live: true, timeout: User.timeout_in.from_now.as_json) + end + + context 'after a delay' do + let(:delay) { 0.seconds } + + before { travel_to delay.from_now } + + context 'after a delay prior to session timeout' do + let(:delay) { User.timeout_in - 1.second } + + it 'responds with live and timeout properties' do + expect(response).to eq( + live: true, + timeout: (User.timeout_in - delay).from_now.as_json, + ) + end + end + + context 'after a delay exceeding session timeout' do + let(:delay) { User.timeout_in + 1.second } + + it 'responds with live and timeout properties' do + expect(response).to eq( + live: false, + timeout: (User.timeout_in - delay).from_now.as_json, + ) + end + end + end + + context 'when a request extends session timeout' do + let(:future_time) { (User.timeout_in - 1.second).from_now } + + before do + travel_to future_time + # Ideally we could repeat the behavior from `establish_warden_session`, but the request + # and controller persist between simulated request calls. + session['warden.user.user.session']['last_request_at'] = future_time.to_i + end + + it 'responds with live and timeout properties' do + expect(response).to eq(live: true, timeout: (future_time + User.timeout_in).as_json) + end + end + end + end + + def establish_warden_session + sign_in(user) + + # Relevant timeout session values are stored on request, but the API controller itself skips + # these so as not to affect the planned timeout. Send a request to some other controller to + # establish the session values. + original_controller = @controller + @controller = AccountsController.new + get :show + @controller = original_controller + end +end