sqrl-client 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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ # Base class of every error raised by the client gem.
6
+ class Error < SQRL::Error; end
7
+
8
+ # No SQRL login URL could be found for the target.
9
+ class DiscoveryError < Error; end
10
+
11
+ # The server posed an +ask=+ question and no +ask_handler+ was configured to answer it.
12
+ class AskRequired < Error
13
+ # @return [SQRL::Ask]
14
+ attr_reader :ask
15
+
16
+ def initialize(ask)
17
+ @ask = ask
18
+ super("the server asks: #{ask.message.inspect} (buttons: #{ask.buttons.map(&:label).inspect}); " \
19
+ "configure an ask_handler to answer it")
20
+ end
21
+ end
22
+
23
+ # The transaction ended without the requested outcome. +tif+ is the last TIF received,
24
+ # +reason+ a Symbol such as +:disabled+, +:superseded+, +:unknown_site+, +:ip_mismatch+,
25
+ # +:command_failed+ or +:transient+.
26
+ class LoginFailed < Error
27
+ attr_reader :tif, :reason, :state, :result
28
+
29
+ def initialize(message, reason:, tif: nil, state: nil, result: nil)
30
+ @reason = reason
31
+ @tif = tif
32
+ @state = state
33
+ @result = result
34
+ super(message)
35
+ end
36
+ end
37
+
38
+ # The HTTP layer failed (connection refused, TLS error, timeout, non-2xx SQRL endpoint).
39
+ class TransportError < Error
40
+ attr_reader :url, :status
41
+
42
+ def initialize(message, url: nil, status: nil)
43
+ @url = url
44
+ @status = status
45
+ super(message)
46
+ end
47
+ end
48
+
49
+ # An operation needs the Identity Unlock Key (rescue code) and it is not available.
50
+ class IdentityLocked < Error; end
51
+ end
52
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ # An identity as a bot uses it: an unlocked {SQRL::UnlockedIdentity} plus, when known,
6
+ # the Identity Unlock Key needed for +enable+, +remove+ and re-keying, optionally backed
7
+ # by an S4 identity file.
8
+ #
9
+ # SQRL::Client::Identity.load("bot.sqrl", password: "…") # S4 file (binary or SQRLDATA text)
10
+ # SQRL::Client::Identity.from_textual(text, rescue_code: "…") # rescue-only textual import
11
+ # SQRL::Client::Identity.create(password: "…") # brand new, returns the rescue code
12
+ # SQRL::Client::Identity.ephemeral # random IUK, no EnScrypt (tests)
13
+ # SQRL::Client::Identity.ephemeral("seed") # deterministic from a seed
14
+ # SQRL::Client::Identity.from_env # SQRL_IDENTITY_FILE / SQRL_IUK_HEX …
15
+ class Identity
16
+ # @return [SQRL::UnlockedIdentity]
17
+ attr_reader :unlocked
18
+ # @return [SQRL::S4Identity, nil] the identity file, when there is one
19
+ attr_reader :s4
20
+ # @return [String, nil] where the identity file lives
21
+ attr_reader :path
22
+ # @return [String, nil] the rescue code, only right after {.create} or {#rekey!}
23
+ attr_reader :rescue_code
24
+
25
+ # A fast clock for identity creation in tests: every call advances one second so a
26
+ # one-second EnScrypt finishes after one iteration.
27
+ def self.fast_clock
28
+ t = 0
29
+ -> { t += 1000 }
30
+ end
31
+
32
+ # Load an S4 identity file (binary +sqrldata+ or +SQRLDATA+ text) and unlock it.
33
+ #
34
+ # @param path [String]
35
+ # @param password [String]
36
+ # @param rescue_code [String, nil] also recover the IUK (slow; needed for enable/remove)
37
+ def self.load(path, password:, rescue_code: nil)
38
+ s4 = S4Identity.parse(File.binread(path))
39
+ from_s4(s4, password: password, rescue_code: rescue_code, path: path)
40
+ end
41
+
42
+ # Unlock a parsed {SQRL::S4Identity}.
43
+ def self.from_s4(s4, password:, rescue_code: nil, path: nil)
44
+ unless s4.password_block?
45
+ raise IdentityLocked,
46
+ "identity file has no password block; import it with the rescue code"
47
+ end
48
+
49
+ identity = new(s4.unlock(password.to_s), s4: s4, path: path)
50
+ identity.unlock_iuk!(rescue_code) if rescue_code
51
+ identity
52
+ end
53
+
54
+ # Import a rescue-only textual identity (the base56 export) with its rescue code. The
55
+ # IUK is recovered, so the identity can enable/remove right away. Pass +password:+ to
56
+ # also build a password block (returned via {#s4}).
57
+ def self.from_textual(text, rescue_code:, password: nil, settings: nil, now_ms: nil)
58
+ s4 = S4Identity.parse_blocks(Textual.decode(text))
59
+ iuk = s4.unlock_iuk(rescue_code)
60
+ imk = SQRL::Identity.imk_from_iuk(iuk)
61
+ previous = s4.type3 ? s4.type3.decrypt(imk) : []
62
+ unlocked = UnlockedIdentity.from_iuk(iuk, previous)
63
+ if password
64
+ kw = now_ms ? { now_ms: now_ms } : {}
65
+ s4.set_password(unlocked, password, settings || Storage::IdentitySettings.new, **kw)
66
+ end
67
+ new(unlocked, s4: s4, iuk: iuk)
68
+ end
69
+
70
+ # Create a brand-new identity. The rescue code is generated unless given and is
71
+ # available as {#rescue_code}; the S4 file is {#s4} and is written to +path+ when
72
+ # given.
73
+ #
74
+ # @param password [String]
75
+ # @param rescue_code [String, nil] 24 digits (with or without dashes)
76
+ # @param settings [SQRL::Storage::IdentitySettings, nil]
77
+ # @param pw_seconds [Integer, nil] EnScrypt time for the password block (default 5)
78
+ # @param rescue_seconds [Numeric, nil] EnScrypt time for the rescue block (default 5)
79
+ # @param path [String, nil]
80
+ # @param now_ms [#call, nil] clock override (see {.fast_clock}) for fast test identities
81
+ def self.create(password:, rescue_code: nil, settings: nil, pw_seconds: nil, rescue_seconds: nil,
82
+ path: nil, now_ms: nil, &progress)
83
+ settings ||= Storage::IdentitySettings.new
84
+ settings = settings.dup.tap { |s| s.pw_verify_seconds = pw_seconds } if pw_seconds
85
+ rescue_code ||= Storage.generate_rescue_code
86
+ iuk = Util.random_bytes(32)
87
+ kw = now_ms ? { now_ms: now_ms } : {}
88
+ rescue_ms = ((rescue_seconds || 5) * 1000).to_i
89
+ s4 = S4Identity.create(iuk, password, rescue_code, settings, rescue_ms, **kw, &progress)
90
+ File.binwrite(path, s4.to_bytes) if path
91
+ new(UnlockedIdentity.from_iuk(iuk), s4: s4, iuk: iuk, path: path,
92
+ rescue_code: Storage.format_rescue_code(rescue_code))
93
+ end
94
+
95
+ # An identity straight from a 32-byte IUK, skipping EnScrypt and storage entirely.
96
+ # With no seed the IUK is random; with a seed it is derived deterministically
97
+ # (32 raw bytes are used as-is, anything else is hashed), which gives reproducible
98
+ # identities for test suites.
99
+ def self.ephemeral(seed = nil, previous: [])
100
+ iuk = case seed
101
+ when nil then Util.random_bytes(32)
102
+ when String then seed.b.bytesize == 32 ? seed.b : Crypto.sha256("sqrl-client-ephemeral:#{seed.to_s.b}")
103
+ else Crypto.sha256("sqrl-client-ephemeral:#{seed.to_s.b}")
104
+ end
105
+ new(UnlockedIdentity.from_iuk(iuk, previous), iuk: iuk, previous_iuks: previous)
106
+ end
107
+
108
+ # Build an identity from environment variables:
109
+ #
110
+ # * +SQRL_IUK_HEX+ – 64 hex characters: an ephemeral identity (tests, CI)
111
+ # * +SQRL_IDENTITY_FILE+ with +SQRL_PASSWORD+ or +SQRL_PASSWORD_FILE+ – an S4 file
112
+ # * +SQRL_RESCUE_CODE+ – optional, recovers the IUK for enable/remove
113
+ #
114
+ # @return [Identity, nil] nil when nothing is configured
115
+ def self.from_env(env = ENV)
116
+ if (hex = env["SQRL_IUK_HEX"]) && !hex.empty?
117
+ return ephemeral(Util.hex_decode(hex))
118
+ end
119
+
120
+ file = env["SQRL_IDENTITY_FILE"]
121
+ return nil if file.nil? || file.empty?
122
+
123
+ password = env["SQRL_PASSWORD"]
124
+ password = File.read(env["SQRL_PASSWORD_FILE"]).chomp if password.nil? && env["SQRL_PASSWORD_FILE"]
125
+ raise IdentityLocked, "SQRL_PASSWORD or SQRL_PASSWORD_FILE is required with SQRL_IDENTITY_FILE" if password.nil?
126
+
127
+ load(file, password: password, rescue_code: env["SQRL_RESCUE_CODE"])
128
+ end
129
+
130
+ # @api private
131
+ def initialize(unlocked, s4: nil, iuk: nil, path: nil, rescue_code: nil, previous_iuks: nil)
132
+ @unlocked = unlocked
133
+ @s4 = s4
134
+ @iuk = iuk&.b
135
+ @path = path
136
+ @rescue_code = rescue_code
137
+ @previous_iuks = previous_iuks || unlocked.previous_iuks
138
+ end
139
+
140
+ # The Identity Unlock Key when it is known (ephemeral identities, freshly created
141
+ # ones, or after {#unlock_iuk!}); nil otherwise.
142
+ attr_reader :iuk
143
+
144
+ # Whether +enable+/+remove+/+rekey!+ can run without a rescue code.
145
+ def iuk? = !@iuk.nil?
146
+
147
+ # Recover the IUK from the identity file's rescue block (slow: EnScrypt).
148
+ #
149
+ # @return [String] the IUK
150
+ def unlock_iuk!(rescue_code)
151
+ return @iuk if @iuk
152
+
153
+ raise IdentityLocked, "this identity has no rescue block; the IUK cannot be recovered" unless @s4&.rescue_block?
154
+
155
+ iuk = @s4.unlock_iuk(rescue_code)
156
+ raise CryptoError, "rescue code does not belong to this identity" unless @unlocked.matches_iuk?(iuk)
157
+
158
+ @iuk = iuk
159
+ end
160
+
161
+ # Re-key: a fresh IUK replaces the current one and the current one becomes the most
162
+ # recent previous identity, exactly as a SQRL client's "rekey identity" does. Needs the
163
+ # current IUK (ephemeral/created identities have it; file identities need
164
+ # +rescue_code:+). File-backed identities are re-encrypted under +password:+ (and a new
165
+ # rescue code, available as {#rescue_code}); the file at {#path} is rewritten.
166
+ #
167
+ # @return [self]
168
+ def rekey!(password: nil, rescue_code: nil, new_rescue_code: nil, settings: nil, now_ms: nil, rescue_seconds: nil)
169
+ old_iuk = @iuk || (rescue_code && unlock_iuk!(rescue_code))
170
+ raise IdentityLocked, "re-keying needs the current IUK (rescue code)" unless old_iuk
171
+
172
+ new_iuk = Util.random_bytes(32)
173
+ if @s4
174
+ raise ArgumentError, "password: is required to re-key a file-backed identity" unless password
175
+
176
+ new_rescue_code ||= Storage.generate_rescue_code
177
+ kw = now_ms ? { now_ms: now_ms } : {}
178
+ rescue_ms = ((rescue_seconds || 5) * 1000).to_i
179
+ @unlocked = @s4.rekey(@unlocked, old_iuk, new_iuk, password, new_rescue_code,
180
+ settings || @s4.settings, rescue_ms, **kw)
181
+ @rescue_code = Storage.format_rescue_code(new_rescue_code)
182
+ File.binwrite(@path, @s4.to_bytes) if @path
183
+ else
184
+ previous = ([old_iuk] + @unlocked.previous_iuks).first(Storage::MAX_PREVIOUS_KEYS)
185
+ @unlocked = UnlockedIdentity.from_iuk(new_iuk, previous)
186
+ end
187
+ @iuk = new_iuk
188
+ self
189
+ end
190
+
191
+ # A detached copy of this identity as it was before the most recent {#rekey!}:
192
+ # handy for testing that a superseded identity is refused.
193
+ def previous_identity(index = 0)
194
+ iuk = @unlocked.previous_iuks[index]
195
+ return nil unless iuk
196
+
197
+ self.class.ephemeral(iuk)
198
+ end
199
+
200
+ # @return [Integer] number of previous identities carried
201
+ def previous_count = @unlocked.previous_count
202
+
203
+ # 16 hex characters identifying the identity without revealing key material.
204
+ def fingerprint = @unlocked.fingerprint
205
+
206
+ # Save the identity file (requires an S4-backed identity).
207
+ def save(path = @path)
208
+ raise IdentityLocked, "ephemeral identities have no file to save" unless @s4
209
+ raise ArgumentError, "no path given" unless path
210
+
211
+ File.binwrite(path, @s4.to_bytes)
212
+ @path = path
213
+ end
214
+
215
+ # The identity file as +SQRLDATA+ text, or the IUK in hex for ephemeral identities
216
+ # (both forms load back with {.load}/{.from_env}).
217
+ def export
218
+ @s4 ? @s4.to_text : Util.hex_encode(@iuk)
219
+ end
220
+
221
+ # @return [String] a short description with secret material redacted
222
+ def inspect
223
+ "#<SQRL::Client::Identity fingerprint=#{fingerprint} iuk=#{iuk? ? "known" : "locked"} " \
224
+ "previous=#{previous_count}#{" path=#{@path}" if @path}>"
225
+ end
226
+ alias to_s inspect
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "http-cookie"
6
+
7
+ module SQRL
8
+ module Client
9
+ # The HTTP layer: Net::HTTP with a persistent cookie jar, redirects, timeouts and the
10
+ # plain-http policy SQRL needs (+https+ everywhere, +http+ only for loopback hosts
11
+ # while developing).
12
+ #
13
+ # Anything responding to +get(url, headers)+ and +post_form(url, body, headers)+ and
14
+ # returning a {Response} can replace it (for example a Faraday adapter).
15
+ class Transport
16
+ # An HTTP response after redirects were followed.
17
+ Response = Struct.new(:status, :headers, :body, :final_url, :redirects, keyword_init: true) do
18
+ def success? = (200..299).cover?(status)
19
+ def redirect? = (300..399).cover?(status)
20
+
21
+ # @return [String, nil] a response header by name (case-insensitive)
22
+ def [](name) = headers[name.to_s.downcase]
23
+
24
+ # @return [Object] the body parsed as JSON
25
+ # @raise [JSON::ParserError]
26
+ def json
27
+ require "json"
28
+ JSON.parse(body)
29
+ rescue JSON::ParserError
30
+ nil
31
+ end
32
+
33
+ # @return [String] a short description with secret material redacted
34
+ def inspect
35
+ "#<SQRL::Client::Transport::Response #{status} #{final_url}>"
36
+ end
37
+ end
38
+
39
+ # HTTP statuses followed as redirects.
40
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
41
+ # User-Agent sent with every request.
42
+ DEFAULT_USER_AGENT = "sqrl-client/#{SQRL::VERSION} (Ruby #{RUBY_VERSION})".freeze
43
+
44
+ # @return [HTTP::CookieJar]
45
+ attr_reader :cookies
46
+ attr_accessor :open_timeout, :read_timeout, :max_redirects, :user_agent, :logger
47
+
48
+ # @param cookies [HTTP::CookieJar] shared cookie jar
49
+ # @param allow_http [Symbol, Boolean] +false+ (default) never speaks plain http;
50
+ # +:loopback+ permits it for loopback hosts only (testing); +true+ everywhere
51
+ # @param verify_tls [Boolean]
52
+ # @param ca_file [String, nil]
53
+ # @param proxy [String, nil, :env] proxy URL; +:env+ (default) honours +http_proxy+ /
54
+ # +https_proxy+ / +no_proxy+; loopback hosts never go through a proxy
55
+ def initialize(cookies: HTTP::CookieJar.new, allow_http: false, verify_tls: true, ca_file: nil,
56
+ proxy: :env, open_timeout: 10, read_timeout: 30, max_redirects: 10,
57
+ user_agent: DEFAULT_USER_AGENT, logger: nil)
58
+ @cookies = cookies
59
+ @allow_http = allow_http
60
+ @verify_tls = verify_tls
61
+ @ca_file = ca_file
62
+ @proxy = proxy
63
+ @open_timeout = open_timeout
64
+ @read_timeout = read_timeout
65
+ @max_redirects = max_redirects
66
+ @user_agent = user_agent
67
+ @logger = logger
68
+ end
69
+
70
+ # GET +url+, following redirects with the cookie jar.
71
+ def get(url, headers = {}, follow_redirects: true)
72
+ request(:get, url, nil, headers, follow_redirects: follow_redirects)
73
+ end
74
+
75
+ # POST an +application/x-www-form-urlencoded+ body (already encoded).
76
+ def post_form(url, body, headers = {}, follow_redirects: true)
77
+ headers = { "Content-Type" => "application/x-www-form-urlencoded" }.merge(headers)
78
+ request(:post, url, body, headers, follow_redirects: follow_redirects)
79
+ end
80
+
81
+ # Remove every cookie (a fresh "browser").
82
+ def clear_cookies!
83
+ @cookies.clear
84
+ self
85
+ end
86
+
87
+ # Cookies in Netscape +cookies.txt+ format (what curl reads with +-b+).
88
+ def cookies_netscape
89
+ lines = ["# Netscape HTTP Cookie File"]
90
+ @cookies.each do |c|
91
+ domain = c.for_domain? ? ".#{c.domain}" : c.domain
92
+ expires = c.expires ? c.expires.to_i : 0
93
+ lines << [domain, c.for_domain? ? "TRUE" : "FALSE", c.path, c.secure? ? "TRUE" : "FALSE",
94
+ expires, c.name, c.value].join("\t")
95
+ end
96
+ "#{lines.join("\n")}\n"
97
+ end
98
+
99
+ # Cookies as an Array of Hashes (serialisable to JSON).
100
+ def cookies_as_hashes
101
+ @cookies.map do |c|
102
+ { name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure?,
103
+ httponly: c.httponly?, expires: c.expires&.iso8601 }
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def request(method, url, body, headers, follow_redirects:, redirects: 0)
110
+ uri = URI.parse(url.to_s)
111
+ check_scheme!(uri)
112
+ http = build_http(uri)
113
+ req = build_request(method, uri, body, headers)
114
+ @logger&.debug { "[sqrl-client] #{method.to_s.upcase} #{uri}" }
115
+ res = http.start { |h| h.request(req) }
116
+ store_cookies(res, uri)
117
+ status = res.code.to_i
118
+ if follow_redirects && REDIRECT_STATUSES.include?(status) && res["location"]
119
+ raise TransportError.new("too many redirects", url: url) if redirects >= @max_redirects
120
+
121
+ target = uri.merge(res["location"]).to_s
122
+ next_method = [307, 308].include?(status) ? method : :get
123
+ next_body = next_method == :get ? nil : body
124
+ next_headers = next_method == :get ? headers.reject { |k, _| k.to_s.casecmp?("content-type") } : headers
125
+ return request(next_method, target, next_body, next_headers, follow_redirects: true,
126
+ redirects: redirects + 1)
127
+ end
128
+ Response.new(status: status, headers: res.each_header.to_h, body: res.body.to_s,
129
+ final_url: uri.to_s, redirects: redirects)
130
+ rescue SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError, EOFError, Net::ProtocolError => e
131
+ raise TransportError.new("#{method.to_s.upcase} #{url}: #{e.class}: #{e.message}", url: url)
132
+ end
133
+
134
+ def check_scheme!(uri)
135
+ case uri.scheme
136
+ when "https" then nil
137
+ when "http"
138
+ allowed = @allow_http == true || (@allow_http == :loopback && URL.loopback?(uri.host.to_s.downcase))
139
+ unless allowed
140
+ raise TransportError.new("plain http is not allowed for #{uri.host} (allow_http: #{@allow_http.inspect})",
141
+ url: uri.to_s)
142
+ end
143
+ else
144
+ raise TransportError.new("unsupported URL scheme #{uri.scheme.inspect}", url: uri.to_s)
145
+ end
146
+ end
147
+
148
+ def build_http(uri)
149
+ http =
150
+ if URL.loopback?(uri.host.to_s.downcase) || @proxy.nil?
151
+ Net::HTTP.new(uri.host, uri.port, nil)
152
+ elsif @proxy == :env
153
+ Net::HTTP.new(uri.host, uri.port)
154
+ else
155
+ p = URI.parse(@proxy.to_s)
156
+ Net::HTTP.new(uri.host, uri.port, p.host, p.port, p.user, p.password)
157
+ end
158
+ http.open_timeout = @open_timeout
159
+ http.read_timeout = @read_timeout
160
+ if uri.scheme == "https"
161
+ http.use_ssl = true
162
+ http.verify_mode = @verify_tls ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
163
+ http.ca_file = @ca_file if @ca_file
164
+ end
165
+ http
166
+ end
167
+
168
+ def build_request(method, uri, body, headers)
169
+ klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
170
+ req = klass.new(uri.request_uri)
171
+ req["User-Agent"] = @user_agent
172
+ req["Accept"] ||= "*/*"
173
+ headers.each { |k, v| req[k.to_s] = v }
174
+ cookie = HTTP::Cookie.cookie_value(@cookies.cookies(uri))
175
+ req["Cookie"] = cookie unless cookie.empty?
176
+ req.body = body if body
177
+ req
178
+ end
179
+
180
+ def store_cookies(res, uri)
181
+ res.get_fields("set-cookie")&.each do |value|
182
+ HTTP::Cookie.parse(value, uri).each { |c| @cookies.add(c) }
183
+ rescue StandardError => e
184
+ @logger&.warn { "[sqrl-client] ignoring unparsable cookie: #{e.message}" }
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqrl/core"
4
+ require_relative "client/errors"
5
+ require_relative "client/identity"
6
+ require_relative "client/transport"
7
+ require_relative "client/discovery"
8
+ require_relative "client/agent"
9
+
10
+ # SQRL namespace, reopened for the client.
11
+ module SQRL
12
+ # A bot-capable SQRL client: everything a script, a test suite or an automated service
13
+ # needs to log in to a SQRL-enabled website without a browser or a human.
14
+ #
15
+ # identity = SQRL::Client::Identity.load("bot.sqrl", password: ENV["SQRL_PASSWORD"])
16
+ # agent = SQRL::Client::Agent.new(identity: identity)
17
+ # result = agent.login("https://example.com/login")
18
+ # result.authenticated? # => true
19
+ # agent.http.get("https://example.com/account") # carries the logged-in cookies
20
+ module Client
21
+ autoload :Capybara, "sqrl/client/capybara"
22
+ autoload :Conformance, "sqrl/client/conformance"
23
+ autoload :CLI, "sqrl/client/cli"
24
+
25
+ # Version of the client gem (released in lockstep with sqrl-core).
26
+ VERSION = SQRL::VERSION
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sqrl-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel P. Clark
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: http-cookie
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqrl-core
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ description: Identity management, the full SQRL command set, CPS handoff, login-page
41
+ discovery, a Capybara helper, a CLI and a server conformance suite.
42
+ email:
43
+ - 6ftdan@gmail.com
44
+ executables:
45
+ - sqrl-client
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - exe/sqrl-client
53
+ - lib/sqrl/client.rb
54
+ - lib/sqrl/client/agent.rb
55
+ - lib/sqrl/client/capybara.rb
56
+ - lib/sqrl/client/cli.rb
57
+ - lib/sqrl/client/conformance.rb
58
+ - lib/sqrl/client/conformance/report.rb
59
+ - lib/sqrl/client/conformance/rspec.rb
60
+ - lib/sqrl/client/conformance/runner.rb
61
+ - lib/sqrl/client/conformance/site.rb
62
+ - lib/sqrl/client/discovery.rb
63
+ - lib/sqrl/client/errors.rb
64
+ - lib/sqrl/client/identity.rb
65
+ - lib/sqrl/client/transport.rb
66
+ homepage: https://github.com/danielpclark/sqrl-ruby
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/danielpclark/sqrl-ruby
71
+ source_code_uri: https://github.com/danielpclark/sqrl-ruby/tree/main/gems/sqrl-client
72
+ changelog_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-client/CHANGELOG.md
73
+ bug_tracker_uri: https://github.com/danielpclark/sqrl-ruby/issues
74
+ documentation_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-client/README.md
75
+ rubygems_mfa_required: 'true'
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.1'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: 'Bot-capable SQRL client: log in to SQRL-enabled websites from scripts and
93
+ test suites'
94
+ test_files: []