sqrl-core 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.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Server
5
+ # In-memory store implementations: thread-safe, process-local. Suitable for tests,
6
+ # development and single-process demos; production deployments use a database-backed
7
+ # implementation (see the framework gems).
8
+ module Memory
9
+ # @return [Stores] a fresh set of in-memory stores
10
+ def self.stores
11
+ Stores.new(nuts: NutStore.new, identities: IdentityStore.new, pending_logins: PendingLoginStore.new)
12
+ end
13
+
14
+ # @see Server::NutStore
15
+ class NutStore < Server::NutStore
16
+ def initialize
17
+ super
18
+ @mutex = Mutex.new
19
+ @nuts = {}
20
+ end
21
+
22
+ # (see SQRL::Server::NutStore#issue)
23
+ def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
24
+ record = NutRecord.new(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server,
25
+ ip: ip, bound_idk: bound_idk, expires_at: expires_at, used_at: nil)
26
+ @mutex.synchronize { @nuts[nut] = record }
27
+ record.dup
28
+ end
29
+
30
+ # (see SQRL::Server::NutStore#consume)
31
+ def consume(nut, now: Time.now)
32
+ @mutex.synchronize do
33
+ record = @nuts[nut]
34
+ return nil if record.nil? || record.used? || record.expired?(now)
35
+
36
+ record.used_at = now
37
+ record.dup
38
+ end
39
+ end
40
+
41
+ # Remove expired records.
42
+ # @return [Integer] number removed
43
+ def prune!(now = Time.now)
44
+ @mutex.synchronize { @nuts.delete_if { |_, r| r.used? || r.expired?(now) } }
45
+ nil
46
+ end
47
+
48
+ # @return [Integer] number of records held
49
+ def size
50
+ @mutex.synchronize { @nuts.size }
51
+ end
52
+ end
53
+
54
+ # @see Server::IdentityStore
55
+ class IdentityStore < Server::IdentityStore
56
+ def initialize
57
+ super
58
+ @mutex = Mutex.new
59
+ @identities = {}
60
+ @superseded = {}
61
+ end
62
+
63
+ # (see SQRL::Server::IdentityStore#find)
64
+ def find(idk)
65
+ @mutex.synchronize { @identities[idk.b]&.dup }
66
+ end
67
+
68
+ # (see SQRL::Server::IdentityStore#create)
69
+ def create(**attrs)
70
+ identity = Identity.new(**attrs)
71
+ @mutex.synchronize do
72
+ raise ArgumentError, "identity already exists" if @identities.key?(identity.idk.b)
73
+
74
+ @identities[identity.idk.b] = identity
75
+ end
76
+ identity.dup
77
+ end
78
+
79
+ # (see SQRL::Server::IdentityStore#update)
80
+ def update(idk, **attrs)
81
+ @mutex.synchronize do
82
+ identity = @identities[idk.b]
83
+ return nil unless identity
84
+
85
+ attrs.each { |k, v| identity[k] = v }
86
+ identity.dup
87
+ end
88
+ end
89
+
90
+ # (see SQRL::Server::IdentityStore#rekey)
91
+ def rekey(old_idk, new_idk, suk:, vuk:, ins:)
92
+ @mutex.synchronize do
93
+ identity = @identities.delete(old_idk.b)
94
+ return nil unless identity
95
+
96
+ identity.idk = new_idk.b
97
+ identity.suk = suk
98
+ identity.vuk = vuk
99
+ identity.ins = ins
100
+ @identities[new_idk.b] = identity
101
+ @superseded[old_idk.b] = new_idk.b
102
+ identity.dup
103
+ end
104
+ end
105
+
106
+ def superseded?(idk)
107
+ @mutex.synchronize { @superseded.key?(idk.b) }
108
+ end
109
+
110
+ # (see SQRL::Server::IdentityStore#remove)
111
+ def remove(idk)
112
+ @mutex.synchronize { @identities.delete(idk.b) }
113
+ end
114
+
115
+ # @return [Integer] number of active identities
116
+ def count
117
+ @mutex.synchronize { @identities.size }
118
+ end
119
+
120
+ # @return [Array<Identity>] every active identity
121
+ def all
122
+ @mutex.synchronize { @identities.values.map(&:dup) }
123
+ end
124
+ end
125
+
126
+ # @see Server::PendingLoginStore
127
+ class PendingLoginStore < Server::PendingLoginStore
128
+ def initialize
129
+ super
130
+ @mutex = Mutex.new
131
+ @logins = {}
132
+ end
133
+
134
+ # (see SQRL::Server::IdentityStore#create)
135
+ def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
136
+ login = PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
137
+ link_user_ref: link_user_ref, created_at: created_at)
138
+ @mutex.synchronize { @logins[id] = login }
139
+ login.dup
140
+ end
141
+
142
+ # (see SQRL::Server::IdentityStore#find)
143
+ def find(id)
144
+ @mutex.synchronize { @logins[id]&.dup }
145
+ end
146
+
147
+ # (see SQRL::Server::IdentityStore#update)
148
+ def update(id, **attrs)
149
+ @mutex.synchronize do
150
+ login = @logins[id]
151
+ return nil unless login
152
+
153
+ attrs.each { |k, v| login[k] = v }
154
+ login.dup
155
+ end
156
+ end
157
+
158
+ # (see SQRL::Server::PendingLoginStore#claim_cps_token)
159
+ def claim_cps_token(token, now: Time.now)
160
+ return nil if token.nil? || token.empty?
161
+
162
+ @mutex.synchronize do
163
+ login = @logins.values.find { |l| l.cps_token && Util.secure_compare(l.cps_token, token) }
164
+ return nil unless login
165
+
166
+ expired = login.cps_token_expires_at && login.cps_token_expires_at <= now
167
+ login.cps_token = nil
168
+ login.cps_token_expires_at = nil
169
+ expired ? nil : login.dup
170
+ end
171
+ end
172
+
173
+ # Remove expired records.
174
+ # @return [Integer] number removed
175
+ def prune!(now = Time.now)
176
+ @mutex.synchronize { @logins.delete_if { |_, l| l.expired?(now) } }
177
+ nil
178
+ end
179
+
180
+ # @return [Integer] number of records held
181
+ def size
182
+ @mutex.synchronize { @logins.size }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SQRL
6
+ module Server
7
+ # Redis-backed store implementations for sites that run several processes without a
8
+ # relational database (Sinatra, Roda, bare Rack, ...).
9
+ #
10
+ # require "sqrl/server/redis"
11
+ # stores = SQRL::Server::Redis.stores(redis: ::Redis.new(url: ENV["REDIS_URL"]))
12
+ #
13
+ # Needs the +redis+ gem (not a dependency of sqrl-core; add it to your Gemfile) and a
14
+ # Redis server of version 2.6 or later (Lua scripting). Atomicity:
15
+ #
16
+ # * a nut is consumed with +GETDEL+ (Redis 6.2+) or an equivalent Lua script, so
17
+ # exactly one of any number of concurrent consumers wins;
18
+ # * a CPS token is claimed by a Lua script that checks the token index against the
19
+ # pending login and clears both in one step;
20
+ # * a re-key renames the identity hash and records the superseded key in one script.
21
+ #
22
+ # Nuts and pending logins carry Redis TTLs derived from their +expires_at+, so they
23
+ # vanish on their own; +prune!+ still exists for records that were given no expiry.
24
+ # Binary values (identity keys, lock keys) are stored base64url-encoded. All keys live
25
+ # under +namespace+ (default "sqrl"), separated by colons; the keys a single script
26
+ # touches share that prefix, which Redis Cluster users should turn into a hash tag
27
+ # (for example "{sqrl}").
28
+ module Redis
29
+ # @param redis [::Redis] a connected client from the +redis+ gem (or any object
30
+ # speaking the same method API)
31
+ # @param namespace [String] key prefix
32
+ # @param default_ttl [Integer] seconds to keep records that were given no expiry
33
+ # @return [Stores]
34
+ def self.stores(redis:, namespace: "sqrl", default_ttl: 24 * 3600)
35
+ Stores.new(nuts: NutStore.new(redis, namespace: namespace, default_ttl: default_ttl),
36
+ identities: IdentityStore.new(redis, namespace: namespace),
37
+ pending_logins: PendingLoginStore.new(redis, namespace: namespace, default_ttl: default_ttl))
38
+ end
39
+
40
+ # Connect with the +redis+ gem and build the stores.
41
+ #
42
+ # @param url [String] a redis:// URL
43
+ def self.connect(url = ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"), **options)
44
+ begin
45
+ require "redis"
46
+ rescue LoadError
47
+ raise SQRL::Error, "SQRL::Server::Redis needs the redis gem: add `gem \"redis\"` to your Gemfile"
48
+ end
49
+ stores(redis: ::Redis.new(url: url), **options)
50
+ end
51
+
52
+ # Lua scripts. The first line of each is a tag the in-process test double uses to
53
+ # dispatch; Redis ignores it as a comment.
54
+ module Scripts
55
+ # Fallback for +GETDEL+ on Redis servers older than 6.2.
56
+ GETDEL = <<~LUA
57
+ -- sqrl:getdel
58
+ local v = redis.call('GET', KEYS[1])
59
+ if v then redis.call('DEL', KEYS[1]) end
60
+ return v
61
+ LUA
62
+
63
+ # Create an identity hash only when the key does not exist yet.
64
+ CREATE_IDENTITY = <<~LUA
65
+ -- sqrl:create
66
+ if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
67
+ redis.call('HSET', KEYS[1], unpack(ARGV))
68
+ return 1
69
+ LUA
70
+
71
+ # Delete an identity hash and its superseded marker.
72
+ REMOVE_IDENTITY = <<~LUA
73
+ -- sqrl:remove
74
+ local v = redis.call('HGETALL', KEYS[1])
75
+ if #v == 0 then return false end
76
+ redis.call('DEL', KEYS[1])
77
+ return v
78
+ LUA
79
+
80
+ # KEYS: old hash, new hash, superseded set. ARGV: new idk, suk, vuk, ins ("" = none), old idk.
81
+ REKEY = <<~LUA
82
+ -- sqrl:rekey
83
+ if redis.call('EXISTS', KEYS[1]) == 0 then return false end
84
+ redis.call('RENAME', KEYS[1], KEYS[2])
85
+ redis.call('HSET', KEYS[2], 'idk', ARGV[1], 'suk', ARGV[2], 'vuk', ARGV[3])
86
+ if ARGV[4] == '' then
87
+ redis.call('HDEL', KEYS[2], 'ins')
88
+ else
89
+ redis.call('HSET', KEYS[2], 'ins', ARGV[4])
90
+ end
91
+ redis.call('SADD', KEYS[3], ARGV[5])
92
+ return redis.call('HGETALL', KEYS[2])
93
+ LUA
94
+
95
+ # KEYS: token index key. ARGV: pending-login key prefix, token.
96
+ # Returns [cps_token_expires_at or "", login id, field, value, field, value, ...].
97
+ CLAIM = <<~LUA
98
+ -- sqrl:claim
99
+ local id = redis.call('GET', KEYS[1])
100
+ if not id then return false end
101
+ redis.call('DEL', KEYS[1])
102
+ local pl = ARGV[1] .. id
103
+ local tok = redis.call('HGET', pl, 'cps_token')
104
+ if tok ~= ARGV[2] then return false end
105
+ local exp = redis.call('HGET', pl, 'cps_token_expires_at') or ''
106
+ redis.call('HDEL', pl, 'cps_token', 'cps_token_expires_at')
107
+ local data = redis.call('HGETALL', pl)
108
+ table.insert(data, 1, id)
109
+ table.insert(data, 1, exp)
110
+ return data
111
+ LUA
112
+ end
113
+
114
+ # Encoding helpers shared by the three stores.
115
+ module Codec
116
+ module_function
117
+
118
+ # @return [String, nil] a Time encoded as epoch seconds
119
+ def time_out(time) = time.nil? ? nil : format("%.6f", time.to_f)
120
+ # @return [Time, nil] a Time decoded from epoch seconds
121
+ def time_in(str) = str.nil? || str.empty? ? nil : Time.at(Float(str))
122
+ # @return [String] a boolean encoded for Redis
123
+ def bool_out(value) = value ? "1" : "0"
124
+ # @return [Boolean] a boolean decoded from Redis
125
+ def bool_in(str) = str == "1"
126
+ # @return [String, nil] binary bytes encoded for Redis (base64url)
127
+ def bin_out(bytes) = bytes.nil? ? nil : Util.b64u_encode(bytes)
128
+ # @return [String, nil] binary bytes decoded from Redis
129
+ def bin_in(str) = str.nil? || str.empty? ? nil : Util.b64u_decode(str)
130
+
131
+ # Milliseconds until +time+, at least 1 (Redis rejects non-positive TTLs).
132
+ def ttl_ms(time, default_seconds)
133
+ return default_seconds * 1000 if time.nil?
134
+
135
+ [((time.to_f - Time.now.to_f) * 1000).ceil, 1].max
136
+ end
137
+
138
+ # @return [Hash] a flat Redis field/value list as a Hash
139
+ def pairs_to_hash(flat)
140
+ return nil if flat.nil? || flat == false
141
+ return flat if flat.is_a?(Hash)
142
+
143
+ Hash[*flat]
144
+ end
145
+ end
146
+
147
+ # Common plumbing.
148
+ class Base
149
+ def initialize(redis, namespace:)
150
+ @redis = redis
151
+ @ns = namespace
152
+ end
153
+
154
+ private
155
+
156
+ def key(kind, id) = "#{@ns}:#{kind}:#{id}"
157
+
158
+ def each_key(kind, &)
159
+ @redis.scan_each(match: "#{@ns}:#{kind}:*", &)
160
+ end
161
+ end
162
+
163
+ # @see Server::NutStore
164
+ class NutStore < Base
165
+ def initialize(redis, namespace:, default_ttl:)
166
+ super(redis, namespace: namespace)
167
+ @default_ttl = default_ttl
168
+ end
169
+
170
+ # (see SQRL::Server::NutStore#issue)
171
+ def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
172
+ payload = JSON.generate("pl" => pending_login_id, "srv" => expected_server, "ip" => ip,
173
+ "idk" => Codec.bin_out(bound_idk), "exp" => Codec.time_out(expires_at))
174
+ @redis.set(key(:nut, nut), payload, px: Codec.ttl_ms(expires_at, @default_ttl))
175
+ NutRecord.new(nut: nut, pending_login_id: pending_login_id, expected_server: expected_server, ip: ip,
176
+ bound_idk: bound_idk, expires_at: expires_at, used_at: nil)
177
+ end
178
+
179
+ # (see SQRL::Server::NutStore#consume)
180
+ def consume(nut, now: Time.now)
181
+ return nil if nut.nil? || nut.empty?
182
+
183
+ payload = getdel(key(:nut, nut))
184
+ return nil unless payload
185
+
186
+ data = JSON.parse(payload)
187
+ record = NutRecord.new(nut: nut, pending_login_id: data["pl"], expected_server: data["srv"], ip: data["ip"],
188
+ bound_idk: Codec.bin_in(data["idk"]), expires_at: Codec.time_in(data["exp"]),
189
+ used_at: now)
190
+ record.expired?(now) ? nil : record
191
+ end
192
+
193
+ # Used nuts are deleted when consumed and expiry is enforced by Redis TTLs; this
194
+ # only removes records whose stored +expires_at+ has passed.
195
+ def prune!(now = Time.now)
196
+ each_key(:nut) do |k|
197
+ payload = @redis.get(k)
198
+ next unless payload
199
+
200
+ exp = Codec.time_in(JSON.parse(payload)["exp"])
201
+ @redis.del(k) if exp && exp <= now
202
+ end
203
+ nil
204
+ end
205
+
206
+ private
207
+
208
+ def getdel(k)
209
+ @redis.getdel(k)
210
+ rescue StandardError => e
211
+ raise unless e.class.name.to_s.include?("CommandError")
212
+
213
+ # Redis < 6.2: emulate GETDEL atomically with a script.
214
+ @redis.eval(Scripts::GETDEL, keys: [k])
215
+ end
216
+ end
217
+
218
+ # @see Server::IdentityStore
219
+ class IdentityStore < Base
220
+ # Hash fields of an identity record.
221
+ FIELDS = %i[idk suk vuk disabled sqrlonly hardlock ins logins user_ref].freeze
222
+
223
+ # (see SQRL::Server::IdentityStore#find)
224
+ def find(idk)
225
+ to_identity(@redis.hgetall(key(:id, Util.b64u_encode(idk))))
226
+ end
227
+
228
+ def create(**attrs)
229
+ identity = Identity.new(**attrs)
230
+ fields = encode(identity.to_h).compact.flatten
231
+ created = @redis.eval(Scripts::CREATE_IDENTITY, keys: [key(:id, identity.idk_b64)], argv: fields)
232
+ raise ArgumentError, "identity already exists" unless created.to_i == 1
233
+
234
+ identity.dup
235
+ end
236
+
237
+ # (see SQRL::Server::IdentityStore#update)
238
+ def update(idk, **attrs)
239
+ k = key(:id, Util.b64u_encode(idk))
240
+ return nil unless @redis.exists?(k)
241
+
242
+ encoded = encode(attrs)
243
+ removals = encoded.select { |_, v| v.nil? }.keys
244
+ writes = encoded.compact
245
+ @redis.hdel(k, *removals) unless removals.empty?
246
+ @redis.hset(k, writes) unless writes.empty?
247
+ find(idk)
248
+ end
249
+
250
+ # (see SQRL::Server::IdentityStore#rekey)
251
+ def rekey(old_idk, new_idk, suk:, vuk:, ins:)
252
+ old_b64 = Util.b64u_encode(old_idk)
253
+ new_b64 = Util.b64u_encode(new_idk)
254
+ flat = @redis.eval(Scripts::REKEY,
255
+ keys: [key(:id, old_b64), key(:id, new_b64), "#{@ns}:superseded"],
256
+ argv: [new_b64, Util.b64u_encode(suk), Util.b64u_encode(vuk), ins.to_s, old_b64])
257
+ to_identity(Codec.pairs_to_hash(flat))
258
+ end
259
+
260
+ def superseded?(idk)
261
+ @redis.sismember("#{@ns}:superseded", Util.b64u_encode(idk)) ? true : false
262
+ end
263
+
264
+ # (see SQRL::Server::IdentityStore#remove)
265
+ def remove(idk)
266
+ flat = @redis.eval(Scripts::REMOVE_IDENTITY, keys: [key(:id, Util.b64u_encode(idk))])
267
+ to_identity(Codec.pairs_to_hash(flat))
268
+ end
269
+
270
+ private
271
+
272
+ def encode(attrs)
273
+ attrs.to_h do |k, v|
274
+ k = k.to_sym
275
+ raise ArgumentError, "unknown identity attribute #{k}" unless FIELDS.include?(k)
276
+
277
+ value =
278
+ case k
279
+ when :idk, :suk, :vuk then Codec.bin_out(v)
280
+ when :disabled, :sqrlonly, :hardlock then Codec.bool_out(v)
281
+ when :logins then v.nil? ? nil : v.to_i.to_s
282
+ else v&.to_s
283
+ end
284
+ [k.to_s, value]
285
+ end
286
+ end
287
+
288
+ def to_identity(hash)
289
+ return nil if hash.nil? || hash.empty?
290
+
291
+ Identity.new(idk: Codec.bin_in(hash["idk"]), suk: Codec.bin_in(hash["suk"]), vuk: Codec.bin_in(hash["vuk"]),
292
+ disabled: Codec.bool_in(hash["disabled"]), sqrlonly: Codec.bool_in(hash["sqrlonly"]),
293
+ hardlock: Codec.bool_in(hash["hardlock"]), ins: hash["ins"], logins: hash["logins"].to_i,
294
+ user_ref: hash["user_ref"])
295
+ end
296
+ end
297
+
298
+ # @see Server::PendingLoginStore
299
+ class PendingLoginStore < Base
300
+ # Hash fields of a pending-login record.
301
+ FIELDS = %i[authenticated_idk authenticated_at btn asked last_command cps_token cps_token_expires_at
302
+ ip return_to link_user_ref created_at expires_at ip_matched].freeze
303
+
304
+ def initialize(redis, namespace:, default_ttl:)
305
+ super(redis, namespace: namespace)
306
+ @default_ttl = default_ttl
307
+ end
308
+
309
+ # (see SQRL::Server::PendingLoginStore#create)
310
+ def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
311
+ login = PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
312
+ link_user_ref: link_user_ref, created_at: created_at)
313
+ k = key(:pl, id)
314
+ @redis.del(k)
315
+ @redis.hset(k, encode(login.to_h.except(:id)).compact)
316
+ @redis.pexpire(k, Codec.ttl_ms(expires_at, @default_ttl))
317
+ login.dup
318
+ end
319
+
320
+ # (see SQRL::Server::IdentityStore#find)
321
+ def find(id)
322
+ return nil if id.nil?
323
+
324
+ to_login(id, @redis.hgetall(key(:pl, id)))
325
+ end
326
+
327
+ # (see SQRL::Server::IdentityStore#update)
328
+ def update(id, **attrs)
329
+ k = key(:pl, id)
330
+ return nil unless @redis.exists?(k)
331
+
332
+ reindex_token(id, k, attrs) if attrs.key?(:cps_token)
333
+ encoded = encode(attrs)
334
+ removals = encoded.select { |_, v| v.nil? }.keys
335
+ writes = encoded.compact
336
+ @redis.hdel(k, *removals) unless removals.empty?
337
+ @redis.hset(k, writes) unless writes.empty?
338
+ @redis.pexpire(k, Codec.ttl_ms(attrs[:expires_at], @default_ttl)) if attrs.key?(:expires_at)
339
+ find(id)
340
+ end
341
+
342
+ # (see SQRL::Server::PendingLoginStore#claim_cps_token)
343
+ def claim_cps_token(token, now: Time.now)
344
+ return nil if token.nil? || token.empty?
345
+
346
+ flat = @redis.eval(Scripts::CLAIM, keys: [key(:cps, token)], argv: ["#{@ns}:pl:", token])
347
+ return nil unless flat.is_a?(Array) && !flat.empty?
348
+
349
+ expires_at = Codec.time_in(flat[0])
350
+ id = flat[1]
351
+ hash = Codec.pairs_to_hash(flat.drop(2))
352
+ return nil if expires_at && expires_at <= now
353
+
354
+ to_login(id, hash)
355
+ end
356
+
357
+ # Remove expired records (Redis TTLs do most of this already).
358
+ # @return [Integer] number removed
359
+ def prune!(now = Time.now)
360
+ each_key(:pl) do |k|
361
+ exp = Codec.time_in(@redis.hget(k, "expires_at"))
362
+ @redis.del(k) if exp && exp <= now
363
+ end
364
+ nil
365
+ end
366
+
367
+ private
368
+
369
+ # Keep the token → login index in step with the +cps_token+ field.
370
+ def reindex_token(id, k, attrs)
371
+ old = @redis.hget(k, "cps_token")
372
+ @redis.del(key(:cps, old)) if old && !old.empty?
373
+ new = attrs[:cps_token]
374
+ return if new.nil? || new.empty?
375
+
376
+ expires_at = if attrs.key?(:cps_token_expires_at)
377
+ attrs[:cps_token_expires_at]
378
+ else
379
+ Codec.time_in(@redis.hget(k,
380
+ "cps_token_expires_at"))
381
+ end
382
+ # The index outlives the token's own expiry by a minute so that a late claim still
383
+ # finds the login and clears the stale token (the expiry itself is enforced by
384
+ # value in claim_cps_token).
385
+ @redis.set(key(:cps, new), id, px: Codec.ttl_ms(expires_at, @default_ttl) + 60_000)
386
+ end
387
+
388
+ def encode(attrs)
389
+ attrs.to_h do |f, v|
390
+ f = f.to_sym
391
+ raise ArgumentError, "unknown pending login attribute #{f}" unless FIELDS.include?(f) || f == :id
392
+
393
+ value =
394
+ case f
395
+ when :authenticated_idk then Codec.bin_out(v)
396
+ when :authenticated_at, :cps_token_expires_at, :created_at, :expires_at then Codec.time_out(v)
397
+ when :asked, :ip_matched then Codec.bool_out(v)
398
+ when :btn then v&.to_i&.to_s
399
+ else v&.to_s
400
+ end
401
+ [f.to_s, value]
402
+ end
403
+ end
404
+
405
+ def to_login(id, hash)
406
+ return nil if hash.nil? || hash.empty?
407
+
408
+ PendingLogin.new(id: id, ip: hash["ip"], return_to: hash["return_to"], link_user_ref: hash["link_user_ref"],
409
+ created_at: Codec.time_in(hash["created_at"]), expires_at: Codec.time_in(hash["expires_at"]),
410
+ authenticated_idk: Codec.bin_in(hash["authenticated_idk"]),
411
+ authenticated_at: Codec.time_in(hash["authenticated_at"]),
412
+ btn: hash["btn"]&.to_i, asked: Codec.bool_in(hash["asked"]),
413
+ last_command: hash["last_command"]&.to_sym, cps_token: hash["cps_token"],
414
+ cps_token_expires_at: Codec.time_in(hash["cps_token_expires_at"]),
415
+ ip_matched: Codec.bool_in(hash["ip_matched"]))
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end