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 +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +106 -6
- data/lib/touchque/client.rb +4 -3
- data/lib/touchque/flow.rb +199 -13
- data/lib/touchque/rails/guard.rb +59 -19
- data/lib/touchque/relay.rb +17 -9
- data/lib/touchque/util.rb +10 -0
- data/lib/touchque/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fd0417df799898bf80db545d3ce9d62bc81ea1fb537631ed1e2d869c389e8e6
|
|
4
|
+
data.tar.gz: ff2565d595c4b7e93c117bf6e414cadb9278d2f739b52a80eec00b0995424bed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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).
|
|
136
|
-
|
|
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
|
|
data/lib/touchque/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/touchque/rails/guard.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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[
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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)
|
data/lib/touchque/relay.rb
CHANGED
|
@@ -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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
254
|
-
res
|
|
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?) }
|
data/lib/touchque/version.rb
CHANGED