sqrl-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE +21 -0
  4. data/README.md +196 -0
  5. data/app/controllers/sqrl/rails/application_controller.rb +70 -0
  6. data/app/controllers/sqrl/rails/assets_controller.rb +22 -0
  7. data/app/controllers/sqrl/rails/callback_controller.rb +101 -0
  8. data/app/controllers/sqrl/rails/cli_controller.rb +18 -0
  9. data/app/controllers/sqrl/rails/identities_controller.rb +57 -0
  10. data/app/controllers/sqrl/rails/logins_controller.rb +24 -0
  11. data/app/controllers/sqrl/rails/refresh_controller.rb +22 -0
  12. data/app/controllers/sqrl/rails/status_controller.rb +21 -0
  13. data/app/helpers/sqrl/rails/login_helper.rb +85 -0
  14. data/app/models/sqrl/rails/identity.rb +48 -0
  15. data/app/models/sqrl/rails/nut.rb +13 -0
  16. data/app/models/sqrl/rails/pending_login.rb +15 -0
  17. data/app/views/layouts/sqrl/rails/application.html.erb +18 -0
  18. data/app/views/sqrl/rails/identities/index.html.erb +27 -0
  19. data/app/views/sqrl/rails/logins/new.html.erb +3 -0
  20. data/config/locales/en.yml +11 -0
  21. data/config/routes.rb +12 -0
  22. data/lib/generators/sqrl/install/install_generator.rb +131 -0
  23. data/lib/generators/sqrl/install/templates/create_sqrl_tables.rb.tt +56 -0
  24. data/lib/generators/sqrl/install/templates/initializer.rb.tt +91 -0
  25. data/lib/sqrl/rails/configuration.rb +145 -0
  26. data/lib/sqrl/rails/controller_helpers.rb +37 -0
  27. data/lib/sqrl/rails/engine.rb +42 -0
  28. data/lib/sqrl/rails/password_policy.rb +120 -0
  29. data/lib/sqrl/rails/rate_limited.rb +62 -0
  30. data/lib/sqrl/rails/sign_in.rb +80 -0
  31. data/lib/sqrl/rails/stores/active_record.rb +279 -0
  32. data/lib/sqrl/rails/stores/cache.rb +311 -0
  33. data/lib/sqrl/rails/user_factory.rb +41 -0
  34. data/lib/sqrl/rails.rb +220 -0
  35. data/lib/sqrl-rails.rb +3 -0
  36. data/lib/tasks/sqrl.rake +12 -0
  37. metadata +129 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Enforces the two account-policy signals a SQRL client can send with +ident+:
