sqrl-core 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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ # The SQRL client <-> server wire protocol: shared types and parsing.
5
+ #
6
+ # A transaction is a sequence of HTTPS POSTs. Every POST body is
7
+ # +client=<b64u>&server=<b64u>&ids=<b64u>[&pids=<b64u>][&urs=<b64u>]+ where
8
+ #
9
+ # * +client+ is a CRLF-separated list of +name=value+ pairs (+ver+, +cmd+, +idk+, ...),
10
+ # * +server+ is, for the first request, the complete +sqrl://+ URL as received, and for
11
+ # every following request the server's previous response *verbatim*,
12
+ # * +ids+ is the Ed25519 signature (per-site identity key) over the concatenation of the
13
+ # +client+ and +server+ base64url strings; +pids+ the same with a previous identity key;
14
+ # +urs+ the same with the Unlock Request Signing Key.
15
+ #
16
+ # The server answers with a base64url-encoded CRLF list containing at least +ver+, +nut+,
17
+ # +tif+ (hex transaction information flags) and +qry+ (the path to POST the next request
18
+ # to).
19
+ module Protocol
20
+ # Protocol version this implementation speaks.
21
+ VERSION = 1
22
+
23
+ module_function
24
+
25
+ # Parse a CRLF-separated +name=value+ list (LF-only input is tolerated).
26
+ #
27
+ # @return [Array<Array(String, String)>] pairs in order
28
+ def parse_pairs(text)
29
+ text.to_s.split("\r\n").flat_map { |l| l.split("\n") }.filter_map do |line|
30
+ next if line.strip.empty?
31
+
32
+ k, v = line.split("=", 2)
33
+ v ? [k.strip, v.chomp("\r")] : [line.strip, ""]
34
+ end
35
+ end
36
+
37
+ # Whether a +ver=+ value (e.g. +1+, +1,2+, +1-3,5+) includes a given version.
38
+ def version_supported?(ver, wanted = VERSION)
39
+ ver.to_s.split(",").any? do |part|
40
+ part = part.strip
41
+ if part.include?("-")
42
+ a, b = part.split("-", 2).map(&:strip)
43
+ a.match?(/\A\d+\z/) && b.match?(/\A\d+\z/) && a.to_i <= wanted && wanted <= b.to_i
44
+ else
45
+ part.match?(/\A\d+\z/) && part.to_i == wanted
46
+ end
47
+ end
48
+ end
49
+
50
+ # Decode a base64url text parameter into a UTF-8 string.
51
+ def decode_text(b64)
52
+ text = Util.b64u_decode(b64).force_encoding(Encoding::UTF_8)
53
+ raise FormatError, "text is not UTF-8" unless text.valid_encoding?
54
+
55
+ text
56
+ end
57
+ end
58
+
59
+ # Transaction Information Flags returned by the server. Immutable value object.
60
+ class Tif
61
+ # The current identity (+idk+) is known to the server.
62
+ ID_MATCH = 0x01
63
+ # The previous identity (+pidk+) is known to the server.
64
+ PREVIOUS_ID_MATCH = 0x02
65
+ # The IP of this request matches the IP that requested the nut.
66
+ IPS_MATCHED = 0x04
67
+ # The account has SQRL authentication disabled.
68
+ SQRL_DISABLED = 0x08
69
+ # The server does not support the requested command / function.
70
+ FUNCTION_NOT_SUPPORTED = 0x10
71
+ # Transient error (e.g. stale nut): the client may retry with the new nut.
72
+ TRANSIENT_ERROR = 0x20
73
+ # The command could not be executed.
74
+ COMMAND_FAILED = 0x40
75
+ # The request was malformed or failed verification.
76
+ CLIENT_FAILURE = 0x80
77
+ # The identity does not match the one that started this transaction.
78
+ BAD_ID_ASSOCIATION = 0x100
79
+ # The identity has been superseded by a newer (re-keyed) identity.
80
+ IDENTITY_SUPERSEDED = 0x200
81
+ # Every bit defined by protocol version 1. "All SQRL clients MUST immediately terminate
82
+ # any connection and abort any authentication operation with any SQRL server which
83
+ # includes TIF bits not defined" (On The Wire, tif).
84
+ DEFINED_BITS = 0x3FF
85
+ # Every TIF bit that means the command did not succeed.
86
+ ERROR_BITS = FUNCTION_NOT_SUPPORTED | TRANSIENT_ERROR | COMMAND_FAILED | CLIENT_FAILURE |
87
+ BAD_ID_ASSOCIATION | IDENTITY_SUPERSEDED
88
+
89
+ attr_reader :value
90
+
91
+ def initialize(value = 0)
92
+ @value = Integer(value)
93
+ freeze
94
+ end
95
+
96
+ # Parse the hexadecimal +tif=+ value.
97
+ #
98
+ # @raise [FormatError]
99
+ def self.parse(hex)
100
+ str = hex.to_s.strip
101
+ raise FormatError, "invalid tif '#{hex}'" unless str.match?(/\A[0-9a-fA-F]+\z/)
102
+
103
+ new(str.hex)
104
+ end
105
+
106
+ # @return [Tif] +value+ wrapped (a Tif is returned unchanged)
107
+ def self.[](value)
108
+ value.is_a?(Tif) ? value : new(value)
109
+ end
110
+
111
+ # Whether only version-1 bits are set.
112
+ def defined?
113
+ @value.nobits?(~DEFINED_BITS)
114
+ end
115
+
116
+ # Test one or more flags.
117
+ def has?(flag)
118
+ @value.anybits?(flag)
119
+ end
120
+
121
+ def id_match? = has?(ID_MATCH)
122
+ def previous_id_match? = has?(PREVIOUS_ID_MATCH)
123
+ def ips_matched? = has?(IPS_MATCHED)
124
+ def sqrl_disabled? = has?(SQRL_DISABLED)
125
+ def function_not_supported? = has?(FUNCTION_NOT_SUPPORTED)
126
+ def transient_error? = has?(TRANSIENT_ERROR)
127
+ def command_failed? = has?(COMMAND_FAILED)
128
+ def client_failure? = has?(CLIENT_FAILURE)
129
+ def bad_id_association? = has?(BAD_ID_ASSOCIATION)
130
+ def identity_superseded? = has?(IDENTITY_SUPERSEDED)
131
+
132
+ # Any of the error flags.
133
+ def error?
134
+ has?(ERROR_BITS)
135
+ end
136
+
137
+ # Lower-case hex as sent on the wire.
138
+ def to_hex
139
+ @value.to_s(16)
140
+ end
141
+
142
+ # @return [Tif] bitwise OR with another Tif or Integer
143
+ def |(other)
144
+ Tif.new(@value | Tif[other].value)
145
+ end
146
+
147
+ # @return [Tif] bitwise AND with another Tif or Integer
148
+ def &(other)
149
+ Tif.new(@value & Tif[other].value)
150
+ end
151
+
152
+ # Clear the given bits.
153
+ def without(flag)
154
+ Tif.new(@value & ~flag)
155
+ end
156
+
157
+ # @return [Integer] the raw flag bits
158
+ def to_i
159
+ @value
160
+ end
161
+
162
+ # @return [Boolean] whether +other+ carries the same values
163
+ def ==(other)
164
+ other.is_a?(Tif) ? other.value == @value : other == @value
165
+ end
166
+ alias eql? ==
167
+
168
+ # @return [Integer] hash consistent with {#==}
169
+ def hash
170
+ @value.hash
171
+ end
172
+
173
+ # @return [String] a short description with secret material redacted
174
+ def inspect
175
+ "#<SQRL::Tif 0x#{to_hex}>"
176
+ end
177
+ alias to_s to_hex
178
+ end
179
+
180
+ # Client commands.
181
+ module Command
182
+ # Ask whether the server knows this identity.
183
+ QUERY = :query
184
+ # Authenticate: log in, create the account or re-key it.
185
+ IDENT = :ident
186
+ # Disable SQRL authentication for the account.
187
+ DISABLE = :disable
188
+ # Re-enable SQRL authentication (needs +urs+).
189
+ ENABLE = :enable
190
+ # Remove the SQRL association (needs +urs+).
191
+ REMOVE = :remove
192
+ # Every command, in wire order.
193
+ ALL = [QUERY, IDENT, DISABLE, ENABLE, REMOVE].freeze
194
+
195
+ module_function
196
+
197
+ # Parse the wire name; returns nil for anything unknown.
198
+ def parse(str)
199
+ sym = str.to_s.to_sym
200
+ ALL.include?(sym) ? sym : nil
201
+ end
202
+
203
+ # The wire name.
204
+ def to_wire(command)
205
+ raise ArgumentError, "unknown command #{command.inspect}" unless ALL.include?(command)
206
+
207
+ command.to_s
208
+ end
209
+ end
210
+
211
+ # Client +opt=+ flags.
212
+ class ClientOptions
213
+ # The +opt=+ flags, in wire order.
214
+ FLAGS = %i[sqrlonly hardlock cps suk noiptest].freeze
215
+
216
+ # Request that the site disable all non-SQRL authentication.
217
+ attr_accessor :sqrlonly
218
+ # Request that the site disable all non-SQRL account recovery.
219
+ attr_accessor :hardlock
220
+ # The client can perform Client Provided Session redirection (+url=+ expected).
221
+ attr_accessor :cps
222
+ # Ask the server to return the account's SUK (needed before enable/remove).
223
+ attr_accessor :suk
224
+ # Do not require the request IP to match the nut's IP.
225
+ attr_accessor :noiptest
226
+
227
+ def initialize(sqrlonly: false, hardlock: false, cps: false, suk: false, noiptest: false)
228
+ @sqrlonly = sqrlonly
229
+ @hardlock = hardlock
230
+ @cps = cps
231
+ @suk = suk
232
+ @noiptest = noiptest
233
+ end
234
+
235
+ alias sqrlonly? sqrlonly
236
+ alias hardlock? hardlock
237
+ alias cps? cps
238
+ alias suk? suk
239
+ alias noiptest? noiptest
240
+
241
+ # Parse an +opt=+ value.
242
+ def self.from_wire(str)
243
+ o = new
244
+ str.to_s.split("~").each do |part|
245
+ flag = part.strip
246
+ o.public_send("#{flag}=", true) if FLAGS.include?(flag.to_sym)
247
+ end
248
+ o
249
+ end
250
+
251
+ # Render the +opt=+ value (+~+ separated), or nil when no option is set.
252
+ def to_wire
253
+ set = FLAGS.select { |f| public_send(f) }
254
+ set.empty? ? nil : set.join("~")
255
+ end
256
+
257
+ # @return [self.class] an unfrozen copy
258
+ def dup
259
+ ClientOptions.new(**to_h)
260
+ end
261
+
262
+ # @return [Hash] the values as a plain Hash
263
+ def to_h
264
+ FLAGS.to_h { |f| [f, public_send(f) ? true : false] }
265
+ end
266
+
267
+ # @return [Boolean] whether +other+ carries the same values
268
+ def ==(other)
269
+ other.is_a?(ClientOptions) && other.to_h == to_h
270
+ end
271
+ alias eql? ==
272
+
273
+ # @return [Integer] hash consistent with {#==}
274
+ def hash
275
+ to_h.hash
276
+ end
277
+
278
+ # @return [String] a short description with secret material redacted
279
+ def inspect
280
+ "#<SQRL::ClientOptions #{to_wire || "(none)"}>"
281
+ end
282
+ end
283
+
284
+ # One button of a server +ask=+.
285
+ AskButton = Struct.new(:label, :url, keyword_init: true) do
286
+ def initialize(label:, url: nil)
287
+ super
288
+ end
289
+ end
290
+
291
+ # A server question (+ask=<msg>~<button1>[;url]~<button2>[;url]+, each part base64url).
292
+ class Ask
293
+ # The question text.
294
+ attr_reader :message
295
+ # Zero, one or two buttons. A client shows "OK" when there are none.
296
+ attr_reader :buttons
297
+ # Button numbers (1 or 2; 0 is the plain "OK" of a buttonless ask) whose answer lets
298
+ # the transaction proceed, or nil to accept any answer. A server-side setting only:
299
+ # it is not part of the wire form.
300
+ attr_reader :accept
301
+
302
+ def initialize(message:, buttons: [], accept: nil)
303
+ @message = message.to_s
304
+ @buttons = buttons.map { |b| b.is_a?(AskButton) ? b : AskButton.new(**b) }.freeze
305
+ @accept = accept.nil? ? nil : Array(accept).map(&:to_i).freeze
306
+ freeze
307
+ end
308
+
309
+ # Whether a +btn=+ answer lets the login proceed. Any answer does when +accept+ is nil.
310
+ def accepts?(btn)
311
+ return true if @accept.nil?
312
+
313
+ @accept.include?(btn.to_i)
314
+ end
315
+
316
+ # Parse the wire form.
317
+ #
318
+ # @raise [FormatError]
319
+ def self.parse(value)
320
+ parts = value.to_s.split("~", -1)
321
+ message = Protocol.decode_text(parts.shift || "")
322
+ buttons = parts.first(2).map do |p|
323
+ label_b64, url = p.split(";", 2)
324
+ AskButton.new(label: Protocol.decode_text(label_b64 || ""), url: url)
325
+ end
326
+ new(message: message, buttons: buttons)
327
+ end
328
+
329
+ # Render the wire form.
330
+ def to_wire
331
+ s = Util.b64u_encode(@message.b)
332
+ @buttons.each do |b|
333
+ s += "~#{Util.b64u_encode(b.label.to_s.b)}"
334
+ s += ";#{b.url}" if b.url
335
+ end
336
+ s
337
+ end
338
+ # @return [Boolean] whether +other+ carries the same values
339
+
340
+ def ==(other)
341
+ other.is_a?(Ask) && other.message == @message && other.buttons == @buttons && other.accept == @accept
342
+ end
343
+ alias eql? ==
344
+ # @return [Integer] hash consistent with {#==}
345
+
346
+ def hash
347
+ [@message, @buttons, @accept].hash
348
+ end
349
+ end
350
+
351
+ # A parsed server response.
352
+ class ServerResponse
353
+ # The response exactly as received (whitespace removed): what the next request's
354
+ # +server=+ parameter carries.
355
+ attr_reader :raw
356
+ # All +name=value+ pairs in order.
357
+ attr_reader :params
358
+ attr_reader :ver, :nut, :tif, :qry, :url, :sin, :suk, :ask, :can
359
+
360
+ # Parse a base64url response body.
361
+ #
362
+ # @raise [ProtocolError, FormatError]
363
+ def self.parse(body)
364
+ new(body)
365
+ end
366
+
367
+ def initialize(body)
368
+ @raw = body.to_s.delete(" \t\r\n\f\v")
369
+ raise ProtocolError, "empty server response" if @raw.empty?
370
+
371
+ decoded = Util.b64u_decode(@raw).force_encoding(Encoding::UTF_8)
372
+ raise ProtocolError, "server response is not UTF-8" unless decoded.valid_encoding?
373
+
374
+ @params = Protocol.parse_pairs(decoded).freeze
375
+ @ver = fetch("ver") or raise ProtocolError, "response lacks ver"
376
+ unless Protocol.version_supported?(@ver, Protocol::VERSION)
377
+ raise ProtocolError, "server speaks version #{@ver}, not #{Protocol::VERSION}"
378
+ end
379
+
380
+ @nut = fetch("nut") or raise ProtocolError, "response lacks nut"
381
+ raise ProtocolError, "response has an empty nut" if @nut.empty?
382
+
383
+ @tif = Tif.parse(fetch("tif") || raise(ProtocolError, "response lacks tif"))
384
+ unless @tif.defined?
385
+ raise ProtocolError, "server returned undefined TIF bits (#{@tif.to_hex}); aborting"
386
+ end
387
+
388
+ @qry = fetch("qry") or raise ProtocolError, "response lacks qry"
389
+ suk = fetch("suk")
390
+ @suk = suk && !suk.empty? ? Util.b64u_decode_fixed(suk, 32) : nil
391
+ ask = fetch("ask")
392
+ @ask = ask && !ask.empty? ? Ask.parse(ask) : nil
393
+ @url = presence(fetch("url"))
394
+ @sin = presence(fetch("sin"))
395
+ @can = presence(fetch("can"))
396
+ freeze
397
+ end
398
+
399
+ # Value of an arbitrary parameter, or nil.
400
+ def [](name)
401
+ fetch(name.to_s)
402
+ # @return [Boolean] whether +other+ carries the same values
403
+ end
404
+
405
+ # @return [Boolean] whether +other+ carries the same values
406
+ def ==(other)
407
+ other.is_a?(ServerResponse) && other.raw == @raw
408
+ end
409
+ # @return [Integer] hash consistent with {#==}
410
+ alias eql? ==
411
+
412
+ # @return [Integer] hash consistent with {#==}
413
+ def hash
414
+ @raw.hash
415
+ end
416
+ # @return [String] a short description with secret material redacted
417
+
418
+ def inspect
419
+ "#<SQRL::ServerResponse tif=0x#{@tif.to_hex} nut=#{@nut} qry=#{@qry}>"
420
+ end
421
+
422
+ private
423
+
424
+ def fetch(name)
425
+ pair = @params.find { |(k, _)| k == name }
426
+ pair && pair[1]
427
+ end
428
+
429
+ def presence(value)
430
+ value.nil? || value.empty? ? nil : value
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Server
5
+ # Configuration of a {Handler}.
6
+ #
7
+ # config = SQRL::Server::Config.new(host: "example.com") do |c|
8
+ # c.on_unknown_identity = :link
9
+ # c.ask = SQRL::Ask.new(message: "Log in to Example?", buttons: [{ label: "Yes" }, { label: "No" }])
10
+ # end
11
+ class Config
12
+ # Accepted values of +on_unknown_identity+.
13
+ UNKNOWN_IDENTITY_POLICIES = %i[create link refuse].freeze
14
+ # URL schemes a server can issue: +sqrl+ (HTTPS) or, in testing mode only, +qrl+.
15
+ SCHEMES = %w[sqrl qrl].freeze
16
+
17
+ # Host (with optional port) placed in issued +sqrl://+ URLs. Must be the public host.
18
+ attr_accessor :host
19
+ # Path of the SQRL endpoint, also used in +qry=+.
20
+ attr_accessor :path
21
+ # Path of the CPS callback used by the default +cps_url+ builder.
22
+ attr_accessor :callback_path
23
+ # The URL scheme issued: always +sqrl+ (which means HTTPS) unless +insecure_http+ is
24
+ # set, in which case it is +qrl+. Read-only; set +insecure_http+ instead.
25
+ attr_reader :scheme
26
+ # Loopback-only testing mode: issue +qrl://+ (plain http) URLs. +qrl://+ is not part
27
+ # of the final SQRL specification and real SQRL clients do not accept it; it exists
28
+ # so a site can be exercised against the browser extension's test server without
29
+ # TLS. Validation refuses it for any host that is not loopback.
30
+ attr_accessor :insecure_http
31
+ # +x=N+ path extension to scope identities to a path prefix (nil for none).
32
+ attr_accessor :path_extension
33
+ # Lifetime in seconds of nuts (login page and per-response).
34
+ attr_accessor :nut_ttl
35
+ # Lifetime in seconds of the one-time CPS landing token.
36
+ attr_accessor :cps_token_ttl
37
+ # Enforce +IPS_MATCHED+ unless the client sends +noiptest+.
38
+ attr_accessor :require_ip_match
39
+ # {Ask} to pose in query replies, or a callable +(context) -> Ask or nil+.
40
+ attr_accessor :ask
41
+ # Secret index challenge (+sin=+): a String, or a callable +(context) -> String or nil+.
42
+ attr_accessor :sin
43
+ # +:create+ (auto account), +:link+ (needs a pending login with +link_user_ref+) or
44
+ # +:refuse+.
45
+ attr_accessor :on_unknown_identity
46
+ # Callable +(identity, pending_login, command)+ invoked after a successful +ident+ or
47
+ # +enable+.
48
+ attr_accessor :on_authenticated
49
+ # Callable +(token, pending_login) -> String+ building the CPS +url=+.
50
+ attr_accessor :cps_url
51
+ # Callable returning the current Time (injectable for tests).
52
+ attr_accessor :clock
53
+ # Logger (anything responding to +debug+/+info+/+warn+), or nil.
54
+ attr_accessor :logger
55
+ # Hosts (Strings, compared case-insensitively with or without port, or Regexps)
56
+ # that an integration deriving +host+ from the request may use; nil allows any.
57
+ attr_accessor :allowed_hosts
58
+ # Run {Handler#prune!} once every this many issued or handled requests; nil disables.
59
+ attr_accessor :prune_every
60
+ # Callable +(context)+ run before a login URL is issued; context has +:ip+ and
61
+ # +:pending_login_id+. Raise {RateLimited} to refuse.
62
+ attr_accessor :before_issue
63
+ # Callable +(context)+ run before a POST is processed (+phase: :request+, with
64
+ # +:ip+ and +:nut+) and again once the command is known (+phase: :command+, adding
65
+ # +:command+ and +:idk+). Raise {RateLimited} to refuse.
66
+ attr_accessor :before_handle
67
+
68
+ def initialize(host: nil, path: "/sqrl/cli", callback_path: "/sqrl/callback", scheme: nil, insecure_http: false,
69
+ path_extension: nil, nut_ttl: 300, cps_token_ttl: 60, require_ip_match: true,
70
+ ask: nil, sin: nil, on_unknown_identity: :create, on_authenticated: nil,
71
+ cps_url: nil, clock: nil, logger: nil, allowed_hosts: nil, prune_every: 1000,
72
+ before_issue: nil, before_handle: nil)
73
+ @allowed_hosts = allowed_hosts
74
+ @prune_every = prune_every
75
+ @before_issue = before_issue
76
+ @before_handle = before_handle
77
+ @host = host
78
+ @path = path
79
+ @callback_path = callback_path
80
+ @insecure_http = insecure_http ? true : false
81
+ @insecure_http = true if scheme.to_s == "qrl"
82
+ @scheme = @insecure_http ? "qrl" : (scheme || "sqrl").to_s
83
+ @path_extension = path_extension
84
+ @nut_ttl = nut_ttl
85
+ @cps_token_ttl = cps_token_ttl
86
+ @require_ip_match = require_ip_match
87
+ @ask = ask
88
+ @sin = sin
89
+ @on_unknown_identity = on_unknown_identity
90
+ @on_authenticated = on_authenticated
91
+ @cps_url = cps_url
92
+ @clock = clock || -> { Time.now }
93
+ @logger = logger
94
+ yield self if block_given?
95
+ validate!
96
+ end
97
+
98
+ # Raise ArgumentError when a value is unusable.
99
+ def validate!
100
+ raise ArgumentError, "host is required" if @host.nil? || @host.to_s.strip.empty?
101
+ raise ArgumentError, "host must not contain a scheme or path" if @host.match?(%r{://|/})
102
+ raise ArgumentError, "path must start with /" unless @path.to_s.start_with?("/")
103
+ raise ArgumentError, "callback_path must start with /" unless @callback_path.to_s.start_with?("/")
104
+ raise ArgumentError, "scheme must be sqrl or qrl" unless SCHEMES.include?(@scheme)
105
+ if insecure_http? && !URL.loopback?(bare_host)
106
+ raise ArgumentError, "insecure_http (plain http, qrl://) is only allowed for loopback hosts, not #{bare_host}"
107
+ end
108
+
109
+ warn_insecure_once if insecure_http?
110
+ unless @path_extension.nil? || (@path_extension.is_a?(Integer) && @path_extension.positive?)
111
+ raise ArgumentError, "path_extension must be nil or a positive Integer"
112
+ end
113
+ raise ArgumentError, "nut_ttl must be a positive number" unless positive_number?(@nut_ttl)
114
+ raise ArgumentError, "cps_token_ttl must be a positive number" unless positive_number?(@cps_token_ttl)
115
+ unless UNKNOWN_IDENTITY_POLICIES.include?(@on_unknown_identity)
116
+ raise ArgumentError, "on_unknown_identity must be one of #{UNKNOWN_IDENTITY_POLICIES.join(", ")}"
117
+ end
118
+ raise ArgumentError, "ask must be an SQRL::Ask or a callable" unless valid_ask?
119
+ raise ArgumentError, "sin must be a String or a callable" unless valid_sin?
120
+ unless @on_authenticated.nil? || callable?(@on_authenticated)
121
+ raise ArgumentError,
122
+ "on_authenticated must be callable"
123
+ end
124
+ raise ArgumentError, "cps_url must be callable" unless @cps_url.nil? || callable?(@cps_url)
125
+ raise ArgumentError, "clock must be callable" unless callable?(@clock)
126
+ unless @allowed_hosts.nil? || (@allowed_hosts.is_a?(Array) && @allowed_hosts.all? do |h|
127
+ h.is_a?(String) || h.is_a?(Regexp)
128
+ end)
129
+ raise ArgumentError, "allowed_hosts must be nil or an Array of Strings and Regexps"
130
+ end
131
+ unless @prune_every.nil? || (@prune_every.is_a?(Integer) && @prune_every.positive?)
132
+ raise ArgumentError, "prune_every must be nil or a positive Integer"
133
+ end
134
+ raise ArgumentError, "before_issue must be callable" unless @before_issue.nil? || callable?(@before_issue)
135
+ raise ArgumentError, "before_handle must be callable" unless @before_handle.nil? || callable?(@before_handle)
136
+
137
+ true
138
+ end
139
+
140
+ # The host without port (IPv6 literals keep their brackets), lower-cased.
141
+ def bare_host
142
+ host, = URL.split_host_port(@host.to_s)
143
+ host.to_s.downcase
144
+ rescue FormatError
145
+ @host.to_s.downcase
146
+ end
147
+
148
+ # Whether +host+ (with or without port) is acceptable under +allowed_hosts+. Always
149
+ # true when no list is configured.
150
+ def host_allowed?(host)
151
+ return true if @allowed_hosts.nil?
152
+
153
+ candidate = host.to_s.downcase
154
+ bare = begin
155
+ URL.split_host_port(candidate).first.to_s
156
+ rescue FormatError
157
+ candidate
158
+ end
159
+ @allowed_hosts.any? do |allowed|
160
+ if allowed.is_a?(Regexp)
161
+ candidate.match?(allowed) || bare.match?(allowed)
162
+ else
163
+ a = allowed.downcase
164
+ a == candidate || a == bare
165
+ end
166
+ end
167
+ end
168
+
169
+ # +https+ (or +http+ in +insecure_http+ mode).
170
+ def http_scheme
171
+ insecure_http? ? "http" : "https"
172
+ end
173
+
174
+ # Whether the site issues +qrl://+ (plain http) URLs; see +insecure_http+.
175
+ def insecure_http? = @insecure_http ? true : false
176
+
177
+ # Warning logged once when +insecure_http+ is enabled.
178
+ INSECURE_WARNING = "[sqrl] insecure_http is a loopback-only testing mode; real SQRL clients require " \
179
+ "sqrl:// over HTTPS"
180
+ @insecure_warned = false
181
+ @insecure_warning_mutex = Mutex.new
182
+
183
+ class << self
184
+ # Reset the one-time insecure_http warning (tests).
185
+ def reset_warnings!
186
+ @insecure_warning_mutex.synchronize { @insecure_warned = false }
187
+ end
188
+
189
+ # Log {INSECURE_WARNING} once per process.
190
+ # @api private
191
+ def warn_insecure_once(logger)
192
+ @insecure_warning_mutex.synchronize do
193
+ return if @insecure_warned
194
+
195
+ @insecure_warned = true
196
+ end
197
+ logger.respond_to?(:warn) ? logger.warn(INSECURE_WARNING) : Kernel.warn(INSECURE_WARNING)
198
+ end
199
+ end
200
+
201
+ # +https://host+
202
+ def origin
203
+ "#{http_scheme}://#{@host}"
204
+ end
205
+
206
+ # @return [Time] the configured clock's current time
207
+ def now
208
+ @clock.call
209
+ end
210
+
211
+ # Resolve the +ask+ setting for a request context.
212
+ def ask_for(context)
213
+ callable?(@ask) ? @ask.call(context) : @ask
214
+ end
215
+
216
+ # Resolve the +sin+ setting for a request context.
217
+ def sin_for(context)
218
+ value = callable?(@sin) ? @sin.call(context) : @sin
219
+ value&.to_s
220
+ end
221
+
222
+ # Build the CPS +url=+ for a token.
223
+ def cps_url_for(token, pending_login)
224
+ return @cps_url.call(token, pending_login) if @cps_url
225
+
226
+ "#{origin}#{@callback_path}?t=#{token}"
227
+ end
228
+
229
+ def require_ip_match? = @require_ip_match ? true : false
230
+
231
+ private
232
+
233
+ # @return [void] see {Config.warn_insecure_once}
234
+ def warn_insecure_once
235
+ self.class.warn_insecure_once(@logger)
236
+ end
237
+
238
+ def positive_number?(v)
239
+ v.is_a?(Numeric) && v.positive?
240
+ end
241
+
242
+ def callable?(v)
243
+ v.respond_to?(:call)
244
+ end
245
+
246
+ def valid_ask?
247
+ @ask.nil? || @ask.is_a?(Ask) || callable?(@ask)
248
+ end
249
+
250
+ def valid_sin?
251
+ @sin.nil? || @sin.is_a?(String) || callable?(@sin)
252
+ end
253
+ end
254
+ end
255
+ end