This solution uses ActionCable for real-time notifications and AJAX requests for session management. Redis serves as both the session store and ActionCable's pub/sub backend.
- ActionCable cannot access the session directly - Sessions are tied to HTTP, not WebSockets
- All session modifications must go through controllers via AJAX requests
- ActionCable is used only for broadcasting notifications to synchronize UI state
# config/application.rb (in each app)
config.session_store :redis_store,
servers: ["redis://localhost:6379/0/session"],
expire_after: 30.minutes,
key: "_shared_session",
same_site: :lax,
secure: Rails.env.production?
# config/cable.yml (in each app)
production:
adapter: redis
url: redis://localhost:6379/1
channel_prefix: shared_session_sync# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user_edipi
def connect
self.current_user_edipi = find_verified_user_edipi
end
private
def find_verified_user_edipi
# Option 1: Use a signed cookie that you set during login
if cookies.signed[:user_edipi]
cookies.signed[:user_edipi]
else
reject_unauthorized_connection
end
end
end
end
# app/channels/application_cable/channel.rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
# Shared channel functionality can go here
end
end# app/channels/session_sync_channel.rb
class SessionSyncChannel < ApplicationCable::Channel
def subscribed
return unless current_user_edipi
# Subscribe to user-specific channel
stream_from "session_sync:#{current_user_edipi}"
# Note: We cannot check session status here directly
# The client will need to make an AJAX call to get initial status
end
def unsubscribed
# Cleanup
stop_all_streams
end
private
def current_user_edipi
connection.current_user_edipi
end
end# app/controllers/session_management_controller.rb
class SessionManagementController < ApplicationController
before_action :require_authenticated_user
def status
render json: {
expires_at: calculate_expiration,
current_time: Time.current.to_i,
edipi: session[:current_user_edipi]
}
end
def heartbeat
if session[:current_user_edipi]
session[:last_activity] = Time.current
# Broadcast activity to all user's connections
ActionCable.server.broadcast(
"session_sync:#{session[:current_user_edipi]}",
{
type: 'activity',
timestamp: Time.current.to_i,
expires_at: calculate_expiration
}
)
render json: { status: 'ok', expires_at: calculate_expiration }
else
render json: { status: 'unauthenticated' }, status: :unauthorized
end
end
def extend_session
if session[:current_user_edipi]
session[:last_activity] = Time.current
# Broadcast extension to all user's connections
ActionCable.server.broadcast(
"session_sync:#{session[:current_user_edipi]}",
{
type: 'session_extended',
expires_at: calculate_expiration
}
)
render json: { status: 'extended', expires_at: calculate_expiration }
else
render json: { status: 'unauthenticated' }, status: :unauthorized
end
end
private
def require_authenticated_user
unless session[:current_user_edipi]
render json: { error: 'Not authenticated' }, status: :unauthorized
end
end
def calculate_expiration
last_activity = session[:last_activity] || Time.current
(last_activity.to_time + 30.minutes).to_i
end
end
# config/routes.rb
Rails.application.routes.draw do
# Session management endpoints
get '/session_management/status', to: 'session_management#status'
post '/session_management/heartbeat', to: 'session_management#heartbeat'
post '/session_management/extend', to: 'session_management#extend_session'
end# app/controllers/sessions_controller.rb (or your auth controller)
class SessionsController < ApplicationController
def create
# Your authentication logic
if user = authenticate_user(params[:email], params[:password])
session[:current_user_edipi] = user.edipi
session[:last_activity] = Time.current
# Set a signed cookie for ActionCable authentication
cookies.signed[:user_edipi] = {
value: user.edipi,
expires: 30.minutes.from_now,
httponly: true
}
# Broadcast fresh login to dismiss any existing warning modals
ActionCable.server.broadcast(
"session_sync:#{user.edipi}",
{
type: 'session_renewed',
expires_at: (Time.current + 30.minutes).to_i,
source: 'fresh_login'
}
)
redirect_to dashboard_path
else
render :new
end
end
def destroy
edipi = session[:current_user_edipi]
# Broadcast logout before clearing session
if edipi
ActionCable.server.broadcast(
"session_sync:#{edipi}",
{
type: 'session_terminated',
source: 'user_logout'
}
)
end
reset_session
cookies.delete(:user_edipi)
redirect_to login_path
end
end# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :check_session_timeout
before_action :track_activity
private
def check_session_timeout
if session[:current_user_edipi] && session[:last_activity]
if Time.current - session[:last_activity].to_time > 30.minutes
reset_session
cookies.delete(:user_edipi)
redirect_to login_path, alert: "Your session has expired"
end
end
end
def track_activity
if session[:current_user_edipi]
# Only update if more than 1 minute has passed (avoid excessive updates)
if !session[:last_activity] ||
(Time.current - session[:last_activity].to_time) > 1.minute
session[:last_activity] = Time.current
# Update the ActionCable auth cookie expiration
cookies.signed[:user_edipi] = {
value: session[:current_user_edipi],
expires: 30.minutes.from_now,
httponly: true
}
end
end
end
end// app/javascript/controllers/session_manager_controller.js
import { Controller } from "@hotwired/stimulus"
import consumer from "../channels/consumer"
export default class extends Controller {
static values = {
edipi: String,
warningTime: { type: Number, default: 120 } // 2 minutes
}
connect() {
if (!this.edipiValue) return
this.expiresAt = null
this.warningShown = false
this.checkInterval = null
this.subscription = null
// Get initial session status
this.fetchSessionStatus().then(() => {
this.setupChannel()
this.startExpirationCheck()
this.setupActivityTracking()
})
}
disconnect() {
if (this.subscription) {
this.subscription.unsubscribe()
}
if (this.checkInterval) {
clearInterval(this.checkInterval)
}
}
async fetchSessionStatus() {
try {
const response = await fetch('/session_management/status', {
credentials: 'same-origin'
})
const data = await response.json()
if (data.expires_at) {
this.expiresAt = data.expires_at
}
} catch (error) {
console.error('Failed to fetch session status:', error)
}
}
setupChannel() {
this.subscription = consumer.subscriptions.create(
{ channel: "SessionSyncChannel" },
{
connected: () => {
console.log("Connected to SessionSyncChannel")
},
disconnected: () => {
console.log("Disconnected from SessionSyncChannel")
},
received: (data) => {
this.handleChannelMessage(data)
}
}
)
}
handleChannelMessage(data) {
switch(data.type) {
case 'activity':
case 'session_extended':
case 'session_renewed':
this.expiresAt = data.expires_at
this.hideWarningModal()
break
case 'session_terminated':
this.handleRemoteLogout()
break
}
}
startExpirationCheck() {
this.checkInterval = setInterval(() => {
this.checkExpiration()
}, 1000)
}
checkExpiration() {
if (!this.expiresAt) return
const now = Math.floor(Date.now() / 1000)
const timeLeft = this.expiresAt - now
if (timeLeft <= 0) {
this.handleSessionExpired()
} else if (timeLeft <= this.warningTimeValue && !this.warningShown) {
this.showWarningModal(timeLeft)
}
if (this.warningShown) {
this.updateCountdown(timeLeft)
}
}
showWarningModal(timeLeft) {
this.warningShown = true
const modal = document.createElement('div')
modal.id = 'session-warning-modal'
modal.className = 'session-modal-overlay'
modal.innerHTML = `
<div class="session-modal">
<div class="session-modal-header">
<h2>Session Expiring Soon</h2>
</div>
<div class="session-modal-body">
<p>Your session will expire in <span id="countdown">${timeLeft}</span> seconds.</p>
<p>Click "Extend Session" to continue working.</p>
</div>
<div class="session-modal-footer">
<button id="extend-btn" class="btn btn-primary">Extend Session</button>
<button id="logout-btn" class="btn btn-secondary">Log Out</button>
</div>
</div>
`
document.body.appendChild(modal)
document.getElementById('extend-btn').addEventListener('click', () => {
this.extendSession()
})
document.getElementById('logout-btn').addEventListener('click', () => {
window.location.href = '/logout'
})
}
updateCountdown(timeLeft) {
const countdown = document.getElementById('countdown')
if (countdown) {
countdown.textContent = Math.max(0, timeLeft)
}
}
hideWarningModal() {
this.warningShown = false
const modal = document.getElementById('session-warning-modal')
if (modal) {
modal.remove()
}
}
async extendSession() {
try {
const response = await fetch('/session_management/extend', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken()
},
credentials: 'same-origin'
})
const data = await response.json()
if (data.expires_at) {
this.expiresAt = data.expires_at
this.hideWarningModal()
}
} catch (error) {
console.error('Failed to extend session:', error)
}
}
setupActivityTracking() {
let lastLocalActivity = Date.now()
let lastServerActivity = Date.now()
const localThreshold = 5000 // 5 seconds for local tracking
const serverThreshold = 60000 // 1 minute for server updates
const trackActivity = async (event) => {
const now = Date.now()
// Check if we should update the expiration time locally
if (now - lastLocalActivity > localThreshold) {
lastLocalActivity = now
// If modal is showing and user is active, hide it immediately
if (this.warningShown) {
// Calculate new expiration based on current activity
this.expiresAt = Math.floor(now / 1000) + (30 * 60) // 30 minutes from now
this.hideWarningModal()
// For focus events, also send immediate heartbeat to sync other windows
if (event && event.type === 'focus') {
this.sendHeartbeat()
}
}
}
// Check if we should send heartbeat to server
if (now - lastServerActivity > serverThreshold) {
lastServerActivity = now
this.sendHeartbeat()
}
}
// User interaction events
['mousedown', 'keydown', 'touchstart'].forEach(event => {
document.addEventListener(event, trackActivity, { passive: true })
})
// Window/tab focus events
window.addEventListener('focus', trackActivity)
// Also handle page visibility changes (for mobile browsers)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
trackActivity(new Event('visibilitychange'))
}
})
}
async sendHeartbeat() {
try {
const response = await fetch('/session_management/heartbeat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken()
},
credentials: 'same-origin'
})
const data = await response.json()
if (data.expires_at) {
this.expiresAt = data.expires_at
}
} catch (error) {
console.error('Heartbeat failed:', error)
}
}
handleSessionExpired() {
clearInterval(this.checkInterval)
window.location.href = '/login?expired=true'
}
handleRemoteLogout() {
clearInterval(this.checkInterval)
this.hideWarningModal()
window.location.href = '/login?logout=remote'
}
getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.content : ''
}
}<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<!-- other head content -->
</head>
<body>
<% if session[:current_user_edipi] %>
<div data-controller="session-manager"
data-session-manager-edipi-value="<%= session[:current_user_edipi] %>"
data-session-manager-warning-time-value="120">
</div>
<% end %>
<%= yield %>
</body>
</html>/* app/assets/stylesheets/session_modal.css */
.session-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.session-modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 400px;
width: 90%;
}
.session-modal-header {
padding: 20px;
border-bottom: 1px solid #e5e5e5;
}
.session-modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.session-modal-body {
padding: 20px;
}
.session-modal-body p {
margin: 10px 0;
}
#countdown {
font-weight: bold;
color: #dc3545;
}
.session-modal-footer {
padding: 20px;
border-top: 1px solid #e5e5e5;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}- Session Access: Only controllers can access/modify the session
- ActionCable Role: Used purely for broadcasting notifications
- Authentication: Uses signed cookies for ActionCable connection auth
- Session Updates: All session touches go through AJAX to controllers
- Synchronization: ActionCable broadcasts ensure all windows update simultaneously
- Session expires after 30 minutes of inactivity
- Modal appears 2 minutes before expiration
- Extending session in one window dismisses modals in all windows
- Fresh login dismisses all existing modals
- Logout in one window logs out all windows
- Activity tracking prevents premature timeout
- Works across multiple apps sharing the same Redis session store