require 'rack/session/abstract/id'
require 'securerandom'
require 'base64'
require 'openssl'
begin
require_relative '../lib/familia'
rescue LoadError
require 'familia'
end
class SecureSessionStore < Rack::Session::Abstract::PersistedSecure
unless defined?(DEFAULT_OPTIONS)
DEFAULT_OPTIONS = {
key: 'project.session',
expire_after: 86_400, namespace: 'session',
sidbits: 256, dbclient: nil,
}.freeze
end
attr_reader :dbclient
def initialize(app, options = {})
raise ArgumentError, 'Secret required for secure sessions' unless options[:secret]
options = DEFAULT_OPTIONS.merge(options)
@dbclient = options[:dbclient] || Familia.dbclient
super
@secret = options[:secret]
@expire_after = options[:expire_after]
@namespace = options[:namespace] || 'session'
@hmac_key = derive_key('hmac')
@encryption_key = derive_key('encryption')
end
private
def get_stringkey(sid)
return nil if sid.to_s.empty?
key = Familia.join(@namespace, sid)
Familia::StringKey.new(key,
ttl: @expire_after,
default: nil)
end
def delete_session(_request, sid, _options)
sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
get_stringkey(sid_string)&.del
generate_sid
end
def valid_session_id?(sid)
return false if sid.to_s.empty?
return false unless sid.match?(/\A[a-f0-9]{64,}\z/)
true
end
def valid_hmac?(data, hmac)
expected = compute_hmac(data)
return false unless hmac.is_a?(String) && expected.is_a?(String) && hmac.bytesize == expected.bytesize
Rack::Utils.secure_compare(expected, hmac)
end
def derive_key(purpose)
OpenSSL::HMAC.hexdigest('SHA256', @secret, "session-#{purpose}")
end
def compute_hmac(data)
OpenSSL::HMAC.hexdigest('SHA256', @hmac_key, data)
end
def find_session(_request, sid)
sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
return [generate_sid, {}] unless sid_string && valid_session_id?(sid_string)
begin
stringkey = get_stringkey(sid_string)
stored_data = stringkey.value if stringkey
return [sid, {}] unless stored_data
data, hmac = stored_data.split('--', 2)
unless hmac && valid_hmac?(data, hmac)
return [generate_sid, {}]
end
session_data = Familia::JsonSerializer.parse(Base64.decode64(data))
[sid, session_data]
rescue Familia::PersistenceError => e
Familia.debug "[Session] Error reading session #{sid_string}: #{e.message}"
[generate_sid, {}]
end
end
def write_session(_request, sid, session_data, _options)
sid_string = sid.respond_to?(:public_id) ? sid.public_id : sid
encoded = Base64.encode64(Familia::JsonSerializer.dump(session_data)).delete("\n")
hmac = compute_hmac(encoded)
signed_data = "#{encoded}--#{hmac}"
stringkey = get_stringkey(sid_string)
stringkey.transaction do
stringkey.set(signed_data)
stringkey.update_expiration(expiration: @expire_after) if @expire_after&.positive?
end
sid
rescue Familia::PersistenceError => e
Familia.debug "[Session] Error writing session #{sid_string}: #{e.message}"
false
end
def cleanup_expired_sessions
end
end
class DemoApp
def initialize
@store = SecureSessionStore.new(
proc { |_env| [200, {}, ['Demo App']] },
secret: 'demo-secret-key-change-in-production',
expire_after: 300, )
end
def call(env)
puts "\n=== Familia::StringKey Session Demo ==="
env['rack.session'] ||= {}
env['HTTP_COOKIE'] ||= ''
session_id = SecureRandom.hex(32)
session_data = {
'user_id' => '12345',
'username' => 'demo_user',
'login_time' => Time.now.to_i,
'preferences' => { 'theme' => 'dark', 'lang' => 'en' },
}
puts 'Writing session data...'
result = @store.send(:write_session, nil, session_id, session_data, {})
puts " Result: #{result ? 'Success' : 'Failed'}"
puts "\nReading session data..."
found_id, found_data = @store.send(:find_session, nil, session_id)
puts " Session ID: #{found_id}"
puts " Data: #{found_data}"
puts "\nDeleting session..."
@store.send(:delete_session, nil, session_id, {})
puts "\nVerifying deletion..."
deleted_id, deleted_data = @store.send(:find_session, nil, session_id)
puts " Data after deletion: #{deleted_data}"
puts " New session ID: #{deleted_id == session_id ? 'Same' : 'Generated'}"
puts "\n✅ Demo complete!"
puts "\nKey Familia Features Used:"
puts '• Familia::StringKey for typed Redis storage'
puts '• Automatic TTL management'
puts '• Direct Redis operations (set, get, del)'
puts '• JSON serialization support'
puts '• No Horreum inheritance required'
[200, { 'Content-Type' => 'text/plain' }, ['Familia StringKey Demo - Check console output']]
end
end
if __FILE__ == $0
begin
Familia.dbclient.ping
rescue Familia::PersistenceError => e
puts "❌ Redis connection failed: #{e.message}"
puts ' Please ensure Redis is running on localhost:6379'
exit 1
end
app = DemoApp.new
app.call({})
end