touchque 5.1.0 → 5.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fd0417df799898bf80db545d3ce9d62bc81ea1fb537631ed1e2d869c389e8e6
4
- data.tar.gz: ff2565d595c4b7e93c117bf6e414cadb9278d2f739b52a80eec00b0995424bed
3
+ metadata.gz: 2762f3481ee0053340ce39c7c40d37dbc67d0394ea86859b6d1c7ab55b9ec015
4
+ data.tar.gz: 4706b4df98f10c0b2f6ade9bb4fe6fad7de8a725719f52bb439a80dc63059cad
5
5
  SHA512:
6
- metadata.gz: 4bb3567bcc20f35485e45312095b1285c9877e85058bd716ca4409af75e050783177491200fde593d4c06de0e35a7e260072c3bf10afc0a67d6d522392074e76
7
- data.tar.gz: 5c38f7c2d6af269aeb6afff09aafd4214d43dd8c001496d9ad541e6f9b734738198e2784285a77fc7644d4520f4e920e5987f2edbb92f154bdc6067f92eb5bd5
6
+ metadata.gz: e3942fa2fc614a43b07f8d990948ce2dd49bf783f5562f1d72c8ff6a8ecc3863f68a3207abd6cb31e63edece987bc45b35774f7792c6b8ccf0d8724c8a0d0361
7
+ data.tar.gz: a97d8111e607aaac9b4e8cd6664dfce13dbd04dff70e39a12f8d0928e761ac408fdc44741fc1a2fe92b369c90167d8c531da9d09c34f7a966f7bc068f012997c
data/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file. The
4
4
  format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [5.2.0] — 2026-11-13
7
+
8
+ ### Fixed
9
+ - **`Touchque::Guard.run(nil, ...)` no longer raises `NoMethodError`.** `nil` is `Touchque::Guard.default_client` (`Touchque.client`, built on the first request that needs it and kept once built; a failed build is tried again on the next request). While it cannot be built the guard answers 500 `misconfigured` (401 `unauthenticated` without a user), logged once through `Touchque.logger`. `Touchque::Guard.status_poll?(nil, ...)` takes it too and is false while it cannot be built.
10
+
11
+ ### Added
12
+ - **`Touchque::Guard.default_client = client`** makes `client` the default client (`Touchque.client`) of `Touchque::Guard.run(nil, ...)`, `status_poll?(nil, ...)` and `require_touchque`: the way to give them your own client. `nil` forgets it, so the next call builds from `config.touchque` / the environment again. `FakeTouchque#install` sets it. At boot, `Touchque.client` is the fail-fast check: it raises `Touchque::ConfigError`, naming what is missing.
13
+ - **`Touchque::Guard.response(result, error_body: nil)`** gives `[status, headers, body]` for a result that is not an approval, Rack style, exactly as `require_touchque` and the relay send it (both use it now): the body as JSON text; `content-type`, `cache-control: no-store`, `retry-after` on 423 / 429 and `x-touchque-state` / `x-touchque-reason` on 400 and above, with lower-case names (Rack 3); `error_body:` with the 5.1 rules (a callable that raises, or a result that cannot be encoded, is logged and the default body sent). It raises `ArgumentError` for an approval.
14
+ - **`Touchque::Testing::FakeTouchque`** (`require "touchque/testing"`): a `Touchque::Client` that answers in memory, for your app's tests, with no network, no API keys and no Node.js. The real guard runs. `link` / `unlink`, `approve` / `reject` / `expire` (the latest request or one by id; `via: "WEBAUTHN"`), `auto_approve`, `number_match`, `secret_ttl_ms`, the offline code `ABCD123` and the time-based code `123456`, `requests` and `calls`, and `install` / `uninstall` (or `install { ... }`) as `Touchque.client`. Each request keeps its expiry and answers it on its status check, like the API. `Touchque::Testing::FakeClient` is another name for it.
15
+ - In Rails the Railtie sets `Touchque.logger` to `Rails.logger` when the app set none, so the SDK's "logged once" lines reach your log. Your own (or `nil`) in an initializer wins.
16
+
17
+ ### Changed
18
+ - **Waiting polls carry `expiresAt`.** A `waiting` or `passkey_required` step with `expiresAt` puts it in its token (claim `rx`, ms since the epoch), and every later answer for that request in those two states carries `expiresAt`: the live one from Touchque's status answer when it has one (a push moved to a passkey during a call gets 180 s more), else the token's. The new token keeps the value used, and the offline QR next to the push carries it on. Only the first answer had it, so a page reading it from the latest answer never stopped by time. `Touchque::Steps.check` gives the live `expiresAt` while a request waits. With neither, polls answer as before.
19
+ - **`enroll: false` says why.** A user with no phone on a guard that may not show the linking QR gets 202 `{"touchque":{"state":"enroll","reason":"enrollment_off"}}` instead of a bare `enroll` step: `require_touchque` and `Touchque::Guard.run` with `enroll: false`, the relay's `POST /login` without `get_login_user` and its passkey registration. `@touchque/web` 2.2.0 stops polling on it.
20
+ - The relay's guard answers name their headers in lower case (`cache-control`, `x-touchque-state` ...), as `Touchque::Guard.response` gives them; `RelayResponse#header` reads names case-insensitively as before.
21
+ - README: "Guard inside your handler" passes `nil` (not `Touchque.client`, which raises without keys) and answers with `Touchque::Guard.response`; it shows `Touchque::Guard.default_client =` and the fail-fast `Touchque.client` at boot, says that a new first-time QR comes about 15 s before the old one expires with new recovery codes (the old QR and codes stop working) and that a first-time QR poll is charged once the QR is gone or within about 15 s of expiring, that waiting polls carry `expiresAt`, what `enrollment_off` means, and that `passkey_required`, `offline` and an `enroll` step with a reason need `@touchque/web` or your own UI following the contract. New section "Testing your routes" with Rails and Rack request tests on `FakeTouchque`.
22
+
6
23
  ## [5.1.0] — 2026-11-12
7
24
 
8
25
  ### Fixed
data/README.md CHANGED
@@ -65,7 +65,9 @@ does this loop for you. Once approved the action runs **once**. Refusals answer
65
65
  blocked), 408 (expired), 423 (frozen) or 429 (rate limited) with `Retry-After`.
66
66
 
67
67
  A frontend of your own: while the first-time QR is up, polls answer `{"state": "enroll", "expiresAt": ...}`
