sqrl-rack 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 +194 -0
- data/assets/sqrl.js +165 -0
- data/lib/roda/plugins/sqrl.rb +109 -0
- data/lib/sinatra/sqrl.rb +86 -0
- data/lib/sqrl/rack/assets.rb +30 -0
- data/lib/sqrl/rack/endpoint.rb +263 -0
- data/lib/sqrl/rack/facade.rb +79 -0
- data/lib/sqrl/rack/host_resolver.rb +115 -0
- data/lib/sqrl/rack/login_block.rb +96 -0
- data/lib/sqrl/rack/middleware.rb +175 -0
- data/lib/sqrl/rack/qr.rb +54 -0
- data/lib/sqrl/rack.rb +25 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4513d44fc6444b9976a2e286cf6edb442c68a03fd02aede2aebce4c57d8c8b3a
|
|
4
|
+
data.tar.gz: 5963d6bf1ce3f90ea6f963d4495e03f23d551c69365d346a42aa4c2ac57e539d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 80aa33c71ed274a44c315c7a10b9d1292f412a13bc7d966fc9d72b05123b00f58d62b2b2bd186d18abf8b397a9bc3917c3bbf18c8e2153804460e05989b3aaf5
|
|
7
|
+
data.tar.gz: 66d970edec43830373ee5954d2753094e286896f3472d1800c365b689e5ab62e9b6c9d0304e6fcb9d4c65913d4c51e6536d50a97890caa65058dc5513c3da933
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `SQRL::Rack::Middleware`: the SQRL endpoint, cookie-bound status poll, CPS and poll
|
|
8
|
+
callbacks, `sqrl.js` and a refresh endpoint, with a request facade (`env["sqrl.server"]`).
|
|
9
|
+
- `Sinatra::SQRL` extension and a Roda `plugin :sqrl` with the same helpers.
|
|
10
|
+
- `SQRL::Rack::LoginBlock` (accessible markup with a server-side SVG QR code) and
|
|
11
|
+
`sqrl.js` (same-device probe on `localhost:25519`, polling with back-off, DOM events,
|
|
12
|
+
`SQRL.refresh`).
|
|
13
|
+
- `SQRL::Rack::HostResolver`: explicit `host`, `allowed_hosts`, a bounded per-host cache.
|
|
14
|
+
- Always issues `sqrl://`; `insecure_http: true` is a loopback-only testing mode.
|
|
15
|
+
- `poll_requires_ip_match` (off by default) and 429 handling for rate-limit hooks.
|
|
16
|
+
- Requires Ruby 3.1+, Rack 2.2 or 3.x, `rqrcode`.
|
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,194 @@
|
|
|
1
|
+
# sqrl-rack
|
|
2
|
+
|
|
3
|
+
SQRL login for Rack applications and Sinatra: a middleware that mounts the SQRL endpoint,
|
|
4
|
+
the browser's status poll and the login callback; a Sinatra extension with view helpers;
|
|
5
|
+
the browser-side `sqrl.js`; and QR code rendering. Built on
|
|
6
|
+
[`sqrl-core`](../sqrl-core).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "sqrl-rack" # require "sqrl/rack", "sinatra/sqrl" or plugin :sqrl (Roda)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Rack
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require "rack/session"
|
|
18
|
+
require "sqrl/rack"
|
|
19
|
+
|
|
20
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: :lax
|
|
21
|
+
use SQRL::Rack::Middleware,
|
|
22
|
+
stores: SQRL::Server::Memory.stores, # swap for a persistent implementation
|
|
23
|
+
host: "example.com", # the public host that ends up in sqrl:// URLs
|
|
24
|
+
on_login: ->(env, identity, pending_login) { env["rack.session"]["user_id"] = identity.idk_b64 },
|
|
25
|
+
after_login: "/"
|
|
26
|
+
|
|
27
|
+
run ->(env) {
|
|
28
|
+
sqrl = env["sqrl.server"]
|
|
29
|
+
[200, { "content-type" => "text/html" }, [sqrl.login_block]]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The middleware serves `POST /sqrl/cli` (the SQRL protocol endpoint), `GET /sqrl/status`
|
|
34
|
+
(cookie-bound JSON poll), `GET /sqrl/callback` (login completion, with or without a CPS
|
|
35
|
+
token) and `GET /sqrl/sqrl.js`. Everything else gets `env["sqrl.server"]`, a
|
|
36
|
+
`SQRL::Rack::Facade` with `login_url`, `login_block`, `script_tag`, `qr_svg`,
|
|
37
|
+
`identity`, `authenticated?` and `logout!`.
|
|
38
|
+
|
|
39
|
+
Every `SQRL::Server::Config` option is accepted (`on_unknown_identity`, `ask`, `sin`,
|
|
40
|
+
`require_ip_match`, `nut_ttl`, `cps_token_ttl`, ...). Without `host:` the request host is
|
|
41
|
+
used. URLs are always `sqrl://` (HTTPS); see "Testing without TLS" below.
|
|
42
|
+
|
|
43
|
+
## Sinatra
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
require "sinatra/sqrl"
|
|
47
|
+
|
|
48
|
+
class App < Sinatra::Base
|
|
49
|
+
enable :sessions
|
|
50
|
+
register Sinatra::SQRL
|
|
51
|
+
set :sqrl, host: "example.com", stores: SQRL::Server::Memory.stores
|
|
52
|
+
|
|
53
|
+
sqrl_on_login { |identity, env| env["rack.session"]["user"] = identity.idk_b64 }
|
|
54
|
+
|
|
55
|
+
get("/login") { erb :login } # <%= sqrl_login_block %>
|
|
56
|
+
get("/") { sqrl_authenticated? ? "Hello #{current_sqrl_identity.idk_b64}" : redirect("/login") }
|
|
57
|
+
post("/logout") { sqrl_logout!; redirect "/" }
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Helpers: `sqrl_login_url`, `sqrl_qr_svg`, `sqrl_script_tag`, `sqrl_login_block`,
|
|
62
|
+
`sqrl_authenticated?`, `current_sqrl_identity`, `sqrl_logout!`, and `sqrl` (the facade).
|
|
63
|
+
|
|
64
|
+
## Roda
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
require "roda/plugins/sqrl"
|
|
68
|
+
|
|
69
|
+
class App < Roda
|
|
70
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: :lax
|
|
71
|
+
plugin :sqrl, host: "example.com", stores: SQRL::Server::Memory.stores
|
|
72
|
+
|
|
73
|
+
sqrl_on_login { |identity, env| env["rack.session"]["user"] = identity.idk_b64 }
|
|
74
|
+
|
|
75
|
+
route do |r|
|
|
76
|
+
r.get("login") { sqrl_login_block }
|
|
77
|
+
r.root { sqrl_authenticated? ? "Hello #{current_sqrl_identity.idk_b64}" : r.redirect("/login") }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`plugin :sqrl` takes every middleware option and adds the same helpers as the Sinatra
|
|
83
|
+
extension (`sqrl`, `sqrl_login_block`, `sqrl_login_url`, `sqrl_qr_svg`, `sqrl_script_tag`,
|
|
84
|
+
`sqrl_authenticated?`, `current_sqrl_identity`, `sqrl_logout!`, class-level
|
|
85
|
+
`sqrl_on_login`). The session must come from a Rack session middleware added with `use`
|
|
86
|
+
before the plugin; Roda's own `sessions` plugin lives inside the routing tree, after the
|
|
87
|
+
middleware, and cannot be used for this. The `roda` gem is not a dependency of
|
|
88
|
+
`sqrl-rack`; add it yourself. See [`examples/roda-app`](../../examples/roda-app).
|
|
89
|
+
|
|
90
|
+
## Hosts
|
|
91
|
+
|
|
92
|
+
The host in a `sqrl://` URL is the string every identity key is derived from, so configure
|
|
93
|
+
it (`host: "example.com"`) in production. Without it the middleware uses the request's
|
|
94
|
+
`Host` header: it warns once, accepts only hosts matching `allowed_hosts:` when that list
|
|
95
|
+
is given (strings, with or without port, or regexps), and keeps per-host state for at most
|
|
96
|
+
`host_cache_limit:` (default 16) distinct hosts; requests for further hosts get `400`.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
use SQRL::Rack::Middleware, stores: stores, allowed_hosts: ["example.com", /\A[a-z0-9-]+\.example\.com\z/]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The scheme is never derived from the request: a site served over plain http (for example
|
|
103
|
+
with TLS terminated upstream) still issues `sqrl://`.
|
|
104
|
+
|
|
105
|
+
## Testing without TLS
|
|
106
|
+
|
|
107
|
+
`qrl://` (plain http) is not part of the final SQRL specification and real SQRL clients do
|
|
108
|
+
not accept it. The browser extension's test server, and the `sqrl-client` gem with
|
|
109
|
+
`insecure: true`, do, so a site can be tried on `localhost` without a certificate:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
use SQRL::Rack::Middleware, stores: stores, host: "localhost:9292", insecure_http: true
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`insecure_http` is refused for any host that is not loopback and logs a warning once. For
|
|
116
|
+
anything a real client will scan, serve HTTPS (a self-signed certificate is fine in
|
|
117
|
+
development).
|
|
118
|
+
|
|
119
|
+
## Poll completions and IP matching
|
|
120
|
+
|
|
121
|
+
The protocol-level check (`require_ip_match`, on by default) refuses a client whose IP
|
|
122
|
+
differs from the browser's unless the client sends `noiptest`. A separate, optional
|
|
123
|
+
hardening, `poll_requires_ip_match: true`, additionally refuses to complete a login
|
|
124
|
+
through the browser's own cookie (the poll path, no CPS token) when the client's IP did
|
|
125
|
+
not match. It is off by default because a phone on another network completing a login
|
|
126
|
+
through the page's poller is a normal SQRL use; turn it on for sites that only expect
|
|
127
|
+
same-network logins.
|
|
128
|
+
|
|
129
|
+
## Rate limiting and pruning
|
|
130
|
+
|
|
131
|
+
Two hooks let you refuse abusive traffic before any state is written. Raise
|
|
132
|
+
`SQRL::Server::RateLimited` (optionally with `retry_after:`) and the middleware answers
|
|
133
|
+
`429`: the SQRL client sees it on `POST /sqrl/cli`, the page's refresh call gets a JSON
|
|
134
|
+
error, and `login_block` degrades to a short "temporarily unavailable" message.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
use SQRL::Rack::Middleware,
|
|
138
|
+
stores: stores, host: "example.com",
|
|
139
|
+
before_issue: ->(ctx) { raise SQRL::Server::RateLimited.new(retry_after: 30) if LoginPages.too_many?(ctx[:ip]) },
|
|
140
|
+
before_handle: ->(ctx) { raise SQRL::Server::RateLimited if ctx[:phase] == :request && Cli.too_many?(ctx[:ip]) },
|
|
141
|
+
prune_every: 500 # run Handler#prune! every 500 requests (default 1000, nil to disable)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`before_handle` runs twice per POST: once as soon as the request arrives
|
|
145
|
+
(`phase: :request`, with `ip` and `nut`) and again when the command is known
|
|
146
|
+
(`phase: :command`, adding `command` and `idk`). With [Rack::Attack](https://github.com/rack/rack-attack)
|
|
147
|
+
the same limits can sit in front of the middleware instead:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
Rack::Attack.throttle("sqrl/cli", limit: 60, period: 60) { |req| req.ip if req.post? && req.path == "/sqrl/cli" }
|
|
151
|
+
Rack::Attack.throttle("sqrl/login-page", limit: 20, period: 60) { |req| req.ip if req.get? && req.path == "/login" }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Expired nuts and pending logins are deleted opportunistically every `prune_every`
|
|
155
|
+
requests; call `handler.prune!` from a scheduler if you would rather control it.
|
|
156
|
+
|
|
157
|
+
## The browser script
|
|
158
|
+
|
|
159
|
+
`sqrl.js` probes for a local client on `localhost:25519`, polls `/sqrl/status` with
|
|
160
|
+
exponential back-off after errors (capped at 10 s), and when the server reports the login
|
|
161
|
+
code as expired shows a "Get a new code" button that calls `GET /sqrl/refresh`. That
|
|
162
|
+
endpoint issues a new pending login and nut for the browser session and returns
|
|
163
|
+
`{"sqrl_url", "status_url", "refresh_url", "qr_svg"}`, which the script swaps into the
|
|
164
|
+
page without a reload (`SQRL.refresh(rootElement)` does the same from your code). The root
|
|
165
|
+
element dispatches `sqrl:cps-detected`, `sqrl:authenticated` (`detail.next`),
|
|
166
|
+
`sqrl:expired`, `sqrl:refreshed` (`detail.sqrlUrl`) and `sqrl:error` events.
|
|
167
|
+
|
|
168
|
+
## How a login completes
|
|
169
|
+
|
|
170
|
+
1. The login page calls `login_url`, which issues a nut and stores a pending-login id in
|
|
171
|
+
the browser session; `login_block` renders the `sqrl://` link, the QR code and
|
|
172
|
+
`sqrl.js`.
|
|
173
|
+
2. The SQRL client POSTs `query` then `ident` to `/sqrl/cli`.
|
|
174
|
+
3. Same device (CPS): the client receives a one-time `url=` and sends the browser to
|
|
175
|
+
`/sqrl/callback?t=TOKEN`. The token is claimed once and must belong to the pending
|
|
176
|
+
login in that browser's session.
|
|
177
|
+
4. Other device: `sqrl.js` polls `/sqrl/status`; once authenticated it navigates to
|
|
178
|
+
`/sqrl/callback`, which completes the browser's own pending login once.
|
|
179
|
+
5. The middleware stores the identity in the session, runs `on_login`, and redirects to the
|
|
180
|
+
pending login's `return_to` or `after_login`.
|
|
181
|
+
|
|
182
|
+
See [`examples/rack-app`](../../examples/rack-app) and
|
|
183
|
+
[`examples/sinatra-app`](../../examples/sinatra-app). and [`examples/roda-app`](../../examples/roda-app).
|
|
184
|
+
|
|
185
|
+
## Testing
|
|
186
|
+
|
|
187
|
+
`bundle exec rake spec:sqrl-rack` runs the middleware, Sinatra and Roda specs with
|
|
188
|
+
Rack::Test, driving the client side with `SQRL::Session`; the repository's integration suite
|
|
189
|
+
boots the three example apps over HTTPS and runs the `sqrl-client` conformance suite and a
|
|
190
|
+
headless browser against them.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT. See [LICENSE](LICENSE).
|
data/assets/sqrl.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* sqrl.js - browser side of SQRL login for the sqrl-rack / sqrl-rails gems.
|
|
3
|
+
*
|
|
4
|
+
* Attaches to every element with [data-sqrl-login]. Attributes:
|
|
5
|
+
* data-sqrl-url the sqrl:// login URL
|
|
6
|
+
* data-sqrl-status URL of the cookie-bound status poll endpoint
|
|
7
|
+
* data-sqrl-refresh URL of the JSON endpoint that hands out a fresh login URL (optional)
|
|
8
|
+
* data-sqrl-poll poll interval in milliseconds (default 2000)
|
|
9
|
+
* data-sqrl-text-* message texts (message, cps, done, expired)
|
|
10
|
+
* Children (optional): a[data-sqrl-link], [data-sqrl-qr], [data-sqrl-message],
|
|
11
|
+
* button[data-sqrl-refresh-button].
|
|
12
|
+
*
|
|
13
|
+
* 1. Same-device Client Provided Session (CPS) probe: native SQRL clients and the browser
|
|
14
|
+
* extension listen on http://localhost:25519/. When the probe image loads, the login link
|
|
15
|
+
* is pointed at http://localhost:25519/<base64url sqrl url>.
|
|
16
|
+
* 2. Polling for logins from another device: the status endpoint is bound to this
|
|
17
|
+
* browser's session cookie and answers {"authenticated": true, "next": url} once done,
|
|
18
|
+
* or {"expired": true} when the login code is no longer valid. Errors back the poll
|
|
19
|
+
* off (up to 10 s) and a success resets it.
|
|
20
|
+
* 3. Refresh: SQRL.refresh(root) asks the refresh endpoint for a new login URL and swaps
|
|
21
|
+
* the link and QR code in place; the "Get a new code" button appears on expiry.
|
|
22
|
+
*
|
|
23
|
+
* Events dispatched on the root element: sqrl:cps-detected, sqrl:authenticated (detail:
|
|
24
|
+
* {next}), sqrl:expired, sqrl:refreshed (detail: {sqrlUrl}), sqrl:error (detail: {error}).
|
|
25
|
+
*/
|
|
26
|
+
(function () {
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
var CPS_ORIGIN = "http://localhost:25519/";
|
|
30
|
+
var MAX_BACKOFF = 10000;
|
|
31
|
+
|
|
32
|
+
function base64url(str) {
|
|
33
|
+
var bytes = new TextEncoder().encode(str);
|
|
34
|
+
var bin = "";
|
|
35
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
36
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function text(root, key, fallback) {
|
|
40
|
+
return root.getAttribute("data-sqrl-text-" + key) || fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setMessage(root, msg) {
|
|
44
|
+
var el = root.querySelector("[data-sqrl-message]");
|
|
45
|
+
if (el) el.textContent = msg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emit(root, name, detail) {
|
|
49
|
+
root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: detail || {} }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applyLink(root) {
|
|
53
|
+
var link = root.querySelector("a[data-sqrl-link]");
|
|
54
|
+
var url = root.getAttribute("data-sqrl-url");
|
|
55
|
+
if (!link || !url) return;
|
|
56
|
+
link.href = root.getAttribute("data-sqrl-cps") === "true" ? CPS_ORIGIN + base64url(url) : url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function probeCps(root) {
|
|
60
|
+
var img = new Image();
|
|
61
|
+
img.onload = function () {
|
|
62
|
+
root.setAttribute("data-sqrl-cps", "true");
|
|
63
|
+
var link = root.querySelector("a[data-sqrl-link]");
|
|
64
|
+
if (link) link.setAttribute("data-sqrl-cps", "true");
|
|
65
|
+
applyLink(root);
|
|
66
|
+
setMessage(root, text(root, "cps", "Local SQRL client detected."));
|
|
67
|
+
emit(root, "sqrl:cps-detected");
|
|
68
|
+
};
|
|
69
|
+
img.onerror = function () {
|
|
70
|
+
root.setAttribute("data-sqrl-cps", "false");
|
|
71
|
+
var link = root.querySelector("a[data-sqrl-link]");
|
|
72
|
+
if (link) link.setAttribute("data-sqrl-cps", "false");
|
|
73
|
+
};
|
|
74
|
+
img.src = CPS_ORIGIN + Math.random().toString(36).slice(2) + ".gif";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startPolling(root) {
|
|
78
|
+
if (root.sqrlStopPolling) root.sqrlStopPolling();
|
|
79
|
+
var statusUrl = root.getAttribute("data-sqrl-status");
|
|
80
|
+
if (!statusUrl) return;
|
|
81
|
+
var base = parseInt(root.getAttribute("data-sqrl-poll") || "2000", 10) || 2000;
|
|
82
|
+
var delay = base;
|
|
83
|
+
var stopped = false;
|
|
84
|
+
function schedule() { if (!stopped) window.setTimeout(tick, delay); }
|
|
85
|
+
function tick() {
|
|
86
|
+
if (stopped) return;
|
|
87
|
+
fetch(statusUrl, { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json" } })
|
|
88
|
+
.then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
|
|
89
|
+
.then(function (j) {
|
|
90
|
+
delay = base;
|
|
91
|
+
if (j.authenticated && j.next) {
|
|
92
|
+
stopped = true;
|
|
93
|
+
setMessage(root, text(root, "done", "Logged in, redirecting…"));
|
|
94
|
+
emit(root, "sqrl:authenticated", { next: j.next });
|
|
95
|
+
window.location.href = j.next;
|
|
96
|
+
} else if (j.expired) {
|
|
97
|
+
stopped = true;
|
|
98
|
+
expire(root);
|
|
99
|
+
} else {
|
|
100
|
+
schedule();
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
.catch(function (e) {
|
|
104
|
+
delay = Math.min(delay * 2, MAX_BACKOFF);
|
|
105
|
+
emit(root, "sqrl:error", { error: e });
|
|
106
|
+
schedule();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
schedule();
|
|
110
|
+
root.sqrlStopPolling = function () { stopped = true; };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expire(root) {
|
|
114
|
+
root.setAttribute("data-sqrl-expired", "true");
|
|
115
|
+
setMessage(root, text(root, "expired", "This login code expired. Reload the page to get a new one."));
|
|
116
|
+
var button = root.querySelector("[data-sqrl-refresh-button]");
|
|
117
|
+
if (button && root.getAttribute("data-sqrl-refresh")) button.hidden = false;
|
|
118
|
+
emit(root, "sqrl:expired");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function refresh(root) {
|
|
122
|
+
var url = root.getAttribute("data-sqrl-refresh");
|
|
123
|
+
if (!url) return Promise.reject(new Error("no data-sqrl-refresh"));
|
|
124
|
+
return fetch(url, { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json" } })
|
|
125
|
+
.then(function (r) { if (!r.ok) throw new Error("refresh " + r.status); return r.json(); })
|
|
126
|
+
.then(function (j) {
|
|
127
|
+
root.setAttribute("data-sqrl-url", j.sqrl_url);
|
|
128
|
+
if (j.status_url) root.setAttribute("data-sqrl-status", j.status_url);
|
|
129
|
+
root.removeAttribute("data-sqrl-expired");
|
|
130
|
+
applyLink(root);
|
|
131
|
+
var qr = root.querySelector("[data-sqrl-qr]");
|
|
132
|
+
if (qr && j.qr_svg) qr.innerHTML = j.qr_svg;
|
|
133
|
+
var button = root.querySelector("[data-sqrl-refresh-button]");
|
|
134
|
+
if (button) button.hidden = true;
|
|
135
|
+
setMessage(root, text(root, "message", ""));
|
|
136
|
+
startPolling(root);
|
|
137
|
+
emit(root, "sqrl:refreshed", { sqrlUrl: j.sqrl_url });
|
|
138
|
+
return j;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function init(root) {
|
|
143
|
+
if (root.getAttribute("data-sqrl-initialized")) return;
|
|
144
|
+
root.setAttribute("data-sqrl-initialized", "true");
|
|
145
|
+
if (root.getAttribute("data-sqrl-url")) probeCps(root);
|
|
146
|
+
var button = root.querySelector("[data-sqrl-refresh-button]");
|
|
147
|
+
if (button) button.addEventListener("click", function () { refresh(root).catch(function () { expire(root); }); });
|
|
148
|
+
startPolling(root);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function initAll() {
|
|
152
|
+
var roots = document.querySelectorAll("[data-sqrl-login]");
|
|
153
|
+
for (var i = 0; i < roots.length; i++) init(roots[i]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (document.readyState === "loading") {
|
|
157
|
+
document.addEventListener("DOMContentLoaded", initAll);
|
|
158
|
+
} else {
|
|
159
|
+
initAll();
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener("turbo:load", initAll);
|
|
162
|
+
window.SQRL = window.SQRL || {};
|
|
163
|
+
window.SQRL.init = initAll;
|
|
164
|
+
window.SQRL.refresh = refresh;
|
|
165
|
+
})();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "roda"
|
|
4
|
+
require "sqrl/rack"
|
|
5
|
+
|
|
6
|
+
# Roda namespace, reopened to add the +:sqrl+ plugin.
|
|
7
|
+
class Roda
|
|
8
|
+
# Roda's plugin namespace.
|
|
9
|
+
module RodaPlugins
|
|
10
|
+
# SQRL login for Roda.
|
|
11
|
+
#
|
|
12
|
+
# require "roda"
|
|
13
|
+
# require "roda/plugins/sqrl" # or just `plugin :sqrl` with sqrl-rack on the load path
|
|
14
|
+
#
|
|
15
|
+
# class App < Roda
|
|
16
|
+
# use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: :lax
|
|
17
|
+
# plugin :sqrl, host: "example.com", stores: SQRL::Server::Memory.stores
|
|
18
|
+
#
|
|
19
|
+
# sqrl_on_login { |identity, env| env["rack.session"]["user"] = identity.idk_b64 }
|
|
20
|
+
#
|
|
21
|
+
# route do |r|
|
|
22
|
+
# r.get("login") { sqrl_login_block }
|
|
23
|
+
# r.root { sqrl_authenticated? ? "hello #{current_sqrl_identity.idk_b64}" : r.redirect("/login") }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# The plugin adds {SQRL::Rack::Middleware} to the application's middleware stack with
|
|
28
|
+
# every option given to +plugin :sqrl+ (see {SQRL::Rack::Middleware} and
|
|
29
|
+
# {SQRL::Server::Config}). Because the middleware runs *before* the Roda routing tree,
|
|
30
|
+
# the session it binds logins to must come from a Rack session middleware
|
|
31
|
+
# (+Rack::Session::Cookie+, +Rack::Session::Pool+, ...) added with +use+ before
|
|
32
|
+
# +plugin :sqrl+. Roda's own +sessions+ plugin keeps the session inside the routing
|
|
33
|
+
# tree, where the middleware cannot see it, and is therefore not supported; the
|
|
34
|
+
# middleware raises a clear error when no +rack.session+ is present.
|
|
35
|
+
module SQRL
|
|
36
|
+
# Accepts every option of SQRL::Rack::Middleware.
|
|
37
|
+
def self.configure(app, opts = {})
|
|
38
|
+
app.opts[:sqrl] = (app.opts[:sqrl] || {}).merge(opts.transform_keys(&:to_sym))
|
|
39
|
+
app.opts[:sqrl_login_hooks] ||= []
|
|
40
|
+
return if app.opts[:sqrl_middleware_installed]
|
|
41
|
+
|
|
42
|
+
app.opts[:sqrl_middleware_installed] = true
|
|
43
|
+
app.use ::SQRL::Rack::Middleware, -> { SQRL.middleware_options(app) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Merge the user hooks into the middleware's +on_login+ (same shape as the Sinatra
|
|
47
|
+
# extension).
|
|
48
|
+
def self.middleware_options(app)
|
|
49
|
+
hooks = app.opts[:sqrl_login_hooks] || []
|
|
50
|
+
opts = (app.opts[:sqrl] || {}).dup
|
|
51
|
+
return opts if hooks.empty?
|
|
52
|
+
|
|
53
|
+
user_hook = opts[:on_login]
|
|
54
|
+
opts[:session_identity] = true unless opts.key?(:session_identity)
|
|
55
|
+
opts[:on_login] = lambda do |env, identity, pending_login|
|
|
56
|
+
user_hook&.call(env, identity, pending_login)
|
|
57
|
+
hooks.each { |h| h.call(identity, env, pending_login) }
|
|
58
|
+
end
|
|
59
|
+
opts
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Class-level API: +plugin :sqrl+ options and the +sqrl_on_login+ hook.
|
|
63
|
+
module ClassMethods
|
|
64
|
+
# Register a block called after every successful SQRL login with
|
|
65
|
+
# +(identity, env, pending_login)+.
|
|
66
|
+
def sqrl_on_login(&block)
|
|
67
|
+
opts[:sqrl_login_hooks] = (opts[:sqrl_login_hooks] || []) + [block]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The options the middleware was installed with.
|
|
71
|
+
def sqrl_options = opts[:sqrl] || {}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Helpers available inside the routing block.
|
|
75
|
+
module InstanceMethods
|
|
76
|
+
# @return [SQRL::Rack::Facade]
|
|
77
|
+
def sqrl
|
|
78
|
+
env["sqrl.server"] or raise ::SQRL::Rack::Error, "SQRL middleware did not run for this request"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [String] a fresh +sqrl://+ login URL for this request
|
|
82
|
+
def sqrl_login_url(**options) = sqrl.login_url(**options).url
|
|
83
|
+
# @return [String] the login URL as an SVG QR code
|
|
84
|
+
def sqrl_qr_svg(**options) = sqrl.qr_svg(**options)
|
|
85
|
+
# @return [String] a +<script>+ tag loading +sqrl.js+
|
|
86
|
+
def sqrl_script_tag = sqrl.script_tag
|
|
87
|
+
# @return [String] the complete login block markup
|
|
88
|
+
def sqrl_login_block(**options) = sqrl.login_block(**options)
|
|
89
|
+
def sqrl_authenticated? = sqrl.authenticated?
|
|
90
|
+
# @return [SQRL::Server::Identity, nil] the identity logged in through SQRL in this session
|
|
91
|
+
def current_sqrl_identity = sqrl.identity
|
|
92
|
+
# Forget the SQRL identity held in the session.
|
|
93
|
+
# @return [void]
|
|
94
|
+
def sqrl_logout! = sqrl.logout!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The same helpers on the request object, for people who route everything through +r+.
|
|
98
|
+
module RequestMethods
|
|
99
|
+
# @return [SQRL::Rack::Facade] the request-scoped facade
|
|
100
|
+
def sqrl = scope.sqrl
|
|
101
|
+
def sqrl_authenticated? = scope.sqrl_authenticated?
|
|
102
|
+
# @return [SQRL::Server::Identity, nil] the identity logged in through SQRL in this session
|
|
103
|
+
def current_sqrl_identity = scope.current_sqrl_identity
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
register_plugin(:sqrl, SQRL)
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/sinatra/sqrl.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "sqrl/rack"
|
|
5
|
+
|
|
6
|
+
# Sinatra namespace, reopened to add the SQRL extension.
|
|
7
|
+
module Sinatra
|
|
8
|
+
# SQRL login for Sinatra.
|
|
9
|
+
#
|
|
10
|
+
# require "sinatra/sqrl"
|
|
11
|
+
#
|
|
12
|
+
# class App < Sinatra::Base
|
|
13
|
+
# enable :sessions
|
|
14
|
+
# register Sinatra::SQRL
|
|
15
|
+
# set :sqrl, host: "example.com", stores: SQRL::Server::Memory.stores
|
|
16
|
+
#
|
|
17
|
+
# sqrl_on_login { |identity, env| env["rack.session"]["user"] = identity.idk_b64 }
|
|
18
|
+
#
|
|
19
|
+
# get("/login") { sqrl_login_block }
|
|
20
|
+
# get("/") { sqrl_authenticated? ? "hello #{current_sqrl_identity.idk_b64}" : redirect("/login") }
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Registering adds {SQRL::Rack::Middleware} to the app. Sinatra builds its own session
|
|
24
|
+
# and +Rack::Protection+ middleware before any +use+d middleware, so the session is
|
|
25
|
+
# available and the SQRL endpoint is reached before the application; with sessions on,
|
|
26
|
+
# +Rack::Protection+'s defaults let the SQRL client's token-less POST through (it has
|
|
27
|
+
# no cross-site referrer). Options given with +set :sqrl+ are read when the app is
|
|
28
|
+
# built, so their order relative to +register+ does not matter.
|
|
29
|
+
module SQRL
|
|
30
|
+
# Instance helpers.
|
|
31
|
+
module Helpers
|
|
32
|
+
# @return [SQRL::Rack::Facade]
|
|
33
|
+
def sqrl
|
|
34
|
+
env["sqrl.server"] or raise ::SQRL::Rack::Error, "SQRL middleware did not run for this request"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String] a fresh +sqrl://+ login URL for this request (see {SQRL::Rack::Facade#login_url})
|
|
38
|
+
def sqrl_login_url(**options) = sqrl.login_url(**options).url
|
|
39
|
+
# @return [String] the login URL as an SVG QR code
|
|
40
|
+
def sqrl_qr_svg(**options) = sqrl.qr_svg(**options)
|
|
41
|
+
# @return [String] a +<script>+ tag loading +sqrl.js+
|
|
42
|
+
def sqrl_script_tag = sqrl.script_tag
|
|
43
|
+
# @return [String] the complete login block markup (see {SQRL::Rack::LoginBlock.render})
|
|
44
|
+
def sqrl_login_block(**options) = sqrl.login_block(**options)
|
|
45
|
+
def sqrl_authenticated? = sqrl.authenticated?
|
|
46
|
+
# @return [SQRL::Server::Identity, nil] the identity logged in through SQRL in this session
|
|
47
|
+
def current_sqrl_identity = sqrl.identity
|
|
48
|
+
# Forget the SQRL identity held in the session.
|
|
49
|
+
# @return [void]
|
|
50
|
+
def sqrl_logout! = sqrl.logout!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Register a block called after every successful SQRL login with
|
|
54
|
+
# +(identity, env, pending_login)+.
|
|
55
|
+
def sqrl_on_login(&block)
|
|
56
|
+
set :sqrl_login_hooks, sqrl_login_hooks + [block]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sinatra extension hook: installs the middleware lazily and the helpers.
|
|
60
|
+
# @param app [Class] the Sinatra application
|
|
61
|
+
def self.registered(app)
|
|
62
|
+
app.helpers Helpers
|
|
63
|
+
app.set :sqrl, {} unless app.respond_to?(:sqrl)
|
|
64
|
+
app.set :sqrl_login_hooks, []
|
|
65
|
+
app.use ::SQRL::Rack::Middleware, -> { middleware_options(app) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The middleware options derived from +set :sqrl+.
|
|
69
|
+
# @api private
|
|
70
|
+
def self.middleware_options(app)
|
|
71
|
+
hooks = app.sqrl_login_hooks
|
|
72
|
+
opts = (app.sqrl || {}).transform_keys(&:to_sym)
|
|
73
|
+
return opts if hooks.empty?
|
|
74
|
+
|
|
75
|
+
user_hook = opts[:on_login]
|
|
76
|
+
opts[:session_identity] = true unless opts.key?(:session_identity)
|
|
77
|
+
opts[:on_login] = lambda do |env, identity, pending_login|
|
|
78
|
+
user_hook&.call(env, identity, pending_login)
|
|
79
|
+
hooks.each { |h| h.call(identity, env, pending_login) }
|
|
80
|
+
end
|
|
81
|
+
opts
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
register SQRL
|
|
86
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Rack
|
|
7
|
+
# The browser-side script shipped with the gem (+assets/sqrl.js+).
|
|
8
|
+
module Assets
|
|
9
|
+
# Directory holding +sqrl.js+.
|
|
10
|
+
ASSETS_DIR = File.expand_path("../../../assets", __dir__)
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Absolute path of +sqrl.js+.
|
|
15
|
+
def javascript_path
|
|
16
|
+
File.join(ASSETS_DIR, "sqrl.js")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Contents of +sqrl.js+ (memoized).
|
|
20
|
+
def javascript
|
|
21
|
+
@javascript ||= File.read(javascript_path, encoding: "UTF-8").freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Short content digest, usable as an ETag or cache-busting token.
|
|
25
|
+
def javascript_digest
|
|
26
|
+
@javascript_digest ||= Digest::SHA256.hexdigest(javascript)[0, 16].freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|