touchque 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6955277b3bc3e32deb8f97a7d137f02f5d3517b7897801a47f2ea7450025459c
4
- data.tar.gz: cbbe23f464baba65fc927cd7d34ec609be4bdc1a33d2841e07a53b1f66a0c61c
3
+ metadata.gz: 8fd0417df799898bf80db545d3ce9d62bc81ea1fb537631ed1e2d869c389e8e6
4
+ data.tar.gz: ff2565d595c4b7e93c117bf6e414cadb9278d2f739b52a80eec00b0995424bed
5
5
  SHA512:
6
- metadata.gz: 89047ee9495e0d3f2871feda126bea65fc520db8a4300eb9e49a9c0e167e2a803c34407dbb052101c1104ddc03be71141dfa3bebb818319ebf3058bb74d4370e
7
- data.tar.gz: 20ce50cfbc756507d749bc4c404755b74f561708bd7c88f8845d77a4a11115f5df2226b1e2c61a3c324b8f1f908440badc97def0a783567c8a4275d11d776afb
6
+ metadata.gz: 4bb3567bcc20f35485e45312095b1285c9877e85058bd716ca4409af75e050783177491200fde593d4c06de0e35a7e260072c3bf10afc0a67d6d522392074e76
7
+ data.tar.gz: 5c38f7c2d6af269aeb6afff09aafd4214d43dd8c001496d9ad541e6f9b734738198e2784285a77fc7644d4520f4e920e5987f2edbb92f154bdc6067f92eb5bd5
data/CHANGELOG.md CHANGED
@@ -3,7 +3,22 @@
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
- ## [Unreleased]
6
+ ## [5.1.0] — 2026-11-12
7
+
8
+ ### Fixed
9
+ - **The first-time QR stays the same while the user scans it.** Every poll with an `enroll` token started again (login request, 404 `device_not_linked`, generate-secret 409, reset), so each 1.5 s poll replaced the QR and the recovery codes on screen and the QR being scanned stopped working. Now:
10
+ - The enroll step carries `"enroll" => { "expiresAt" }`: the API's `expiresAt` when it is ISO 8601, else now + its `ttlMs`; its token carries that expiry.
11
+ - A poll with that token reads only the link state (`GET /users/{externalUsername}`). Not linked, with more than 15 s left: 202 `{"touchque":{"state":"enroll","expiresAt":"..."},"token":"..."}` with no `enroll` object, so the page keeps the QR and the recovery codes it shows; no new secret, no push. Linked: the push starts as before. Expired, about to expire, or unknown to Touchque: a new QR comes with `enroll.qrCodeDataUrl`.
12
+ - `Guard.status_poll?` is true for such a poll while the first-time QR is up (more than 15 s before it expires), so the relay's `POST /login` no longer counts those polls against the per-user limit. Renewing an expiring QR still counts.
13
+ - Pages on `@touchque/web` need 2.1.0, which keeps the QR it shows for such a step.
14
+ - **A start is always charged.** A first-time QR poll that starts after all (the user linked: the push; the QR expired or is gone: a new QR) calls `Guard.run`'s new `before_start:` first, which may answer instead. The relay's `POST /login` charges it to the username's budget like a new attempt and answers its usual 429 over the limit, so replaying an old enroll token can no longer push on the poll budget.
15
+ - **`require_touchque` answers JSON when `TQ_API_KEY` / `TQ_API_SECRET` are missing.** It raised `Touchque::ConfigError` from `Touchque.client`, so Rails answered its own 500 page. `Touchque.client` is now built inside the guard's error handling on the request, and kept once built. Without keys the action answers 500 `misconfigured` with `Cache-Control: no-store` (401 `unauthenticated` when nobody is signed in) and never runs; it is logged once through `Touchque.logger`: `[Touchque] TQ_API_KEY and TQ_API_SECRET are not set: routes guarded by Touchque answer 500 misconfigured.`
16
+
17
+ ### Added
18
+ - **`error_body:` for `require_touchque`: refusals in your API's own error shape.** `require_touchque "SEND_MONEY", error_body: ->(r) { { message: r[:message], code: r[:code] } }` (a proc run in the controller, or a method name). It is called for every answer of 400 and above (401, 403, 408, 423, 429, 500, 503), never for a 202 step or an approval, with the refusal `{ status:, code:, message:, touchque: }` (a copy of the step). What the result encodes to as JSON decides: a JSON object (a Hash, a Struct or Data, an object with `to_hash` or whose `as_json` is a Hash) is sent with `"touchque"` set to the step (the SDK's key wins, no `token` is added); anything else as it is, as JSON. Status and headers stay the same. One that raises, or a result that cannot be encoded, is logged (`[Touchque] errorBody failed: <message>`) and the default body sent.
19
+ - **Refusal headers.** Every guard answer of 400 and above, from `require_touchque` and the relay, carries `X-Touchque-State` and, when the step has a reason, `X-Touchque-Reason`; `@touchque/web` 2.1.0 reads the step from them when the body has no `touchque` key. Cross-origin pages must expose them (`Access-Control-Expose-Headers`). `Touchque::Guard.headers(result)` gives them, with `Cache-Control` and `Retry-After`, for your own action.
20
+ - `Touchque::Guard.refusal(result)`, and `Touchque::Guard.error_body(result, callable)` for the same shape in your own action (JSON text; the default body, logged, when the callable raises); `Touchque::Guard.default_client` (`Touchque.client`, or `nil` when it cannot be built, logged once), `Touchque::Guard.unauthenticated` and `Touchque::Guard.misconfigured`, the guard's 401 and 500 answers.
21
+ - README: "Guard inside your handler" (`Touchque::Guard.run` after your own checks, what to send back, the 202 and first-time QR polls, `before_start:` for your own rate limits), the `error_body:` option, the refusal headers and what a missing key answers.
7
22
 
