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
This commit is contained in:
Vishnu Narayanan
2026-06-15 17:14:34 +05:30
committed by GitHub
parent ee6382109a
commit ba0ba46c9c
3 changed files with 77 additions and 6 deletions
@@ -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)
+9 -3
View File
@@ -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
@@ -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