68
- without `enroll`, so keep showing the QR you have; a new one comes with `enroll.qrCodeDataUrl`.
68
+ without `enroll`, so keep showing the QR you have. About 15 s before it expires a new QR comes with
69
+ `enroll.qrCodeDataUrl` and new recovery codes: the old QR and the old codes stop working. The other states
70
+ are listed under [Guard inside your handler](#guard-inside-your-handler).
69
71
 
70
72
  The approving user is `current_user`'s email (else id, else username). For your login action
71
73
  (nobody is signed in yet) pass `user: -> { session[:password_verified_user] }`.
@@ -79,9 +81,10 @@ Without `TQ_API_KEY` / `TQ_API_SECRET` (or `config.touchque`) nothing raises: th
79
81
  **500** with `Cache-Control: no-store` and the body
80
82
  `{"touchque":{"state":"blocked","reason":"misconfigured"},"error":{"code":"misconfigured","message":"Two-factor approval is not configured correctly."}}`
81
83
  (401 `unauthenticated` when nobody is signed in), and never runs. It is logged once, through
82
- `Touchque.logger`: `[Touchque] TQ_API_KEY and TQ_API_SECRET are not set: routes guarded by Touchque
83
- answer 500 misconfigured.` A 500 rather than a 503: it is a deploy mistake for your monitoring to
84
- catch, not an outage to retry. `@touchque/web` shows "Approvals are not set up correctly on this site."
84
+ `Touchque.logger` (in Rails, `Rails.logger` unless you set one): `[Touchque] TQ_API_KEY and
85
+ TQ_API_SECRET are not set: routes guarded by Touchque answer 500 misconfigured.` A 500 rather than a
86
+ 503: it is a deploy mistake for your monitoring to catch, not an outage to retry. `@touchque/web` shows
87
+ "Approvals are not set up correctly on this site."
85
88
 
86
89
  **Refusal headers.** Every answer of 400 and above carries `X-Touchque-State: <state>`, plus
87
90
  `X-Touchque-Reason: <reason>` when the step has one; `@touchque/web` 2.1.0 reads the step from them when
@@ -148,7 +151,8 @@ def create
148
151
 
149
152
  input = Touchque::Guard.input_from_headers(->(name) { request.headers[name] })
150
153
  result = Touchque::Guard.run(
151
- Touchque.client, current_user.email, "SEND_MONEY",
154
+ nil, # the client: Touchque.client, built on this request, then kept
155
+ current_user.email, "SEND_MONEY",
152
156
  details: { "Amount" => "#{amount} EUR", "To" => account.iban }, # shown on the phone, bound to the approval
153
157
  ip: request.remote_ip, user_agent: request.user_agent, **input
154
158
  )
@@ -157,35 +161,59 @@ def create
157
161
  return render(json: { sent: true })
158
162
  end
159
163
 
160
- # Cache-Control: no-store, Retry-After on 423 / 429, X-Touchque-State (and -Reason) on 400 and above
161
- Touchque::Guard.headers(result).each { |name, value| response.headers[name] = value }
162
- render json: result[:body], status: result[:status]
164
+ # Not approved (yet): what require_touchque sends for the same result.
165
+ status, headers, body = Touchque::Guard.response(result)
166
+ response.headers.merge!(headers)
167
+ render json: body, status: status
163
168
  end
164
169
  ```
165
170
 
171
+ In Sinatra or plain Rack, `status, headers, body = Touchque::Guard.response(result)` and answer
172
+ `[status, headers, [body]]`.
173
+
174
+ - **The client.** `nil` is `Touchque::Guard.default_client`: `Touchque.client`, built from
175
+ `config.touchque` or `TQ_API_KEY` / `TQ_API_SECRET` on the first request that needs it and kept once
176
+ built, so nothing raises at boot or in a request. While a key is missing the answer is 500
177
+ `misconfigured` (401 `unauthenticated` without a user), logged once through `Touchque.logger`, and a
178
+ later request tries again. Do not pass `Touchque.client` itself: it raises `Touchque::ConfigError`
179
+ without keys. `Touchque::Guard.status_poll?` takes `nil` the same way.
180
+ - **Your own client for `nil`** (your `base_url:`, say): `Touchque::Guard.default_client =
181
+ Touchque::Client.new(...)` once at boot; `Touchque::Guard.default_client = nil` forgets it, and the
182
+ next call builds from `config.touchque` / the environment again. The SDK logs through `Touchque.logger`
183
+ (in Rails, `Rails.logger` unless you set one). To fail fast at boot instead of answering 500, call
184
+ `Touchque.client` there: it raises `Touchque::ConfigError`, naming what is missing
185
+ (`Touchque::Guard.default_client` answers `nil` instead, and logs it once).
166
186
  - **202** `{"touchque": step, "token": "..."}`: show the step and send the same request again with
167
- `X-Touchque-Token: <token>` until it is approved (`@touchque/web` does this loop).
187
+ `X-Touchque-Token: <token>` until it is approved (`@touchque/web` does this loop). While it waits
188
+ (`waiting`, `passkey_required`), every answer carries the request's `expiresAt`, as Touchque has it now
189
+ (a push moved to a passkey during a call gets more time): stop polling once it is past.
168
190
  - **200** with `result[:approved]`: run the action once. The approval is used up there: the same token
169
- never approves twice.
170
- - **401, 403, 408, 423, 429, 500, 503**: send `status` and `body` as they are, with
171
- `Touchque::Guard.headers(result)`: `Cache-Control: no-store`, `Retry-After` on 423 and 429,
172
- `X-Touchque-State` and `X-Touchque-Reason`. For your own error shape,
173
- `render json: Touchque::Guard.error_body(result, callable), status: result[:status]` sends what
174
- `error_body:` would: it returns JSON text, and the default body (logged) when the callable raises.
191
+ never approves twice. `Touchque::Guard.response` raises `ArgumentError` for it.
192
+ - **401, 403, 408, 423, 429, 500, 503**: `Touchque::Guard.response(result)` gives `[status, headers,
193
+ body]`, Rack style: the body as JSON text, the headers with lower-case names (`content-type`,
194
+ `cache-control: no-store`, `retry-after` on 423 and 429, `x-touchque-state` and `x-touchque-reason`).
195
+ For your own error shape pass a callable,
196
+ `Touchque::Guard.response(result, error_body: ->(r) { { message: r[:message], code: r[:code] } })`:
197
+ the rules of `require_touchque`'s `error_body:`; one that raises, or a result that cannot be encoded,
198
+ is logged and the default body sent.
175
199
  - **First-time QR**: a user with no phone linked gets the step `enroll` with
176
200
  `enroll.qrCodeDataUrl`, the recovery codes and `enroll.expiresAt`. While the first-time QR is up,
177
- polls answer `{state: "enroll", expiresAt}` without `enroll`: keep showing the QR you have. A new QR
178
- comes with `enroll.qrCodeDataUrl` when the old one expired. Pages on `@touchque/web` need 2.1.0.
201
+ polls answer `{state: "enroll", expiresAt}` without `enroll`: keep showing the QR you have. About 15 s
202
+ before it expires a poll brings a new QR with `enroll.qrCodeDataUrl` and new recovery codes: the old
203
+ QR and the old codes stop working. Pages on `@touchque/web` need 2.1.0.
204
+ - **No linking QR** (`enroll: false`, on `Touchque::Guard.run` or `require_touchque`; the relay's
205
+ pre-auth `POST /login` and its passkey registration do the same): a user with no phone gets 202
206
+ `{"touchque": {"state": "enroll", "reason": "enrollment_off"}}`. It is final: stop polling and tell
207
+ them to link the Touchque app first.
208
+ - **Other 202 states** (`passkey_required`, `offline`, an `enroll` with a `reason`) need
209
+ `@touchque/web`, or your own UI following the contract.
179
210
  - **Rate limits**: `Touchque::Guard.status_poll?` tells a poll from a new attempt (a poll only reads the
180
211
  status). If you count attempts per user, pass `Touchque::Guard.run` `before_start:`, a callable it calls
181
- when a first-time QR poll starts after all (the user linked, or the QR expired), to charge that start;
212
+ when a first-time QR poll starts after all (the user linked, or the QR is gone or within about 15 s of
213
+ expiring), to charge that start;
182
214
  return a result to answer instead, or nil or false to go on (anything else answers 503). The relay does
183
215
  this for `POST /login`.
184
216
 
185
- `Touchque.client` raises `Touchque::ConfigError` when a key is missing. `Touchque::Guard.default_client`
186
- returns `nil` instead (and logs it once), and `Touchque::Guard.misconfigured` is the 500 answer
187
- `require_touchque` sends then.
188
-
189
217
  ## The three primitives (any framework)
190
218
 
191
219
  ```ruby
@@ -196,7 +224,7 @@ approval = tq.complete(step["requestId"], "jane@acme.com", "SEND_MONEY", details
196
224
  # uses the approval once, and only for this user, action and amount
197
225
  ```
198
226
 
199
- `Touchque::Guard.run(tq, user, action, token:, offline:, code:, ...)` is the whole two-phase
227
+ `Touchque::Guard.run(nil, user, action, token:, offline:, code:, ...)` is the whole two-phase
200
228
  contract for Sinatra, Hanami or plain Rack: see "Guard inside your handler".
201
229
 
202
230
  ## Waiting in one call
@@ -230,10 +258,12 @@ Routes: `POST /login`, `/enroll/start`, `GET /enroll/status`, `/offline/challeng
230
258
  `/passkey/register/options|verify`, `GET /passkey/credentials`, `DELETE /passkey/credentials/:id`.
231
259
  Pre-auth routes are rate limited (20 per IP, 5 per username a minute; pass `rate_limit: { store: }`
232
260
  for Redis across processes). Polls of an approval on its way have their own per-IP budget; a
233
- first-time QR poll that starts after all (the user linked, or the QR expired) counts against the
234
- username's budget like a new attempt. Guard answers of 400 and above carry `X-Touchque-State` (and
235
- `X-Touchque-Reason`). The offline routes stay off until `get_login_user` is set. Adding a passkey for a
236
- user with a linked phone waits for the phone first.
261
+ first-time QR poll that starts after all (the user linked, or the QR is gone or within about 15 s of
262
+ expiring) counts against the username's budget like a new attempt. Guard answers of 400 and above carry `X-Touchque-State` (and
263
+ `X-Touchque-Reason`). The offline routes stay off until `get_login_user` is set, and without it
264
+ `POST /login` never shows the linking QR: a user with no phone gets 202
265
+ `{"touchque": {"state": "enroll", "reason": "enrollment_off"}}`. Adding a passkey for a user with a
266
+ linked phone waits for the phone first.
237
267
 
