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 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
@@ -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