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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Rack
|
|
5
|
+
# Rack middleware that mounts SQRL login in any Rack application.
|
|
6
|
+
#
|
|
7
|
+
# use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET")
|
|
8
|
+
# use SQRL::Rack::Middleware,
|
|
9
|
+
# stores: SQRL::Server::Memory.stores,
|
|
10
|
+
# host: "example.com",
|
|
11
|
+
# on_login: ->(env, identity, pending_login) { env["rack.session"]["user_id"] = User.for_sqrl(identity).id },
|
|
12
|
+
# after_login: "/"
|
|
13
|
+
#
|
|
14
|
+
# It answers +POST path+ (the SQRL endpoint; never reaches the app, so no CSRF layer
|
|
15
|
+
# sees it), +GET status_path+, +GET callback_path+ and +GET script_path+, and exposes
|
|
16
|
+
# +env["sqrl.server"]+ (a {Facade}) to everything else.
|
|
17
|
+
#
|
|
18
|
+
# Options: every {Server::Config} keyword (+host+ defaults to the request host;
|
|
19
|
+
# +scheme+ is always +sqrl+; +insecure_http: true+ switches a loopback-only site to
|
|
20
|
+
# +qrl://+ for testing with the browser extension's test server) plus
|
|
21
|
+
# +stores:+, +status_path:+, +script_path:+, +session_key:+, +identity_key:+,
|
|
22
|
+
# +ip:+ (callable resolving the client IP), +on_login:+ (callable
|
|
23
|
+
# +(env, identity, pending_login)+), +after_login:+ (path, URL or callable
|
|
24
|
+
# +(env, completion) -> String+; a same-site +return_to+ recorded on the pending login
|
|
25
|
+
# wins), +failure_path:+ (redirect target when the callback fails; 403 when nil),
|
|
26
|
+
# +session_identity:+ (store the +idk+ in the session; defaults to true when
|
|
27
|
+
# +on_login+ is nil), +refresh_path:+ (JSON endpoint handing the page a fresh login
|
|
28
|
+
# URL), +allowed_hosts:+ and +host_cache_limit:+ (see {HostResolver}; derived hosts
|
|
29
|
+
# beyond the limit get 400), and the rate-limit hooks +before_issue:+ /
|
|
30
|
+
# +before_handle:+ (see {Server::Config}; a hook raising {Server::RateLimited} turns
|
|
31
|
+
# into HTTP 429).
|
|
32
|
+
class Middleware
|
|
33
|
+
# Option defaults.
|
|
34
|
+
DEFAULTS = {
|
|
35
|
+
path: "/sqrl/cli", callback_path: "/sqrl/callback", status_path: "/sqrl/status", script_path: "/sqrl/sqrl.js",
|
|
36
|
+
refresh_path: "/sqrl/refresh", session_key: Endpoint::SESSION_KEY, identity_key: Endpoint::IDENTITY_KEY,
|
|
37
|
+
after_login: "/"
|
|
38
|
+
}.freeze
|
|
39
|
+
# Options consumed by {Endpoint} rather than {SQRL::Server::Config}.
|
|
40
|
+
ENDPOINT_KEYS = %i[status_path script_path refresh_path session_key identity_key ip poll_requires_ip_match].freeze
|
|
41
|
+
# Options consumed by the middleware itself.
|
|
42
|
+
MIDDLEWARE_KEYS = %i[stores on_login after_login failure_path session_identity host_cache_limit].freeze
|
|
43
|
+
|
|
44
|
+
# @param app [#call]
|
|
45
|
+
# @param options [Hash, #call] options, or a callable returning them (resolved on
|
|
46
|
+
# the first request, which lets a Sinatra app finish its +set+ calls first)
|
|
47
|
+
def initialize(app, options = {})
|
|
48
|
+
@app = app
|
|
49
|
+
@raw_options = options
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Rack entry point.
|
|
54
|
+
# @param env [Hash]
|
|
55
|
+
# @return [Array] a Rack response triple
|
|
56
|
+
def call(env)
|
|
57
|
+
request = ::Rack::Request.new(env)
|
|
58
|
+
endpoint = endpoint_for(request)
|
|
59
|
+
path = request.path_info
|
|
60
|
+
if request.post? && path == options[:path]
|
|
61
|
+
endpoint.cli(request)
|
|
62
|
+
elsif request.get? && path == endpoint.status_path
|
|
63
|
+
endpoint.status(request)
|
|
64
|
+
elsif request.get? && path == endpoint.callback_path
|
|
65
|
+
complete(endpoint, request)
|
|
66
|
+
elsif request.get? && path == endpoint.script_path
|
|
67
|
+
endpoint.script(request)
|
|
68
|
+
elsif request.get? && path == endpoint.refresh_path
|
|
69
|
+
endpoint.refresh(request)
|
|
70
|
+
else
|
|
71
|
+
env["sqrl.server"] = Facade.new(endpoint, request)
|
|
72
|
+
@app.call(env)
|
|
73
|
+
end
|
|
74
|
+
rescue HostNotAllowed => e
|
|
75
|
+
[400, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["#{e.message}\n"]]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolved options (frozen).
|
|
79
|
+
def options
|
|
80
|
+
@options ||= @mutex.synchronize do
|
|
81
|
+
@options || begin
|
|
82
|
+
raw = @raw_options.respond_to?(:call) ? @raw_options.call : @raw_options
|
|
83
|
+
DEFAULTS.merge((raw || {}).transform_keys(&:to_sym)).freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The shared stores.
|
|
89
|
+
def stores
|
|
90
|
+
@stores ||= options[:stores] || Server::Memory.stores
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The {HostResolver} deciding host and scheme per request and bounding the
|
|
94
|
+
# per-host endpoint cache.
|
|
95
|
+
def host_resolver
|
|
96
|
+
return @host_resolver if @host_resolver
|
|
97
|
+
|
|
98
|
+
opts = options # resolved outside the lock: it takes the same mutex
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
scheme = opts[:scheme] || (opts[:insecure_http] ? "qrl" : nil)
|
|
101
|
+
@host_resolver ||= HostResolver.new(host: opts[:host], scheme: scheme,
|
|
102
|
+
allowed_hosts: opts[:allowed_hosts],
|
|
103
|
+
limit: opts[:host_cache_limit], logger: opts[:logger])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One {Endpoint} per (host, scheme, mount prefix) combination, built lazily.
|
|
108
|
+
#
|
|
109
|
+
# @raise [HostNotAllowed] when the request host is not acceptable
|
|
110
|
+
def endpoint_for(request)
|
|
111
|
+
host, scheme = host_resolver.resolve(request)
|
|
112
|
+
prefix = request.script_name.to_s
|
|
113
|
+
host_resolver.fetch([host, scheme, prefix]) { build_endpoint(host, scheme, prefix) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def build_endpoint(host, scheme, prefix)
|
|
119
|
+
config_opts = options.reject { |k, _| ENDPOINT_KEYS.include?(k) || MIDDLEWARE_KEYS.include?(k) }
|
|
120
|
+
config_opts[:host] = host
|
|
121
|
+
config_opts[:scheme] = scheme
|
|
122
|
+
config_opts[:path] = prefix + options[:path]
|
|
123
|
+
config_opts[:callback_path] = prefix + options[:callback_path]
|
|
124
|
+
config = Server::Config.new(**config_opts)
|
|
125
|
+
handler = Server::Handler.new(config: config, stores: stores)
|
|
126
|
+
Endpoint.new(handler: handler, session_key: options[:session_key], identity_key: options[:identity_key],
|
|
127
|
+
status_path: options[:status_path], callback_path: options[:callback_path],
|
|
128
|
+
script_path: options[:script_path], refresh_path: options[:refresh_path], ip: options[:ip],
|
|
129
|
+
poll_requires_ip_match: options.fetch(:poll_requires_ip_match, false))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def complete(endpoint, request)
|
|
133
|
+
completion = endpoint.callback(request)
|
|
134
|
+
return failure(request) unless completion
|
|
135
|
+
|
|
136
|
+
env = request.env
|
|
137
|
+
on_login = options[:on_login]
|
|
138
|
+
keep = options.fetch(:session_identity, on_login.nil?)
|
|
139
|
+
env["rack.session"].delete(endpoint.identity_key) unless keep
|
|
140
|
+
on_login&.call(env, completion.identity, completion.pending_login)
|
|
141
|
+
redirect(after_login_url(request, completion))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def after_login_url(request, completion)
|
|
145
|
+
return_to = completion.return_to
|
|
146
|
+
return return_to if same_site_path?(return_to)
|
|
147
|
+
|
|
148
|
+
target = options[:after_login]
|
|
149
|
+
target = target.call(request.env, completion) if target.respond_to?(:call)
|
|
150
|
+
target.to_s.empty? ? "#{request.script_name}/" : target
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A same-site, root-relative path. Rejects protocol-relative URLs (//host), the
|
|
154
|
+
# backslash form browsers normalise to a slash (/\host), and control characters or
|
|
155
|
+
# whitespace that URL parsers strip before interpreting the rest.
|
|
156
|
+
def same_site_path?(path)
|
|
157
|
+
path.is_a?(String) && path.match?(%r{\A/(?![/\\])}) && !path.match?(/[\x00-\x20\x7f\\]/)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def failure(_request)
|
|
161
|
+
if options[:failure_path]
|
|
162
|
+
redirect(options[:failure_path])
|
|
163
|
+
else
|
|
164
|
+
[403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" },
|
|
165
|
+
["SQRL login failed or expired. Reload the login page and try again."]]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def redirect(location)
|
|
170
|
+
[303, { "location" => location, "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" },
|
|
171
|
+
[%(<!doctype html><title>Redirecting</title><a href="#{CGI.escapeHTML(location)}">Continue</a>)]]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
data/lib/sqrl/rack/qr.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rqrcode"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module SQRL
|
|
7
|
+
module Rack
|
|
8
|
+
# QR code rendering for +sqrl://+ URLs.
|
|
9
|
+
module QR
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Render +url+ as an accessible SVG QR code.
|
|
13
|
+
#
|
|
14
|
+
# @param url [String] the login URL
|
|
15
|
+
# @param size [Integer] rendered width and height in CSS pixels (ignored when
|
|
16
|
+
# +module_size+ is given)
|
|
17
|
+
# @param module_size [Integer, nil] pixels per QR module
|
|
18
|
+
# @param color [String] foreground colour
|
|
19
|
+
# @param background [String] background colour
|
|
20
|
+
# @param title [String] accessible name
|
|
21
|
+
# @param quiet_zone [Integer] modules of margin around the code
|
|
22
|
+
# @param level [Symbol] error correction level (:l, :m, :q, :h)
|
|
23
|
+
# @return [String] an +<svg role="img">+ element
|
|
24
|
+
def svg(url, size: 256, module_size: nil, color: "#000000", background: "#ffffff",
|
|
25
|
+
title: "SQRL login QR code", quiet_zone: 4, level: :m)
|
|
26
|
+
modules = RQRCode::QRCode.new(url.to_s, level: level).modules
|
|
27
|
+
count = modules.length
|
|
28
|
+
total = count + (quiet_zone * 2)
|
|
29
|
+
pixels = module_size ? module_size * total : size
|
|
30
|
+
path = +""
|
|
31
|
+
modules.each_with_index do |row, y|
|
|
32
|
+
row.each_with_index do |dark, x|
|
|
33
|
+
path << "M#{x + quiet_zone} #{y + quiet_zone}h1v1h-1z" if dark
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
<<~SVG.strip
|
|
37
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{total} #{total}" width="#{pixels}" height="#{pixels}" role="img" aria-label="#{h(title)}" shape-rendering="crispEdges" class="sqrl-qr"><title>#{h(title)}</title><rect width="#{total}" height="#{total}" fill="#{h(background)}"/><path d="#{path}" fill="#{h(color)}"/></svg>
|
|
38
|
+
SVG
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Render +url+ as PNG bytes (needs the +chunky_png+ gem that +rqrcode+ depends on).
|
|
42
|
+
#
|
|
43
|
+
# @return [String] binary PNG data
|
|
44
|
+
def png(url, size: 256, level: :m)
|
|
45
|
+
RQRCode::QRCode.new(url.to_s, level: level).as_png(size: size, border_modules: 4).to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def h(str)
|
|
49
|
+
CGI.escapeHTML(str.to_s)
|
|
50
|
+
end
|
|
51
|
+
private_class_method :h
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/sqrl/rack.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "sqrl/core"
|
|
5
|
+
|
|
6
|
+
module SQRL
|
|
7
|
+
# SQRL login for Rack applications: the framework-neutral {Endpoint}, the {Middleware}
|
|
8
|
+
# that mounts it, the per-request {Facade} for applications, the login markup
|
|
9
|
+
# ({LoginBlock}), QR rendering ({QR}) and the browser script ({Assets}).
|
|
10
|
+
module Rack
|
|
11
|
+
# Same as SQRL::VERSION; the gems release in lockstep.
|
|
12
|
+
VERSION = SQRL::VERSION
|
|
13
|
+
|
|
14
|
+
# Raised for configuration problems (for example a missing session middleware).
|
|
15
|
+
class Error < SQRL::Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative "rack/host_resolver"
|
|
20
|
+
require_relative "rack/assets"
|
|
21
|
+
require_relative "rack/qr"
|
|
22
|
+
require_relative "rack/login_block"
|
|
23
|
+
require_relative "rack/endpoint"
|
|
24
|
+
require_relative "rack/facade"
|
|
25
|
+
require_relative "rack/middleware"
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sqrl-rack
|
|
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: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rqrcode
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: sqrl-core
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 0.1.0
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 0.1.0
|
|
60
|
+
description: Rack middleware serving the SQRL endpoint, status poll and CPS callback,
|
|
61
|
+
a Sinatra extension with view helpers, the browser-side script and QR code rendering.
|
|
62
|
+
email:
|
|
63
|
+
- 6ftdan@gmail.com
|
|
64
|
+
executables: []
|
|
65
|
+
extensions: []
|
|
66
|
+
extra_rdoc_files: []
|
|
67
|
+
files:
|
|
68
|
+
- CHANGELOG.md
|
|
69
|
+
- LICENSE
|
|
70
|
+
- README.md
|
|
71
|
+
- assets/sqrl.js
|
|
72
|
+
- lib/roda/plugins/sqrl.rb
|
|
73
|
+
- lib/sinatra/sqrl.rb
|
|
74
|
+
- lib/sqrl/rack.rb
|
|
75
|
+
- lib/sqrl/rack/assets.rb
|
|
76
|
+
- lib/sqrl/rack/endpoint.rb
|
|
77
|
+
- lib/sqrl/rack/facade.rb
|
|
78
|
+
- lib/sqrl/rack/host_resolver.rb
|
|
79
|
+
- lib/sqrl/rack/login_block.rb
|
|
80
|
+
- lib/sqrl/rack/middleware.rb
|
|
81
|
+
- lib/sqrl/rack/qr.rb
|
|
82
|
+
homepage: https://github.com/danielpclark/sqrl-ruby
|
|
83
|
+
licenses:
|
|
84
|
+
- MIT
|
|
85
|
+
metadata:
|
|
86
|
+
homepage_uri: https://github.com/danielpclark/sqrl-ruby
|
|
87
|
+
source_code_uri: https://github.com/danielpclark/sqrl-ruby/tree/main/gems/sqrl-rack
|
|
88
|
+
changelog_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-rack/CHANGELOG.md
|
|
89
|
+
bug_tracker_uri: https://github.com/danielpclark/sqrl-ruby/issues
|
|
90
|
+
documentation_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-rack/README.md
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.1'
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 3.6.9
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: SQRL login for Rack applications and Sinatra
|
|
109
|
+
test_files: []
|