238
268
  ## Webhooks
239
269
 
@@ -446,6 +476,79 @@ end
446
476
 
447
477
  `Touchque::SafeApproval.verify` makes the guard's check off chain, so a co-signer or your own server never has to trust Touchque: it computes the Safe transaction hash (EIP-712) and the governance hash from the raw fields, rebuilds the 149-byte message the phone signed and checks the P-256 signature. `purpose:` defaults to `"approve"`: a schedule approval covers no Safe nonce or gas fields, so it is accepted only when you pass `purpose: "schedule"`, never as permission to execute. It refuses a high `s`, an approval that expired or is valid for more than a day, another approver index, and, when you pass `rows:` or `display_digest:` from your own decoding, rows the phone did not show. `"reason"` is `approval_malformed`, `public_key_invalid`, `approver_index_mismatch`, `approval_expired`, `rows_differ`, `high_s` or `signature_invalid`. `safe_tx_hash`, `governance_hash`, `message` and `display_digest` are public too.
448
478
 
479
+ ## Testing your routes
480
+
481
+ `Touchque::Testing::FakeTouchque` (`require "touchque/testing"`) is a `Touchque::Client` that answers in
482
+ memory: no network, no API keys, no Node.js. The real guard runs, so your routes answer what they answer
483
+ in production, and your test plays the phone. A Rails request test:
484
+
485
+ ```ruby
486
+ # test/integration/transfers_test.rb
487
+ require "test_helper"
488
+ require "touchque/testing"
489
+
490
+ class TransfersTest < ActionDispatch::IntegrationTest
491
+ setup { @tq = Touchque::Testing::FakeTouchque.new.install } # now Touchque.client
492
+ teardown { @tq.uninstall }
493
+
494
+ test "a transfer needs an approval on the phone" do
495
+ sign_in_as users(:jane) # your own sign-in helper
496
+ post transfers_path, params: { amount: 5 }, as: :json
497
+ assert_response :accepted # 202: the step and a token
498
+ token = response.parsed_body["token"]
499
+
500
+ @tq.approve # the person approves on the phone
501
+ post transfers_path, params: { amount: 5 }, as: :json, headers: { "X-Touchque-Token" => token }
502
+ assert_response :success
503
+ assert_equal [{ "label" => "Amount", "value" => "5 EUR" }], @tq.requests.last["details"]
504
+ end
505
+ end
506
+ ```
507
+
508
+ Sinatra or plain Rack, with `rack-test`:
509
+
510
+ ```ruby
511
+ require "rack/test"
512
+ require "touchque/testing"
513
+
514
+ class TransferTest < Minitest::Test
515
+ include Rack::Test::Methods
516
+
517
+ def app = MyApp # Touchque::Guard.run(nil, ...) in its route
518
+
519
+ def test_a_transfer_needs_an_approval
520
+ Touchque::Testing::FakeTouchque.new.install do |tq| # the default client inside the block
521
+ post "/transfer", JSON.generate(amount: 5), "CONTENT_TYPE" => "application/json"
522
+ assert_equal 202, last_response.status
523
+ tq.approve
524
+ header "X-Touchque-Token", JSON.parse(last_response.body)["token"]
525
+ post "/transfer", JSON.generate(amount: 5), "CONTENT_TYPE" => "application/json"
526
+ assert_equal 200, last_response.status
527
+ end
528
+ end
529
+ end
530
+ ```
531
+
532
+ - `install` makes the fake the default client (`Touchque::Guard.default_client = tq`, so
533
+ `Touchque.client`), which `require_touchque`, `Touchque::Guard.run(nil, ...)` and a relay given no
534
+ `client:` use, until `uninstall`; with a block, for the block only. Install it
535
+ after your app configured Touchque (`Touchque.configure` drops it). A `Touchque::Rack::Relay` keeps the
536
+ client of its first request: give it `client: tq` in a test that builds it.
537
+ - `tq.approve`, `tq.reject`, `tq.expire`: the latest request, or one by its id
538
+ (`tq.approve(request_id)`; `via: "WEBAUTHN"` approves with a passkey).
539
+ - `FakeTouchque.new(auto_approve: true)` (or `tq.auto_approve = true`): every request is approved as it
540
+ is made. The first request still answers 202; the next one runs the action.
541
+ - Every user has a phone linked. `tq.unlink(user)` shows them the first-time QR instead
542
+ (`FakeTouchque.new(linked: false)`: nobody has one); `tq.link(user)` is the phone scanning it.
543
+ `tq.secret_ttl_ms` is how long a QR works (5 minutes).
544
+ - `FakeTouchque.new(number_match: true)`: pushes ask for the number `47`.
545
+ - `tq.requests` (`"id"`, `"user"`, `"type"`, `"details"`, `"referenceId"`, `"status"`, `"expiresAt"`,
546
+ answered on each status check) and `tq.calls` (`"method"`, `"path"`, `"body"`): what your app asked
547
+ Touchque, in order.
548
+ - Offline approval: the code is `ABCD123` (`FakeTouchque::OFFLINE_CODE`), the time-based code `123456`
549
+ (`FakeTouchque::TOTP_CODE`). Calls outside the step-up (recovery, agents …) raise
550
+ `Touchque::APIError` 404. `Touchque::Testing::FakeClient` is another name for it.
551
+
449
552
  ## Errors, case codes and retries
450
553
 
451
554
  | Error | When |
@@ -468,7 +571,7 @@ never follows redirects and refuses plain `http://` except for localhost.
468
571
 
469
572
  ## Logging
470
573
 
471
- The SDK writes nothing to `$stderr` on its own. To see its own problems (a misconfigured guard, a failed `on_challenge`, a relay without `get_login_user`), set a logger: `Touchque.logger = Rails.logger` (or any `Logger`). Lines carry an error's class and message, never the request.
574
+ The SDK writes nothing to `$stderr` on its own. To see its own problems (a misconfigured guard, a failed `on_challenge`, a relay without `get_login_user`), set a logger: `Touchque.logger = Logger.new($stdout)` (or any `Logger`). In Rails the Railtie sets it to `Rails.logger` when your app set none; set your own in an initializer (`nil` for none). Lines carry an error's class and message, never the request.
472
575
 
473
576
  ## Development
474
577
 
@@ -28,7 +28,7 @@ module Touchque
28
28
  raise ConfigError, "api_key must start with \"tq_\". Did you accidentally swap api_key and api_secret?" unless api_key.start_with?("tq_")
29
29
 
30
30
  Util.assert_safe_url!(base_url, "base_url")
31
- @http = HTTP.new(api_key: api_key, api_secret: api_secret, base_url: base_url.chomp("/"), timeout: timeout)
31
+ @http = transport(api_key: api_key, api_secret: api_secret, base_url: base_url.chomp("/"), timeout: timeout)
32
32
  @auth = Resources::Auth.new(@http)
33
33
  @login = Resources::Login.new(@http)
34
34
  @webauthn = Resources::WebAuthn.new(@http)
@@ -78,5 +78,12 @@ module Touchque
78
78
  login.verify(user, type: type, **options)
79
79
  yield
80
80
  end
