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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0fcfd84f13a0b97bdd7742c8ff8d7cc918cada5c3ce355f51233c77e69463aa6
4
+ data.tar.gz: 0eef85f22ed8e989649837454f1ceb28994dfd3d3f71ac10b54dac0631ddbd93
5
+ SHA512:
6
+ metadata.gz: 896a8745c0a4fdcb39babdb3ad61776b377eac8d60a45a50eb5edbababc62e3e8139f83667edb7ab9cdcd67903c1fa26427dc24f0424b5bd9f2c1ddc05a06442
7
+ data.tar.gz: 6651d2647ce80e827fe772770e69eae96bf46c1a7e9e4d1088e7c113759db729e9cafc0de2d7eac1c335a4c6cedf1843bc16fe9f4afc634f8605fa5e38e0ce29
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-20
4
+
5
+ First release.
6
+
7
+ - `SQRL::Client::Agent`: login, query, disable, enable, remove against any SQRL site, with
8
+ login-page discovery, previous-identity handling, `ask` and `sin` answering, transient
9
+ retry and CPS handoff on the same cookie jar.
10
+ - Identities: S4 files, textual identities, ephemeral identities, environment loading,
11
+ rekey and password change.
12
+ - `SQRL::Client::Transport` (Net::HTTP, cookie jar, TLS options; plain HTTP refused unless
13
+ `insecure: true`), `SQRL::Client::Capybara` helper, and the `sqrl-client` executable with
14
+ Netscape/JSON cookie export.
15
+ - A 23-case server conformance suite usable from Ruby, RSpec (`it_behaves_like "a SQRL
16
+ server"`) and the command line, with JUnit output.
17
+ - Requires Ruby 3.1+ and `http-cookie`.
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,154 @@
1
+ # sqrl-client
2
+
3
+ A bot-capable [SQRL](https://www.grc.com/sqrl/sqrl.htm) client for Ruby: everything a
4
+ script, a test suite or an automated service needs to log in to a SQRL-enabled website
5
+ without a browser or a human. It is also the test driver for the `sqrl-*` server gems and
6
+ ships a conformance suite any SQRL server can be checked against.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ gem "sqrl-client"
12
+ ```
13
+
14
+ ```ruby
15
+ require "sqrl/client"
16
+
17
+ identity = SQRL::Client::Identity.load("bot.sqrl", password: ENV["SQRL_PASSWORD"])
18
+ agent = SQRL::Client::Agent.new(identity: identity)
19
+ result = agent.login("https://example.com/login") # a login page, a sqrl:// URL or a localhost:25519 link
20
+
21
+ result.authenticated? # => true
22
+ result.created? # => true on the first visit (the site created the account)
23
+ result.idk # => the site-specific public key, base64url
24
+ result.landing # => the response of following the CPS url= (logged-in page)
25
+ agent.http.get("https://example.com/account") # same cookie jar: already logged in
26
+ ```
27
+
28
+ ## Identities
29
+
30
+ | Call | Use |
31
+ | --- | --- |
32
+ | `Identity.load(path, password:)` | an S4 identity file (binary `sqrldata` or `SQRLDATA` text), as exported by any SQRL client |
33
+ | `Identity.from_textual(text, rescue_code:, password:)` | import a base56 textual identity |
34
+ | `Identity.create(password:)` | a brand-new identity; `#rescue_code` is shown once |
35
+ | `Identity.ephemeral` / `Identity.ephemeral("seed")` | an identity straight from a random or deterministic IUK, no EnScrypt; ideal for tests |
36
+ | `Identity.from_env` | `SQRL_IUK_HEX`, or `SQRL_IDENTITY_FILE` + `SQRL_PASSWORD`/`SQRL_PASSWORD_FILE` (+ `SQRL_RESCUE_CODE`) |
37
+
38
+ `enable`, `remove` and `rekey!` need the Identity Unlock Key. Ephemeral and freshly created
39
+ identities have it; file identities get it from `rescue_code:` (slow: EnScrypt).
40
+
41
+ ## The agent
42
+
43
+ ```ruby
44
+ agent = SQRL::Client::Agent.new(
45
+ identity: identity,
46
+ cps: true, # request a Client Provided Session and follow its url=
47
+ unknown_site: :create, # or :refuse to never create accounts
48
+ options: { sqrlonly: false, hardlock: false, noiptest: false },
49
+ alt_id: nil,
50
+ ask_handler: ->(ask) { ask.buttons.index { |b| b.label =~ /allow/i }.to_i + 1 },
51
+ insecure: false # true: accept plain http and qrl:// for loopback test servers only
52
+ )
53
+
54
+ agent.login(url) # query → (ask) → ident; creates / logs in / re-keys as the site answers
55
+ agent.query(url) # does the site know me? (state :unknown / :known / :previous_known)
56
+ agent.disable(url)
57
+ agent.enable(url, rescue_code: "…") # rescue code not needed for identities that hold the IUK
58
+ agent.remove(url, rescue_code: "…")
59
+ ```
60
+
61
+ Every result carries the full `transcript` of the exchange (signatures are not recorded),
62
+ the TIF history, the cancel URL and the cookie jar. Failures raise `LoginFailed` with a
63
+ `reason` (`:disabled`, `:superseded`, `:unknown_site`, `:ip_mismatch`, `:transient`, …),
64
+ `AskRequired` when the site asks a question and no handler is set, `DiscoveryError` when no
65
+ `sqrl://` link is on the page, or `TransportError`.
66
+
67
+ Transient errors are retried with the fresh nut, previous identities are offered in turn
68
+ (`PREVIOUS_ID_MATCH` re-keys the account), `sin`/`ins` and `pins` are answered, and the CPS
69
+ `url=` is validated to point at the site before it is followed.
70
+
71
+ SQRL is `sqrl://` over HTTPS, and that is all the agent speaks by default. `insecure: true`
72
+ (CLI: `--insecure`) is a loopback-only testing mode that also accepts plain-http pages and
73
+ `qrl://` links, which the browser extension's test server and this repository's own spec
74
+ site use; `qrl://` is not part of the final SQRL specification and real clients refuse
75
+ it. A self-signed development certificate is trusted with `Transport.new(ca_file:)`
76
+ (CLI: `--ca-file`); `--no-verify-tls` turns verification off altogether.
77
+
78
+ ## Capybara
79
+
80
+ ```ruby
81
+ visit "/login"
82
+ SQRL::Client::Capybara.login(page, identity: SQRL::Client::Identity.ephemeral("bot"))
83
+ expect(page).to have_content("Welcome")
84
+ ```
85
+
86
+ The protocol runs out of band; the **browser** then visits the one-time CPS URL, exactly as a
87
+ native client would redirect the tab, so the browser's own session is the one logged in.
88
+ `cps: false` performs `ident` only and waits for the page's poller to redirect.
89
+
90
+ ## Command line
91
+
92
+ ```
93
+ sqrl-client identity create --out bot.sqrl --password-file pw.txt # prints the rescue code once
94
+ sqrl-client identity import --textual id.txt --rescue-code 1234-… --out bot.sqrl
95
+ sqrl-client identity show --identity bot.sqrl [--json]
96
+ sqrl-client identity export --identity bot.sqrl --format s4-text|textual|binary [--out FILE]
97
+ sqrl-client identity rekey --identity bot.sqrl --password … --rescue-code … # new IUK, old one kept as previous
98
+ sqrl-client identity change-password --identity bot.sqrl --password OLD --new-password NEW
99
+ sqrl-client identity previous --identity bot.sqrl # superseded identities, most recent first
100
+ sqrl-client login https://example.com/login --identity bot.sqrl --cookies-out jar.txt [--no-cps] [--alt-id ID] [--json]
101
+ sqrl-client query | disable | enable | remove URL … # enable/remove need --rescue-code
102
+ sqrl-client conformance https://example.com/login [--report junit.xml] [--feature ask=on] [--only CASE,…]
103
+ ```
104
+
105
+ Identities come from `--identity FILE` with `--password`/`--password-file`, from the
106
+ `SQRL_IDENTITY_FILE`/`SQRL_PASSWORD`/`SQRL_PASSWORD_FILE`/`SQRL_RESCUE_CODE` variables, or
107
+ from `--iuk-hex`/`SQRL_IUK_HEX` for throwaway test identities. `--pw-seconds` and
108
+ `--rescue-seconds` shorten EnScrypt for test identities. Exit codes: 0 success, 1 the site
109
+ refused (protocol), 2 transport failure, 3 usage. Cookies are written in Netscape format
110
+ (`curl -b jar.txt`) or JSON (`--cookies-format json`).
111
+
112
+ ## Conformance suite
113
+
114
+ ```ruby
115
+ report = SQRL::Client::Conformance::Runner.new(login_url: "https://example.com/login").run
116
+ puts report # PASS / FAIL / SKIP per case
117
+ report.passed?
118
+ report.to_junit_xml
119
+ ```
120
+
121
+ Cases cover account creation, login with and without CPS, disable/enable/remove, re-keying
122
+ and superseded identities, `ask`/`btn`, `sin`/`ins`, Alt-IDs, nut replay, unknown nuts,
123
+ tampered requests, wrong `server=` echoes, bad signatures, identity creation without
124
+ `suk`/`vuk`, one-time CPS tokens, transient recovery and concurrent logins. Feature flags
125
+ (`features: { ask: true, sin: true, cps_single_use: false, … }`) skip what a site does not
126
+ do; a custom `site:` adapter handles pages with unusual layouts.
127
+
128
+ In RSpec:
129
+
130
+ ```ruby
131
+ require "sqrl/client/conformance/rspec"
132
+
133
+ RSpec.describe "my site" do
134
+ let(:login_url) { "http://127.0.0.1:9292/login" }
135
+ it_behaves_like "a SQRL server"
136
+ end
137
+ ```
138
+
139
+ ## Limits
140
+
141
+ Ruby cannot wipe memory reliably; key material lives in ordinary Strings. Sites that show
142
+ only a QR image (no `sqrl://` link anywhere in the page) are not supported yet. SQRL for
143
+ APIs is not specified yet and therefore not implemented.
144
+
145
+ ## Testing
146
+
147
+ The gem's own specs boot a small Rack site backed by `SQRL::Server::Handler`; this
148
+ repository's integration suite (`spec/integration`) drives every example application with
149
+ this client over HTTPS, and `spec/interop` runs the conformance suite against the Rust
150
+ reference server. Run `bundle exec rake spec:sqrl-client`.
151
+
152
+ ## License
153
+
154
+ MIT. See [LICENSE](LICENSE).
data/exe/sqrl-client ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sqrl/client"
5
+ require "sqrl/client/cli"
6
+
7
+ exit SQRL::Client::CLI.run(ARGV)
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SQRL
6
+ module Client
7
+ # The outcome of one {Agent} operation.
8
+ class LoginResult
9
+ # @return [Symbol] +:query+, +:ident+, +:disable+, +:enable+ or +:remove+
10
+ attr_reader :command
11
+ # @return [String] the +sqrl://+ URL that was used
12
+ attr_reader :sqrl_url
13
+ # @return [SQRL::Session::State] state after the last exchange
14
+ attr_reader :state
15
+ # @return [SQRL::Tif, nil] TIF of the last response
16
+ attr_reader :tif
17
+ # @return [Array<SQRL::Tif>] every TIF received, in order
18
+ attr_reader :tif_history
19
+ # @return [String] the identity's per-site public key, base64url
20
+ attr_reader :idk
21
+ # @return [String, nil] the validated CPS +url=+ when the server sent one
22
+ attr_reader :cps_url
23
+ # @return [Transport::Response, nil] the response of following the CPS URL
24
+ attr_reader :landing
25
+ # @return [Hash, nil] the last JSON answer of the status poll (non-CPS logins)
26
+ attr_reader :status
27
+ # @return [HTTP::CookieJar] the cookies, including those set by the landing page
28
+ attr_reader :cookies
29
+ # @return [Array<Hash>] one entry per wire exchange (signatures are not recorded)
30
+ attr_reader :transcript
31
+ # @return [SQRL::Ask, nil] the question the server posed, if any
32
+ attr_reader :ask
33
+ # @return [Integer, nil] the button that answered it
34
+ attr_reader :button
35
+ # @return [String, nil] the cancel URL offered by the site
36
+ attr_reader :cancel_url
37
+
38
+ # @api private
39
+ def initialize(command:, sqrl_url:, state:, tif:, tif_history:, idk:, cookies:, transcript:, created: false,
40
+ rekeyed: false, authenticated: false, cps_url: nil, landing: nil, status: nil, ask: nil,
41
+ button: nil, cancel_url: nil)
42
+ @command = command
43
+ @sqrl_url = sqrl_url
44
+ @state = state
45
+ @tif = tif
46
+ @tif_history = tif_history
47
+ @idk = idk
48
+ @cookies = cookies
49
+ @transcript = transcript
50
+ @created = created
51
+ @rekeyed = rekeyed
52
+ @authenticated = authenticated
53
+ @cps_url = cps_url
54
+ @landing = landing
55
+ @status = status
56
+ @ask = ask
57
+ @button = button
58
+ @cancel_url = cancel_url
59
+ end
60
+
61
+ # Whether the site accepted the login (or enable) and the session is established.
62
+ def authenticated? = @authenticated
63
+ # Whether this operation created the account on the site.
64
+ def created? = @created
65
+ # Whether this operation moved the site's account to a re-keyed identity.
66
+ def rekeyed? = @rekeyed
67
+ # Whether the site knows the identity (after a +query+).
68
+ def known? = state.known?
69
+ def disabled? = state.disabled?
70
+
71
+ # @return [Hash] a JSON-friendly summary (no secrets)
72
+ def to_h
73
+ { command: command, sqrl_url: sqrl_url, state: state.kind, disabled: state.disabled?, tif: tif&.to_hex,
74
+ idk: idk, authenticated: authenticated?, created: created?, rekeyed: rekeyed?, cps_url: cps_url,
75
+ landing_status: landing&.status, landing_url: landing&.final_url, ask: ask&.message, button: button }
76
+ end
77
+
78
+ # @return [String] a short description with secret material redacted
79
+ def inspect
80
+ "#<SQRL::Client::LoginResult #{command} #{state.kind} authenticated=#{authenticated?}>"
81
+ end
82
+ end
83
+
84
+ # A headless SQRL client that performs whole transactions against a site: discovery of
85
+ # the login link, the +query+ → +ident+ dance (including account creation, re-keying,
86
+ # previous identities, +ask+ answers and +sin+/+ins+), the CPS handoff, and the account
87
+ # commands +disable+, +enable+ and +remove+.
88
+ class Agent
89
+ # Seconds between status polls for non-CPS logins.
90
+ DEFAULT_POLL_INTERVAL = 0.5
91
+
92
+ attr_reader :identity, :transport, :options, :alt_id, :logger
93
+ attr_accessor :cps, :follow_cps, :unknown_site, :ask_handler, :max_transient_retries, :poll, :poll_interval,
94
+ :poll_timeout
95
+
96
+ # @param identity [Identity]
97
+ # @param transport [#get, #post_form] defaults to a fresh {Transport}
98
+ # @param cps [Boolean] request a Client Provided Session (+url=+)
99
+ # @param follow_cps [Boolean] GET the CPS +url=+ with the agent's own cookie jar; set
100
+ # to false when something else (a browser) will visit it, since the token is one-time
101
+ # @param unknown_site [Symbol] +:create+ an account on sites that do not know the
102
+ # identity, or +:refuse+ to
103
+ # @param options [Hash] +sqrlonly:+, +hardlock:+, +noiptest:+
104
+ # @param alt_id [String, nil] SQRL Alt-ID (a different identity on the same site)
105
+ # @param ask_handler [#call, nil] receives the {SQRL::Ask} and returns the button
106
+ # number (0 for the plain OK, 1 or 2); without one an +ask+ raises {AskRequired}
107
+ # @param max_transient_retries [Integer]
108
+ # @param poll [Boolean] for non-CPS logins, poll the site's status URL when one was
109
+ # discovered
110
+ # @param poll_timeout [Numeric] seconds
111
+ # +insecure: true+ accepts the loopback-only +qrl://+ (plain http) testing mode and
112
+ # lets the default transport speak plain http to loopback hosts. Off by default:
113
+ # SQRL is +sqrl://+ over HTTPS.
114
+ def initialize(identity:, transport: nil, cps: true, follow_cps: true, unknown_site: :create, options: {},
115
+ alt_id: nil, ask_handler: nil, max_transient_retries: 2, poll: true,
116
+ poll_interval: DEFAULT_POLL_INTERVAL, poll_timeout: 10, logger: nil, insecure: false)
117
+ raise ArgumentError, "unknown_site must be :create or :refuse" unless %i[create refuse].include?(unknown_site)
118
+
119
+ @identity = identity
120
+ @insecure = insecure ? true : false
121
+ @transport = transport || Transport.new(logger: logger, allow_http: @insecure ? :loopback : false)
122
+ @cps = cps
123
+ @follow_cps = follow_cps
124
+ @unknown_site = unknown_site
125
+ @options = { sqrlonly: false, hardlock: false, noiptest: false }.merge(options)
126
+ @alt_id = alt_id
127
+ @ask_handler = ask_handler
128
+ @max_transient_retries = max_transient_retries
129
+ @poll = poll
130
+ @poll_interval = poll_interval
131
+ @poll_timeout = poll_timeout
132
+ @logger = logger
133
+ end
134
+
135
+ # The transport, for the bot's own requests after logging in.
136
+ alias http transport
137
+
138
+ # Whether plain-http / +qrl://+ loopback testing mode is on.
139
+ def insecure? = @insecure
140
+
141
+ # Log in (creating the account, or re-keying it, as the site's answer dictates).
142
+ #
143
+ # @param target [String] a login page URL, a +sqrl://+ URL or a
144
+ # +localhost:25519/<b64>+ CPS link
145
+ # @return [LoginResult]
146
+ # @raise [LoginFailed, AskRequired, DiscoveryError, TransportError]
147
+ def login(target)
148
+ run(target, :ident)
149
+ end
150
+
151
+ # Ask the site whether it knows the identity, without logging in.
152
+ def query(target)
153
+ run(target, :query)
154
+ end
155
+
156
+ # Disable SQRL login for the account on the site.
157
+ def disable(target)
158
+ run(target, :disable)
159
+ end
160
+
161
+ # Re-enable SQRL login. Needs the IUK (ephemeral/created identities have it; file
162
+ # identities take +rescue_code:+).
163
+ def enable(target, rescue_code: nil)
164
+ run(target, :enable, rescue_code: rescue_code)
165
+ end
166
+
167
+ # Remove the SQRL association from the account. Needs the IUK like {#enable}.
168
+ def remove(target, rescue_code: nil)
169
+ run(target, :remove, rescue_code: rescue_code)
170
+ end
171
+
172
+ # Discover the login link for +target+ without talking SQRL.
173
+ #
174
+ # @return [Discovery::Result]
175
+ def discover(target)
176
+ direct = Discovery.from_target(target)
177
+ return direct if direct
178
+
179
+ page = @transport.get(target)
180
+ unless page.success?
181
+ raise TransportError.new("login page #{target} answered #{page.status}", url: target, status: page.status)
182
+ end
183
+
184
+ Discovery.find(page.body, base_url: page.final_url)
185
+ end
186
+
187
+ private
188
+
189
+ def client_options
190
+ ClientOptions.new(cps: @cps, sqrlonly: @options[:sqrlonly], hardlock: @options[:hardlock],
191
+ noiptest: @options[:noiptest])
192
+ end
193
+
194
+ def run(target, command, rescue_code: nil)
195
+ found = discover(target)
196
+ session = Session.new(@identity.unlocked, found.sqrl_url, options: client_options, alt_id: @alt_id,
197
+ insecure: @insecure)
198
+ ctx = Context.new(found, session)
199
+ transient = 0
200
+
201
+ loop do
202
+ query_until_settled(ctx)
203
+ break if command == :query
204
+
205
+ ctx.pre_state = session.state
206
+ answer_ask(ctx)
207
+ request = build_command(ctx, command, rescue_code)
208
+ exchange(ctx, request)
209
+ break unless session.state.transient?
210
+
211
+ transient += 1
212
+ next unless transient > @max_transient_retries
213
+
214
+ raise LoginFailed.new("the server kept answering with a transient error", reason: :transient,
215
+ tif: session.last_response.tif,
216
+ state: session.state)
217
+ end
218
+
219
+ finish(ctx, command)
220
+ end
221
+
222
+ # Small bag of per-transaction state. @api private
223
+ Context = Struct.new(:found, :session, :pre_state, :tifs, :transcript, :ask, :button, :landing, :status) do
224
+ def initialize(found, session)
225
+ super(found, session, nil, [], [], nil, nil, nil, nil)
226
+ end
227
+ end
228
+
229
+ # Query until the server has classified the identity, retrying on transient errors
230
+ # and walking the previous identities when the current one is unknown.
231
+ def query_until_settled(ctx)
232
+ session = ctx.session
233
+ transient = 0
234
+ loop do
235
+ exchange(ctx, session.query)
236
+ state = session.state
237
+ case state.kind
238
+ when :transient
239
+ transient += 1
240
+ next if transient <= @max_transient_retries
241
+
242
+ raise LoginFailed.new("the server kept answering with a transient error", reason: :transient,
243
+ tif: session.last_response.tif, state: state)
244
+ when :unknown
245
+ next if session.has_previous? && session.try_next_previous
246
+
247
+ return
248
+ when :known, :previous_known
249
+ return
250
+ when :superseded
251
+ raise LoginFailed.new("this identity was superseded on the site by a newer one", reason: :superseded,
252
+ tif: session.last_response.tif, state: state)
253
+ else
254
+ reason = session.ip_check_failed? ? :ip_mismatch : :failed
255
+ raise LoginFailed.new(state.message || "query failed", reason: reason, tif: session.last_response.tif,
256
+ state: state)
257
+ end
258
+ end
259
+ end
260
+
261
+ def answer_ask(ctx)
262
+ ask = ctx.session.pending_ask
263
+ return unless ask
264
+
265
+ ctx.ask = ask
266
+ raise AskRequired, ask unless @ask_handler
267
+
268
+ btn = @ask_handler.call(ask).to_i
269
+ ctx.button = btn
270
+ ctx.session.set_button(btn)
271
+ end
272
+
273
+ def build_command(ctx, command, rescue_code)
274
+ session = ctx.session
275
+ state = session.state
276
+ case command
277
+ when :ident
278
+ if state.unknown? && @unknown_site == :refuse
279
+ raise LoginFailed.new("#{session.url.host} does not know this identity and unknown_site is :refuse",
280
+ reason: :unknown_site, tif: session.last_response.tif, state: state)
281
+ end
282
+ if state.disabled?
283
+ raise LoginFailed.new("SQRL login is disabled for this account on #{session.url.host}", reason: :disabled,
284
+ tif: session.last_response.tif, state: state)
285
+ end
286
+ session.ident
287
+ when :disable
288
+ session.disable
289
+ when :enable, :remove
290
+ iuk = state.previous_known? ? nil : unlock_key(rescue_code)
291
+ command == :enable ? session.enable(iuk) : session.remove(iuk)
292
+ else
293
+ raise ArgumentError, "unknown command #{command.inspect}"
294
+ end
295
+ end
296
+
297
+ def unlock_key(rescue_code)
298
+ return @identity.iuk if @identity.iuk?
299
+ return @identity.unlock_iuk!(rescue_code) if rescue_code
300
+
301
+ raise IdentityLocked,
302
+ "enable/remove need the Identity Unlock Key: pass rescue_code: or use an identity that has it"
303
+ end
304
+
305
+ def exchange(ctx, request)
306
+ response = @transport.post_form(request.url, request.body, { "Accept" => "text/plain" })
307
+ unless response.success?
308
+ raise TransportError.new("SQRL endpoint #{request.url} answered HTTP #{response.status}", url: request.url,
309
+ status: response.status)
310
+ end
311
+
312
+ parsed = ctx.session.feed(response.body.strip)
313
+ ctx.tifs << parsed.tif
314
+ ctx.transcript << { command: request.command, url: request.url, client_text: request.client_text,
315
+ http_status: response.status, tif: parsed.tif.to_hex, response: parsed.params.to_h }
316
+ @logger&.debug { "[sqrl-client] #{request.command} -> tif=0x#{parsed.tif.to_hex} (#{ctx.session.state.kind})" }
317
+ parsed
318
+ end
319
+
320
+ def finish(ctx, command)
321
+ session = ctx.session
322
+ state = session.state
323
+ tif = session.last_response&.tif
324
+ authenticated = false
325
+ created = false
326
+ rekeyed = false
327
+ cps_url = nil
328
+
329
+ case command
330
+ when :ident, :enable
331
+ unless !tif.error? && tif.id_match? && !(command == :ident && tif.sqrl_disabled?)
332
+ raise LoginFailed.new("#{command} failed: #{state.message || state.kind}", reason: failure_reason(session),
333
+ tif: tif, state: state)
334
+ end
335
+ authenticated = true
336
+ created = command == :ident && ctx.pre_state&.unknown?
337
+ rekeyed = ctx.pre_state&.previous_known? || false
338
+ when :disable
339
+ unless !tif.error? && tif.sqrl_disabled?
340
+ raise LoginFailed.new("disable failed: #{state.message || state.kind}", reason: failure_reason(session),
341
+ tif: tif, state: state)
342
+ end
343
+ when :remove
344
+ if tif.error? || tif.id_match?
345
+ raise LoginFailed.new("remove failed: #{state.message || state.kind}", reason: failure_reason(session),
346
+ tif: tif, state: state)
347
+ end
348
+ end
349
+
350
+ if command != :query
351
+ cps_url = session.cps_url
352
+ if authenticated
353
+ if cps_url
354
+ ctx.landing = @transport.get(cps_url) if @follow_cps
355
+ elsif @poll && ctx.found.status_url
356
+ ctx.status = poll_status(ctx)
357
+ end
358
+ end
359
+ end
360
+
361
+ LoginResult.new(command: command, sqrl_url: ctx.found.sqrl_url, state: state, tif: tif, tif_history: ctx.tifs,
362
+ idk: session.idk_b64, cookies: @transport.respond_to?(:cookies) ? @transport.cookies : nil,
363
+ transcript: ctx.transcript, created: created, rekeyed: rekeyed, authenticated: authenticated,
364
+ cps_url: cps_url, landing: ctx.landing, status: ctx.status, ask: ctx.ask, button: ctx.button,
365
+ cancel_url: session.cancel_url)
366
+ end
367
+
368
+ def failure_reason(session)
369
+ tif = session.last_response&.tif
370
+ return :unknown if tif.nil?
371
+ return :superseded if tif.identity_superseded?
372
+ return :disabled if tif.sqrl_disabled?
373
+ return :ip_mismatch if session.ip_check_failed?
374
+ return :client_failure if tif.client_failure?
375
+ return :not_supported if tif.function_not_supported?
376
+
377
+ :command_failed
378
+ end
379
+
380
+ # Poll the discovered status URL until it reports the login, following its +next+
381
+ # URL (which completes a cookie-based login) when it gives one.
382
+ def poll_status(ctx)
383
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @poll_timeout
384
+ last = nil
385
+ loop do
386
+ response = @transport.get(ctx.found.status_url, { "Accept" => "application/json" })
387
+ last = response.json || {}
388
+ if last["authenticated"]
389
+ ctx.landing = @transport.get(URI.join(ctx.found.status_url, last["next"]).to_s) if last["next"]
390
+ return last
391
+ end
392
+ return last if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
393
+
394
+ sleep @poll_interval
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ # Drives a Capybara session through a SQRL login, the way a native client on the same
6
+ # machine would: the protocol runs out of band with an {Agent}, then the *browser*
7
+ # visits the server's CPS +url=+, so the browser's own session cookie is the one that
8
+ # gets logged in.
9
+ #
10
+ # visit "/login"
11
+ # SQRL::Client::Capybara.login(page, identity: SQRL::Client::Identity.ephemeral("bot"))
12
+ # expect(page).to have_content("Welcome")
13
+ module Capybara
14
+ module_function
15
+
16
+ # @param page [Capybara::Session] showing a page with a SQRL login link
17
+ # @param identity [Identity]
18
+ # @param cps [Boolean] +true+: visit the CPS URL in the browser; +false+: perform
19
+ # +ident+ only and wait for the page's own poller to navigate
20
+ # @param wait [Numeric] seconds to wait for the non-CPS redirect
21
+ # @param agent_opts [Hash] passed to {Agent#initialize}
22
+ # @return [LoginResult]
23
+ def login(page, identity:, cps: true, wait: 10, **agent_opts)
24
+ found = Discovery.find(page.html, base_url: page.current_url)
25
+ agent = Agent.new(identity: identity, cps: cps, follow_cps: false, poll: false, **agent_opts)
26
+ result = agent.login(found.sqrl_url)
27
+ if cps
28
+ raise LoginFailed.new("the server sent no CPS url=", reason: :no_cps, tif: result.tif) unless result.cps_url
29
+
30
+ page.visit(result.cps_url)
31
+ else
32
+ start_path = page.current_path
33
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
34
+ sleep 0.1 while page.current_path == start_path && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
35
+ end
36
+ result
37
+ end
38
+
39
+ # Run +disable+/+enable+/+remove+ against the page's login link.
40
+ def command(page, command, identity:, rescue_code: nil, **agent_opts)
41
+ found = Discovery.find(page.html, base_url: page.current_url)
42
+ agent = Agent.new(identity: identity, cps: false, poll: false, **agent_opts)
43
+ case command
44
+ when :disable then agent.disable(found.sqrl_url)
45
+ when :enable then agent.enable(found.sqrl_url, rescue_code: rescue_code)
46
+ when :remove then agent.remove(found.sqrl_url, rescue_code: rescue_code)
47
+ else raise ArgumentError, "command must be :disable, :enable or :remove"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end