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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/omniauth/sqrl.rb +18 -0
- data/lib/omniauth/strategies/sqrl.rb +395 -0
- data/lib/omniauth-sqrl.rb +3 -0
- metadata +93 -0
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
|
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: []
|