81
+
82
+ private
83
+
84
+ # What every resource calls the API through (Touchque::Testing::FakeTouchque answers in memory).
85
+ def transport(**options)
86
+ HTTP.new(**options)
87
+ end
81
88
  end
82
89
  end
data/lib/touchque/flow.rb CHANGED
@@ -11,7 +11,8 @@ module Touchque
11
11
 
12
12
  module_function
13
13
 
14
- # Starts an approval for +action+; never waits.
14
+ # Starts an approval for +action+; never waits. A user with no phone linked gets the
15
+ # linking QR ("enroll"); with enroll: false, {"state" => "enroll", "reason" => "enrollment_off"}.
15
16
  def start(client, action, user, details: nil, reference_id: nil, ip: nil, user_agent: nil, enroll: true, browser_proof: nil, context: nil)
16
17
  raise ConfigError, "start() needs the user id" if user.to_s.empty?
17
18
 
@@ -37,7 +38,8 @@ module Touchque
37
38
  code = e.code || e.error
38
39
  why = e.case_code ? { "caseCode" => e.case_code } : {}
39
40
  if e.status == 404 && (code == "device_not_linked" || e.message.downcase.include?("linked device"))
40
- return enroll ? enroll_step(client, user) : { "state" => "enroll" }
41
+ # No phone, and this guard may not show the linking QR: final, a page stops polling.
42
+ return enroll ? enroll_step(client, user) : { "state" => "enroll", "reason" => "enrollment_off" }
41
43
  end
42
44
  return { "state" => "frozen", "retryAfter" => e.retry_after }.merge(why) if e.status == 423
43
45
  return { "state" => "rate_limited", "retryAfter" => e.retry_after }.merge(why) if e.status == 429
@@ -113,12 +115,15 @@ module Touchque
113
115
  out
114
116
  end
115
117
 
116
- # Where a started approval is now.
118
+ # Where a started approval is now. While it waits (waiting, passkey_required) the step has
119
+ # the request's live "expiresAt" when Touchque sends it: a switch to a passkey during a call
120
+ # extends it.
117
121
  def check(client, request_id)
118
122
  s = client.login.status(request_id)
119
123
  state = s["status"] == "PENDING" && s["requiresPasskey"] ? "passkey_required" : STATE_MAP.fetch(s["status"], "waiting")
120
124
  step = { "state" => state, "requestId" => request_id }
121
125
  step["assurance"] = s["assurance"] if state == "approved" && s["assurance"]
126
+ step["expiresAt"] = s["expiresAt"] if %w[waiting passkey_required].include?(state) && iso_ms(s["expiresAt"])
122
127
  step
123
128
  end
124
129
 
@@ -177,11 +182,15 @@ module Touchque
177
182
  # Framework-agnostic step-up guard (the Rails concern is a thin wrapper over it).
178
183
  # Contract, the same in every Touchque server SDK:
179
184
  # 1st request -> 202 {"touchque" => step, "token" => ...} show the step in your UI
180
- # repeat with the token -> 202 while waiting, then the protected action runs once
185
+ # repeat with the token -> 202 while waiting (each answer carries the request's live "expiresAt"),
186
+ # then the protected action runs once
181
187
  # refused -> 403 / 408 / 423 / 429 {"touchque" => {"state" => ...}}
182
188
  # first-time QR up -> 202 {"touchque" => {"state" => "enroll", "expiresAt" => ...}} with no
183
- # "enroll": keep showing the QR; a new one comes with
184
- # "enroll" => {"qrCodeDataUrl"} once the old one expired
189
+ # "enroll": keep showing the QR; about 15 s before it expires a new one
190
+ # comes with "enroll" => {"qrCodeDataUrl"} and new recovery codes
191
+ # no phone, enroll: false -> 202 {"touchque" => {"state" => "enroll", "reason" => "enrollment_off"}}: final
192
+ # run(nil, ...) uses default_client (Touchque.client; set with default_client=); response(result)
193
+ # gives the Rack-style answer to send.
185
194
  # Request headers the browser sends back: X-Touchque-Token, X-Touchque-Offline: 1,
186
195
  # X-Touchque-Code (+ X-Touchque-Code-Type: totp), X-Touchque-Browser (silent sign-ins).
187
196
  module Guard
@@ -196,6 +205,8 @@ module Touchque
196
205
  STATUS = { "waiting" => 202, "enroll" => 202, "passkey_required" => 202, "offline" => 202, "approved" => 200,
197
206
  "rejected" => 403, "blocked" => 403, "expired" => 408, "frozen" => 423, "rate_limited" => 429 }.freeze
198
207
  WAITING = %w[waiting passkey_required offline].freeze
208
+ # Steps whose token carries the request's expiry (claim "rx"): every poll answers an expiresAt.
209
+ EXPIRING = %w[waiting passkey_required].freeze
199
210
  # A first-time QR this close to its expiry is replaced instead of kept on screen.
200
211
  ENROLL_MARGIN_MS = 15_000
201
212
  # The refusal message for a state when the guard set none (anything else: "The approval was refused.").
@@ -245,10 +256,14 @@ module Touchque
245
256
  # it is up (more than 15 s before it expires), and no offline switch or code. Relays do
246
257
  # not count it against the per-user push limit. Such an enroll poll can still start
247
258
  # (the user linked, or the QR is gone): relays charge that through run's before_start.
248
- # Renewing an expiring QR counts.
259
+ # Renewing an expiring QR counts. +client+: as run's; nil is default_client, and false
260
+ # while it cannot be built.
249
261
  def status_poll?(client, user, action, details: nil, reference_id: nil, token: nil, offline: false, code: nil)
250
262
  return false if user.to_s.empty? || token.to_s.empty? || offline || !code.to_s.empty?
251
263
 
264
+ client ||= default_client
265
+ return false unless client
266
+
252
267
  claims = Steps.verify_guard_token(client.api_secret, token)
253
268
  return false unless claims && bound?(claims, user, action, details, reference_id)
254
269
  return claims["ee"].is_a?(Integer) && Util.now_ms < claims["ee"] - ENROLL_MARGIN_MS if claims["st"] == "enroll"
@@ -281,9 +296,11 @@ module Touchque
281
296
  claims["u"] == user && claims["a"] == action && claims["d"] == Details.digest(details) && claims["r"] == reference_id
282
297
  end
283
298
 
284
- # Touchque.client for the per-route adapters: built on the request, kept once built.
285
- # nil when it cannot be built (TQ_API_KEY / TQ_API_SECRET missing, a bad key or URL),
286
- # logged once per process; the adapter then answers 500 misconfigured.
299
+ # Touchque.client for run(nil, ...) and the per-route adapters: built on the request,
300
+ # kept once built. nil when it cannot be built (TQ_API_KEY / TQ_API_SECRET missing, a bad
301
+ # key or URL), logged once per process through Touchque.logger; the guard then answers
302
+ # 500 misconfigured, and a later request tries again. To fail fast at boot instead, call
303
+ # Touchque.client there: it raises Touchque::ConfigError, naming what is missing.
287
304
  def default_client
288
305
  Touchque.client
289
306
  rescue ConfigError => e
@@ -299,6 +316,14 @@ module Touchque
299
316
  nil
300
317
  end
301
318
 
319
+ # Makes +client+ the default client (Touchque.client) of run(nil, ...), status_poll?(nil,
320
+ # ...) and require_touchque: the way to give them your own client (built with your
321
+ # base_url, say; Touchque.logger is the SDK's logger). nil forgets it, so the next call
322
+ # builds from Touchque.configure / the environment again.
323
+ def default_client=(client)
324
+ Touchque.client = client
325
+ end
326
+
302
327
  # The 401 for a request without a signed-in user.
303
328
  def unauthenticated
304
329
  error(401, { "state" => "blocked", "reason" => "unauthenticated" }, "unauthenticated", "Sign in first.")
@@ -337,6 +362,30 @@ module Touchque
337
362
  result[:body].to_json
338
363
  end
339
364
 