6
+ #
7
+ # * +sqrlonly+ – "disable all non-SQRL authentication": password logins for the user
8
+ # must be refused ({Sessions}).
9
+ # * +hardlock+ – "disable all non-SQRL account recovery": password resets for the
10
+ # user must be refused ({Passwords}).
11
+ #
12
+ # The concerns target the controllers that `bin/rails generate authentication`
13
+ # produces (the generator adds the +include+ lines), but work in any controller that
14
+ # takes an email address or a password-reset token:
15
+ #
16
+ # class SessionsController < ApplicationController
17
+ # include SQRL::Rails::PasswordPolicy::Sessions # before_action on :create
18
+ # end
19
+ #
20
+ # class PasswordsController < ApplicationController
21
+ # include SQRL::Rails::PasswordPolicy::Passwords # before_action on :create, :edit, :update
22
+ # end
23
+ #
24
+ # Override +sqrl_policy_user+ to change how the user is looked up, and
25
+ # +sqrl_policy_refused(reason)+ to change the response (the default redirects to the
26
+ # sign-in page with a flash alert from the +sqrl.password_policy+ translations).
27
+ module PasswordPolicy
28
+ # Shared lookup and response helpers.
29
+ module Base
30
+ extend ActiveSupport::Concern
31
+
32
+ private
33
+
34
+ # The user the current request is about: by +email_address+/+email+ parameter,
35
+ # or by password-reset token when the request carries one.
36
+ #
37
+ # @return [Object, nil]
38
+ def sqrl_policy_user
39
+ return @user if instance_variable_defined?(:@user) && @user
40
+
41
+ model = SQRL::Rails.config.user_model
42
+ token = params[:token].presence
43
+ if token
44
+ found =
45
+ if model.respond_to?(:find_by_password_reset_token)
46
+ model.find_by_password_reset_token(token)
47
+ elsif model.respond_to?(:find_by_token_for)
48
+ model.find_by_token_for(:password_reset, token)
49
+ end
50
+ return found if found
51
+ end
52
+ email = params[:email_address].presence || params[:email].presence
53
+ return nil if email.nil? || !model.respond_to?(:column_names)
54
+
55
+ column = %w[email_address email].find { |c| model.column_names.include?(c) }
56
+ column && model.find_by(column => email.to_s.strip.downcase)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ # Respond to a refused request. +reason+ is +:sqrl_only+ or +:hard_lock+.
62
+ def sqrl_policy_refused(reason)
63
+ message = I18n.t("sqrl.password_policy.#{reason}",
64
+ default: if reason == :sqrl_only
65
+ "This account signs in with SQRL only."
66
+ else
67
+ "Password recovery is disabled for this account; use SQRL."
68
+ end)
69
+ flash[:alert] = message if respond_to?(:flash)
70
+ target = sqrl_policy_redirect_path
71
+ redirect_to target, allow_other_host: false
72
+ end
73
+
74
+ def sqrl_policy_redirect_path
75
+ if respond_to?(:new_session_path, true)
76
+ send(:new_session_path)
77
+ elsif respond_to?(:root_path, true)
78
+ send(:root_path)
79
+ else
80
+ "/"
81
+ end
82
+ end
83
+ end
84
+
85
+ # Refuses password logins for +sqrlonly+ users.
86
+ module Sessions
87
+ extend ActiveSupport::Concern
88
+ include Base
89
+
90
+ included do
91
+ before_action :refuse_password_login_for_sqrl_only_users, only: :create
92
+ end
93
+
94
+ private
95
+
96
+ def refuse_password_login_for_sqrl_only_users
97
+ user = sqrl_policy_user
98
+ sqrl_policy_refused(:sqrl_only) if user && SQRL::Rails.sqrl_only?(user)
99
+ end
100
+ end
101
+
102
+ # Refuses password resets for +hardlock+ users.
103
+ module Passwords
104
+ extend ActiveSupport::Concern
105
+ include Base
106
+
107
+ included do
108
+ before_action :refuse_password_reset_for_hard_locked_users, only: %i[create edit update]
109
+ end
110
+
111
+ private
112
+
113
+ def refuse_password_reset_for_hard_locked_users
114
+ user = sqrl_policy_user
115
+ sqrl_policy_refused(:hard_lock) if user && SQRL::Rails.hard_locked?(user)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # A small, version-independent rate limiter for the engine's public actions,
6
+ # configured through +config.sqrl.rate_limit = { to: 60, within: 1.minute }+.
7
+ # Requests are counted per client IP (or per +by:+ callable) in +Rails.cache+ (or the
8
+ # +cache:+ option); over the limit the action answers 429. +nil+ (the default)
9
+ # disables it. Works on Rails 7.1 as well as on versions that have their own
10
+ # +rate_limit+ macro.
11
+ module RateLimited
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ before_action :sqrl_enforce_rate_limit
16
+ end
17
+
18
+ # The configured limit for an action name, or nil.
19
+ #
20
+ # @param action [Symbol] +:cli+, +:login+, +:status+, +:callback+, +:identities+
21
+ # @return [Hash, nil]
22
+ def self.limit_for(action)
23
+ limit = SQRL::Rails.config.rate_limit
24
+ return nil unless limit.is_a?(Hash) && limit[:to] && limit[:within]
25
+
26
+ only = limit[:only]
27
+ return nil if only && !Array(only).map(&:to_sym).include?(action.to_sym)
28
+
29
+ limit
30
+ end
31
+
32
+ private
33
+
34
+ # Name used to scope the limit; controllers override it.
35
+ def sqrl_rate_limit_scope
36
+ :cli
37
+ end
38
+
39
+ def sqrl_enforce_rate_limit
40
+ limit = RateLimited.limit_for(sqrl_rate_limit_scope)
41
+ return unless limit
42
+
43
+ by = limit[:by] ? limit[:by].call(request) : request.remote_ip
44
+ cache = limit[:cache] || ::Rails.cache
45
+ window = limit[:within].to_i
46
+ bucket = (Time.now.to_i / window).to_s
47
+ key = "sqrl:rate_limit:#{sqrl_rate_limit_scope}:#{by}:#{bucket}"
48
+ count = cache.increment(key, 1, expires_in: window)
49
+ if count.nil?
50
+ # Stores without increment support (or a missing key on some stores): fall back
51
+ # to a read/write cycle. Not atomic, but still bounded.
52
+ count = cache.read(key).to_i + 1
53
+ cache.write(key, count, expires_in: window)
54
+ end
55
+ return if count <= limit[:to].to_i
56
+
57
+ response.set_header("retry-after", (window - (Time.now.to_i % window)).to_s)
58
+ head :too_many_requests
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # The default sign-in adapter and redirect resolution.
6
+ module SignIn
7
+ # Session key used by the plain-session fallback.
8
+ SESSION_USER_KEY = :user_id
9
+ # Session key holding the user's class name in the plain-session fallback.
10
+ SESSION_USER_TYPE_KEY = :sqrl_user_type
11
+
12
+ module_function
13
+
14
+ # Sign +user+ in on +controller+.
15
+ #
16
+ # 1. Rails' generated +Authentication+ concern: +start_new_session_for(user)+.
17
+ # 2. Warden (if the app uses it directly): +warden.set_user(user)+.
18
+ # 3. Fallback: +session[:user_id]+ (and the user's class name).
19
+ def default(controller, _identity, user)
20
+ if controller.respond_to?(:start_new_session_for, true)
21
+ controller.send(:start_new_session_for, user)
22
+ elsif controller.request.env["warden"].respond_to?(:set_user)
23
+ controller.request.env["warden"].set_user(user)
24
+ else
25
+ controller.session[SESSION_USER_KEY] = user.id
26
+ controller.session[SESSION_USER_TYPE_KEY] = user.class.name
27
+ end
28
+ end
29
+
30
+ # The user signed in through {default}'s fallback, for applications without their
31
+ # own +current_user+.
32
+ def session_user(session)
33
+ id = session[SESSION_USER_KEY]
34
+ return nil if id.nil?
35
+
36
+ klass = session[SESSION_USER_TYPE_KEY].presence&.safe_constantize || SQRL::Rails.config.user_model
37
+ klass.find_by(klass.primary_key => id)
38
+ end
39
+
40
+ # Where to go after a successful login.
41
+ def after(controller, completion)
42
+ return_to = completion.return_to
43
+ return return_to if same_site_path?(return_to)
44
+
45
+ custom = SQRL::Rails.config.after_sign_in
46
+ return custom.call(controller, completion) if custom
47
+
48
+ if controller.respond_to?(:after_authentication_url, true)
49
+ controller.send(:after_authentication_url)
50
+ elsif controller.respond_to?(:main_app) && controller.main_app.respond_to?(:root_path)
51
+ controller.main_app.root_path
52
+ else
53
+ "/"
54
+ end
55
+ end
56
+
57
+ # The signed-in user for linking purposes.
58
+ def current_user(controller)
59
+ custom = SQRL::Rails.config.current_user
60
+ return custom.call(controller) if custom
61
+
62
+ if Object.const_defined?(:Current) && ::Current.respond_to?(:user)
63
+ controller.send(:resume_session) if controller.respond_to?(:resume_session, true)
64
+ user = ::Current.user
65
+ return user if user
66
+ end
67
+ session_user(controller.session)
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
72
+ # A same-site, root-relative path. Rejects protocol-relative URLs (//host), the
73
+ # backslash form browsers normalise to a slash (/\host), and control characters or
74
+ # whitespace that URL parsers strip before interpreting the rest.
75
+ def same_site_path?(path)
76
+ path.is_a?(String) && path.match?(%r{\A/(?![/\\])}) && !path.match?(/[\x00-\x20\x7f\\]/)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ module Stores
6
+ # ActiveRecord-backed implementations of the three {SQRL::Server} store interfaces,
7
+ # over the tables created by `bin/rails generate sqrl:install`:
8
+ #
9
+ # * +sqrl_identities+ – one row per SQRL association; +idk+, +suk+ and +vuk+ are
10
+ # stored base64url-encoded; a re-key updates the row in place and leaves a
11
+ # tombstone row for the old +idk+ with +superseded_by+ set.
12
+ # * +sqrl_nuts+ – issued nuts; +consume+ is a single conditional +UPDATE+, so a nut
13
+ # can only ever be used once even under concurrent requests.
14
+ # * +sqrl_pending_logins+ – one row per login page view; +claim_cps_token+ is a
15
+ # compare-and-set on the token.
16
+ #
17
+ # Key material crosses the boundary as raw bytes (what the protocol core uses) and
18
+ # is encoded/decoded here.
19
+ module ActiveRecord
20
+ # @return [SQRL::Server::Stores]
21
+ def self.stores
22
+ SQRL::Server::Stores.new(nuts: NutStore.new, identities: IdentityStore.new,
23
+ pending_logins: PendingLoginStore.new)
24
+ end
25
+
26
+ # Runs a store operation with an explicitly checked-out connection, returned to
27
+ # the pool as soon as it finishes (Rails 7.1 would otherwise keep a thread's
28
+ # implicit connection until the thread dies, starving concurrent callers).
29
+ module WithConnection
30
+ private
31
+
32
+ def with_connection(&)
33
+ ::ActiveRecord::Base.connection_pool.with_connection(&)
34
+ end
35
+ end
36
+
37
+ # Encoding helpers shared by the stores.
38
+ module Codec
39
+ module_function
40
+
41
+ # @return [String, nil] binary bytes as base64url for a string column
42
+ def encode(bytes)
43
+ bytes.nil? ? nil : SQRL::Util.b64u_encode(bytes.b)
44
+ end
45
+
46
+ # @return [String, nil] binary bytes from a base64url column
47
+ def decode(text)
48
+ text.nil? || text.empty? ? nil : SQRL::Util.b64u_decode(text)
49
+ end
50
+ end
51
+
52
+ # @see SQRL::Server::NutStore
53
+ class NutStore < SQRL::Server::NutStore
54
+ include WithConnection
55
+
56
+ # Issue on ActiveRecord; same contract as +SQRL::Server::NutStore#issue+.
57
+ def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
58
+ with_connection do
59
+ row = Nut.create!(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server, ip: ip,
60
+ bound_idk: Codec.encode(bound_idk), expires_at: expires_at)
61
+ to_record(row)
62
+ end
63
+ end
64
+
65
+ # Consume on ActiveRecord; same contract as +SQRL::Server::NutStore#consume+.
66
+ def consume(nut, now: Time.now)
67
+ return nil if nut.nil? || nut.empty?
68
+
69
+ with_connection do
70
+ scope = Nut.where(nut: nut, used_at: nil).where("expires_at IS NULL OR expires_at > ?", now)
71
+ next nil unless scope.update_all(used_at: now) == 1
72
+
73
+ to_record(Nut.find_by!(nut: nut))
74
+ end
75
+ end
76
+
77
+ # Delete expired rows.
78
+ # @return [Integer] number deleted
79
+ def prune!(now = Time.now)
80
+ with_connection { Nut.where("used_at IS NOT NULL OR expires_at <= ?", now).delete_all }
81
+ nil
82
+ end
83
+
84
+ private
85
+
86
+ def to_record(row)
87
+ SQRL::Server::NutRecord.new(nut: row.nut, pending_login_id: row.pending_login_id,
88
+ expected_server: row.expected_server, ip: row.ip,
89
+ bound_idk: Codec.decode(row.bound_idk), expires_at: row.expires_at,
90
+ used_at: row.used_at)
91
+ end
92
+ end
93
+
94
+ # @see SQRL::Server::IdentityStore
95
+ class IdentityStore < SQRL::Server::IdentityStore
96
+ # Identity columns that +update+ may change.
97
+ ATTRIBUTE_COLUMNS = %i[suk vuk disabled sqrlonly hardlock ins logins].freeze
98
+
99
+ # Find on ActiveRecord; same contract as +SQRL::Server::IdentityStore#find+.
100
+ def find(idk)
101
+ row = Identity.active.find_by(idk: Codec.encode(idk))
102
+ row && to_record(row)
103
+ end
104
+
105
+ # Create on ActiveRecord; same contract as +SQRL::Server::IdentityStore#create+.
106
+ def create(**attrs)
107
+ identity = SQRL::Server::Identity.new(**attrs)
108
+ row = Identity.new(idk: Codec.encode(identity.idk), suk: Codec.encode(identity.suk),
109
+ vuk: Codec.encode(identity.vuk), disabled: identity.disabled?,
110
+ sqrlonly: identity.sqrlonly?, hardlock: identity.hardlock?, ins: identity.ins,
111
+ logins: identity.logins.to_i, last_login_at: Time.current)
112
+ assign_user(row, identity.user_ref)
113
+ begin
114
+ row.save!
115
+ rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
116
+ raise ArgumentError, "identity already exists" if Identity.exists?(idk: row.idk)
117
+
118
+ raise
119
+ end
120
+ to_record(row)
121
+ end
122
+
123
+ # Update on ActiveRecord; same contract as +SQRL::Server::IdentityStore#update+.
124
+ def update(idk, **attrs)
125
+ row = Identity.active.find_by(idk: Codec.encode(idk))
126
+ return nil unless row
127
+
128
+ changes = {}
129
+ attrs.each do |key, value|
130
+ case key
131
+ when :suk, :vuk then changes[key] = Codec.encode(value)
132
+ when :idk then changes[key] = Codec.encode(value)
133
+ when :user_ref then assign_user(row, value)
134
+ when *ATTRIBUTE_COLUMNS then changes[key] = value
135
+ else raise ArgumentError, "unknown identity attribute #{key}"
136
+ end
137
+ end
138
+ changes[:last_login_at] = Time.current if attrs.key?(:logins)
139
+ row.assign_attributes(changes)
140
+ row.save!
141
+ to_record(row)
142
+ end
143
+
144
+ # Rekey on ActiveRecord; same contract as +SQRL::Server::IdentityStore#rekey+.
145
+ def rekey(old_idk, new_idk, suk:, vuk:, ins:)
146
+ old_b64 = Codec.encode(old_idk)
147
+ new_b64 = Codec.encode(new_idk)
148
+ Identity.transaction do
149
+ row = Identity.active.lock.find_by(idk: old_b64)
150
+ return nil unless row
151
+
152
+ row.update!(idk: new_b64, suk: Codec.encode(suk), vuk: Codec.encode(vuk), ins: ins)
153
+ Identity.create!(idk: old_b64, superseded_by: new_b64, disabled: true)
154
+ to_record(row)
155
+ end
156
+ end
157
+
158
+ def superseded?(idk)
159
+ Identity.where(idk: Codec.encode(idk)).where.not(superseded_by: nil).exists?
160
+ end
161
+
162
+ # Remove on ActiveRecord; same contract as +SQRL::Server::IdentityStore#remove+.
163
+ def remove(idk)
164
+ row = Identity.active.find_by(idk: Codec.encode(idk))
165
+ return nil unless row
166
+
167
+ record = to_record(row)
168
+ row.destroy!
169
+ record
170
+ end
171
+
172
+ # The ActiveRecord row for an identity record (for applications).
173
+ def row_for(identity)
174
+ Identity.active.find_by(idk: identity.respond_to?(:idk_b64) ? identity.idk_b64 : identity.to_s)
175
+ end
176
+
177
+ private
178
+
179
+ def assign_user(row, user_ref)
180
+ if user_ref.nil?
181
+ row.user_type = nil
182
+ row.user_id = nil
183
+ else
184
+ type, id = user_ref.to_s.split(":", 2)
185
+ row.user_type = type
186
+ row.user_id = id
187
+ end
188
+ end
189
+
190
+ def to_record(row)
191
+ SQRL::Server::Identity.new(idk: Codec.decode(row.idk), suk: Codec.decode(row.suk),
192
+ vuk: Codec.decode(row.vuk), disabled: row.disabled, sqrlonly: row.sqrlonly,
193
+ hardlock: row.hardlock, ins: row.ins, logins: row.logins.to_i,
194
+ user_ref: row.user_type && row.user_id ? "#{row.user_type}:#{row.user_id}" : nil)
195
+ end
196
+ end
197
+
198
+ # @see SQRL::Server::PendingLoginStore
199
+ class PendingLoginStore < SQRL::Server::PendingLoginStore
200
+ include WithConnection
201
+
202
+ # Pending-login columns that +update+ may change.
203
+ COLUMNS = %i[authenticated_at btn asked ip return_to link_user_ref expires_at cps_token
204
+ cps_token_expires_at ip_matched].freeze
205
+
206
+ # Create on ActiveRecord; same contract as +SQRL::Server::PendingLoginStore#create+.
207
+ def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
208
+ row = PendingLogin.create!(login_id: id, ip: ip, return_to: return_to, expires_at: expires_at,
209
+ link_user_ref: link_user_ref, created_at: created_at)
210
+ to_record(row)
211
+ end
212
+
213
+ # Find on ActiveRecord; same contract as +SQRL::Server::IdentityStore#find+.
214
+ def find(id)
215
+ row = id && PendingLogin.find_by(login_id: id)
216
+ row && to_record(row)
217
+ end
218
+
219
+ # Update on ActiveRecord; same contract as +SQRL::Server::IdentityStore#update+.
220
+ def update(id, **attrs)
221
+ row = PendingLogin.find_by(login_id: id)
222
+ return nil unless row
223
+
224
+ changes = {}
225
+ attrs.each do |key, value|
226
+ case key
227
+ when :authenticated_idk then changes[key] = Codec.encode(value)
228
+ when :last_command then changes[key] = value&.to_s
229
+ when *COLUMNS then changes[key] = value
230
+ else raise ArgumentError, "unknown pending login attribute #{key}"
231
+ end
232
+ end
233
+ row.update!(changes)
234
+ to_record(row)
235
+ end
236
+
237
+ # Claim cps token on ActiveRecord; same contract as +SQRL::Server::PendingLoginStore#claim_cps_token+.
238
+ def claim_cps_token(token, now: Time.now)
239
+ return nil if token.nil? || token.empty?
240
+
241
+ with_connection do
242
+ row = PendingLogin.find_by(cps_token: token)
243
+ next nil unless row
244
+
245
+ claimed = PendingLogin.where(id: row.id, cps_token: token)
246
+ .update_all(cps_token: nil, cps_token_expires_at: nil) == 1
247
+ next nil unless claimed
248
+ next nil if row.cps_token_expires_at && row.cps_token_expires_at <= now
249
+
250
+ row.cps_token = nil
251
+ row.cps_token_expires_at = nil
252
+ to_record(row)
253
+ end
254
+ end
255
+
256
+ # Delete expired rows.
257
+ # @return [Integer] number deleted
258
+ def prune!(now = Time.now)
259
+ PendingLogin.where("expires_at <= ?", now).delete_all
260
+ nil
261
+ end
262
+
263
+ private
264
+
265
+ def to_record(row)
266
+ SQRL::Server::PendingLogin.new(
267
+ id: row.login_id, ip: row.ip, return_to: row.return_to, link_user_ref: row.link_user_ref,
268
+ created_at: row.created_at, expires_at: row.expires_at,
269
+ authenticated_idk: Codec.decode(row.authenticated_idk), authenticated_at: row.authenticated_at,
270
+ btn: row.btn, asked: row.asked, last_command: row.last_command&.to_sym,
271
+ cps_token: row.cps_token, cps_token_expires_at: row.cps_token_expires_at,
272
+ ip_matched: row.respond_to?(:ip_matched) ? row.ip_matched == true : false
273
+ )
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end