omniauth-sqrl 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 927b439fc77276b449c3e35936a4ce01c14bed47a469b9144bf6555d97ee0bd9
4
+ data.tar.gz: 843d5fcf4c202557ede06d6da88bd86c3f22d636943fcc0d0444e3590687439d
5
+ SHA512:
6
+ metadata.gz: 683e5faf1c3b49ce5547445431d1719d8f4073959feb1a2d935457edb24db64c6cce732142fdc1550ef6032fae8286b8639b71c143a55ff6769531968dd6affe
7
+ data.tar.gz: 696a7d90157eaf5397d452e81b8d6094074f0dece1c1ebd36f350ce4a48964a84ab00b126d23455519893af85e4495601d44c026d3dee34b873617cd7fe1615b
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-20
4
+
5
+ First release.
6
+
7
+ - `provider :sqrl`: request phase renders the login page; the strategy serves the SQRL
8
+ endpoint, status poll, script and refresh routes itself; the callback builds an auth hash
9
+ keyed by the identity key with `extra["sqrl"]` facts (created, re-keyed from, options,
10
+ disabled, via).
11
+ - Failure keys `invalid_credentials` and `sqrl_disabled`; `link_user_ref` for account
12
+ linking; `info:` callable; test mode supported.
13
+ - Host handling (`allowed_hosts`, bounded cache), rate-limit hooks, `insecure_http`
14
+ testing mode, `poll_requires_ip_match` (off by default).
15
+ - Works with `devise :omniauthable`.
16
+ - Requires Ruby 3.1+, OmniAuth 2.x.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel P. Clark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # omniauth-sqrl
2
+
3
+ An [OmniAuth](https://github.com/omniauth/omniauth) strategy for **SQRL** (Secure Quick
4
+ Reliable Login). Add the provider and your application gets password-less SQRL login with
5
+ the same `/auth/:provider` flow as every other OmniAuth strategy.
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "omniauth-sqrl"
10
+
11
+ # config/initializers/omniauth.rb (Rails) or your Rack config
12
+ use OmniAuth::Builder do
13
+ provider :sqrl,
14
+ stores: SQRL::Rails::Stores.active_record, # or SQRL::Server::Memory.stores for development
15
+ host: "example.com" # the public host placed in sqrl:// URLs
16
+ end
17
+ ```
18
+
19
+ ## How it works
20
+
21
+ | Request | Handled by | Purpose |
22
+ | --- | --- | --- |
23
+ | `POST /auth/sqrl` | request phase | issues a login nut bound to the browser session and renders the login page (link, QR code, `sqrl.js`) |
24
+ | `POST /auth/sqrl/cli` | other phase | the SQRL client's protocol endpoint (`query`, `ident`, `disable`, `enable`, `remove`) |
25
+ | `GET /auth/sqrl/status` | other phase | cookie-bound JSON poll used by `sqrl.js` for logins from another device |
26
+ | `GET /auth/sqrl/sqrl.js` | other phase | the browser script |
27
+ | `GET /auth/sqrl/callback?t=TOKEN` | callback phase | Client Provided Session landing: the one-time token is claimed and the auth hash is built |
28
+ | `GET /auth/sqrl/callback` | callback phase | completion from the poller (the browser's own pending login) |
29
+
30
+ All of these are served by the OmniAuth middleware itself, so the SQRL endpoint never
31
+ reaches your controllers and needs no CSRF exemption.
32
+
33
+ ## The auth hash
34
+
35
+ ```ruby
36
+ request.env["omniauth.auth"]
37
+ # => {
38
+ # "provider" => "sqrl",
39
+ # "uid" => "<identity key, base64url>",
40
+ # "info" => {}, # SQRL carries no profile data; see the info: option
41
+ # "credentials" => {},
42
+ # "extra" => { "sqrl" => {
43
+ # "idk" => "...", "suk" => "...", "vuk" => "...",
44
+ # "created" => true, # this ident created the account
45
+ # "rekeyed_from" => nil, # previous identity key when the account was re-keyed
46
+ # "options" => { "sqrlonly" => false, "hardlock" => false },
47
+ # "disabled" => false, "logins" => 1,
48
+ # "via" => "cps", # or "poll"
49
+ # "user_ref" => nil # the ref given through link_user_ref:, if any
50
+ # } }
51
+ # }
52
+ ```
53
+
54
+ Failures redirect to `/auth/failure?message=…` with `invalid_credentials` (missing,
55
+ foreign, reused or expired token) or `sqrl_disabled`.
56
+
57
+ ## Options
58
+
59
+ | Option | Default | Meaning |
60
+ | --- | --- | --- |
61
+ | `stores:` | process-wide in-memory stores (development only) | `SQRL::Server::Stores` (nuts, identities, pending logins) |
62
+ | `host:` | request host | host (with port) placed in `sqrl://` URLs |
63
+ | `insecure_http:` | `false` | loopback-only testing mode issuing `qrl://` (plain http) URLs; not part of SQRL, refused for other hosts |
64
+ | `poll_requires_ip_match:` | `false` | optional hardening: refuse poll (cookie) completions when the client's IP did not match the browser's |
65
+ | `path_extension:` | `nil` | `x=N` to scope identities to a path prefix |
66
+ | `nut_ttl:` / `cps_token_ttl:` | 300 / 60 seconds | lifetimes |
67
+ | `require_ip_match:` | `true` | refuse clients whose IP differs from the browser's unless they send `noiptest` |
68
+ | `ask:` / `sin:` | `nil` | `SQRL::Ask` (or callable) and secret-index challenge |
69
+ | `on_unknown_identity:` | `:create` | `:create`, `:link` (requires `link_user_ref:`) or `:refuse` |
70
+ | `link_user_ref:` | `nil` | callable `(env) -> ref` naming the signed-in user to link a new identity to |
71
+ | `login_block:` | `{}` | options of `SQRL::Rack::LoginBlock.render` (`qr:`, `link_text:`, `texts:`, …) |
72
+ | `title:` / `cancel_url:` | "Sign in with SQRL" / the origin | login page title and cancel link |
73
+ | `info:` | `nil` | callable `(identity) -> Hash` to fill `info` |
74
+ | `ip:` | `Rack::Request#ip` | callable resolving the client IP (trusted proxies) |
75
+ | `cli_path:` / `status_path:` / `script_path:` | under the provider prefix | endpoint paths |
76
+
77
+ ## Devise
78
+
79
+ `devise-sqrl` gives Devise a native `:sqrl_authenticatable` module. If you prefer the
80
+ OmniAuth route, the strategy works with Devise's `:omniauthable` as usual:
81
+
82
+ ```ruby
83
+ devise :database_authenticatable, :omniauthable, omniauth_providers: [:sqrl]
84
+ # config/initializers/devise.rb
85
+ config.omniauth :sqrl, stores: SQRL::Rails::Stores.active_record, host: "example.com"
86
+ ```
87
+
88
+ and in `Users::OmniauthCallbacksController#sqrl`, find or create the user by
89
+ `request.env["omniauth.auth"]["uid"]`.
90
+
91
+ ## Request-phase CSRF
92
+
93
+ OmniAuth 2 accepts only `POST` on `/auth/sqrl` and validates an authenticity token. Rails
94
+ applications keep using `omniauth-rails_csrf_protection`; a Sinatra application can render
95
+ `Rack::Protection::AuthenticityToken.token(session)` into the login form, as
96
+ `examples/omniauth-app` does. Nothing about SQRL requires disabling this protection.
97
+
98
+ ## Test mode
99
+
100
+ `OmniAuth.config.test_mode = true` with `OmniAuth.config.mock_auth[:sqrl]` short-circuits
101
+ the flow exactly like any other strategy.
102
+
103
+ ## Example and testing
104
+
105
+ [`examples/omniauth-app`](../../examples/omniauth-app) is a Sinatra application using the
106
+ strategy; the repository's integration suite boots it over HTTPS and runs the conformance
107
+ suite against it. `bundle exec rake spec:omniauth-sqrl` runs the strategy specs with
108
+ Rack::Test.
109
+
110
+ ## License
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omniauth"
4
+ require "sqrl/core"
5
+ require "sqrl/rack"
6
+
7
+ # OmniAuth namespace, reopened to add the SQRL strategy.
8
+ module OmniAuth
9
+ # OmniAuth strategy for SQRL login. See {OmniAuth::Strategies::SQRL}.
10
+ module SQRL
11
+ # Same as SQRL::VERSION; the gems release in lockstep.
12
+ VERSION = ::SQRL::VERSION
13
+ end
14
+ end
15
+
16
+ require_relative "strategies/sqrl"
17
+
18
+ OmniAuth.config.add_camelization "sqrl", "SQRL"
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "cgi"
5
+ require "json"
6
+
7
+ module OmniAuth
8
+ # OmniAuth's strategy namespace.
9
+ module Strategies
10
+ # SQRL (Secure Quick Reliable Login) as an OmniAuth provider.
11
+ #
12
+ # use OmniAuth::Builder do
13
+ # provider :sqrl, stores: SQRL::Server::Memory.stores, host: "example.com"
14
+ # end
15
+ #
16
+ # Phases:
17
+ #
18
+ # * request phase (+POST /auth/sqrl+): issues a login nut bound to the browser
19
+ # session and renders a login page (link, QR code, +sqrl.js+);
20
+ # * other phase: serves the SQRL client's +POST /auth/sqrl/cli+, the browser's
21
+ # +GET /auth/sqrl/status+ poll and +GET /auth/sqrl/sqrl.js+, straight from the
22
+ # middleware (no CSRF layer of the application is involved);
23
+ # * callback phase (+GET /auth/sqrl/callback?t=TOKEN+ reached by the SQRL client's
24
+ # Client Provided Session redirect, or +GET /auth/sqrl/callback+ by the poller):
25
+ # claims the one-time token or the cookie-bound pending login and builds the auth
26
+ # hash with +uid+ = the identity key (base64url) and the SQRL facts under
27
+ # +extra["sqrl"]+.
28
+ #
29
+ # Options (beyond OmniAuth's own): +stores:+ ({::SQRL::Server::Stores}; in-memory by
30
+ # default, which is for development only), +host:+ (public host placed in
31
+ # +sqrl://+ URLs; defaults to the request host), +insecure_http:+ (loopback-only
32
+ # testing mode issuing +qrl://+ URLs; never on by default), +poll_requires_ip_match:+
33
+ # (optional hardening, off by default), +path_extension:+, +nut_ttl:+,
34
+ # +cps_token_ttl:+, +require_ip_match:+, +ask:+, +sin:+, +on_unknown_identity:+
35
+ # (+:create+, +:link+ or +:refuse+), +link_user_ref:+ (callable +(env) -> ref+ naming
36
+ # the signed-in user for +:link+), +login_block:+ (options of
37
+ # {::SQRL::Rack::LoginBlock.render}), +title:+, +cancel_url:+, +info:+ (callable
38
+ # +(identity) -> Hash+ to fill the info hash), +ip:+ (callable resolving the client
39
+ # IP), +cli_path:+, +status_path:+, +script_path:+ (defaults under the provider's
40
+ # path prefix) and +logger:+.
41
+ class SQRL
42
+ include OmniAuth::Strategy
43
+
44
+ # Session key holding the pending-login id.
45
+ SESSION_KEY = "omniauth.sqrl.pending_login"
46
+ # Session key holding the logged-in identity key.
47
+ IDENTITY_KEY = "omniauth.sqrl.identity"
48
+ # Seconds the created/re-keyed facts of a pending login are remembered.
49
+ FACTS_TTL = 3600
50
+ # Maximum number of remembered fact records.
51
+ FACTS_LIMIT = 10_000
52
+
53
+ option :name, "sqrl"
54
+ option :stores, nil
55
+ option :host, nil
56
+ option :scheme, nil
57
+ option :insecure_http, false
58
+ option :poll_requires_ip_match, false
59
+ option :path_extension, nil
60
+ option :nut_ttl, 300
61
+ option :cps_token_ttl, 60
62
+ option :require_ip_match, true
63
+ option :ask, nil
64
+ option :sin, nil
65
+ option :on_unknown_identity, :create
66
+ option :link_user_ref, nil
67
+ option :login_block, {}
68
+ option :title, "Sign in with SQRL"
69
+ option :cancel_url, nil
70
+ option :info, nil
71
+ option :ip, nil
72
+ option :cli_path, nil
73
+ option :status_path, nil
74
+ option :script_path, nil
75
+ option :refresh_path, nil
76
+ option :logger, nil
77
+ # Hosts accepted when +host:+ is derived from the request (see
78
+ # {::SQRL::Rack::HostResolver}); nil allows any, up to +host_cache_limit+ distinct ones.
79
+ option :allowed_hosts, nil
80
+ option :host_cache_limit, ::SQRL::Rack::HostResolver::DEFAULT_LIMIT
81
+ # Rate-limit hooks, see {::SQRL::Server::Config}.
82
+ option :before_issue, nil
83
+ option :before_handle, nil
84
+ option :prune_every, 1000
85
+
86
+ # A {::SQRL::Rack::Endpoint} that remembers, per pending login, whether the
87
+ # +ident+ created the account or re-keyed it, so the callback can report it.
88
+ class Endpoint < ::SQRL::Rack::Endpoint
89
+ # Cli on the OmniAuth strategy; same contract as +SQRL::Rack::Endpoint#cli+.
90
+ def cli(request)
91
+ result = handler.handle(nut: request.GET["nut"].to_s, body: form_body(request), ip: client_ip(request))
92
+ SQRL.record_facts(result) if result.authenticated?
93
+ [200, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store",
94
+ "x-sqrl-tif" => result.tif.to_hex }, [result.body]]
95
+ rescue ::SQRL::Server::RateLimited => e
96
+ rate_limited(e)
97
+ end
98
+ end
99
+
100
+ @facts = {}
101
+ @facts_mutex = Mutex.new
102
+
103
+ class << self
104
+ # Remember the outcome of an authenticating +ident+/+enable+ for its pending login.
105
+ def record_facts(result)
106
+ id = result.pending_login_id
107
+ return unless id
108
+
109
+ @facts_mutex.synchronize do
110
+ prune_facts
111
+ @facts[id] = { created: result.created?, rekeyed_from: result.rekeyed_from && ::SQRL::Util.b64u_encode(result.rekeyed_from),
112
+ tif: result.tif.value, at: Process.clock_gettime(Process::CLOCK_MONOTONIC) }
113
+ end
114
+ end
115
+
116
+ # Take (and forget) the recorded facts for a pending login.
117
+ def take_facts(id)
118
+ @facts_mutex.synchronize { @facts.delete(id) }
119
+ end
120
+
121
+ private
122
+
123
+ def prune_facts
124
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
125
+ @facts.delete_if { |_, f| now - f[:at] > FACTS_TTL }
126
+ @facts.shift while @facts.size >= FACTS_LIMIT
127
+ end
128
+ end
129
+
130
+ # The {::SQRL::Server::Stores} this provider uses: the +stores:+ option, or one
131
+ # process-wide set of in-memory stores (OmniAuth::Builder rebuilds the strategy for
132
+ # every request, so a per-instance default would forget everything).
133
+ def stores
134
+ options.stores || self.class.default_stores
135
+ end
136
+
137
+ @default_stores = nil
138
+
139
+ class << self
140
+ # Process-wide in-memory stores used when no +stores:+ option is given.
141
+ def default_stores
142
+ @facts_mutex.synchronize do
143
+ @default_stores ||= begin
144
+ unless ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
145
+ OmniAuth.logger.warn("omniauth-sqrl is using in-memory stores; identities are lost on restart. " \
146
+ "Pass stores: for production.")
147
+ end
148
+ ::SQRL::Server::Memory.stores
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ # The identity that authenticated in the callback phase.
155
+ attr_reader :identity, :completion
156
+
157
+ uid { identity.idk_b64 }
158
+
159
+ info do
160
+ filler = options.info
161
+ filler.respond_to?(:call) ? (filler.call(identity) || {}) : {}
162
+ end
163
+
164
+ credentials { {} }
165
+
166
+ extra do
167
+ facts = @facts || {}
168
+ {
169
+ "sqrl" => {
170
+ "idk" => identity.idk_b64,
171
+ "suk" => ::SQRL::Util.b64u_encode(identity.suk),
172
+ "vuk" => ::SQRL::Util.b64u_encode(identity.vuk),
173
+ "created" => facts.fetch(:created, false),
174
+ "rekeyed_from" => facts[:rekeyed_from],
175
+ "options" => { "sqrlonly" => identity.sqrlonly?, "hardlock" => identity.hardlock? },
176
+ "disabled" => identity.disabled?,
177
+ "logins" => identity.logins,
178
+ "via" => completion.via.to_s,
179
+ "user_ref" => identity.user_ref
180
+ }
181
+ }
182
+ end
183
+
184
+ # Renders the SQRL login page.
185
+ def request_phase
186
+ login = endpoint.issue(request, cancel_url: cancel_url, link_user_ref: link_user_ref)
187
+ body = login_page(login)
188
+ [200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }, [body]]
189
+ rescue ::SQRL::Server::RateLimited => e
190
+ headers = { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }
191
+ headers["retry-after"] = e.retry_after.to_i.to_s if e.retry_after
192
+ [429, headers, [%(<!doctype html><title>#{CGI.escapeHTML(options.title.to_s)}</title>) +
193
+ ::SQRL::Rack::LoginBlock.unavailable(message: e.message)]]
194
+ rescue ::SQRL::Rack::HostNotAllowed => e
195
+ [400, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["#{e.message}\n"]]
196
+ end
197
+
198
+ # Serves the SQRL endpoint, the status poll, the refresh endpoint and the browser
199
+ # script.
200
+ def other_phase
201
+ if request.post? && on_path?(full_path(cli_path))
202
+ endpoint.cli(request)
203
+ elsif request.get? && on_path?(full_path(status_path))
204
+ endpoint.status(request)
205
+ elsif request.get? && on_path?(full_path(refresh_path))
206
+ endpoint.refresh(request)
207
+ elsif request.get? && on_path?(full_path(script_path))
208
+ endpoint.script(request)
209
+ else
210
+ call_app!
211
+ end
212
+ rescue ::SQRL::Rack::HostNotAllowed => e
213
+ [400, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["#{e.message}\n"]]
214
+ end
215
+
216
+ # Completes the login from the CPS token or the cookie-bound pending login.
217
+ def callback_phase
218
+ completion = endpoint.callback(request)
219
+ return fail!(failure_reason) unless completion
220
+
221
+ session.delete(IDENTITY_KEY)
222
+ @completion = completion
223
+ @identity = completion.identity
224
+ @facts = self.class.take_facts(completion.pending_login.id) || {}
225
+ super
226
+ end
227
+
228
+ # Paths relative to the mount point (without +SCRIPT_NAME+).
229
+ def cli_path = options.cli_path || "#{path_prefix}/#{name}/cli"
230
+ # @return [String] full path of the status poll
231
+ def status_path = options.status_path || "#{path_prefix}/#{name}/status"
232
+ # @return [String] full path of +sqrl.js+
233
+ def script_path = options.script_path || "#{path_prefix}/#{name}/sqrl.js"
234
+ # @return [String] full path of the refresh endpoint
235
+ def refresh_path = options.refresh_path || "#{path_prefix}/#{name}/refresh"
236
+
237
+ # The request-scoped {Endpoint}.
238
+ def endpoint
239
+ @endpoint ||= Endpoint.new(handler: handler, session_key: SESSION_KEY, identity_key: IDENTITY_KEY,
240
+ poll_requires_ip_match: options.poll_requires_ip_match ? true : false,
241
+ status_path: status_path, callback_path: relative_callback_path,
242
+ script_path: script_path, refresh_path: refresh_path, ip: options.ip)
243
+ end
244
+
245
+ # The {::SQRL::Server::Handler} for this request's host. Handlers are kept per
246
+ # (options, host, scheme, mount) in a bounded cache shared by every strategy
247
+ # instance built from the same options (OmniAuth::Builder rebuilds the strategy on
248
+ # each request).
249
+ #
250
+ # @raise [::SQRL::Rack::HostNotAllowed] when a derived host is not acceptable
251
+ def handler
252
+ @handler ||= begin
253
+ resolver = self.class.host_resolver_for(options)
254
+ host, scheme = resolver.resolve(request)
255
+ resolver.fetch([host, scheme, script_name.to_s]) do
256
+ ::SQRL::Server::Handler.new(config: server_config(host, scheme), stores: stores)
257
+ end
258
+ end
259
+ end
260
+
261
+ # @return [SQRL::Server::Config] the protocol configuration for this request's host
262
+ def server_config(host, scheme)
263
+ ::SQRL::Server::Config.new(
264
+ host: host, scheme: scheme, insecure_http: options.insecure_http ? true : false,
265
+ path: full_path(cli_path), callback_path: full_path(relative_callback_path),
266
+ path_extension: options.path_extension, nut_ttl: options.nut_ttl.to_i, cps_token_ttl: options.cps_token_ttl.to_i,
267
+ require_ip_match: options.require_ip_match ? true : false, ask: options.ask, sin: options.sin,
268
+ on_unknown_identity: options.on_unknown_identity.to_sym, logger: options.logger,
269
+ allowed_hosts: options.allowed_hosts, prune_every: options.prune_every,
270
+ before_issue: options.before_issue, before_handle: options.before_handle
271
+ )
272
+ end
273
+
274
+ @host_resolvers = {}
275
+
276
+ class << self
277
+ # One {::SQRL::Rack::HostResolver} (and so one bounded handler cache) per distinct
278
+ # provider configuration: OmniAuth::Builder rebuilds the strategy for every
279
+ # request from the same option values, so equal configurations share it.
280
+ def host_resolver_for(options)
281
+ key = options.to_hash.reject { |k, _| k.to_s == "stores" }.merge("stores" => options.stores.object_id)
282
+ @facts_mutex.synchronize do
283
+ scheme = options.scheme || (options.insecure_http ? "qrl" : nil)
284
+ @host_resolvers[key] ||= ::SQRL::Rack::HostResolver.new(host: options.host, scheme: scheme,
285
+ allowed_hosts: options.allowed_hosts,
286
+ limit: options.host_cache_limit,
287
+ logger: options.logger || OmniAuth.logger)
288
+ end
289
+ end
290
+
291
+ # Forget cached host resolvers and handlers (tests).
292
+ def reset_host_resolvers!
293
+ @facts_mutex.synchronize { @host_resolvers.clear }
294
+ end
295
+ end
296
+
297
+ private
298
+
299
+ # OmniAuth's +callback_path+ includes +SCRIPT_NAME+; the endpoint wants it without.
300
+ def relative_callback_path
301
+ path = callback_path
302
+ prefix = script_name
303
+ prefix.empty? || !path.start_with?(prefix) ? path : path[prefix.length..]
304
+ end
305
+
306
+ def full_path(path)
307
+ "#{script_name}#{path}"
308
+ end
309
+
310
+ def cancel_url
311
+ custom = options.cancel_url
312
+ return custom.call(env) if custom.respond_to?(:call)
313
+ return custom if custom
314
+
315
+ origin = session["omniauth.origin"]
316
+ safe_cancel_target?(origin) ? origin : "#{script_name}/"
317
+ end
318
+
319
+ # OmniAuth fills +omniauth.origin+ from +params[:origin]+ or the Referer, so it is
320
+ # attacker-influenced: accept only a same-site root-relative path or an absolute
321
+ # http(s) URL on this site's host; anything else (javascript:, other hosts) is dropped.
322
+ def safe_cancel_target?(value)
323
+ return false unless value.is_a?(String) && !value.empty?
324
+ return !value.match?(/[\x00-\x20\x7f\\]/) if value.match?(%r{\A/(?![/\\])})
325
+
326
+ uri = URI.parse(value)
327
+ return false unless %w[http https].include?(uri.scheme.to_s.downcase) && uri.host
328
+
329
+ hostport = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
330
+ request_host = request.host_with_port
331
+ [request.host, request_host, handler.config.host].compact.any? do |h|
332
+ h.casecmp?(hostport) || h.casecmp?(uri.host)
333
+ end
334
+ rescue URI::InvalidURIError
335
+ false
336
+ end
337
+
338
+ def link_user_ref
339
+ ref = options.link_user_ref
340
+ ref.respond_to?(:call) ? ref.call(env) : ref
341
+ end
342
+
343
+ # Why the callback could not complete: a disabled identity when the pending login
344
+ # was authenticated by one, otherwise a missing, foreign, used or expired token.
345
+ def failure_reason
346
+ id = session[SESSION_KEY]
347
+ login = id && stores.pending_logins.find(id)
348
+ if login&.authenticated?
349
+ identity = stores.identities.find(login.authenticated_idk)
350
+ return :sqrl_disabled if identity&.disabled?
351
+ end
352
+ :invalid_credentials
353
+ end
354
+
355
+ def login_block_options
356
+ deep_symbolize(options.login_block.respond_to?(:to_hash) ? options.login_block.to_hash : {})
357
+ end
358
+
359
+ def deep_symbolize(value)
360
+ case value
361
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
362
+ when Array then value.map { |v| deep_symbolize(v) }
363
+ else value
364
+ end
365
+ end
366
+
367
+ def login_page(login)
368
+ block = ::SQRL::Rack::LoginBlock.render(
369
+ login_url: login.url, status_url: endpoint.status_url(request), cancel_url: cancel_url,
370
+ script_url: endpoint.script_url(request), **login_block_options
371
+ )
372
+ title = CGI.escapeHTML(options.title.to_s)
373
+ <<~HTML
374
+ <!doctype html>
375
+ <html lang="en">
376
+ <head>
377
+ <meta charset="utf-8">
378
+ <meta name="viewport" content="width=device-width, initial-scale=1">
379
+ <title>#{title}</title>
380
+ <style>
381
+ body{font-family:system-ui,sans-serif;max-width:40em;margin:3em auto;line-height:1.5;padding:0 1em}
382
+ .sqrl-login__link{display:inline-block;padding:.6em 1.2em;background:#007cc3;color:#fff;border-radius:.4em;text-decoration:none}
383
+ .sqrl-login__qr svg{display:block;margin:1em 0;max-width:100%}
384
+ </style>
385
+ </head>
386
+ <body>
387
+ <h1>#{title}</h1>
388
+ #{block}
389
+ </body>
390
+ </html>
391
+ HTML
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "omniauth/sqrl"
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omniauth-sqrl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel P. Clark
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: omniauth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqrl-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: sqrl-rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ email:
55
+ - 6ftdan@gmail.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE
62
+ - README.md
63
+ - lib/omniauth-sqrl.rb
64
+ - lib/omniauth/sqrl.rb
65
+ - lib/omniauth/strategies/sqrl.rb
66
+ homepage: https://github.com/danielpclark/sqrl-ruby
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/danielpclark/sqrl-ruby
71
+ source_code_uri: https://github.com/danielpclark/sqrl-ruby/tree/main/gems/omniauth-sqrl
72
+ changelog_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/omniauth-sqrl/CHANGELOG.md
73
+ bug_tracker_uri: https://github.com/danielpclark/sqrl-ruby/issues
74
+ documentation_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/omniauth-sqrl/README.md
75
+ rubygems_mfa_required: 'true'
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.1'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: OmniAuth strategy for SQRL login
93
+ test_files: []