I'm posting the feature I wrote in case it is helpful for others
and in general in the sake of posterity. I haven't deployed this so it
may change further, but this is roughly where I've landed for the time
being:
# frozen_string_literal: true
require 'rodauth'
# This feature changes how `remember` feature works so that the whole session cookie is persisted instead of a
# remember token being persisted which builds a new session.
#
# Fundamentally, this is because mobile browsers (particularly on iOS and particularly older iOS devices) will
# aggressively remove the browser from memory and clear session cookies.
#
# The consequence is a many-fold:
#
# * CSRF tokens are lost resulting in form errors or us disabling CSRF on critical forms
# * Users with 2FA enabled will get re-prompted whenever they switch away from browser and back
#
# Instead of outright replacing the `remember` feature, this extends it so that we can carry over existing sessions
# using remember tokens, but in the future we could unload the upstream `remember` feature.
module Rodauth
Feature.define(:remember2, :Remember2) do
depends :remember
# Replace the upstream mechanism (i.e. don't call `super`) and set `session_options` instead of a new cookie.
def remember_login
set_session_expiry(remember_deadline_interval)
end
# If this is being called, then an old-style remember token exists and it has just created a fresh session. In this
# case, we want to remove that cookie and turn our session cookie into a persistent one.
def after_load_memory
super
disable_remember_login # removes key from DB
forget_login # via its `super` will remove original remember cookie altogether
remember_login # configure current session cookie to be persistent
end
def forget_login
super
set_session_expiry(nil)
end
# We override `remember`'s `load_memory` so we can refresh the current login or port the old login by way of
# triggering `after_load_memory`.
def load_memory
# If a remember cookie _exists_, pretend we are restoring the session so that the remember key is validated. If
# it is valid, it will create a new session, `after_load_memory` will run, and the session will be ported.
#
# This will manifest as one final "re-prompt MFA" or CSRF expiry, but then they will be in new land. We could
# restore original session instead, but it gets messy.
if request.cookies.key?(remember_cookie_key)
session.clear
super()
end
refresh_remembered_login
end
# Non-auth requests will need to call this to make sure the token continues to be refreshed
def refresh_remembered_login
if extend_remember_deadline?
set_session_expiry(session[:remember_for])
end
end
def set_session_expiry(expiry)
expiry = _to_duration(expiry)
set_session_option(:expire_after, expiry)
if expiry.nil?
set_session_option(:renew, true) # force it to be re-set
remove_session_value(:remember_for)
else
set_session_value(:remember_for, expiry.to_i) unless session.blank?
end
end
def set_session_option(key, value)
request.env['rack.session.options'][key.to_sym] = value
end
private
# upstream remember feature defaults to time as `{days: 14}` which is akin to `ActiveSupport::Duration`'s `parts`,
# but Rodauth/Sequel does not offer a way to turn this conveniently into seconds. I happen to be in context of
# a Rails app, so I can use ActiveSupport::Duration, but others may need to do their own logic here to turn it to
# something Rack understands.
def _to_duration(data)
case data
when Hash
secs = ActiveSupport::Duration.send(:calculate_total_seconds, data)
ActiveSupport::Duration.new(secs, data.dup)
when ActiveSupport::Duration
data
when nil, 0
nil
when Integer # secs
ActiveSupport::Duration.build(data.dup)
else
raise "unexpected duration value: #{data.inspect}"
end
end
end
end
Clearly, if I didn't care about porting existing remember cookies, this could be simpler, but there is value in extending the existing `remember` feature, in that the `/remember` route works mostly as intended (will switch between persisted and session cookies).
From here, I may want to build upon password grace period feature to re-auth with password, something like every 2 weeks. This would be a different TTL than the extant grace period, which I'd still want to be short to require passwords on forms like password or login change.