365
+ JSON_TYPE = "application/json; charset=utf-8"
366
+
367
+ # The HTTP answer for a result of run that is not an approval, Rack style:
368
+ # [status, headers, body], the body as JSON text: what require_touchque and the relay
369
+ # send for it. headers: content-type, cache-control: no-store, retry-after on 423 / 429
370
+ # and, on 400 and above, x-touchque-state / x-touchque-reason (lower-case names, as Rack 3
371
+ # wants). +error_body+: a callable for your own error shape on 400 and above, as
372
+ # error_body(result, callable): one that raises, or a result that cannot be encoded, is
373
+ # logged and the default body sent. An approval raises ArgumentError: run your action.
374
+ #
375
+ # status, headers, body = Touchque::Guard.response(result)
376
+ # [status, headers, [body]] # a Rack response
377
+ def response(result, error_body: nil)
378
+ raise ArgumentError, "Guard.response: the result is an approval: run your action instead" if result[:approved]
379
+ unless error_body.nil? || error_body.respond_to?(:call)
380
+ raise ArgumentError, "Guard.response: error_body must respond to call"
381
+ end
382
+
383
+ body = error_body && result[:status] >= 400 ? Guard.error_body(result, error_body) : result[:body].to_json
384
+ out = { "content-type" => JSON_TYPE }
385
+ Guard.headers(result).each { |name, value| out[name.downcase] = value }
386
+ [result[:status], out, body]
387
+ end
388
+
340
389
  # An error_body result in the form it is encoded in: a Struct or Data as its Hash, an
341
390
  # object with to_hash as that, one whose as_json is a Hash as that; anything else as it is.
342
391
  def jsonable(value)
@@ -353,8 +402,13 @@ module Touchque
353
402
  end
354
403
 
355
404
  # Runs one step of the guard: {status: 200, approved: approval} (run your protected
356
- # action once) or {status:, body: {"touchque" => step, "token" => ...}} to send back
357
- # with headers(result).
405
+ # action once) or {status:, body: {"touchque" => step, "token" => ...}} to send back as
406
+ # response(result) gives it.
407
+ # +client+: your Touchque::Client, or nil for default_client (Touchque.client, built on
408
+ # the first request that needs it; while it cannot be built the answer is 500
409
+ # misconfigured, 401 unauthenticated without a user, logged once).
410
+ # +enroll+: false for a guard that may not show the linking QR: a user with no phone gets
411
+ # 202 {"state" => "enroll", "reason" => "enrollment_off"}.
358
412
  # Silent sign-ins: with +silent_sign_in+ on, a new attempt that carries a browser
359
413
  # proof first asks Touchque (risk.check); only an answer of exactly prompt: false
360
414
  # approves at once with no push. Otherwise, and on any error, the usual push starts.
@@ -368,6 +422,9 @@ module Touchque
368
422
  code: nil, code_type: nil, enroll: true, browser_proof: nil, silent_sign_in: false, context: nil, before_start: nil)
369
423
  return unauthenticated if user.to_s.empty?
370
424
 
425
+ client ||= default_client
426
+ return misconfigured unless client
427
+
371
428
  norm = Details.normalize(details)
372
429
  norm = nil if norm.empty?
373
430
  base = { "u" => user, "a" => action, "d" => Details.digest(details), "r" => reference_id }
@@ -377,6 +434,11 @@ module Touchque
377
434
  claims = base.merge("st" => step["state"], "rid" => step["requestId"], "n" => step["number"],
378
435
  "oc" => step.dig("offline", "challengeId"), "exp" => Util.now_ms + TOKEN_TTL_MS)
379
436
  claims["cc"] = step["caseCode"] if step["caseCode"]
437
+ # A push or passkey: its token carries when the request expires (rx), for polls without a live expiry.
438
+ if EXPIRING.include?(step["state"])
439
+ expiry = Steps.iso_ms(step["expiresAt"])
440
+ claims["rx"] = expiry if expiry
441
+ end
380
442
  # A first-time QR: its token carries when the QR expires (ee), so polls keep it on screen.
381
443
  if step["state"] == "enroll" && step["enroll"].is_a?(Hash) && !step["enroll"]["qrCodeDataUrl"].to_s.empty?
382
444
  ee = Steps.iso_ms(step["enroll"]["expiresAt"])
@@ -393,7 +455,15 @@ module Touchque
393
455
  n = bound && bound["n"]
394
456
  oc = bound && bound["oc"]
395
457
  st = bound && bound["st"]
396
- keep = { "oc" => oc, "rid" => rid, "n" => n }
458
+ # The request's expiry (rx) stays with it: waiting / passkey answers carry it as expiresAt (the
459
+ # live one from Touchque's status answer when there is one), and the offline steps carry it on.
460
+ rx = bound && bound["rx"].is_a?(Integer) ? bound["rx"] : nil
461
+ keep_rx = rx ? { "rx" => rx } : {}
462
+ expiring = lambda do |step, live = nil|
463
+ at = live || rx
464
+ at ? step.merge("expiresAt" => Steps.iso_of(at)) : step
465
+ end
466
+ keep = { "oc" => oc, "rid" => rid, "n" => n }.merge(keep_rx)
397
467
 
398
468
  # Offline: the user typed the code from the phone.
399
469
  if bound && !code.to_s.empty? && (st == "offline" || code_type == "totp")
@@ -416,7 +486,7 @@ module Touchque
416
486
  return issue.call({ "state" => "rejected", "requestId" => rid, "reason" => "request_rejected" }) if res["reason"] == "request_rejected"
417
487
  # The push now waits for a passkey (Vishing Shield): no code may finish it.
418
488
  if res["reason"] == "passkey_required" && rid
419
- return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
489
+ return issue.call(expiring.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }), { "n" => n })
420
490
  end
421
491
 
422
492
  return issue.call({ "state" => res["reason"] == "expired" ? "expired" : "blocked", "reason" => res["reason"] || "offline_failed" })
@@ -428,12 +498,12 @@ module Touchque
428
498
  ch = client.offline.challenge(user, type: action, details: norm, client_ip: ip, user_agent: user_agent, request_id: rid)
429
499
  offline_step = ch.slice("challengeId", "qrDataUrl", "expiresAt", "totpAvailable")
430
500
  offline_step["challengeCode"] = ch["challengeCode"] if ch["challengeCode"] && !ch["challengeCode"].empty?
431
- return issue.call({ "state" => "offline", "offline" => offline_step }, { "rid" => rid, "n" => ch["challengeCode"] || n })
501
+ return issue.call({ "state" => "offline", "offline" => offline_step }, { "rid" => rid, "n" => ch["challengeCode"] || n }.merge(keep_rx))
432
502
  rescue APIError => e
433
503
  err = e.code || e.error
434
504
  return issue.call({ "state" => "rejected", "requestId" => rid, "reason" => "request_rejected" }) if e.status == 409 && err == "request_rejected"
435
505
  if rid && err == "passkey_required"
436
- return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
506
+ return issue.call(expiring.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }), { "n" => n })
437
507
  end
438
508
  return issue.call({ "state" => "blocked", "reason" => e.code || e.error || "offline_unavailable" }) if e.status < 500
439
509
 
@@ -444,6 +514,8 @@ module Touchque
444
514
  # Waiting on a push / passkey, or the offline QR next to a push still open: poll, run once approved.
445
515
  if bound && rid && WAITING.include?(st)
446
516
  now = Steps.check(client, rid)
517
+ # The live expiry: a switch to a passkey during a call extends it. The new token keeps it (rx).
518
+ live = Steps.iso_ms(now["expiresAt"])
447
519
  if now["state"] == "approved"
448
520
  begin
449
521
  return { status: 200, approved: Steps.complete(client, rid, user, action, details: details, reference_id: reference_id) }
@@ -455,13 +527,15 @@ module Touchque
455
527
  end
456
528
  # The push behind the QR moved to a passkey after a tap during a call.
457
529
  if st == "offline" && now["state"] == "passkey_required"
458
- return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
530
+ return issue.call(expiring.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, live), { "n" => n })
459
531
  end
460
532
  # Nothing changed for the user: keep showing the QR they have.
