feat: add idp initated logout handler

This commit is contained in:
Shivam Mishra
2025-09-09 15:13:07 +05:30
parent 92a04e4ab4
commit b15f408e6a
4 changed files with 368 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# Provides session management functionality for Devise Token Auth tokens.
# Handles logout operations, session limits, and token cleanup.
module SessionManageable
extend ActiveSupport::Concern
# Clears all active sessions for the user.
# @return [void]
def logout_all_sessions!
self.tokens = {}
save!
end
# Logs out a specific session by client ID.
# @param client_id [String] the client identifier to logout
# @return [Boolean] true if session was found and removed
def logout_session!(client_id)
return false unless client_id.present? && tokens.present?
removed = tokens.delete(client_id)
save! if removed
removed.present?
end
# Removes tokens that expired before the given timestamp.
# @param timestamp [Time, Integer] cutoff time for token cleanup
# @return [void]
def reset_tokens_before!(timestamp)
return unless tokens.present?
self.tokens = tokens.select do |_client_id, token_data|
(token_data['expiry'] || 0) >= timestamp.to_i
end
save!
end
# Returns count of non-expired active sessions.
# @return [Integer] number of active sessions
def active_session_count
return 0 unless tokens.present?
current_time = Time.current.to_i
tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time }
end
# Checks if user has exceeded configured session limit.
# @return [Boolean] true if session limit is exceeded
def session_limit_exceeded?
active_session_count >= session_limit
end
# Returns session information for all active tokens.
# @return [Array<Hash>] array of session info, sorted by expiry (newest first)
def session_info
return [] unless tokens.present?
tokens.map do |client_id, token_data|
{
client_id: client_id,
expiry: Time.zone.at(token_data['expiry'] || 0)
}
end.sort_by { |session| session[:expiry] }.reverse
end
private
# Returns configured session limit from GlobalConfig.
# Defaults to infinity if not configured.
# @return [Integer, Float] session limit or Float::INFINITY
def session_limit
@session_limit ||= GlobalConfig.get(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: Current.account
)&.to_i || Float::INFINITY
end
end
+14
View File
@@ -2,6 +2,19 @@
# SAML authentication is sensitive to URL mismatches, so OmniAuth needs the correct host
OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
# Handle IdP-initiated Single Logout (SLO) session destruction
def handle_saml_slo_session_destroy(env, _session)
# Extract user info from SAML logout request
logout_request = env['omniauth.strategy'].response_object
name_id = logout_request.name_id if logout_request.respond_to?(:name_id)
return unless name_id
# Find user by email and verify it's a SAML user
user = User.from_email(name_id)
user&.logout_all_sessions! if user&.provider == 'saml'
end
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), {
provider_ignores_state: true
@@ -34,6 +47,7 @@ Rails.application.config.middleware.use OmniAuth::Builder do
env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url
env['omniauth.strategy'].options[:idp_cert] = settings.certificate
env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
env['omniauth.strategy'].options[:idp_slo_session_destroy] = method(:handle_saml_slo_session_destroy)
else
# Set a dummy certificate to avoid the error
env['omniauth.strategy'].options[:idp_cert] = 'DUMMY'
@@ -16,6 +16,10 @@ module Enterprise::DeviseOverrides::SessionsController
def destroy
create_audit_event('sign_out')
# Logout all sessions for SAML users
@resource.logout_all_sessions! if @resource&.provider == 'saml'
super
end
@@ -0,0 +1,272 @@
require 'rails_helper'
RSpec.describe SessionManageable do
let(:user) { create(:user) }
let(:current_time) { Time.current.to_i }
let(:expired_time) { 1.hour.ago.to_i }
let(:future_time) { 1.hour.from_now.to_i }
before do
# Mock GlobalConfig for session limit
allow(GlobalConfig).to receive(:get).with(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: nil
).and_return('5')
end
describe '#logout_all_sessions!' do
it 'clears all tokens and saves the user' do
user.tokens = {
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
}
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
it 'works with empty tokens' do
user.tokens = {}
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
it 'works with nil tokens' do
user.tokens = nil
expect(user).to receive(:save!)
user.logout_all_sessions!
expect(user.tokens).to eq({})
end
end
describe '#logout_session!' do
before do
user.tokens = {
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
}
end
it 'removes specific client token and saves' do
expect(user).to receive(:save!)
result = user.logout_session!('client1')
expect(result).to be true
expect(user.tokens).not_to have_key('client1')
expect(user.tokens).to have_key('client2')
end
it 'returns false for non-existent client' do
result = user.logout_session!('non_existent_client')
expect(result).to be false
end
it 'returns false for empty client_id' do
result = user.logout_session!('')
expect(result).to be false
result = user.logout_session!(nil)
expect(result).to be false
end
it 'returns false when no tokens present' do
user.tokens = nil
result = user.logout_session!('client1')
expect(result).to be false
end
end
describe '#reset_tokens_before!' do
before do
user.tokens = {
'expired_client' => { 'token' => 'token1', 'expiry' => expired_time },
'current_client' => { 'token' => 'token2', 'expiry' => future_time },
'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time }
}
end
it 'removes tokens that expired before the given timestamp' do
expect(user).to receive(:save!)
user.reset_tokens_before!(current_time)
expect(user.tokens).not_to have_key('expired_client')
expect(user.tokens).to have_key('current_client')
expect(user.tokens).to have_key('edge_case_client')
end
it 'handles timestamp as Time object' do
timestamp = Time.zone.at(current_time)
expect(user).to receive(:save!)
user.reset_tokens_before!(timestamp)
expect(user.tokens).not_to have_key('expired_client')
expect(user.tokens).to have_key('current_client')
end
it 'does nothing when no tokens present' do
user.tokens = nil
user.reset_tokens_before!(current_time)
# tokens gets initialized to empty hash during the process
expect(user.tokens).to eq({})
end
it 'handles tokens with missing expiry' do
user.tokens = {
'no_expiry_client' => { 'token' => 'token1' },
'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 }
}
expect(user).to receive(:save!)
user.reset_tokens_before!(current_time)
expect(user.tokens).to be_empty
end
end
describe '#active_session_count' do
it 'counts only non-expired tokens' do
user.tokens = {
'expired1' => { 'token' => 'token1', 'expiry' => expired_time },
'active1' => { 'token' => 'token2', 'expiry' => future_time },
'active2' => { 'token' => 'token3', 'expiry' => future_time },
'expired2' => { 'token' => 'token4', 'expiry' => expired_time }
}
expect(user.active_session_count).to eq(2)
end
it 'returns 0 when no tokens present' do
user.tokens = nil
expect(user.active_session_count).to eq(0)
user.tokens = {}
expect(user.active_session_count).to eq(0)
end
it 'handles tokens with missing expiry' do
user.tokens = {
'no_expiry' => { 'token' => 'token1' },
'active' => { 'token' => 'token2', 'expiry' => future_time }
}
expect(user.active_session_count).to eq(1)
end
end
describe '#session_limit_exceeded?' do
it 'returns true when active sessions exceed limit' do
# Mock 3 session limit
allow(GlobalConfig).to receive(:get).and_return('3')
user.tokens = {
'active1' => { 'token' => 'token1', 'expiry' => future_time },
'active2' => { 'token' => 'token2', 'expiry' => future_time },
'active3' => { 'token' => 'token3', 'expiry' => future_time },
'active4' => { 'token' => 'token4', 'expiry' => future_time }
}
expect(user.session_limit_exceeded?).to be true
end
it 'returns false when within limit' do
allow(GlobalConfig).to receive(:get).and_return('5')
user.tokens = {
'active1' => { 'token' => 'token1', 'expiry' => future_time },
'active2' => { 'token' => 'token2', 'expiry' => future_time }
}
expect(user.session_limit_exceeded?).to be false
end
it 'handles infinite limit' do
allow(GlobalConfig).to receive(:get).and_return(nil)
user.tokens = {}
(1..100).each do |i|
user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time }
end
expect(user.session_limit_exceeded?).to be false
end
end
describe '#session_info' do
it 'returns session information sorted by expiry (newest first)' do
earlier_time = 2.hours.from_now.to_i
later_time = 3.hours.from_now.to_i
user.tokens = {
'client1' => { 'token' => 'token1', 'expiry' => earlier_time },
'client2' => { 'token' => 'token2', 'expiry' => later_time }
}
sessions = user.session_info
expect(sessions.size).to eq(2)
expect(sessions[0][:client_id]).to eq('client2')
expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time))
expect(sessions[1][:client_id]).to eq('client1')
expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time))
end
it 'returns empty array when no tokens' do
user.tokens = nil
expect(user.session_info).to eq([])
user.tokens = {}
expect(user.session_info).to eq([])
end
it 'handles tokens with missing expiry' do
user.tokens = {
'client1' => { 'token' => 'token1' },
'client2' => { 'token' => 'token2', 'expiry' => future_time }
}
sessions = user.session_info
expect(sessions.size).to eq(2)
expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time))
expect(sessions[1][:expiry]).to eq(Time.zone.at(0))
end
end
describe 'private methods' do
describe '#session_limit' do
it 'returns configured limit from GlobalConfig' do
allow(GlobalConfig).to receive(:get).with(
'USER_SESSION_LIMIT',
'USER_SESSION_LIMIT_PER_USER',
account: nil
).and_return('10')
limit = user.send(:session_limit)
expect(limit).to eq(10)
end
it 'returns infinity when no limit configured' do
allow(GlobalConfig).to receive(:get).and_return(nil)
limit = user.send(:session_limit)
expect(limit).to eq(Float::INFINITY)
end
it 'memoizes the result' do
allow(GlobalConfig).to receive(:get).and_return('5')
# First call
limit1 = user.send(:session_limit)
# Second call should use memoized value
limit2 = user.send(:session_limit)
expect(limit1).to eq(limit2)
expect(GlobalConfig).to have_received(:get).once
end
end
end
end