touchque 5.0.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +105 -0
  3. data/LICENSE +21 -0
  4. data/README.md +383 -0
  5. data/lib/touchque/agent.rb +211 -0
  6. data/lib/touchque/agent_card.rb +159 -0
  7. data/lib/touchque/approvals.rb +214 -0
  8. data/lib/touchque/client.rb +81 -0
  9. data/lib/touchque/code_reply.rb +53 -0
  10. data/lib/touchque/cross_app_access.rb +204 -0
  11. data/lib/touchque/crypto.rb +215 -0
  12. data/lib/touchque/errors.rb +246 -0
  13. data/lib/touchque/flow.rb +362 -0
  14. data/lib/touchque/hosted_login.rb +401 -0
  15. data/lib/touchque/http.rb +127 -0
  16. data/lib/touchque/login_session.rb +86 -0
  17. data/lib/touchque/org_agent.rb +398 -0
  18. data/lib/touchque/rack/relay.rb +75 -0
  19. data/lib/touchque/rails/guard.rb +132 -0
  20. data/lib/touchque/rails/hosted_login.rb +143 -0
  21. data/lib/touchque/rails/sessions_controller.rb +12 -0
  22. data/lib/touchque/railtie.rb +34 -0
  23. data/lib/touchque/relay.rb +533 -0
  24. data/lib/touchque/resources/actions.rb +212 -0
  25. data/lib/touchque/resources/agents.rb +251 -0
  26. data/lib/touchque/resources/device_risk.rb +130 -0
  27. data/lib/touchque/resources/identity.rb +331 -0
  28. data/lib/touchque/resources/login.rb +325 -0
  29. data/lib/touchque/resources/mobile.rb +115 -0
  30. data/lib/touchque/resources/people.rb +237 -0
  31. data/lib/touchque/resources/qr_pairings.rb +82 -0
  32. data/lib/touchque/resources/safe_guard.rb +87 -0
  33. data/lib/touchque/resources/webhook.rb +177 -0
  34. data/lib/touchque/safe_approval.rb +283 -0
  35. data/lib/touchque/security_events.rb +162 -0
  36. data/lib/touchque/util.rb +260 -0
  37. data/lib/touchque/version.rb +5 -0
  38. data/lib/touchque.rb +124 -0
  39. metadata +83 -0
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # Base class of every error the SDK raises.
5
+ class Error < StandardError; end
6
+
7
+ # Touchque answered with an error (status 400 or more). +status+ is the HTTP
8
+ # status, +code+ the machine-readable code (+device_not_linked+,
9
+ # +unknown_action+, +already_used+...) and +data+ the decoded JSON body.
10
+ class APIError < Error
11
+ attr_reader :status, :data
12
+
13
+ def initialize(status, data = nil, message = nil)
14
+ @status = status
15
+ @data = data.is_a?(Hash) ? data : {}
16
+ super(message || @data["message"] || @data["error"] || "Touchque API error: HTTP #{status}")
17
+ end
18
+
19
+ # Machine-readable error code, e.g. "device_not_linked".
20
+ def code
21
+ @data["code"]
22
+ end
23
+
24
+ # The API's +error+ field.
25
+ def error
26
+ @data["error"]
27
+ end
28
+
29
+ # Why a request was refused (offline codes, blocked requests).
30
+ def reason
31
+ @data["reason"].is_a?(String) ? @data["reason"] : nil
32
+ end
33
+
34
+ # Offline sign: wrong codes left before the challenge locks.
35
+ def attempts_left
36
+ @data["attemptsLeft"]
37
+ end
38
+
39
+ # Seconds to wait before retrying (429 / 423).
40
+ def retry_after
41
+ @data["retryAfter"]
42
+ end
43
+
44
+ # "Why?" case code of a refusal (+K7P-2QX+): show it to the user ("Tell your
45
+ # support team: K7P-2QX"); your team opens the decision by it in the dashboard.
46
+ def case_code
47
+ @data["caseCode"].is_a?(String) ? @data["caseCode"] : nil
48
+ end
49
+
50
+ # Where to send the person instead (help desk: +never_locked_out+).
51
+ def route
52
+ @data["route"]
53
+ end
54
+
55
+ # The error for an answer: a FeatureError subclass for the three feature
56
+ # codes, else APIError.
57
+ def self.for(status, data = nil, message = nil)
58
+ klass = data.is_a?(Hash) ? FeatureError::BY_ERROR[data["error"]] : nil
59
+ (klass || APIError).new(status, data, message)
60
+ end
61
+ end
62
+
63
+ # The organization behind your workspace cannot use this feature here (HTTP
64
+ # 403). +feature_reason+:
65
+ # - +:not_in_plan+: not in its plan (+feature_not_in_plan+); an owner or admin
66
+ # adds it in the Touchque dashboard;
67
+ # - +:read_only+: being turned off (+feature_read_only+); reads still work for
68
+ # 14 days, changes are refused;
69
+ # - +:off+: switched off on this Touchque deployment (+feature_off+).
70
+ # +feature+ is the catalog id (+device_risk+, +ai_agents+, +mobile_sdk+,
71
+ # +ciam+...). An APIError, so existing +rescue+ blocks still catch it; rescue
72
+ # one of the three subclasses to tell them apart.
73
+ class FeatureError < APIError
74
+ REASON = nil
75
+
76
+ # The catalog id of the feature, or nil.
77
+ def feature
78
+ @data["feature"].is_a?(String) ? @data["feature"] : nil
79
+ end
80
+
81
+ # +:not_in_plan+, +:read_only+ or +:off+.
82
+ def feature_reason
83
+ self.class::REASON
84
+ end
85
+ end
86
+
87
+ # +feature_not_in_plan+: the feature is not in the organization's plan.
88
+ class FeatureNotInPlanError < FeatureError
89
+ REASON = :not_in_plan
90
+ end
91
+
92
+ # +feature_read_only+: the feature is being turned off; reads work, changes are refused.
93
+ class FeatureReadOnlyError < FeatureError
94
+ REASON = :read_only
95
+ end
96
+
97
+ # +feature_off+: the feature is switched off on this Touchque deployment.
98
+ class FeatureOffError < FeatureError
99
+ REASON = :off
100
+ end
101
+
102
+ FeatureError::BY_ERROR = {
103
+ "feature_not_in_plan" => FeatureNotInPlanError,
104
+ "feature_read_only" => FeatureReadOnlyError,
105
+ "feature_off" => FeatureOffError,
106
+ }.freeze
107
+
108
+ # The request never reached Touchque (DNS, connection refused, timeout). Unlike
109
+ # APIError, generally safe to retry.
110
+ class NetworkError < Error
111
+ def initialize(message)
112
+ super("Touchque: network request failed: #{message}")
113
+ end
114
+ end
115
+
116
+ # The user did not answer in time, or the request expired.
117
+ class TimeoutError < Error
118
+ attr_reader :request_id
119
+
120
+ def initialize(message, request_id = nil)
121
+ @request_id = request_id
122
+ super(message)
123
+ end
124
+ end
125
+
126
+ # The user rejected the request on their phone.
127
+ class RejectedError < Error
128
+ attr_reader :request_id
129
+
130
+ def initialize(request_id)
131
+ @request_id = request_id
132
+ super("Touchque: request '#{request_id}' was rejected by the user.")
133
+ end
134
+ end
135
+
136
+ # A webhook is not from Touchque, was changed, or is too old.
137
+ class WebhookSignatureError < Error
138
+ def initialize(message)
139
+ super("Touchque: webhook rejected: #{message}")
140
+ end
141
+ end
142
+
143
+ # A correctly signed webhook with this +jti+ was already accepted. Usually a
144
+ # Touchque retry of a delivery you processed: answer 200 so it stops, but do not
145
+ # run your side effects again. A WebhookSignatureError, so existing rescues
146
+ # still reject it.
147
+ class WebhookReplayError < WebhookSignatureError
148
+ attr_reader :jti
149
+
150
+ def initialize(jti)
151
+ @jti = jti
152
+ super("this webhook (jti) was already accepted")
153
+ end
154
+ end
155
+
156
+ # Missing or invalid configuration, an undefined action type, or a wrong argument.
157
+ class ConfigError < Error
158
+ def initialize(message)
159
+ super("Touchque config error: #{message}")
160
+ end
161
+ end
162
+
163
+ # This request must be approved with a passkey. +reason+ is "policy" when a
164
+ # phishing-resistant policy requires it (no push was sent), or "step_up" when
165
+ # the user tapped Approve during a call (Vishing Shield) and the request, still
166
+ # open, now waits for a passkey in the browser.
167
+ class PasskeyRequiredError < Error
168
+ attr_reader :request_id, :reason
169
+
170
+ def initialize(request_id, reason = "policy")
171
+ @request_id = request_id
172
+ @reason = reason
173
+ super(if reason == "step_up"
174
+ "Touchque: request '#{request_id}' must now be approved with a passkey: the user approved on the phone during a call."
175
+ else
176
+ "Touchque: request '#{request_id}' must be approved with a passkey (phishing-resistant policy); no push was sent."
177
+ end)
178
+ end
179
+ end
180
+
181
+ # An AI agent's approval ended without a usable approval: +status+ is rejected,
182
+ # blocked, revoked, expired, stale (approved too long ago) or redeemed (already used).
183
+ class ApprovalError < Error
184
+ attr_reader :approval_id, :status, :reason
185
+
186
+ def initialize(approval_id, status, reason = nil)
187
+ @approval_id = approval_id
188
+ @status = status
189
+ @reason = reason
190
+ super("Touchque: approval '#{approval_id}' is #{status}#{reason ? " (#{reason})" : ""}.")
191
+ end
192
+ end
193
+
194
+ # An approval does not say what you are about to execute (another person,
195
+ # action type, amount, recipient...), or it is not a valid token from Touchque.
196
+ # Do not execute.
197
+ class ApprovalMismatchError < Error
198
+ def initialize(message)
199
+ super("Touchque: #{message}")
200
+ end
201
+ end
202
+
203
+ # McpAuth refused a request: answer with +status+ (401, 403) and the
204
+ # WWW-Authenticate header in +www_authenticate+. +error+ is missing_token,
205
+ # invalid_token, invalid_dpop_proof or insufficient_scope.
206
+ class McpAuthError < Error
207
+ attr_reader :status, :error, :www_authenticate
208
+
209
+ def initialize(status, error, message, www_authenticate)
210
+ @status = status
211
+ @error = error
212
+ @www_authenticate = www_authenticate
213
+ super(message)
214
+ end
215
+ end
216
+
217
+ # A Touchque Login sign-in or token check failed; +error_code+ is machine-readable.
218
+ class HostedLoginError < Error
219
+ attr_reader :error_code, :description, :status
220
+
221
+ def initialize(error_code, description = nil, status = nil)
222
+ @error_code = error_code
223
+ @description = description
224
+ @status = status
225
+ super(description ? "#{error_code}: #{description}" : error_code)
226
+ end
227
+ end
228
+
229
+ # A My Account API call failed; step_up? means the sign-in is too old for it (RFC 9470).
230
+ class MyAccountError < Error
231
+ attr_reader :error_code, :status, :description, :max_age
232
+
233
+ def initialize(error_code, status, description = nil, max_age = nil)
234
+ @error_code = error_code
235
+ @status = status
236
+ @description = description
237
+ @max_age = max_age
238
+ super(description ? "#{error_code}: #{description}" : error_code)
239
+ end
240
+
241
+ # True when the person must sign in again (authorization_url with max_age: 0) before this change.
242
+ def step_up?
243
+ error_code == "insufficient_user_authentication"
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # The headless step-up flow shared by every integration: start → show the step in
5
+ # YOUR UI → check → complete exactly once. A "step" is a plain Hash, safe to send to
6
+ # the browser as JSON: state (waiting | approved | rejected | expired | enroll |
7
+ # passkey_required | offline | blocked | frozen | rate_limited) and whatever the user
8
+ # has to see (the matching number, the enrollment QR, the offline QR). No secrets.
9
+ module Steps
10
+ STATE_MAP = { "PENDING" => "waiting", "CONFIRMED" => "approved", "REJECTED" => "rejected", "EXPIRED" => "expired" }.freeze
11
+
12
+ module_function
13
+
14
+ # Starts an approval for +action+; never waits.
15
+ def start(client, action, user, details: nil, reference_id: nil, ip: nil, user_agent: nil, enroll: true, browser_proof: nil, context: nil)
16
+ raise ConfigError, "start() needs the user id" if user.to_s.empty?
17
+
18
+ norm = Details.normalize(details)
19
+ norm = nil if norm.empty?
20
+ begin
21
+ res = client.login.request(user, type: action, reference_id: reference_id, client_ip: ip, user_agent: user_agent,
22
+ details: norm, browser_proof: browser_proof, context: context)
23
+ canary = res.dig("canary", "url").is_a?(String) ? { "url" => res.dig("canary", "url") } : nil
24
+ if res["requiresPasskey"]
25
+ step = { "state" => "passkey_required", "requestId" => res["requestId"], "expiresAt" => res["expiresAt"], "details" => norm }.merge(why(res))
26
+ step["canary"] = canary if canary
27
+ return step
28
+ end
29
+ step = { "state" => "waiting", "requestId" => res["requestId"], "number" => res["challengeCode"],
30
+ "expiresAt" => res["expiresAt"], "details" => norm }.merge(why(res))
31
+ # Only when the workspace collects behavioural signals.
32
+ step["telemetryToken"] = res["telemetryToken"] if res["telemetryToken"] && !res["telemetryToken"].empty?
33
+ # Mirror Alarm: the page showing this first step loads the canary.
34
+ step["canary"] = canary if canary
35
+ step
36
+ rescue APIError => e
37
+ code = e.code || e.error
38
+ why = e.case_code ? { "caseCode" => e.case_code } : {}
39
+ if e.status == 404 && (code == "device_not_linked" || e.message.downcase.include?("linked device"))
40
+ return enroll ? enroll_step(client, user) : { "state" => "enroll" }
41
+ end
42
+ return { "state" => "frozen", "retryAfter" => e.retry_after }.merge(why) if e.status == 423
43
+ return { "state" => "rate_limited", "retryAfter" => e.retry_after }.merge(why) if e.status == 429
44
+ if e.status == 400 && (code == "unknown_action" || e.message.downcase.include?("invalid action type"))
45
+ raise ConfigError, "Unknown action \"#{action}\". Create it once with client.action_types.define(\"#{action}\") or in the dashboard (Action Types)."
46
+ end
47
+ if e.status == 403
48
+ reason = (code == "blocked" ? e.reason : nil) ||
49
+ (code == "passkey_not_registered" || e.error == "phishing_resistant_required" ? "passkey_not_registered" : nil) ||
50
+ (code == "action_disabled" ? "action_disabled" : nil) || code || "blocked"
51
+ return { "state" => "blocked", "reason" => reason }.merge(why)
52
+ end
53
+ raise
54
+ end
55
+ end
56
+
57
+ # First-time linking. Never unlinks a phone: a secret is only re-issued when the
58
+ # account is confirmed NOT linked.
59
+ def enroll_step(client, user)
60
+ gen = client.auth.generate_secret(user)
61
+ { "state" => "enroll", "enroll" => gen.slice("qrCodeDataUrl", "recoveryCodes", "expiresAt") }
62
+ rescue APIError => e
63
+ raise unless e.status == 409
64
+
65
+ current = begin
66
+ client.auth.get_user(user)
67
+ rescue APIError
68
+ nil
69
+ end
70
+ return { "state" => "enroll", "reason" => "already_linked" } if current && (current["used"] || current["deviceId"])
71
+
72
+ reset = client.auth.reset_secret(user)
73
+ { "state" => "enroll", "enroll" => reset.slice("qrCodeDataUrl", "recoveryCodes", "expiresAt") }
74
+ end
75
+
76
+ # "Why?": the case code and plain reasons of a login request's answer, for its step.
77
+ def why(res)
78
+ out = {}
79
+ out["caseCode"] = res["caseCode"] if res["caseCode"].is_a?(String) && !res["caseCode"].empty?
80
+ reasons = (res["reasons"].is_a?(Array) ? res["reasons"] : []).filter_map do |r|
81
+ { "code" => r["code"], "text" => r["text"].is_a?(String) ? r["text"] : "" } if r.is_a?(Hash) && r["code"].is_a?(String)
82
+ end
83
+ out["reasons"] = reasons unless reasons.empty?
84
+ out
85
+ end
86
+
87
+ # Where a started approval is now.
88
+ def check(client, request_id)
89
+ s = client.login.status(request_id)
90
+ state = s["status"] == "PENDING" && s["requiresPasskey"] ? "passkey_required" : STATE_MAP.fetch(s["status"], "waiting")
91
+ step = { "state" => state, "requestId" => request_id }
92
+ step["assurance"] = s["assurance"] if state == "approved" && s["assurance"]
93
+ step
94
+ end
95
+
96
+ # Uses an approved request exactly once and checks it is for THIS user, action and
97
+ # transaction. ApprovalMismatchError when it is for something else.
98
+ def complete(client, request_id, user, action, details: nil, reference_id: nil)
99
+ res = client.login.consume(request_id)
100
+ same_user = res["externalUsername"].to_s.downcase == user.to_s.downcase
101
+ same_details = Details.digest(details) == Details.digest(res["details"])
102
+ # No reference and an empty one are the same: the API answers null for both.
103
+ same_ref = (res["referenceId"].to_s.empty? ? nil : res["referenceId"]) == (reference_id.to_s.empty? ? nil : reference_id)
104
+ if !same_user || res["type"] != action || !same_details || !same_ref
105
+ raise ApprovalMismatchError, "this approval is for a different user, action or transaction."
106
+ end
107
+
108
+ approval = {
109
+ "requestId" => request_id, "user" => res["externalUsername"], "action" => res["type"],
110
+ "assurance" => res["assurance"], "confirmedVia" => res["confirmedVia"], "approvalProof" => res["approvalProof"]
111
+ }
112
+ if res.key?("provisional")
113
+ # Vishing Shield: approved during a call. Treat the session as limited: critical actions are
114
+ # refused until APPROVAL_RECONFIRMED, and APPROVAL_REVOKED means end it.
115
+ approval.merge!("provisional" => res["provisional"] ? true : false, "revoked" => res["revoked"] ? true : false,
116
+ "revokeReason" => res["revokeReason"], "reconfirmedAt" => res["reconfirmedAt"])
117
+ end
118
+ approval
119
+ end
120
+
121
+ # The browser echoes this token back while it waits. Signed with a key derived from
122
+ # the API secret, it binds the approval to one user, action and transaction.
123
+ def sign_guard_token(api_secret, claims)
124
+ body = Util.b64url(Util.json(claims))
125
+ "v1.#{body}.#{Util.b64url(Util.hmac(token_key(api_secret), body))}"
126
+ end
127
+
128
+ def verify_guard_token(api_secret, token)
129
+ return nil if token.nil? || token.empty? || token.length > 4096
130
+
131
+ parts = token.split(".", -1)
132
+ return nil unless parts.length == 3 && parts[0] == "v1"
133
+ return nil unless Util.secure_compare(Util.b64url(Util.hmac(token_key(api_secret), parts[1])), parts[2])
134
+
135
+ claims = Util.parse_json(Util.b64url_decode(parts[1]).force_encoding("UTF-8"))
136
+ return nil unless claims.is_a?(Hash) && claims["exp"].is_a?(Numeric) && claims["exp"] >= Util.now_ms
137
+
138
+ claims
139
+ rescue ArgumentError
140
+ nil
141
+ end
142
+
143
+ def token_key(api_secret)
144
+ Util.hmac(api_secret, "touchque-guard-token-v1")
145
+ end
146
+ end
147
+
148
+ # Framework-agnostic step-up guard (the Rails concern is a thin wrapper over it).
149
+ # Contract, the same in every Touchque server SDK:
150
+ # 1st request -> 202 {"touchque" => step, "token" => ...} show the step in your UI
151
+ # repeat with the token -> 202 while waiting, then the protected action runs once
152
+ # refused -> 403 / 408 / 423 / 429 {"touchque" => {"state" => ...}}
153
+ # Request headers the browser sends back: X-Touchque-Token, X-Touchque-Offline: 1,
154
+ # X-Touchque-Code (+ X-Touchque-Code-Type: totp), X-Touchque-Browser (silent sign-ins).
155
+ module Guard
156
+ TOKEN_HEADER = "X-Touchque-Token"
157
+ OFFLINE_HEADER = "X-Touchque-Offline"
158
+ CODE_HEADER = "X-Touchque-Code"
159
+ CODE_TYPE_HEADER = "X-Touchque-Code-Type"
160
+ BROWSER_HEADER = "X-Touchque-Browser"
161
+
162
+ TOKEN_TTL_MS = 10 * 60 * 1000
163
+ MAX_BROWSER_PROOF = 4096
164
+ STATUS = { "waiting" => 202, "enroll" => 202, "passkey_required" => 202, "offline" => 202, "approved" => 200,
165
+ "rejected" => 403, "blocked" => 403, "expired" => 408, "frozen" => 423, "rate_limited" => 429 }.freeze
166
+ WAITING = %w[waiting passkey_required offline].freeze
167
+
168
+ module_function
169
+
170
+ # The guard headers from a header getter (name => value or nil).
171
+ def input_from_headers(get)
172
+ {
173
+ token: presence(get.call(TOKEN_HEADER)),
174
+ offline: %w[1 true].include?(get.call(OFFLINE_HEADER)),
175
+ code: presence(get.call(CODE_HEADER)),
176
+ code_type: presence(get.call(CODE_TYPE_HEADER))
177
+ }
178
+ end
179
+
180
+ # +silent_sign_in+: false/nil (default), true, or { max_age_seconds: Integer >= 0 }.
181
+ def assert_silent_sign_in!(option, who)
182
+ return if option.nil? || option == true || option == false
183
+
184
+ if option.is_a?(Hash) && (option.keys.map(&:to_s) - ["max_age_seconds"]).empty?
185
+ max = option[:max_age_seconds] || option["max_age_seconds"]
186
+ return if max.nil? || (max.is_a?(Integer) && max >= 0)
187
+ end
188
+ raise ArgumentError, "#{who}: silent_sign_in must be true, false or { max_age_seconds: Integer >= 0 }."
189
+ end
190
+
191
+ def silent_on?(option)
192
+ option == true || option.is_a?(Hash)
193
+ end
194
+
195
+ # 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.
197
+ def status_poll?(client, user, action, details: nil, reference_id: nil, token: nil, offline: false, code: nil)
198
+ return false if user.to_s.empty? || token.to_s.empty? || offline || !code.to_s.empty?
199
+
200
+ claims = Steps.verify_guard_token(client.api_secret, token)
201
+ return false unless claims && claims["rid"]
202
+
203
+ bound?(claims, user, action, details, reference_id) && WAITING.include?(claims["st"])
204
+ end
205
+
206
+ def bound?(claims, user, action, details, reference_id)
207
+ claims["u"] == user && claims["a"] == action && claims["d"] == Details.digest(details) && claims["r"] == reference_id
208
+ end
209
+
210
+ # 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.
212
+ # Silent sign-ins: with +silent_sign_in+ on, a new attempt that carries a browser
213
+ # proof first asks Touchque (risk.check); only an answer of exactly prompt: false
214
+ # approves at once with no push. Otherwise, and on any error, the usual push starts.
215
+ 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
220
+
221
+ norm = Details.normalize(details)
222
+ norm = nil if norm.empty?
223
+ base = { "u" => user, "a" => action, "d" => Details.digest(details), "r" => reference_id }
224
+ secret = client.api_secret
225
+ issue = lambda do |step, extra = {}|
226
+ step = step.merge("details" => norm) if norm && step["details"].nil? && step["state"] != "blocked"
227
+ claims = base.merge("st" => step["state"], "rid" => step["requestId"], "n" => step["number"],
228
+ "oc" => step.dig("offline", "challengeId"), "exp" => Util.now_ms + TOKEN_TTL_MS)
229
+ claims["cc"] = step["caseCode"] if step["caseCode"]
230
+ claims.merge!(extra)
231
+ { status: STATUS.fetch(step["state"]), body: { "touchque" => step.compact, "token" => Steps.sign_guard_token(secret, claims) } }
232
+ end
233
+
234
+ begin
235
+ claims = Steps.verify_guard_token(secret, token)
236
+ bound = claims && bound?(claims, user, action, details, reference_id) ? claims : nil
237
+ rid = bound && bound["rid"]
238
+ n = bound && bound["n"]
239
+ oc = bound && bound["oc"]
240
+ st = bound && bound["st"]
241
+ keep = { "oc" => oc, "rid" => rid, "n" => n }
242
+
243
+ # Offline: the user typed the code from the phone.
244
+ if bound && !code.to_s.empty? && (st == "offline" || code_type == "totp")
245
+ totp = code_type == "totp"
246
+ res = totp ? client.offline.verify_totp(user, code, type: action, client_ip: ip, request_id: rid) : client.offline.verify(oc.to_s, code)
247
+ for_this = (res["externalUsername"].to_s.empty? || res["externalUsername"].downcase == user.downcase) &&
248
+ (res["type"].to_s.empty? || res["type"] == action)
249
+ if res["approved"] && for_this
250
+ return { status: 200, approved: {
251
+ "requestId" => oc || "offline-totp", "user" => user, "action" => action,
252
+ "assurance" => { "phishingResistant" => false, "method" => totp ? "offline_totp" : "offline_code" },
253
+ "confirmedVia" => totp ? "OFFLINE_TOTP" : "OFFLINE_CODE", "approvalProof" => nil
254
+ } }
255
+ end
256
+ if res["reason"] == "invalid_code" && !totp
257
+ return issue.call({ "state" => "offline", "offline" => { "challengeId" => oc.to_s, "attemptsLeft" => res["attemptsLeft"] } }, keep)
258
+ end
259
+ return issue.call({ "state" => "offline", "reason" => "invalid_code" }, keep) if res["reason"] == "invalid_code"
260
+ # The phone rejected the push this QR belongs to: the whole sign-in is over.
261
+ return issue.call({ "state" => "rejected", "requestId" => rid, "reason" => "request_rejected" }) if res["reason"] == "request_rejected"
262
+ # The push now waits for a passkey (Vishing Shield): no code may finish it.
263
+ if res["reason"] == "passkey_required" && rid
264
+ return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
265
+ end
266
+
267
+ return issue.call({ "state" => res["reason"] == "expired" ? "expired" : "blocked", "reason" => res["reason"] || "offline_failed" })
268
+ end
269
+
270
+ if offline
271
+ begin
272
+ # Tie the QR to the push it follows: a phone-side rejection kills it; number matching carries over.
273
+ ch = client.offline.challenge(user, type: action, details: norm, client_ip: ip, user_agent: user_agent, request_id: rid)
274
+ offline_step = ch.slice("challengeId", "qrDataUrl", "expiresAt", "totpAvailable")
275
+ offline_step["challengeCode"] = ch["challengeCode"] if ch["challengeCode"] && !ch["challengeCode"].empty?
276
+ return issue.call({ "state" => "offline", "offline" => offline_step }, { "rid" => rid, "n" => ch["challengeCode"] || n })
277
+ rescue APIError => e
278
+ err = e.code || e.error
279
+ return issue.call({ "state" => "rejected", "requestId" => rid, "reason" => "request_rejected" }) if e.status == 409 && err == "request_rejected"
280
+ if rid && err == "passkey_required"
281
+ return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
282
+ end
283
+ return issue.call({ "state" => "blocked", "reason" => e.code || e.error || "offline_unavailable" }) if e.status < 500
284
+
285
+ raise
286
+ end
287
+ end
288
+
289
+ # Waiting on a push / passkey, or the offline QR next to a push still open: poll, run once approved.
290
+ if bound && rid && WAITING.include?(st)
291
+ now = Steps.check(client, rid)
292
+ if now["state"] == "approved"
293
+ begin
294
+ return { status: 200, approved: Steps.complete(client, rid, user, action, details: details, reference_id: reference_id) }
295
+ rescue APIError => e
296
+ raise unless e.status == 409
297
+
298
+ return issue.call({ "state" => "expired", "reason" => e.code || "already_used" })
299
+ end
300
+ end
301
+ # The push behind the QR moved to a passkey after a tap during a call.
302
+ if st == "offline" && now["state"] == "passkey_required"
303
+ return issue.call({ "state" => "passkey_required", "requestId" => rid, "number" => n }, { "n" => n })
304
+ end
305
+ # Nothing changed for the user: keep showing the QR they have.
306
+ return issue.call({ "state" => "offline", "offline" => { "challengeId" => oc.to_s } }, keep) if st == "offline" && %w[waiting expired].include?(now["state"])
307
+ if %w[waiting passkey_required].include?(now["state"])
308
+ waiting = { "state" => now["state"], "requestId" => rid, "number" => n }
309
+ # "Why?": the case code stays on screen while it waits.
310
+ waiting["caseCode"] = bound["cc"] if bound["cc"]
311
+ return issue.call(waiting, { "n" => n })
312
+ end
313
+ return issue.call({ "state" => now["state"], "requestId" => rid })
314
+ end
315
+
316
+ # Anything else (no or foreign token, enrollment finished, new attempt): start.
317
+ proof = browser_proof.is_a?(String) && !browser_proof.empty? && browser_proof.length <= MAX_BROWSER_PROOF ? browser_proof : nil
318
+ if proof && silent_on?(silent_sign_in)
319
+ silent = silent_approval(client, user, action, proof, ip, user_agent, silent_sign_in, context)
320
+ return { status: 200, approved: silent } if silent
321
+ end
322
+ issue.call(Steps.start(client, action, user, details: norm, reference_id: reference_id, ip: ip, user_agent: user_agent,
323
+ enroll: enroll, browser_proof: proof, context: context))
324
+ rescue ConfigError => e
325
+ Touchque.log :error, "[Touchque] #{e.message}"
326
+ error(500, { "state" => "blocked", "reason" => "misconfigured" }, "misconfigured", "Two-factor approval is not configured correctly.")
327
+ rescue StandardError => e
328
+ # Anything else (Touchque unreachable, an approval for another transaction, a bug)
329
+ # fails closed with the contract's 503, never a bare 500.
330
+ Touchque.log :error, "[Touchque] approval failed: #{e.class}: #{e.message}"
331
+ error(503, { "state" => "blocked", "reason" => "unavailable" }, "unavailable", "Two-factor approval is temporarily unavailable.")
332
+ end
333
+ end
334
+
335
+ def error(status, step, code, message)
336
+ { status: status, body: { "touchque" => step, "error" => { "code" => code, "message" => message } } }
337
+ end
338
+
339
+ # Fails closed: only an answer of exactly prompt: false approves.
340
+ def silent_approval(client, user, action, proof, ip, user_agent, option, context)
341
+ max_age = option.is_a?(Hash) ? (option[:max_age_seconds] || option["max_age_seconds"]) : nil
342
+ res = client.risk.check(user, type: action, client_ip: ip, user_agent: user_agent, browser_proof: proof,
343
+ max_age_seconds: max_age, context: context)
344
+ return nil unless res["prompt"] == false
345
+
346
+ {
347
+ "requestId" => "silent", "user" => user, "action" => action,
348
+ "assurance" => { "phishingResistant" => false, "method" => "silent" },
349
+ "confirmedVia" => "SILENT", "approvalProof" => nil,
350
+ # Nothing was sent to the phone; why Touchque let this known browser in.
351
+ "reasons" => res["reasons"]
352
+ }
353
+ rescue StandardError => e
354
+ Touchque.log :warn, "[Touchque] silent sign-in check failed, asking the phone: #{e.class}: #{e.message}"
355
+ nil
356
+ end
357
+
358
+ def presence(value)
359
+ value.nil? || value.to_s.empty? ? nil : value.to_s
360
+ end
361
+ end
362
+ end