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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Rails
5
+ # Store implementations shipped with the Rails integration.
6
+ module Stores
7
+ # The three {SQRL::Server} store interfaces on an +ActiveSupport::Cache::Store+, for
8
+ # applications that keep short-lived login state in Redis or Memcached rather than
9
+ # in the database:
10
+ #
11
+ # config.sqrl.stores = :cache # uses Rails.cache
12
+ # config.sqrl.cache_store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
13
+ #
14
+ # Single use is implemented as read-then-+delete+: every supported store deletes a
15
+ # key atomically and reports whether it existed, so concurrent consumers of one nut
16
+ # or one CPS token see exactly one winner. Nuts and pending logins expire through
17
+ # the cache's own TTL, so +prune!+ has nothing to do.
18
+ #
19
+ # Identities are durable account data and are kept here too (they are what a
20
+ # cache store makes you lose on eviction), so this set is meant for sites whose
21
+ # cache is persistent (Redis with persistence) or that pair it with their own
22
+ # identity store:
23
+ #
24
+ # stores = SQRL::Rails::Stores::Cache.stores(Rails.cache)
25
+ # stores.identities = SQRL::Rails::Stores::ActiveRecord::IdentityStore.new
26
+ # config.sqrl.stores = stores
27
+ #
28
+ # +FileStore+, +MemoryStore+ and +NullStore+ cannot share state between processes
29
+ # or delete atomically across them, so they are refused in production unless
30
+ # +allow_unsafe: true+ is passed.
31
+ module Cache
32
+ # Cache store classes without an atomic delete; refused in production.
33
+ UNSAFE_STORES = %w[ActiveSupport::Cache::FileStore ActiveSupport::Cache::MemoryStore
34
+ ActiveSupport::Cache::NullStore].freeze
35
+
36
+ # @param cache [ActiveSupport::Cache::Store]
37
+ # @param namespace [String] key prefix
38
+ # @param identity_ttl [Integer, nil] seconds identities live (nil: never expire)
39
+ # @param allow_unsafe [Boolean] accept process-local stores in production
40
+ # @return [SQRL::Server::Stores]
41
+ def self.stores(cache = ::Rails.cache, namespace: "sqrl", identity_ttl: nil, allow_unsafe: false)
42
+ check!(cache, allow_unsafe: allow_unsafe)
43
+ SQRL::Server::Stores.new(nuts: NutStore.new(cache, namespace: namespace),
44
+ identities: IdentityStore.new(cache, namespace: namespace, ttl: identity_ttl),
45
+ pending_logins: PendingLoginStore.new(cache, namespace: namespace))
46
+ end
47
+
48
+ def self.check!(cache, allow_unsafe: false)
49
+ raise Error, "config.sqrl.cache_store must be an ActiveSupport::Cache::Store" unless cache.respond_to?(:read) && cache.respond_to?(:delete)
50
+ return if allow_unsafe || !defined?(::Rails) || !::Rails.respond_to?(:env) || !::Rails.env.production?
51
+ return unless UNSAFE_STORES.include?(cache.class.name)
52
+
53
+ raise Error, "#{cache.class.name} is process-local and cannot back SQRL stores in production; " \
54
+ "use a Redis or Memcached cache store (or pass allow_unsafe: true)"
55
+ end
56
+
57
+ # Serialization shared by the stores: binary fields are base64url strings, times
58
+ # are integers, so any cache serializer (Marshal, JSON, MessagePack) works.
59
+ module Codec
60
+ module_function
61
+
62
+ # @return [String, nil] binary bytes as base64url
63
+ def bin(bytes) = bytes.nil? ? nil : SQRL::Util.b64u_encode(bytes.b)
64
+ # @return [String, nil] binary bytes from base64url
65
+ def unbin(text) = text.nil? ? nil : SQRL::Util.b64u_decode(text)
66
+ # @return [Integer, nil] a Time as epoch seconds
67
+ def time(value) = value&.to_i
68
+ # @return [Time, nil] a Time from epoch seconds
69
+ def untime(value) = value.nil? ? nil : Time.at(value.to_i)
70
+ end
71
+
72
+ # Common plumbing.
73
+ class Base
74
+ def initialize(cache, namespace:)
75
+ @cache = cache
76
+ @namespace = namespace
77
+ end
78
+
79
+ private
80
+
81
+ def key(*parts)
82
+ ([@namespace] + parts).join(":")
83
+ end
84
+
85
+ # Seconds until +expires_at+ (at least 1), or nil.
86
+ def ttl_until(expires_at, now)
87
+ return nil if expires_at.nil?
88
+
89
+ [(expires_at.to_i - now.to_i), 1].max
90
+ end
91
+ end
92
+
93
+ # @see SQRL::Server::NutStore
94
+ class NutStore < Base
95
+ # Issue on the cache store; same contract as +SQRL::Server::NutStore#issue+.
96
+ def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
97
+ record = SQRL::Server::NutRecord.new(nut: nut, pending_login_id: pending_login_id,
98
+ expected_server: expected_server, ip: ip, bound_idk: bound_idk,
99
+ expires_at: expires_at, used_at: nil)
100
+ @cache.write(key("nut", nut), dump(record), expires_in: ttl_until(expires_at, Time.now))
101
+ record
102
+ end
103
+
104
+ # Consume on the cache store; same contract as +SQRL::Server::NutStore#consume+.
105
+ def consume(nut, now: Time.now)
106
+ return nil if nut.nil? || nut.empty?
107
+
108
+ data = @cache.read(key("nut", nut))
109
+ return nil unless data
110
+ # Deleting is the single-use guarantee: only one caller gets +true+.
111
+ return nil unless @cache.delete(key("nut", nut))
112
+
113
+ record = load(data)
114
+ return nil if record.expires_at && record.expires_at <= now
115
+
116
+ record.used_at = now
117
+ record
118
+ end
119
+
120
+ # Entries expire through the cache TTL.
121
+ def prune!(_now = Time.now)
122
+ nil
123
+ end
124
+
125
+ private
126
+
127
+ def dump(r)
128
+ { "nut" => r.nut, "pending_login_id" => r.pending_login_id, "expected_server" => r.expected_server,
129
+ "ip" => r.ip, "bound_idk" => Codec.bin(r.bound_idk), "expires_at" => Codec.time(r.expires_at) }
130
+ end
131
+
132
+ def load(h)
133
+ SQRL::Server::NutRecord.new(nut: h["nut"], pending_login_id: h["pending_login_id"],
134
+ expected_server: h["expected_server"], ip: h["ip"],
135
+ bound_idk: Codec.unbin(h["bound_idk"]),
136
+ expires_at: Codec.untime(h["expires_at"]), used_at: nil)
137
+ end
138
+ end
139
+
140
+ # @see SQRL::Server::IdentityStore
141
+ class IdentityStore < Base
142
+ def initialize(cache, namespace:, ttl: nil)
143
+ super(cache, namespace: namespace)
144
+ @ttl = ttl
145
+ end
146
+
147
+ # Find on the cache store; same contract as +SQRL::Server::IdentityStore#find+.
148
+ def find(idk)
149
+ data = @cache.read(key("id", Codec.bin(idk)))
150
+ data && load(data)
151
+ end
152
+
153
+ def create(**attrs)
154
+ identity = SQRL::Server::Identity.new(**attrs)
155
+ k = key("id", Codec.bin(identity.idk))
156
+ raise ArgumentError, "identity already exists" if @cache.exist?(k)
157
+
158
+ write(identity)
159
+ identity
160
+ end
161
+
162
+ # Update on the cache store; same contract as +SQRL::Server::IdentityStore#update+.
163
+ def update(idk, **attrs)
164
+ identity = find(idk)
165
+ return nil unless identity
166
+
167
+ attrs.each { |name, value| identity[name] = value }
168
+ write(identity)
169
+ identity
170
+ end
171
+
172
+ # Rekey on the cache store; same contract as +SQRL::Server::IdentityStore#rekey+.
173
+ def rekey(old_idk, new_idk, suk:, vuk:, ins:)
174
+ identity = find(old_idk)
175
+ return nil unless identity
176
+
177
+ identity.idk = new_idk
178
+ identity.suk = suk
179
+ identity.vuk = vuk
180
+ identity.ins = ins
181
+ write(identity)
182
+ @cache.delete(key("id", Codec.bin(old_idk)))
183
+ @cache.write(key("superseded", Codec.bin(old_idk)), Codec.bin(new_idk), expires_in: @ttl)
184
+ identity
185
+ end
186
+
187
+ def superseded?(idk)
188
+ @cache.exist?(key("superseded", Codec.bin(idk)))
189
+ end
190
+
191
+ # Remove on the cache store; same contract as +SQRL::Server::IdentityStore#remove+.
192
+ def remove(idk)
193
+ identity = find(idk)
194
+ return nil unless identity
195
+
196
+ @cache.delete(key("id", Codec.bin(idk)))
197
+ identity
198
+ end
199
+
200
+ private
201
+
202
+ def write(identity)
203
+ @cache.write(key("id", Codec.bin(identity.idk)), dump(identity), expires_in: @ttl)
204
+ end
205
+
206
+ def dump(i)
207
+ { "idk" => Codec.bin(i.idk), "suk" => Codec.bin(i.suk), "vuk" => Codec.bin(i.vuk),
208
+ "disabled" => i.disabled == true, "sqrlonly" => i.sqrlonly == true, "hardlock" => i.hardlock == true,
209
+ "ins" => i.ins, "logins" => i.logins.to_i, "user_ref" => i.user_ref }
210
+ end
211
+
212
+ def load(h)
213
+ SQRL::Server::Identity.new(idk: Codec.unbin(h["idk"]), suk: Codec.unbin(h["suk"]), vuk: Codec.unbin(h["vuk"]),
214
+ disabled: h["disabled"], sqrlonly: h["sqrlonly"], hardlock: h["hardlock"],
215
+ ins: h["ins"], logins: h["logins"].to_i, user_ref: h["user_ref"])
216
+ end
217
+ end
218
+
219
+ # @see SQRL::Server::PendingLoginStore
220
+ class PendingLoginStore < Base
221
+ # Create on the cache store; same contract as +SQRL::Server::PendingLoginStore#create+.
222
+ def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
223
+ login = SQRL::Server::PendingLogin.new(id: id, ip: ip, return_to: return_to, expires_at: expires_at,
224
+ link_user_ref: link_user_ref, created_at: created_at)
225
+ write(login)
226
+ login
227
+ end
228
+
229
+ # Find on the cache store; same contract as +SQRL::Server::IdentityStore#find+.
230
+ def find(id)
231
+ return nil if id.nil?
232
+
233
+ data = @cache.read(key("login", id))
234
+ return nil unless data
235
+
236
+ login = load(data)
237
+ # The cache TTL has a one-second floor; honour the exact expiry ourselves.
238
+ login.expires_at && login.expires_at <= Time.now ? nil : login
239
+ end
240
+
241
+ # Update on the cache store; same contract as +SQRL::Server::IdentityStore#update+.
242
+ def update(id, **attrs)
243
+ login = find(id)
244
+ return nil unless login
245
+
246
+ old_token = login.cps_token
247
+ attrs.each { |name, value| login[name] = value }
248
+ write(login)
249
+ if attrs.key?(:cps_token)
250
+ @cache.delete(key("cps", old_token)) if old_token && old_token != login.cps_token
251
+ if login.cps_token
252
+ @cache.write(key("cps", login.cps_token), id, expires_in: ttl_until(login.cps_token_expires_at, Time.now))
253
+ end
254
+ end
255
+ login
256
+ end
257
+
258
+ # Claim cps token on the cache store; same contract as +SQRL::Server::PendingLoginStore#claim_cps_token+.
259
+ def claim_cps_token(token, now: Time.now)
260
+ return nil if token.nil? || token.empty?
261
+
262
+ id = @cache.read(key("cps", token))
263
+ return nil unless id
264
+ # The index entry is the single-use guard.
265
+ return nil unless @cache.delete(key("cps", token))
266
+
267
+ login = find(id)
268
+ return nil unless login && login.cps_token == token
269
+
270
+ expired = login.cps_token_expires_at && login.cps_token_expires_at <= now
271
+ login.cps_token = nil
272
+ login.cps_token_expires_at = nil
273
+ write(login)
274
+ expired ? nil : login
275
+ end
276
+
277
+ # Entries expire through the cache TTL.
278
+ def prune!(_now = Time.now)
279
+ nil
280
+ end
281
+
282
+ private
283
+
284
+ def write(login)
285
+ @cache.write(key("login", login.id), dump(login), expires_in: ttl_until(login.expires_at, Time.now))
286
+ end
287
+
288
+ def dump(l)
289
+ { "id" => l.id, "authenticated_idk" => Codec.bin(l.authenticated_idk),
290
+ "authenticated_at" => Codec.time(l.authenticated_at), "btn" => l.btn, "asked" => l.asked == true,
291
+ "last_command" => l.last_command&.to_s, "cps_token" => l.cps_token,
292
+ "cps_token_expires_at" => Codec.time(l.cps_token_expires_at), "ip" => l.ip, "return_to" => l.return_to,
293
+ "link_user_ref" => l.link_user_ref, "created_at" => Codec.time(l.created_at),
294
+ "expires_at" => Codec.time(l.expires_at), "ip_matched" => l.ip_matched == true }
295
+ end
296
+
297
+ def load(h)
298
+ SQRL::Server::PendingLogin.new(id: h["id"], authenticated_idk: Codec.unbin(h["authenticated_idk"]),
299
+ authenticated_at: Codec.untime(h["authenticated_at"]), btn: h["btn"],
300
+ asked: h["asked"], last_command: h["last_command"]&.to_sym,
301
+ cps_token: h["cps_token"],
302
+ cps_token_expires_at: Codec.untime(h["cps_token_expires_at"]),
303
+ ip: h["ip"], return_to: h["return_to"], link_user_ref: h["link_user_ref"],
304
+ created_at: Codec.untime(h["created_at"]),
305
+ expires_at: Codec.untime(h["expires_at"]), ip_matched: h["ip_matched"])
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SQRL
6
+ module Rails
7
+ # Default +create_user+: builds a user record for a brand-new SQRL identity using
8
+ # only the columns the model has. SQRL carries no profile data, so placeholders are
9
+ # generated: a random password when the model has +password_digest+ (so
10
+ # +has_secure_password+ validations pass) and a non-routable email when it has an
11
+ # +email+ or +email_address+ column. Applications that need real data set
12
+ # +config.sqrl.create_user+ or use +on_unknown_identity = :link+.
13
+ module UserFactory
14
+ module_function
15
+
16
+ # @param identity [SQRL::Server::Identity]
17
+ # @param model [Class] the user model
18
+ # @return [Object] the saved user
19
+ def call(identity, model = SQRL::Rails.config.user_model)
20
+ model.create!(attributes_for(identity, model))
21
+ end
22
+
23
+ # @return [Hash] placeholder attributes matching the columns and writers the user model has
24
+ def attributes_for(identity, model)
25
+ columns = model.respond_to?(:column_names) ? model.column_names : []
26
+ handle = identity.idk_b64[0, 16].downcase.tr("_-", "xy")
27
+ attrs = {}
28
+ attrs[:email_address] = "sqrl-#{handle}@sqrl.invalid" if columns.include?("email_address")
29
+ attrs[:email] = "sqrl-#{handle}@sqrl.invalid" if columns.include?("email")
30
+ attrs[:name] = "SQRL user #{handle[0, 8]}" if columns.include?("name")
31
+ attrs[:username] = "sqrl-#{handle}" if columns.include?("username")
32
+ if model.method_defined?(:password=)
33
+ password = SecureRandom.hex(32)
34
+ attrs[:password] = password
35
+ attrs[:password_confirmation] = password if model.method_defined?(:password_confirmation=)
36
+ end
37
+ attrs
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/sqrl/rails.rb ADDED
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "sqrl/core"
5
+ require "sqrl/rack"
6
+
7
+ # SQRL namespace, reopened for the Rails integration.
8
+ module SQRL
9
+ # SQRL login for Rails: an isolated engine serving the SQRL endpoint, status poll,
10
+ # CPS callback and browser script; ActiveRecord-backed stores; a view helper that
11
+ # renders the login block; and a sign-in adapter that plugs into the authentication
12
+ # stack generated by `bin/rails generate authentication` (Rails 8) or into any custom
13
+ # session scheme.
14
+ #
15
+ # # Gemfile
16
+ # gem "sqrl-rails"
17
+ # # then
18
+ # bin/rails generate sqrl:install
19
+ # bin/rails db:migrate
20
+ module Rails
21
+ # Same as SQRL::VERSION; the gems release in lockstep.
22
+ VERSION = SQRL::VERSION
23
+
24
+ # Raised for configuration problems.
25
+ class Error < SQRL::Error; end
26
+
27
+ # Paths of the engine's routes, relative to the mount point.
28
+ ENGINE_PATHS = { cli: "/cli", status: "/status", callback: "/callback", login: "/login",
29
+ script: "/sqrl.js", identities: "/identities", refresh: "/refresh" }.freeze
30
+
31
+ @mutex = Mutex.new
32
+ @host_resolver = nil
33
+
34
+ class << self
35
+ # The application-wide configuration (+Rails.application.config.sqrl+).
36
+ #
37
+ # @return [Configuration]
38
+ def config
39
+ ::Rails.application.config.sqrl
40
+ end
41
+
42
+ # The configured stores, resolved once.
43
+ #
44
+ # @return [Server::Stores]
45
+ def stores
46
+ @stores ||= resolve_stores(config.stores)
47
+ end
48
+
49
+ # Forget memoized handlers, endpoints and stores (tests, configuration changes).
50
+ def reset!
51
+ @mutex.synchronize do
52
+ @host_resolver = nil
53
+ @stores = nil
54
+ @mount_path = nil
55
+ end
56
+ end
57
+
58
+ # The {SQRL::Rack::HostResolver} deciding which host and scheme SQRL URLs are issued
59
+ # for, built from +config.host+, +config.scheme+, +config.allowed_hosts+ and
60
+ # +config.host_cache_limit+. It also bounds the per-host endpoint cache.
61
+ #
62
+ # @return [SQRL::Rack::HostResolver]
63
+ def host_resolver
64
+ @mutex.synchronize do
65
+ scheme = config.scheme.presence || (config.insecure_http ? "qrl" : nil)
66
+ @host_resolver ||= SQRL::Rack::HostResolver.new(host: config.host.presence, scheme: scheme,
67
+ allowed_hosts: config.allowed_hosts,
68
+ limit: config.host_cache_limit,
69
+ logger: config.logger || ::Rails.logger)
70
+ end
71
+ end
72
+
73
+ # Path the engine is mounted at in the host application (+/sqrl+ by default).
74
+ def mount_path
75
+ config.mount_path || (@mount_path ||= detect_mount_path) || "/sqrl"
76
+ end
77
+
78
+ # Public (host-level) path of an engine route, for example +path_for(:status)+.
79
+ def path_for(name, script_name: "")
80
+ "#{script_name}#{mount_path}#{ENGINE_PATHS.fetch(name)}"
81
+ end
82
+
83
+ # The {SQRL::Rack::Endpoint} for a request's host and scheme, built lazily and
84
+ # memoized per [host, scheme] in a bounded cache.
85
+ #
86
+ # @param request [ActionDispatch::Request, Rack::Request]
87
+ # @return [SQRL::Rack::Endpoint]
88
+ # @raise [SQRL::Rack::HostNotAllowed] when the request host may not be used
89
+ def endpoint_for(request)
90
+ resolver = host_resolver
91
+ host, scheme = resolver.resolve(request)
92
+ resolver.fetch([host, scheme]) { build_endpoint(host, scheme) }
93
+ end
94
+
95
+ # The {Server::Handler} for a request (see {endpoint_for}).
96
+ def handler_for(request)
97
+ endpoint_for(request).handler
98
+ end
99
+
100
+ # Whether any active SQRL identity of +user+ asked the site to disable every other
101
+ # (non-SQRL) way of authenticating (+opt=sqrlonly+). Password logins for such a
102
+ # user must be refused; see {PasswordPolicy}.
103
+ #
104
+ # @param user [Object, nil]
105
+ # @return [Boolean]
106
+ def sqrl_only?(user)
107
+ return false if user.nil?
108
+
109
+ Identity.active.for_user(user).where(sqrlonly: true).exists?
110
+ end
111
+
112
+ # Whether any active SQRL identity of +user+ asked the site to disable every other
113
+ # (non-SQRL) way of recovering the account (+opt=hardlock+). Password resets for
114
+ # such a user must be refused; see {PasswordPolicy}.
115
+ #
116
+ # @param user [Object, nil]
117
+ # @return [Boolean]
118
+ def hard_locked?(user)
119
+ return false if user.nil?
120
+
121
+ Identity.active.for_user(user).where(hardlock: true).exists?
122
+ end
123
+
124
+ # Opaque reference stored on SQRL identities to point at the application's user:
125
+ # +"User:42"+.
126
+ def user_ref(user)
127
+ return nil if user.nil?
128
+ return user if user.is_a?(String)
129
+
130
+ type = user.class.respond_to?(:polymorphic_name) ? user.class.polymorphic_name : user.class.name
131
+ "#{type}:#{user.id}"
132
+ end
133
+
134
+ # Resolve a reference made by {user_ref} back to the record, or nil.
135
+ def user_from_ref(ref)
136
+ return nil if ref.blank?
137
+
138
+ type, id = ref.to_s.split(":", 2)
139
+ return nil if type.blank? || id.blank?
140
+
141
+ klass = type.safe_constantize
142
+ return nil unless klass.respond_to?(:find_by)
143
+
144
+ klass.find_by(klass.primary_key => id)
145
+ end
146
+
147
+ # The host application's +Authentication+ concern from `rails generate
148
+ # authentication`, when present.
149
+ #
150
+ # @return [Module, nil]
151
+ def host_authentication_concern
152
+ return nil unless Object.const_defined?(:Authentication)
153
+
154
+ concern = Object.const_get(:Authentication)
155
+ if (concern.is_a?(Module) && concern.method_defined?(:start_new_session_for, false)) ||
156
+ (concern.is_a?(Module) && concern.private_method_defined?(:start_new_session_for, false))
157
+ concern
158
+ end
159
+ rescue NameError
160
+ nil
161
+ end
162
+
163
+ private
164
+
165
+ def resolve_stores(value)
166
+ case value
167
+ when nil, :active_record, "active_record" then Stores::ActiveRecord.stores
168
+ when :memory, "memory" then Server::Memory.stores
169
+ when :cache, "cache" then Stores::Cache.stores(config.cache_store || ::Rails.cache)
170
+ when Server::Stores then value
171
+ else
172
+ raise Error, "config.sqrl.stores must be :active_record, :cache, :memory or an SQRL::Server::Stores"
173
+ end
174
+ end
175
+
176
+ def build_endpoint(host, scheme)
177
+ mount = mount_path
178
+ server_config = Server::Config.new(**config.server_options, host: host, scheme: scheme,
179
+ path: "#{mount}#{ENGINE_PATHS[:cli]}",
180
+ callback_path: "#{mount}#{ENGINE_PATHS[:callback]}")
181
+ handler = Server::Handler.new(config: server_config, stores: stores)
182
+ SQRL::Rack::Endpoint.new(handler: handler, session_key: config.session_key, identity_key: config.identity_key,
183
+ status_path: ENGINE_PATHS[:status], callback_path: ENGINE_PATHS[:callback],
184
+ script_path: ENGINE_PATHS[:script], refresh_path: ENGINE_PATHS[:refresh],
185
+ poll_requires_ip_match: config.poll_requires_ip_match ? true : false,
186
+ ip: config.ip || method(:client_ip))
187
+ end
188
+
189
+ def client_ip(request)
190
+ request.respond_to?(:remote_ip) ? request.remote_ip : request.ip
191
+ end
192
+
193
+ # Find where the host application mounted the engine.
194
+ def detect_mount_path
195
+ ::Rails.application.routes.routes.each do |route|
196
+ app = route.app
197
+ # A mount is wrapped in ActionDispatch::Routing::Mapper::Constraints; plain
198
+ # dispatchers return themselves from #app.
199
+ app = app.app if app.respond_to?(:app) && !app.equal?(app.app)
200
+ next unless app == Engine
201
+
202
+ return route.path.spec.to_s.sub(/\(\.:format\)\z/, "").sub(%r{/\z}, "")
203
+ end
204
+ nil
205
+ rescue StandardError
206
+ nil
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ require_relative "rails/configuration"
213
+ require_relative "rails/stores/active_record"
214
+ require_relative "rails/stores/cache"
215
+ require_relative "rails/user_factory"
216
+ require_relative "rails/sign_in"
217
+ require_relative "rails/controller_helpers"
218
+ require_relative "rails/password_policy"
219
+ require_relative "rails/rate_limited"
220
+ require_relative "rails/engine"
data/lib/sqrl-rails.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sqrl/rails"
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :sqrl do
4
+ desc "Delete expired SQRL nuts and pending logins"
5
+ task prune: :environment do
6
+ stores = SQRL::Rails.stores
7
+ now = Time.current
8
+ stores.nuts.prune!(now)
9
+ stores.pending_logins.prune!(now)
10
+ puts "SQRL: pruned expired nuts and pending logins"
11
+ end
12
+ end