461
- return issue.call({ "state" => "offline", "offline" => { "challengeId" => oc.to_s } }, keep) if st == "offline" && %w[waiting expired].include?(now["state"])
533
+ if st == "offline" && %w[waiting expired].include?(now["state"])
534
+ return issue.call({ "state" => "offline", "offline" => { "challengeId" => oc.to_s } }, live ? keep.merge("rx" => live) : keep)
535
+ end
462
536
  if %w[waiting passkey_required].include?(now["state"])
463
- waiting = { "state" => now["state"], "requestId" => rid, "number" => n }
464
- # "Why?": the case code stays on screen while it waits.
537
+ waiting = expiring.call({ "state" => now["state"], "requestId" => rid, "number" => n }, live)
538
+ # "Why?": the case code stays on screen while it waits, and so does when the request expires.
465
539
  waiting["caseCode"] = bound["cc"] if bound["cc"]
466
540
  return issue.call(waiting, { "n" => n })
467
541
  end
@@ -37,6 +37,8 @@ module Touchque
37
37
  #
38
38
  # Touchque.client is built on the first request: without TQ_API_KEY / TQ_API_SECRET the
39
39
  # action answers 500 misconfigured (401 without a signed-in user) and never runs.
40
+ # enroll: false: a user with no phone gets 202 {"touchque": {"state": "enroll",
41
+ # "reason": "enrollment_off"}} instead of the linking QR.
40
42
  module Guard
41
43
  def self.included(base)
42
44
  base.extend(ClassMethods)
@@ -86,22 +88,29 @@ module Touchque
86
88
  request.env["touchque.approval"] = result[:approved]
87
89
  return
88
90
  end
89
- Touchque::Guard.headers(result).each { |name, value| response.headers[name] = value }
90
- render json: touchque_body(result, options[:error_body]), status: result[:status]
91
+ touchque_render(result, options[:error_body])
91
92
  end
92
93
 
93
- # The guard's body, or error_body's (JSON text; the default body when it fails) for an
94
- # answer of 400 and above.
95
- def touchque_body(result, error_body)
96
- return result[:body] if error_body.nil? || result[:status] < 400
94
+ # Renders a guard result that is not an approval as Touchque::Guard.response gives it
95
+ # (render sets the JSON content type).
96
+ def touchque_render(result, error_body)
97
+ status, headers, body = Touchque::Guard.response(result, error_body: touchque_error_body(error_body))
98
+ headers.each { |name, value| response.headers[name] = value unless name == "content-type" }
99
+ render json: body, status: status
100
+ end
101
+
102
+ # error_body as a callable for Touchque::Guard.response: a proc runs in the controller,
103
+ # a Symbol names one of its methods.
104
+ def touchque_error_body(error_body)
105
+ return nil if error_body.nil?
97
106
 
98
- Touchque::Guard.error_body(result, lambda { |refusal|
107
+ lambda do |refusal|
99
108
  case error_body
100
109
  when Proc then instance_exec(refusal, &error_body)
101
110
  when Symbol then send(error_body, refusal)
102
111
  else error_body.call(refusal)
103
112
  end
104
- })
113
+ end
105
114
  end
106
115
 
107
116
  def touchque_value(option)
@@ -10,9 +10,17 @@ module Touchque
10
10
  # config.touchque.api_secret = "..."
11
11
  # config.touchque.login = { domain: "acme.login.touchque.com", client_id: "...", client_secret: "..." }
12
12
  # config.touchque.session_secret = "..." # Touchque Login cookies; default: secret_key_base
13
+ #
14
+ # Touchque.logger defaults to Rails.logger, so the SDK's own problems (a guard without its
15
+ # keys, a failing error_body) reach your log. Set your own in an initializer (nil: none).
13
16
  class Railtie < ::Rails::Railtie
14
17
  config.touchque = ActiveSupport::OrderedOptions.new
15
18
 
19
+ # Before the app's config/initializers, which may set their own.
20
+ initializer "touchque.logger" do
21
+ Touchque.logger ||= ::Rails.logger
22
+ end
23
+
16
24
  initializer "touchque.configure" do |app|
17
25
  settings = app.config.touchque
18
26
  credentials = app.respond_to?(:credentials) ? (app.credentials.config[:touchque] || {}) : {}
@@ -105,6 +105,9 @@ module Touchque
105
105
  # get_user_id (required) the signed-in user, for the signed-in routes.
106
106
  # get_login_user the user who passed YOUR first factor. STRONGLY RECOMMENDED: /login
107
107
  # and the offline routes then act for this user and ignore the body's.
108
+ # Without it /login never shows the linking QR: a user with no phone gets
109
+ # 202 {touchque: {state: "enroll", reason: "enrollment_off"}}, as passkey
110
+ # registration does.
108
111
  # rate_limit { window_seconds:, per_ip: 20, per_user: 5, store: } or false.
109
112
  # allow_offline_without_first_factor let the offline routes trust the body's username.
110
113
  # action_type for /login and the offline routes (default LOGIN).
@@ -256,10 +259,12 @@ module Touchque
256
259
  end
257
260
  end
258
261
 
259
- # A guard answer that is not an approval, with Guard.headers (no-store, Retry-After, X-Touchque-State / -Reason).
262
+ # A guard answer that is not an approval, as Guard.response gives it (no-store, Retry-After,
263
+ # X-Touchque-State / -Reason); the Rack relay adds the content type itself.
260
264
  def guard_response(result)
261
- res = RelayResponse.new(result[:status], result[:body])
262
- Guard.headers(result).each { |name, value| res.set_header(name, value) }
265
+ status, headers, body_json = Guard.response(result)
266
+ res = RelayResponse.new(status, JSON.parse(body_json))
267
+ headers.each { |name, value| res.set_header(name, value) unless name == "content-type" }
263
268
  res
264
269
  end
265
270
 
