Skip to content

Instantly share code, notes, and snippets.

@gangelo
Last active August 22, 2025 11:58
Show Gist options
  • Select an option

  • Save gangelo/0437713e66c87bd6b8d05f7a95bf19ac to your computer and use it in GitHub Desktop.

Select an option

Save gangelo/0437713e66c87bd6b8d05f7a95bf19ac to your computer and use it in GitHub Desktop.
GRF Session Extend code

Rails 8 Multi-App Session Synchronization Solution

Architecture Overview

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.

Important Constraints

  • 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

Implementation

1. Shared Redis Configuration

# 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

2. ActionCable Setup

# 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

3. Session Sync Channel (Notifications Only)

# 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

4. Session Management Controller

# 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

5. Authentication Controller Updates

# 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

6. Application Controller

# 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

7. JavaScript Implementation (Stimulus Controller)

// 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 : ''
  }
}

8. HTML Setup

<!-- 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>

9. CSS Styles

/* 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;
}

Key Architecture Points

  1. Session Access: Only controllers can access/modify the session
  2. ActionCable Role: Used purely for broadcasting notifications
  3. Authentication: Uses signed cookies for ActionCable connection auth
  4. Session Updates: All session touches go through AJAX to controllers
  5. Synchronization: ActionCable broadcasts ensure all windows update simultaneously

Testing Checklist

  • 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

Q. This code will eventually be put into a gem that will be used by all the apps, and some of the apps (not all) already use action cable and therefore have cable.yml setups in place with different channel_prefixes. How do I deal with this? I'd eventually like the cable.yml for THIS solution in the gem to be configured in the gem itself.

A. Excellent point! This is a real challenge. When your gem needs its own ActionCable configuration independent of the host app's configuration, you have a few options:

Option 1: Separate Redis Connection for Session Sync (Recommended)

Create a dedicated ActionCable server instance for your gem with its own Redis connection:

# In your gem: lib/your_gem/session_sync_cable.rb
module YourGem
  class SessionSyncCable
    class << self
      def server
        @server ||= ActionCable::Server::Base.new(config: cable_config)
      end
      
      def broadcast(channel, message)
        server.pubsub.broadcast(channel, message)
      end
      
      private
      
      def cable_config
        config = ActionCable::Server::Configuration.new
        config.cable = {
          "adapter" => "redis",
          "url" => YourGem.configuration.redis_url || "redis://localhost:6379/2",
          "channel_prefix" => "shared_session_sync"
        }
        config
      end
    end
  end
end

Then in your controllers/channels, use your gem's server:

# Instead of ActionCable.server.broadcast
YourGem::SessionSyncCable.broadcast(
  "session_sync:#{session[:current_user_edipi]}", 
  { type: 'activity', ... }
)

Option 2: Raw Redis Pub/Sub (Simpler)

Skip ActionCable entirely for broadcasting and use Redis directly:

# In your gem
module YourGem
  class SessionSync
    CHANNEL_PREFIX = "shared_session_sync"
    
    def self.redis
      @redis ||= Redis.new(url: YourGem.configuration.redis_url)
    end
    
    def self.broadcast(user_edipi, message)
      channel = "#{CHANNEL_PREFIX}:session_sync:#{user_edipi}"
      redis.publish(channel, message.to_json)
    end
  end
end

Then create a hybrid approach where:

  • Your gem uses raw Redis for publishing
  • The JavaScript still uses ActionCable for subscribing (but with a custom channel that handles the Redis subscription)

Option 3: Namespace Within Existing ActionCable

If you must use the host app's ActionCable setup, you could namespace your channels uniquely:

# In your gem's channel
class YourGem::SessionSyncChannel < ApplicationCable::Channel
  def subscribed
    # Use a totally unique channel name that won't conflict
    stream_from "gem_shared_session_sync_#{current_user_edipi}_channel"
  end
end

Recommendation

I'd go with Option 1 (separate ActionCable server instance) because:

  • Complete isolation from host app's ActionCable configuration
  • All apps using your gem share the same session sync channels
  • No conflicts with existing ActionCable usage
  • Can be configured entirely within the gem

Would you like me to show a complete implementation of Option 1 with proper gem configuration?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment