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.
@@ -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
@@ -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: []