@@ -0,0 +1,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "set"
5
+ require_relative "../touchque"
6
+
7
+ module Touchque
8
+ # Helpers for your app's tests (require "touchque/testing"; never in production code).
9
+ module Testing
10
+ # A Touchque client for your app's tests: no network, no API keys, no Node.js. It is a
11
+ # real Touchque::Client whose API calls are answered in memory, so the real guard runs:
12
+ # your routes answer exactly what they answer in production (202 and a token, then your
13
+ # action once approved), and your test plays the phone.
14
+ #
15
+ # require "touchque/testing"
16
+ #
17
+ # tq = Touchque::Testing::FakeTouchque.new.install # the default client (Touchque.client)
18
+ # post "/transfers", params: { amount: 5 }, as: :json
19
+ # assert_response 202
20
+ # tq.approve # the person approves on the phone
21
+ # post "/transfers", params: { amount: 5 }, as: :json,
22
+ # headers: { "X-Touchque-Token" => response.parsed_body["token"] }
23
+ # assert_response :success
24
+ # tq.uninstall
25
+ #
26
+ # +linked+ (default true): every user has a phone linked, so a guarded route sends a
27
+ # push. unlink(user) gives that user the first-time QR instead; with linked: false only
28
+ # the users you link have one.
29
+ #
30
+ # The phone is yours to drive: approve, reject or expire the latest request (or one by
31
+ # its id), or set auto_approve to approve every request as it is made. +requests+ lists
32
+ # the approval requests ("id", "user", "type", "details", "referenceId", "status":
33
+ # PENDING / CONFIRMED / REJECTED / EXPIRED, "expiresAt": 30 s after it was made, answered
34
+ # on each status check), +calls+ every API call ("method", "path", "body"), in order.
35
+ #
36
+ # +number_match+: pushes ask for the number "47". +secret_ttl_ms+: how long a first-time
37
+ # QR works (5 minutes, like the API). The offline code is "ABCD123" and the time-based
38
+ # code "123456" (+offline_code+, +totp_code+).
39
+ #
40
+ # It answers the calls the step-up guard makes (approval requests, first-time linking,
41
+ # offline codes, silent sign-in checks) and defining action types, like the shared fake
42
+ # API the SDKs' own tests use. Any other call raises Touchque::APIError 404.
43
+ class FakeTouchque < Client
44
+ # The code the phone shows for an offline QR (X-Touchque-Code).
45
+ OFFLINE_CODE = "ABCD123"
46
+ # The time-based code (X-Touchque-Code-Type: totp).
47
+ TOTP_CODE = "123456"
48
+
49
+ NOT_INSTALLED = Object.new.freeze
50
+ private_constant :NOT_INSTALLED
51
+
52
+ # Approve every new request at once, as if the person tapped Approve straight away.
53
+ attr_accessor :auto_approve
54
+ # Ask for the number "47" on every push.
55
+ attr_accessor :number_match
56
+ # How long a first-time QR works, in ms.
57
+ attr_accessor :secret_ttl_ms
58
+ # The codes the fake accepts for an offline QR and as the time-based code.
59
+ attr_accessor :offline_code, :totp_code
60
+ # Whether users you did not link or unlink have a phone (the linked: option).
61
+ attr_reader :linked_by_default
62
+ # The approval requests made so far, oldest first.
63
+ attr_reader :requests
64
+ # Every API call so far: {"method", "path", "body"}, oldest first.
65
+ attr_reader :calls
66
+
67
+ def initialize(linked: true, auto_approve: false, number_match: false)
68
+ @lock = Monitor.new
69
+ @linked_by_default = linked
70
+ @auto_approve = auto_approve
71
+ @number_match = number_match
72
+ @secret_ttl_ms = 300_000
73
+ @offline_code = OFFLINE_CODE
74
+ @totp_code = TOTP_CODE
75
+ @requests = []
76
+ @calls = []
77
+ @linked = Set.new
78
+ @unlinked = Set.new
79
+ @pending = {}
80
+ @challenges = {}
81
+ @defined_types = {}
82
+ @seq = 0
83
+ @replaced = NOT_INSTALLED
84
+ # Signs the guard tokens only: nothing leaves the process.
85
+ super(api_key: "tq_fake_key", api_secret: "fake_secret", base_url: "http://localhost")
86
+ end
87
+
88
+ # ── the phone ──────────────────────────────────────────────────────
89
+
90
+ # +user+ has a phone linked (as if they scanned the first-time QR).
91
+ def link(user)
92
+ @lock.synchronize do
93
+ @linked << user
94
+ @unlinked.delete(user)
95
+ @pending.delete(user)
96
+ end
97
+ self
98
+ end
99
+
100
+ # +user+ has no phone linked: a guarded route shows them the first-time QR.
101
+ def unlink(user)
102
+ @lock.synchronize do
103
+ @unlinked << user
104
+ @linked.delete(user)
105
+ @pending.delete(user)
106
+ end
107
+ self
108
+ end
109
+
110
+ def linked?(user)
111
+ @lock.synchronize { @linked.include?(user) || (linked_by_default && !@unlinked.include?(user)) }
112
+ end
113
+
114
+ # The person approves on the phone: the latest request, or +request_id+.
115
+ # via: "WEBAUTHN" approves it with a passkey. Returns the request's id.
116
+ def approve(request_id = nil, via: "DEVICE")
117
+ @lock.synchronize do
118
+ request = pick(request_id)
119
+ request.merge!("status" => "CONFIRMED", "confirmedVia" => via)
120
+ request["id"]
121
+ end
122
+ end
123
+
124
+ # The person taps Reject: the latest request, or +request_id+.
125
+ def reject(request_id = nil)
126
+ @lock.synchronize do
127
+ request = pick(request_id)
128
+ request["status"] = "REJECTED"
129
+ request["id"]
130
+ end
131
+ end
132
+
133
+ # Nobody answered in time: the latest request, or +request_id+.
134
+ def expire(request_id = nil)
135
+ @lock.synchronize do
136
+ request = pick(request_id)
137
+ request["status"] = "EXPIRED"
138
+ request["id"]
139
+ end
140
+ end
141
+
142
+ # ── the default client ─────────────────────────────────────────────
143
+
144
+ # Makes this the default client (Touchque::Guard.default_client = self, so
145
+ # Touchque.client), which require_touchque, Touchque::Guard.run(nil, ...) and a relay
146
+ # given no client use, until uninstall.
147
+ # With a block: installed for the block only. Install it after your app configured
148
+ # Touchque: Touchque.configure drops it.
149
+ def install
150
+ @lock.synchronize do
151
+ @replaced = Touchque.instance_variable_get(:@client) if @replaced.equal?(NOT_INSTALLED)
152
+ Touchque::Guard.default_client = self
153
+ end
154
+ return self unless block_given?
155
+
156
+ begin
157
+ yield self
158
+ ensure
159
+ uninstall
160
+ end
161
+ end
162
+
163
+ # Puts back the default client that install replaced.
164
+ def uninstall
165
+ @lock.synchronize do
166
+ next if @replaced.equal?(NOT_INSTALLED)
167
+
168
+ Touchque::Guard.default_client = @replaced
169
+ @replaced = NOT_INSTALLED
170
+ end
171
+ self
172
+ end
173
+
174
+ private
175
+
176
+ def transport(**options)
177
+ Transport.new(self, **options)
178
+ end
179
+
180
+ def pick(request_id)
181
+ request = request_id ? find(request_id) : @requests.last
182
+ return request if request
183
+
184
+ raise ArgumentError, request_id ? "FakeTouchque: no approval request #{request_id}" : "FakeTouchque: no approval request yet"
185
+ end
186
+
187
+ def find(request_id)
188
+ @requests.find { |r| r["id"] == request_id }
189
+ end
190
+
191
+ # What the fake answers: [status, data].
192
+ def answer(method, path_with_query, body)
193
+ path = path_with_query.split("?", 2).first
194
+ @lock.synchronize do
195
+ @calls << { "method" => method, "path" => path, "body" => body }
196
+ ROUTES.each do |route_method, pattern, handler|
197
+ next unless route_method == method && (m = pattern.match(path))
198
+
199
+ return send(handler, body.is_a?(Hash) ? body : {}, *m.captures.map { |part| URI.decode_www_form_component(part) })
200
+ end
201
+ [404, { "error" => "FakeTouchque does not answer #{method} #{path}" }]
202
+ end
203
+ end
204
+
205
+ def login_request(body)
206
+ user = body["externalUsername"]
207
+ return [404, { "code" => "device_not_linked", "error" => "No linked device found for this user" }] unless linked?(user)
208
+
209
+ @seq += 1
210
+ number = number_match || body["requireNumberMatch"] == true ? "47" : nil
211
+ request = {
212
+ "id" => format("00000000-0000-4000-8000-%012d", @seq), "user" => user, "type" => body["type"],
213
+ "referenceId" => body["referenceId"], "details" => body["details"], "status" => "PENDING",
214
+ "consumed" => false, "challengeCode" => number, "confirmedVia" => nil,
215
+ "expiresAt" => Steps.iso_of(Util.now_ms + 30_000)
216
+ }
217
+ request.merge!("status" => "CONFIRMED", "confirmedVia" => "DEVICE") if auto_approve
218
+ @requests << request
219
+ [200, { "message" => "Request sent", "requestId" => request["id"], "challengeCode" => number,
220
+ "expiresAt" => request["expiresAt"], "caseCode" => "K7P-2QX" }]
221
+ end
222
+
223
+ def approval(request)
224
+ passkey = request["confirmedVia"] == "WEBAUTHN"
225
+ {
226
+ "externalUsername" => request["user"], "type" => request["type"], "referenceId" => request["referenceId"],
227
+ "details" => request["details"], "confirmedVia" => request["confirmedVia"],
228
+ "assurance" => request["status"] == "CONFIRMED" ? { "phishingResistant" => passkey, "method" => passkey ? "passkey" : "push" } : nil,
229
+ "provisional" => false, "reconfirmedAt" => nil, "revoked" => false, "revokeReason" => nil
230
+ }
231
+ end
232
+
233
+ def login_status(_body, request_id)
234
+ request = find(request_id)
235
+ return [404, { "code" => "not_found" }] unless request
236
+
237
+ # The request's expiry, as the API answers it on every status check.
238
+ [200, { "status" => request["status"], "consumed" => request["consumed"], "expiresAt" => request["expiresAt"] }.merge(approval(request))]
239
+ end
240
+
241
+ def login_consume(_body, request_id)
242
+ request = find(request_id)
243
+ return [404, { "code" => "not_found" }] unless request
244
+ unless request["status"] == "CONFIRMED"
245
+ return [409, { "code" => { "REJECTED" => "rejected", "EXPIRED" => "expired" }.fetch(request["status"], "not_approved") }]
246
+ end
247
+ return [409, { "code" => "already_used" }] if request["consumed"]
248
+
249
+ request["consumed"] = true
250
+ [200, { "consumed" => true, "requestId" => request["id"], "approvalProof" => nil }.merge(approval(request))]
251
+ end
252
+
253
+ def new_secret(user)
254
+ @seq += 1
255
+ expires = Util.now_ms + secret_ttl_ms
256
+ @pending[user] = expires
257
+ { "secret" => "S#{@seq}", "qrCodeDataUrl" => "data:image/png;base64,QR#{@seq}-#{user}", "externalUsername" => user,
258
+ "expiresAt" => Steps.iso_of(expires), "ttlMs" => secret_ttl_ms, "recoveryCodes" => ["r#{@seq}"] }
259
+ end
260
+
261
+ def generate_secret(body)
262
+ user = body["externalUsername"]
263
+ return [409, { "error" => "already linked" }] if linked?(user)
264
+ return [409, { "error" => "Secret already generated" }] if @pending.key?(user)
265
+
266
+ [200, new_secret(user)]
267
+ end
268
+
269
+ # Like the API: a reset unlinks the phone (the guard only resets a user it found not linked).
270
+ def reset_secret(body)
271
+ user = body["externalUsername"]
272
+ @unlinked << user
273
+ @linked.delete(user)
274
+ [200, { "success" => true }.merge(new_secret(user))]
275
+ end
276
+
277
+ def get_user(_body, user)
278
+ linked = linked?(user)
279
+ return [404, { "error" => "not found" }] if !linked && !@pending.key?(user)
280
+
281
+ [200, { "externalUsername" => user, "deviceId" => linked ? "dev-1" : nil, "used" => linked, "frozen" => false, "device" => nil }]
282
+ end
283
+
284
+ def offline_challenge(body)
285
+ request_id = body["requestId"]
286
+ follows = request_id ? find(request_id) : nil
287
+ return [404, { "error" => "request_not_found" }] if request_id && follows.nil?
288
+ return [409, { "error" => "request_rejected" }] if follows && follows["status"] == "REJECTED"
289
+
290
+ @seq += 1
291
+ challenge_id = "ch-#{@seq}"
292
+ @challenges[challenge_id] = { "user" => body["externalUsername"], "type" => body["type"], "used" => false, "requestId" => request_id }
293
+ out = { "challengeId" => challenge_id, "qr" => "TQ2.fake", "qrDataUrl" => "data:image/png;base64,OFFLINE",
294
+ "expiresAt" => Steps.iso_of(Util.now_ms + 120_000), "expiresInSeconds" => 120, "totpAvailable" => true }
295
+ number = follows&.fetch("challengeCode", nil) || (body["requireNumberMatch"] == true ? "47" : nil)
296
+ out["challengeCode"] = number if number
297
+ [200, out]
298
+ end
299
+
300
+ def rejected?(request_id)
301
+ request = request_id ? find(request_id) : nil
302
+ request ? request["status"] == "REJECTED" : false
303
+ end
304
+
305
+ def offline_verify(body)
306
+ challenge = @challenges[body["challengeId"]]
307
+ return [404, { "approved" => false, "reason" => "unknown_challenge" }] unless challenge
308
+ return [410, { "approved" => false, "reason" => "used" }] if challenge["used"]
309
+ return [410, { "approved" => false, "reason" => "request_rejected" }] if rejected?(challenge["requestId"])
310
+ return [401, { "approved" => false, "reason" => "invalid_code", "attemptsLeft" => 4 }] unless body["code"] == offline_code
311
+
312
+ challenge["used"] = true
313
+ [200, { "approved" => true, "challengeId" => body["challengeId"], "externalUsername" => challenge["user"], "type" => challenge["type"] }]
314
+ end
315
+
316
+ def offline_totp_verify(body)
317
+ return [410, { "approved" => false, "reason" => "request_rejected" }] if rejected?(body["requestId"])
318
+ return [401, { "approved" => false, "reason" => "invalid_code" }] unless body["code"] == totp_code
319
+
320
+ [200, { "approved" => true, "externalUsername" => body["externalUsername"] }]
321
+ end
322
+
323
+ # Silent sign-ins: the fake always asks the phone.
324
+ def risk_check(body)
325
+ reason = if body["browserProof"]
326
+ { "code" => "new_browser", "text" => "This browser has not been approved on the phone for this account" }
327
+ else
328
+ { "code" => "no_browser_key", "text" => "No device-bound browser key was presented" }
329
+ end
330
+ [200, { "prompt" => true, "reasons" => [reason], "assurance" => "normal", "silentMode" => "enforce", "caseCode" => "CHK-2QX" }]
331
+ end
332
+
333
+ def define_action_type(body)
334
+ @seq += 1
335
+ defined = { "id" => "at-#{@seq}", "type" => body["type"], "name" => body["name"], "description" => body["description"] || "",
336
+ "critical" => body["critical"] ? true : false, "active" => true }
337
+ @defined_types[body["type"]] = defined
338
+ [200, defined]
339
+ end
340
+
341
+ def list_action_types(_body)
342
+ [200, @defined_types.values]
343
+ end
344
+
345
+ # What the fake answers: method, path pattern, handler (body, path parts).
346
+ ROUTES = [
347
+ ["POST", %r{\A/login/request\z}, :login_request],
348
+ ["GET", %r{\A/login/status/([^/]+)\z}, :login_status],
349
+ ["POST", %r{\A/login/([^/]+)/consume\z}, :login_consume],
350
+ ["POST", %r{\A/auth/generate-secret\z}, :generate_secret],
351
+ ["POST", %r{\A/auth/secret/reset\z}, :reset_secret],
352
+ ["GET", %r{\A/users/([^/]+)\z}, :get_user],
353
+ ["POST", %r{\A/offline/challenge\z}, :offline_challenge],
354
+ ["POST", %r{\A/offline/verify\z}, :offline_verify],
355
+ ["POST", %r{\A/offline/totp/verify\z}, :offline_totp_verify],
356
+ ["POST", %r{\A/risk/check\z}, :risk_check],
357
+ ["POST", %r{\A/action-types\z}, :define_action_type],
358
+ ["GET", %r{\A/action-types\z}, :list_action_types]
359
+ ].freeze
360
+ private_constant :ROUTES
361
+
362
+ # The HTTP of a FakeTouchque: every call goes to the fake as JSON, and comes back as
363
+ # JSON or as the error the real client raises.
364
+ class Transport < HTTP
365
+ def initialize(fake, **options)
366
+ @fake = fake
367
+ super(**options)
368
+ end
369
+
370
+ private
371
+
372
+ def send_request(method, path_with_query, body, raw: false)
373
+ sent = body.nil? ? {} : JSON.parse(Util.json(Util.stringify(body)))
374
+ status, data = @fake.__send__(:answer, method, path_with_query, sent)
375
+ text = Util.json(data)
376
+ return text if raw && status < 300
377
+ raise APIError.for(status, JSON.parse(text)) if status >= 300
378
+
379
+ JSON.parse(text)
380
+ end
381
+ end
382
+ private_constant :Transport
383
+ end
384
+
385
+ # Another name for FakeTouchque.
386
+ FakeClient = FakeTouchque
387
+ end
388
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Touchque
4
- VERSION = "5.1.0"
4
+ VERSION = "5.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: touchque
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Touchque
@@ -52,6 +52,7 @@ files:
52
52
  - lib/touchque/resources/webhook.rb
53
53
  - lib/touchque/safe_approval.rb
54
54
  - lib/touchque/security_events.rb
55
+ - lib/touchque/testing.rb
55
56
  - lib/touchque/util.rb
56
57
  - lib/touchque/version.rb
57
58
  homepage: https://touchque.com