8
23
  ## [5.0.0] — 2026-11-12
9
24
 
data/README.md CHANGED
@@ -17,9 +17,6 @@ Ruby 3.1+, no runtime dependencies beyond the standard library (`net/http`, `ope
17
17
  gem "touchque"
18
18
  ```
19
19
 
20
- > **Not on RubyGems yet.** Until it is, take it from GitHub:
21
- > `gem "touchque", git: "https://github.com/Touchque/touchque-ruby"`.
22
-
23
20
  ## Setup
24
21
 
25
22
  Get an API key and secret from your [Touchque dashboard](https://dashboard.touchque.com):
@@ -67,6 +64,9 @@ again with header `X-Touchque-Token: <token>`; [`@touchque/web`](https://www.npm
67
64
  does this loop for you. Once approved the action runs **once**. Refusals answer 403 (rejected,
68
65
  blocked), 408 (expired), 423 (frozen) or 429 (rate limited) with `Retry-After`.
69
66
 
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`.
69
+
70
70
  The approving user is `current_user`'s email (else id, else username). For your login action
71
71
  (nobody is signed in yet) pass `user: -> { session[:password_verified_user] }`.
72
72
 
@@ -74,6 +74,51 @@ The approving user is `current_user`'s email (else id, else username). For your
74
74
  shown on the phone use `request.remote_ip`: configure `config.action_dispatch.trusted_proxies`
75
75
  behind a load balancer.
76
76
 
77
+ **A missing key.** `Touchque.client` is built on the first guarded request, inside the guard.
78
+ Without `TQ_API_KEY` / `TQ_API_SECRET` (or `config.touchque`) nothing raises: the action answers
79
+ **500** with `Cache-Control: no-store` and the body
80
+ `{"touchque":{"state":"blocked","reason":"misconfigured"},"error":{"code":"misconfigured","message":"Two-factor approval is not configured correctly."}}`
81
+ (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."
85
+
86
+ **Refusal headers.** Every answer of 400 and above carries `X-Touchque-State: <state>`, plus
87
+ `X-Touchque-Reason: <reason>` when the step has one; `@touchque/web` 2.1.0 reads the step from them when
88
+ the body has no `touchque` key. A frontend on another origin must list them in
89
+ `Access-Control-Expose-Headers`.
90
+
91
+ ### Your own error shape (`error_body:`)
92
+
93
+ Refusals (403, 408, 423, 429) answer `{"touchque": step, "token": "..."}`; the guard's own errors (401,
94
+ 500, 503) answer `{"touchque": step, "error": {"code", "message"}}`. To answer in your API's own error
95
+ shape, pass `error_body:`:
96
+
97
+ ```ruby
98
+ require_touchque "SEND_MONEY", only: :create,
99
+ error_body: ->(r) { { message: r[:message], code: r[:code] } }
100
+ ```
101
+
102
+ It is called for every answer of 400 and above (401, 403, 408, 423, 429, 500, 503), never for a 202
103
+ step or an approval, with the refusal, a Hash:
104
+
105
+ - `status:`;
106
+ - `code:`: the error's code, else the step's `reason`, else its `state`;
107
+ - `message:`: the error's message, else by state: "The approval was rejected.", "The approval expired.
108
+ Try again.", "Too many rejected approvals. Try again later." (`frozen`), "Too many attempts. Try again
109
+ later." (`rate_limited`), "The approval was refused." (anything else);
110
+ - `touchque:`: the step as it would be sent.
111
+
112
+ The proc runs in the controller (`request` and `t(...)` work there), or name a method:
113
+ `error_body: :touchque_error`. What the result encodes to as JSON decides. A JSON object (a Hash, a
114
+ Struct or Data, an object with `to_hash` or whose `as_json` is a Hash) is sent with `"touchque"` set to
115
+ the step, replacing yours, so `@touchque/web` still sees the state: 403
116
+ `{"message":"The approval was rejected.","code":"rejected","touchque":{"state":"rejected",...}}`. No
117
+ `token` is added. Anything else (a String, a number, an Array, `nil`) is sent as it is, as JSON. The
118
+ proc gets a copy of the step. The status and the headers stay the same (JSON, `Cache-Control: no-store`,
119
+ `Retry-After`, `X-Touchque-State`). One that raises, or a result that cannot be encoded, is logged
120
+ (`[Touchque] errorBody failed: <message>`) and the default body is sent.
121
+
77
122
  ## Action types
78
123
 
79
124
  `"SEND_MONEY"` above is an action type: one kind of approval, with the title the phone shows and
@@ -89,6 +134,58 @@ The guard refuses what is not ready. A name that is not defined is a setup error
89
134
  turned off in the dashboard answers 403 with `reason: "action_disabled"`, which stops that kind of
90
135
  approval at once, without a deploy.
91
136
 
137
+ ## Guard inside your handler
138
+
139
+ When your own checks come first (the balance, who owns the account), run the guard in your action,
140
+ after them, with `Touchque::Guard.run`: the same two-phase contract as `require_touchque`, for Rails,
141
+ Sinatra, Hanami or plain Rack.
142
+
143
+ ```ruby
144
+ def create
145
+ # Your checks first, on every request (each poll comes back through them).
146
+ account = current_user.accounts.find(params.require(:from))
147
+ return render(json: { error: "insufficient_funds" }, status: 422) if account.balance < amount
148
+
149
+ input = Touchque::Guard.input_from_headers(->(name) { request.headers[name] })
150
+ result = Touchque::Guard.run(
151
+ Touchque.client, current_user.email, "SEND_MONEY",
152
+ details: { "Amount" => "#{amount} EUR", "To" => account.iban }, # shown on the phone, bound to the approval
153
+ ip: request.remote_ip, user_agent: request.user_agent, **input
154
+ )
155
+ if result[:approved]
156
+ Transfers.send!(account, amount) # approved: runs once
157
+ return render(json: { sent: true })
158
+ end
159
+
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]
163
+ end
164
+ ```
165
+
166
+ - **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).
168
+ - **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.
175
+ - **First-time QR**: a user with no phone linked gets the step `enroll` with
176
+ `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.
179
+ - **Rate limits**: `Touchque::Guard.status_poll?` tells a poll from a new attempt (a poll only reads the
180
+ 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;
182
+ return a result to answer instead, or nil or false to go on (anything else answers 503). The relay does
183
+ this for `POST /login`.
184
+
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
+
92
189
  ## The three primitives (any framework)
93
190
 
94
191
  ```ruby
@@ -100,7 +197,7 @@ approval = tq.complete(step["requestId"], "jane@acme.com", "SEND_MONEY", details
100
197
  ```
101
198
 
102
199
  `Touchque::Guard.run(tq, user, action, token:, offline:, code:, ...)` is the whole two-phase
103
- contract for Sinatra, Hanami or plain Rack.
200
+ contract for Sinatra, Hanami or plain Rack: see "Guard inside your handler".
104
201
 
105
202
  ## Waiting in one call
106
203
 
@@ -132,8 +229,11 @@ Routes: `POST /login`, `/enroll/start`, `GET /enroll/status`, `/offline/challeng
132
229
  `/passkey/authenticate/options|verify`, `/passkey/login/options|verify`,
133
230
  `/passkey/register/options|verify`, `GET /passkey/credentials`, `DELETE /passkey/credentials/:id`.
134
231
  Pre-auth routes are rate limited (20 per IP, 5 per username a minute; pass `rate_limit: { store: }`
135
- for Redis across processes). The offline routes stay off until `get_login_user` is set. Adding a
136
- passkey for a user with a linked phone waits for the phone first.
232
+ 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.
137
237
 
138
238
  ## Webhooks
139
239
 
@@ -12,6 +12,9 @@ module Touchque
12
12
  #
13
13
  # Thread-safe: create one and reuse it (in Rails, Touchque.client).
14
14
  class Client
15
+ # @api private: the ConfigError message for a missing key or secret.
16
+ MISSING_KEYS = "Set TQ_API_KEY and TQ_API_SECRET (or pass api_key: and api_secret:)."
17
+
15
18
  attr_reader :auth, :login, :webauthn, :webhook, :offline, :action_types, :actions, :risk, :recovery,
16
19
  :assist, :agents, :identity, :mobile, :qr_pairings, :device_risk, :safe_guard
17
20
 
@@ -21,9 +24,7 @@ module Touchque
21
24
  api_key ||= ENV.fetch("TQ_API_KEY", nil)
22
25
  api_secret ||= ENV.fetch("TQ_API_SECRET", nil)
23
26
  base_url ||= ENV["TQ_API_URL"].to_s.empty? ? DEFAULT_BASE_URL : ENV.fetch("TQ_API_URL")
24
- if api_key.to_s.empty? || api_secret.to_s.empty?
25
- raise ConfigError, "Set TQ_API_KEY and TQ_API_SECRET (or pass api_key: and api_secret:)."
26
- end
27
+ raise ConfigError, MISSING_KEYS if api_key.to_s.empty? || api_secret.to_s.empty?
27
28
  raise ConfigError, "api_key must start with \"tq_\". Did you accidentally swap api_key and api_secret?" unless api_key.start_with?("tq_")
28
29
 
29
30
  Util.assert_safe_url!(base_url, "base_url")
data/lib/touchque/flow.rb CHANGED
@@ -57,8 +57,7 @@ module Touchque
57
57
  # First-time linking. Never unlinks a phone: a secret is only re-issued when the
58
58
  # account is confirmed NOT linked.
59
59
  def enroll_step(client, user)
60
- gen = client.auth.generate_secret(user)
61
- { "state" => "enroll", "enroll" => gen.slice("qrCodeDataUrl", "recoveryCodes", "expiresAt") }
60
+ enroll_qr(client.auth.generate_secret(user))
62
61
  rescue APIError => e
63
62
  raise unless e.status == 409
64
63
 
@@ -69,8 +68,38 @@ module Touchque
69
68
  end
70
69
  return { "state" => "enroll", "reason" => "already_linked" } if current && (current["used"] || current["deviceId"])
71
70
 
72
- reset = client.auth.reset_secret(user)
73
- { "state" => "enroll", "enroll" => reset.slice("qrCodeDataUrl", "recoveryCodes", "expiresAt") }
71
+ enroll_qr(client.auth.reset_secret(user))
72
+ end
73
+
74
+ # The enroll step for a new linking QR. "expiresAt" is when the QR stops working: the
75
+ # API's expiresAt when it is an ISO 8601 date-time, else now + its ttlMs, else none.
76
+ def enroll_qr(res)
77
+ enroll = res.slice("qrCodeDataUrl", "recoveryCodes")
78
+ if iso_ms(res["expiresAt"])
79
+ enroll["expiresAt"] = res["expiresAt"]
80
+ elsif res["ttlMs"].is_a?(Numeric) && res["ttlMs"].positive?
81
+ enroll["expiresAt"] = iso_of(Util.now_ms + res["ttlMs"].to_i)
82
+ end
83
+ { "state" => "enroll", "enroll" => enroll }
84
+ end
85
+
86
+ ISO_8601 = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})\z/i
87
+
88
+ # @api private: milliseconds since the epoch of an ISO 8601 date-time with a zone
89
+ # ("2026-11-12T10:05:00.000Z"), or nil.
90
+ def iso_ms(value)
91
+ return nil unless value.is_a?(String) && value.match?(ISO_8601)
92
+
93
+ require "time"
94
+ time = Time.iso8601(value)
95
+ time.to_i * 1000 + time.usec / 1000
96
+ rescue ArgumentError
97
+ nil
98
+ end
99
+
100
+ # @api private: milliseconds since the epoch as ISO 8601 in UTC, like JavaScript's toISOString().
101
+ def iso_of(ms)
102
+ Time.at(ms / 1000, ms % 1000, :millisecond).utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
74
103
  end
75
104
 
76
105
  # "Why?": the case code and plain reasons of a login request's answer, for its step.
@@ -150,6 +179,9 @@ module Touchque
150
179
  # 1st request -> 202 {"touchque" => step, "token" => ...} show the step in your UI
151
180
  # repeat with the token -> 202 while waiting, then the protected action runs once
152
181
  # refused -> 403 / 408 / 423 / 429 {"touchque" => {"state" => ...}}
182
+ # 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
153
185
  # Request headers the browser sends back: X-Touchque-Token, X-Touchque-Offline: 1,
154
186
  # X-Touchque-Code (+ X-Touchque-Code-Type: totp), X-Touchque-Browser (silent sign-ins).
155
187
  module Guard
@@ -164,6 +196,22 @@ module Touchque
164
196
  STATUS = { "waiting" => 202, "enroll" => 202, "passkey_required" => 202, "offline" => 202, "approved" => 200,
165
197
  "rejected" => 403, "blocked" => 403, "expired" => 408, "frozen" => 423, "rate_limited" => 429 }.freeze
166
198
  WAITING = %w[waiting passkey_required offline].freeze
199
+ # A first-time QR this close to its expiry is replaced instead of kept on screen.
200
+ ENROLL_MARGIN_MS = 15_000
201
+ # The refusal message for a state when the guard set none (anything else: "The approval was refused.").
202
+ REFUSAL_MESSAGES = {
203
+ "rejected" => "The approval was rejected.",
204
+ "expired" => "The approval expired. Try again.",
205
+ "frozen" => "Too many rejected approvals. Try again later.",
206
+ "rate_limited" => "Too many attempts. Try again later."
207
+ }.freeze
208
+
209
+ @unconfigured_logged = false
210
+
211
+ class << self
212
+ # @api private: the adapters logged once that the client cannot be built.
213
+ attr_accessor :unconfigured_logged
214
+ end
167
215
 
168
216
  module_function
169
217
 
@@ -193,30 +241,132 @@ module Touchque
193
241
  end
194
242
 
195
243
  # True when this request only polls an approval already on its way: a valid token
196
- # bound to this user/action/details, still waiting, and no offline switch or code.
244
+ # bound to this user/action/details, still waiting, or showing the first-time QR while
245
+ # it is up (more than 15 s before it expires), and no offline switch or code. Relays do
246
+ # not count it against the per-user push limit. Such an enroll poll can still start
247
+ # (the user linked, or the QR is gone): relays charge that through run's before_start.
248
+ # Renewing an expiring QR counts.
197
249
  def status_poll?(client, user, action, details: nil, reference_id: nil, token: nil, offline: false, code: nil)
198
250
  return false if user.to_s.empty? || token.to_s.empty? || offline || !code.to_s.empty?
199
251
 
200
252
  claims = Steps.verify_guard_token(client.api_secret, token)
201
- return false unless claims && claims["rid"]
253
+ return false unless claims && bound?(claims, user, action, details, reference_id)
254
+ return claims["ee"].is_a?(Integer) && Util.now_ms < claims["ee"] - ENROLL_MARGIN_MS if claims["st"] == "enroll"
202
255
 
203
- bound?(claims, user, action, details, reference_id) && WAITING.include?(claims["st"])
256
+ claims["rid"] ? WAITING.include?(claims["st"]) : false
257
+ end
258
+
259
+ # The headers for a result that is not an approval: Cache-Control: no-store,
260
+ # Retry-After when the step has one (423, 429) and, for status >= 400,
261
+ # X-Touchque-State plus X-Touchque-Reason when the step has a reason. @touchque/web
262
+ # rebuilds the step from them when a body has no "touchque" key; cross-origin pages
263
+ # need them in Access-Control-Expose-Headers.
264
+ def headers(result)
265
+ step = result[:body].is_a?(Hash) && result[:body]["touchque"].is_a?(Hash) ? result[:body]["touchque"] : {}
266
+ out = { "Cache-Control" => "no-store" }
267
+ out["Retry-After"] = step["retryAfter"].to_s if step["retryAfter"]
268
+ if result[:status] >= 400 && header_code?(step["state"])
269
+ out["X-Touchque-State"] = step["state"]
270
+ out["X-Touchque-Reason"] = step["reason"] if header_code?(step["reason"])
271
+ end
272
+ out
273
+ end
274
+
275
+ # A state or reason code that is safe as a header value.
276
+ def header_code?(value)
277
+ value.is_a?(String) && value.match?(/\A[A-Za-z0-9_.:-]{1,100}\z/)
204
278
  end
205
279
 
206
280
  def bound?(claims, user, action, details, reference_id)
207
281
  claims["u"] == user && claims["a"] == action && claims["d"] == Details.digest(details) && claims["r"] == reference_id
208
282
  end
209
283
 
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.
287
+ def default_client
288
+ Touchque.client
289
+ rescue ConfigError => e
290
+ unless Guard.unconfigured_logged
291
+ Guard.unconfigured_logged = true
292
+ line = if e.message.end_with?(Client::MISSING_KEYS)
293
+ "[Touchque] TQ_API_KEY and TQ_API_SECRET are not set: routes guarded by Touchque answer 500 misconfigured."
294
+ else
295
+ "[Touchque] #{e.message}"
296
+ end
297
+ Touchque.log :error, line
298
+ end
299
+ nil
300
+ end
301
+
302
+ # The 401 for a request without a signed-in user.
303
+ def unauthenticated
304
+ error(401, { "state" => "blocked", "reason" => "unauthenticated" }, "unauthenticated", "Sign in first.")
305
+ end
306
+
307
+ # The 500 for a setup error: missing keys, an undefined action type.
308
+ def misconfigured
309
+ error(500, { "state" => "blocked", "reason" => "misconfigured" }, "misconfigured", "Two-factor approval is not configured correctly.")
310
+ end
311
+
312
+ # What an error_body callable gets for a result with status >= 400:
313
+ # { status:, code:, message:, touchque: }. code: the error's code, else the step's
314
+ # reason, else its state; message: the error's message, else one for the state ("The
315
+ # approval was rejected." ...); touchque: a copy of the step as it would be sent.
316
+ def refusal(result)
317
+ body = Util.deep_copy(result[:body]) # the callable may change what it gets, never the answer
318
+ step = body["touchque"] || {}
319
+ error = body["error"] || {}
320
+ { status: result[:status], code: error["code"] || step["reason"] || step["state"],
321
+ message: error["message"] || REFUSAL_MESSAGES.fetch(step["state"], "The approval was refused."), touchque: step }
322
+ end
323
+
324
+ # Your own error shape for a result with status >= 400, as the JSON text to send: what
325
+ # +callable+ returns for refusal(result). What it encodes to decides (a Struct or Data as
326
+ # its Hash, to_hash, an as_json Hash, else to_json): a JSON object gets "touchque" set
327
+ # to the step, replacing yours, so @touchque/web still sees the state (no token is
328
+ # added); anything else is sent as it is. When the callable raises, or its result cannot
329
+ # be encoded, this logs "[Touchque] errorBody failed: ..." and returns the default body.
330
+ def error_body(result, callable)
331
+ json = jsonable(callable.call(refusal(result))).to_json
332
+ return json unless json.start_with?("{")
333
+
334
+ JSON.parse(json).merge("touchque" => result[:body]["touchque"]).to_json
335
+ rescue StandardError => e
336
+ Touchque.log :error, "[Touchque] errorBody failed: #{e.message}"
337
+ result[:body].to_json
338
+ end
339
+
340
+ # An error_body result in the form it is encoded in: a Struct or Data as its Hash, an
341
+ # object with to_hash as that, one whose as_json is a Hash as that; anything else as it is.
342
+ def jsonable(value)
343
+ case value
344
+ when Hash, Array, String, Numeric, Symbol, true, false, nil then value
345
+ when Struct then value.to_h
346
+ else
347
+ return value.to_h if defined?(::Data) && value.is_a?(::Data)
348
+ return value.to_hash if value.respond_to?(:to_hash)
349
+
350
+ json = value.respond_to?(:as_json) ? value.as_json : nil
351
+ json.is_a?(Hash) ? json : value
352
+ end
353
+ end
354
+
210
355
  # Runs one step of the guard: {status: 200, approved: approval} (run your protected
211
- # action once) or {status:, body: {"touchque" => step, "token" => ...}} to send back.
356
+ # action once) or {status:, body: {"touchque" => step, "token" => ...}} to send back
357
+ # with headers(result).
212
358
  # Silent sign-ins: with +silent_sign_in+ on, a new attempt that carries a browser
213
359
  # proof first asks Touchque (risk.check); only an answer of exactly prompt: false
214
360
  # approves at once with no push. Otherwise, and on any error, the usual push starts.
361
+ # +before_start+ (for rate limiters): called at most once, only for a poll with a
362
+ # first-time QR token, right before it would start instead of keeping the QR (the user
363
+ # linked: the silent check or the push; the QR expired, is about to, or is gone: a new
364
+ # QR). Return nil or false to go on, or a result ({status:, body:}) to answer instead;
365
+ # anything else fails closed (503 unavailable), so nothing starts. A relay charges the
366
+ # user's budget there, since status_poll? exempted that poll.
215
367
  def run(client, user, action, details: nil, reference_id: nil, ip: nil, user_agent: nil, token: nil, offline: false,
216
- code: nil, code_type: nil, enroll: true, browser_proof: nil, silent_sign_in: false, context: nil)
217
- if user.to_s.empty?
218
- return error(401, { "state" => "blocked", "reason" => "unauthenticated" }, "unauthenticated", "Sign in first.")
219
- end
368
+ code: nil, code_type: nil, enroll: true, browser_proof: nil, silent_sign_in: false, context: nil, before_start: nil)
369
+ return unauthenticated if user.to_s.empty?
220
370
 
221
371
  norm = Details.normalize(details)
222
372
  norm = nil if norm.empty?
@@ -227,6 +377,11 @@ module Touchque
227
377
  claims = base.merge("st" => step["state"], "rid" => step["requestId"], "n" => step["number"],
228
378
  "oc" => step.dig("offline", "challengeId"), "exp" => Util.now_ms + TOKEN_TTL_MS)
229
379
  claims["cc"] = step["caseCode"] if step["caseCode"]
380
+ # A first-time QR: its token carries when the QR expires (ee), so polls keep it on screen.
381
+ if step["state"] == "enroll" && step["enroll"].is_a?(Hash) && !step["enroll"]["qrCodeDataUrl"].to_s.empty?
382
+ ee = Steps.iso_ms(step["enroll"]["expiresAt"])
383
+ claims["ee"] = ee if ee
384
+ end
230
385
  claims.merge!(extra)
231
386
  { status: STATUS.fetch(step["state"]), body: { "touchque" => step.compact, "token" => Steps.sign_guard_token(secret, claims) } }
232
387
  end
@@ -313,6 +468,26 @@ module Touchque
313
468
  return issue.call({ "state" => now["state"], "requestId" => rid })
314
469
  end
315
470
 
471
+ # The first-time QR is up: while the phone is not linked and the QR lives, keep it on screen
472
+ # (no new secret, no push); the page keeps the QR and recovery codes it shows. Linked,
473
+ # expiring or gone: start.
474
+ if bound && st == "enroll" && (ee = bound["ee"]).is_a?(Integer)
475
+ if link_state(client, user) == false && Util.now_ms < ee - ENROLL_MARGIN_MS
476
+ return issue.call({ "state" => "enroll", "expiresAt" => Steps.iso_of(ee) }, { "ee" => ee })
477
+ end
478
+
479
+ # This poll starts after all (a push, or a new QR): the caller may charge or refuse it first.
480
+ # Anything truthy it returns is the answer; what is not a result fails closed (503).
481
+ instead = before_start&.call
482
+ if instead
483
+ unless instead.is_a?(Hash) && instead[:status].is_a?(Integer) && instead[:body].is_a?(Hash)
484
+ raise ArgumentError, "before_start must return nil or false to go on, or a result { status:, body: } to answer"
485
+ end
486
+
487
+ return instead
488
+ end
489
+ end
490
+
316
491
  # Anything else (no or foreign token, enrollment finished, new attempt): start.
317
492
  proof = browser_proof.is_a?(String) && !browser_proof.empty? && browser_proof.length <= MAX_BROWSER_PROOF ? browser_proof : nil
318
493
  if proof && silent_on?(silent_sign_in)
@@ -323,7 +498,7 @@ module Touchque
323
498
  enroll: enroll, browser_proof: proof, context: context))
324
499
  rescue ConfigError => e
325
500
  Touchque.log :error, "[Touchque] #{e.message}"
326
- error(500, { "state" => "blocked", "reason" => "misconfigured" }, "misconfigured", "Two-factor approval is not configured correctly.")
501
+ misconfigured
327
502
  rescue StandardError => e
328
503
  # Anything else (Touchque unreachable, an approval for another transaction, a bug)
329
504
  # fails closed with the contract's 503, never a bare 500.
@@ -336,6 +511,17 @@ module Touchque
336
511
  { status: status, body: { "touchque" => step, "error" => { "code" => code, "message" => message } } }
337
512
  end
338
513
 
514
+ # Whether a phone is linked to the user (used, or a deviceId); nil when Touchque does
515
+ # not know the user (404: the QR is gone). Other errors raise.
516
+ def link_state(client, user)
517
+ current = client.auth.get_user(user)
518
+ current["used"] || current["deviceId"] ? true : false
519
+ rescue APIError => e
520
+ raise unless e.status == 404
521
+
522
+ nil
523
+ end
524
+
339
525
  # Fails closed: only an answer of exactly prompt: false approves.
340
526
  def silent_approval(client, user, action, proof, ip, user_agent, option, context)
341
527
  max_age = option.is_a?(Hash) ? (option[:max_age_seconds] || option["max_age_seconds"]) : nil
@@ -20,17 +20,38 @@ module Touchque
20
20
  # 423 / 429 with Retry-After. Options: user: (default: current_user's email, id or
21
21
  # username; for your login action, whoever just passed your password check), details:,
22
22
  # reference_id:, context: (procs run in the controller, or method names),
23
- # silent_sign_in: (true or { max_age_seconds: }), enroll:, and before_action's
24
- # only: / except: / if: / unless:.
23
+ # silent_sign_in: (true or { max_age_seconds: }), enroll:, error_body:, and
24
+ # before_action's only: / except: / if: / unless:.
25
+ #
26
+ # Every answer of 400 and above carries X-Touchque-State (and X-Touchque-Reason when the
27
+ # step has a reason).
28
+ #
29
+ # error_body: your own error shape for every answer of 400 and above (never a 202 step,
30
+ # never an approval): a proc run in the controller, or a method name, given the refusal
31
+ # ({ status:, code:, message:, touchque: }, with a copy of the step). What it encodes to
32
+ # decides: a JSON object (a Hash, Struct, Data...) is sent with "touchque" set to the
33
+ # step; anything else as it is. Status and headers stay; one that raises, or a result
34
+ # that cannot be encoded, is logged and the default body sent.
35
+ #
36
+ # require_touchque "SEND_MONEY", error_body: ->(r) { { message: r[:message], code: r[:code] } }
37
+ #
38
+ # Touchque.client is built on the first request: without TQ_API_KEY / TQ_API_SECRET the
39
+ # action answers 500 misconfigured (401 without a signed-in user) and never runs.
25
40
  module Guard
26
41
  def self.included(base)
27
42
  base.extend(ClassMethods)
28
43
  end
29
44
 
30
45
  module ClassMethods
31
- def require_touchque(action, user: nil, details: nil, reference_id: nil, context: nil, silent_sign_in: false, enroll: true, **filter_options)
46
+ def require_touchque(action, user: nil, details: nil, reference_id: nil, context: nil, silent_sign_in: false, enroll: true,
47
+ error_body: nil, **filter_options)
32
48
  Touchque::Guard.assert_silent_sign_in!(silent_sign_in, "require_touchque")
33
- options = { user: user, details: details, reference_id: reference_id, context: context, silent_sign_in: silent_sign_in, enroll: enroll }
49
+ unless error_body.nil? || error_body.is_a?(Symbol) || error_body.respond_to?(:call)
50
+ raise ArgumentError, "require_touchque: error_body must be a proc, a method name or respond to call."
51
+ end
52
+
53
+ options = { user: user, details: details, reference_id: reference_id, context: context, silent_sign_in: silent_sign_in,
54
+ enroll: enroll, error_body: error_body }
34
55
  before_action(**filter_options) { touchque_guard!(action, options) }
35
56
  end
36
57
  end
@@ -43,25 +64,44 @@ module Touchque
43
64
  private
44
65
 
45
66
  def touchque_guard!(action, options)
46
- header = ->(name) { request.headers[name] }
47
- input = Touchque::Guard.input_from_headers(header)
48
- silent = options[:silent_sign_in]
49
- result = Touchque::Guard.run(
50
- Touchque.client, touchque_value(options[:user]) || touchque_default_user, action,
51
- details: touchque_value(options[:details]), reference_id: touchque_value(options[:reference_id]),
52
- ip: request.remote_ip, user_agent: request.user_agent, token: input[:token], offline: input[:offline],
53
- code: input[:code], code_type: input[:code_type], enroll: options[:enroll],
54
- browser_proof: Touchque::Guard.silent_on?(silent) ? header.call(Touchque::Guard::BROWSER_HEADER) : nil,
55
- silent_sign_in: silent, context: touchque_value(options[:context])
56
- )
67
+ user = touchque_value(options[:user]) || touchque_default_user
68
+ client = Touchque::Guard.default_client
69
+ result = if client.nil?
70
+ # No client (TQ_API_KEY / TQ_API_SECRET missing): an answer, never an exception.
71
+ user.to_s.empty? ? Touchque::Guard.unauthenticated : Touchque::Guard.misconfigured
72
+ else
73
+ header = ->(name) { request.headers[name] }
74
+ input = Touchque::Guard.input_from_headers(header)
75
+ silent = options[:silent_sign_in]
76
+ Touchque::Guard.run(
77
+ client, user, action,
78
+ details: touchque_value(options[:details]), reference_id: touchque_value(options[:reference_id]),
79
+ ip: request.remote_ip, user_agent: request.user_agent, token: input[:token], offline: input[:offline],
80
+ code: input[:code], code_type: input[:code_type], enroll: options[:enroll],
81
+ browser_proof: Touchque::Guard.silent_on?(silent) ? header.call(Touchque::Guard::BROWSER_HEADER) : nil,
82
+ silent_sign_in: silent, context: touchque_value(options[:context])
83
+ )
84
+ end
57
85
  if result[:approved]
58
86
  request.env["touchque.approval"] = result[:approved]
59
87
  return
60
88
  end
61
- response.headers["Cache-Control"] = "no-store"
62
- retry_after = result[:body].dig("touchque", "retryAfter")
63
- response.headers["Retry-After"] = retry_after.to_s if retry_after
64
- render json: result[:body], status: result[:status]
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
+ end
92
+
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
97
+
98
+ Touchque::Guard.error_body(result, lambda { |refusal|
99
+ case error_body
100
+ when Proc then instance_exec(refusal, &error_body)
101
+ when Symbol then send(error_body, refusal)
102
+ else error_body.call(refusal)
103
+ end
104
+ })
65
105
  end
66
106
 
67
107
  def touchque_value(option)
@@ -221,17 +221,25 @@ module Touchque
221
221
  details = @get_details&.call(ctx[:req])
222
222
  proof = Guard.silent_on?(@silent_sign_in) ? Guard.presence(ctx[:header].call(Guard::BROWSER_HEADER)) : nil
223
223
  # Polling an approval already sent is not a new attempt: its own, larger per-IP budget.
224
- limited = if Guard.status_poll?(@tq, user, @action_type, details: details, token: input[:token], offline: input[:offline], code: input[:code])
225
- allow(ctx, "login-poll", nil, POLLS_PER_ATTEMPT)
226
- else
227
- allow(ctx, "login", user)
228
- end
224
+ poll = Guard.status_poll?(@tq, user, @action_type, details: details, token: input[:token], offline: input[:offline], code: input[:code])
225
+ limited = poll ? allow(ctx, "login-poll", nil, POLLS_PER_ATTEMPT) : allow(ctx, "login", user)
229
226
  return limited if limited
230
227
 
228
+ # A first-time QR poll that starts after all (the user linked, or the QR is gone) is
229
+ # charged like a new attempt: a replayed token must not push on the poll budget.
230
+ refused = nil
231
+ before_start = if poll
232
+ lambda do
233
+ refused = allow(ctx, "login", user)
234
+ refused && { status: refused.status, body: refused.body }
235
+ end
236
+ end
231
237
  result = Guard.run(@tq, user, @action_type, details: details, ip: ctx[:ip].empty? ? nil : ctx[:ip], user_agent: ctx[:user_agent],
232
238
  token: input[:token], offline: input[:offline], code: input[:code], code_type: input[:code_type],
233
239
  # Pre-auth: a typed username proves nothing, so no linking QR unless get_login_user ties this route to the password step.
234
- enroll: !@get_login_user.nil?, browser_proof: proof, silent_sign_in: @silent_sign_in)
240
+ enroll: !@get_login_user.nil?, browser_proof: proof, silent_sign_in: @silent_sign_in,
241
+ before_start: before_start)
242
+ return refused if refused
235
243
  return guard_response(result) unless result[:approved]
236
244
 
237
245
  approved = result[:approved]
@@ -248,11 +256,11 @@ module Touchque
248
256
  end
249
257
  end
250
258
 
259
+ # A guard answer that is not an approval, with Guard.headers (no-store, Retry-After, X-Touchque-State / -Reason).
251
260
  def guard_response(result)
252
261
  res = RelayResponse.new(result[:status], result[:body])
253
- retry_after = result[:body].dig("touchque", "retryAfter")
254
- res.set_header("Retry-After", retry_after) if retry_after
255
- res.set_header("Cache-Control", "no-store")
262
+ Guard.headers(result).each { |name, value| res.set_header(name, value) }
263
+ res
256
264
  end
257
265
 
258
266
  # The username used for on_authenticated is the one Touchque returns for the code, never the body's.
data/lib/touchque/util.rb CHANGED
@@ -83,6 +83,16 @@ module Touchque
83
83
  end
84
84
  end
85
85
 
86
+ # A deep copy of JSON-like data: Hashes, Arrays and Strings copied, anything else as it is.
87
+ def deep_copy(value)
88
+ case value
89
+ when Hash then value.to_h { |k, v| [k, deep_copy(v)] }
90
+ when Array then value.map { |v| deep_copy(v) }
91
+ when String then value.dup
92
+ else value
93
+ end
94
+ end
95
+
86
96
  # A hash without nil values (and, for strings, without empty ones).
87
97
  def compact(hash)
88
98
  hash.reject { |_, v| v.nil? || (v.is_a?(String) && v.empty?) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Touchque
4
- VERSION = "5.0.0"
4
+ VERSION = "5.1.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.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Touchque