From ba0ba46c9c1c9ec09ebfc8b67bb1d122bfe3315a Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 15 Jun 2026 17:14:34 +0530 Subject: [PATCH] fix: skip session tracking and use short-lived token for impersonation (CW-7169) (#14622) ## Description SuperAdmin impersonation SSO logins no longer create UserSession rows visible to the customer. Impersonation tokens use a 2-day lifespan instead of ~2 months, so they naturally evict first and don't linger in the user's token list. Server-side detection via Redis value (`'impersonation'` vs `'normal'`) without changing the `valid_sso_auth_token?` signature. Backward compatible with in-flight tokens. Depends on #14556. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Specs cover: impersonation login skips UserSession creation, impersonation token has short lifespan, normal SSO login still creates UserSession row. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- .../devise_overrides/sessions_controller.rb | 19 +++++-- app/models/concerns/sso_authenticatable.rb | 12 +++-- .../sessions_controller_spec.rb | 52 +++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 1ef6d9511..7dae29d13 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -20,7 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def render_create_success - track_user_session + track_user_session unless @impersonation render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -66,7 +66,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def authenticate_resource_with_sso_token - @token = @resource.create_token + # DTA evicts the earliest-expiring token after save when at max_number_of_devices. + # The short-lived impersonation token would always be that one, so pre-evict to make room. + make_room_for_impersonation_token if @impersonation + @token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil) @resource.save! sign_in(:user, @resource, store: false, bypass: false) @@ -74,11 +77,21 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController @resource.invalidate_sso_auth_token(params[:sso_auth_token]) end + def make_room_for_impersonation_token + return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices + + oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first + @resource.tokens.delete(oldest_client_id) if oldest_client_id + end + def process_sso_auth_token return if params[:email].blank? user = User.from_email(params[:email]) - @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) + return unless user&.valid_sso_auth_token?(params[:sso_auth_token]) + + @resource = user + @impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token]) end def handle_mfa_required(user) diff --git a/app/models/concerns/sso_authenticatable.rb b/app/models/concerns/sso_authenticatable.rb index 98b50f336..633e71ee3 100644 --- a/app/models/concerns/sso_authenticatable.rb +++ b/app/models/concerns/sso_authenticatable.rb @@ -1,9 +1,9 @@ module SsoAuthenticatable extend ActiveSupport::Concern - def generate_sso_auth_token + def generate_sso_auth_token(impersonation: false) token = SecureRandom.hex(32) - ::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes) + ::Redis::Alfred.setex(sso_token_key(token), impersonation ? 'impersonation' : 'normal', 5.minutes) token end @@ -20,8 +20,14 @@ module SsoAuthenticatable "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}" end + def sso_auth_token_impersonation?(token) + ::Redis::Alfred.get(sso_token_key(token)) == 'impersonation' + end + def generate_sso_link_with_impersonation - "#{generate_sso_link}&impersonation=true" + encoded_email = ERB::Util.url_encode(email) + "#{ENV.fetch('FRONTEND_URL', + nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token(impersonation: true)}&impersonation=true" end private diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb index 7e12011b2..f40af6362 100644 --- a/spec/controllers/devise_overrides/sessions_controller_spec.rb +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -180,4 +180,56 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do end end end + + describe 'impersonation SSO login' do + let(:user) { create(:user, password: 'Test@123456') } + let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' } + + before { request.env['HTTP_USER_AGENT'] = browser_ua } + + it 'does not create a UserSession row for impersonation login' do + sso_token = user.generate_sso_auth_token(impersonation: true) + + expect do + post :create, params: { email: user.email, sso_auth_token: sso_token } + end.not_to change(user.user_sessions, :count) + + expect(response).to have_http_status(:success) + end + + it 'creates a short-lived token for impersonation login' do + sso_token = user.generate_sso_auth_token(impersonation: true) + + post :create, params: { email: user.email, sso_auth_token: sso_token } + + expect(response).to have_http_status(:success) + token_entry = user.reload.tokens.values.last + # 2-day lifespan: expiry should be within ~3 days from now (token creation + lifespan) + expect(token_entry['expiry']).to be < (3.days.from_now).to_i + end + + it 'creates a normal UserSession row for regular SSO login' do + sso_token = user.generate_sso_auth_token + + expect do + post :create, params: { email: user.email, sso_auth_token: sso_token } + end.to change(user.user_sessions, :count).by(1) + end + + it 'preserves the impersonation token when target user is at the device cap' do + allow(DeviseTokenAuth).to receive(:max_number_of_devices).and_return(5) + 5.times do |i| + user.tokens["existing#{i}"] = { 'token' => 'x', 'expiry' => (Time.current + (30 + i).days).to_i } + end + user.save! + sso_token = user.generate_sso_auth_token(impersonation: true) + + post :create, params: { email: user.email, sso_auth_token: sso_token } + + expect(response).to have_http_status(:success) + new_client_id = response.headers['client'] + expect(user.reload.tokens.keys).to include(new_client_id) + expect(user.tokens.size).to eq(5) + end + end end