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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +131 -28
- data/lib/touchque/client.rb +8 -1
- data/lib/touchque/flow.rb +94 -20
- data/lib/touchque/rails/guard.rb +17 -8
- data/lib/touchque/railtie.rb +8 -0
- data/lib/touchque/relay.rb +8 -3
- data/lib/touchque/testing.rb +388 -0
- data/lib/touchque/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2762f3481ee0053340ce39c7c40d37dbc67d0394ea86859b6d1c7ab55b9ec015
|
|
4
|
+
data.tar.gz: 4706b4df98f10c0b2f6ade9bb4fe6fad7de8a725719f52bb439a80dc63059cad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
83
|
-
answer 500 misconfigured.` A 500 rather than a
|
|
84
|
-
catch, not an outage to retry. `@touchque/web` shows
|
|
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,
|
|
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
|
-
#
|
|
161
|
-
Touchque::Guard.
|
|
162
|
-
|
|
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**:
|
|
171
|
-
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
`error_body
|
|
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.
|
|
178
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
236
|
-
|
|
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 =
|
|
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
|
|
data/lib/touchque/client.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
184
|
-
# "enroll" => {"qrCodeDataUrl"}
|
|
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,
|
|
285
|
-
# nil when it cannot be built (TQ_API_KEY / TQ_API_SECRET missing, a bad
|
|
286
|
-
# logged once per process; the
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/touchque/rails/guard.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
94
|
-
#
|
|
95
|
-
def
|
|
96
|
-
|
|
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
|
-
|
|
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)
|
data/lib/touchque/railtie.rb
CHANGED
|
@@ -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] || {}) : {}
|
data/lib/touchque/relay.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
262
|
-
|
|
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
|
data/lib/touchque/version.rb
CHANGED
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.
|
|
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
|