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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +105 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/lib/touchque/agent.rb +211 -0
- data/lib/touchque/agent_card.rb +159 -0
- data/lib/touchque/approvals.rb +214 -0
- data/lib/touchque/client.rb +81 -0
- data/lib/touchque/code_reply.rb +53 -0
- data/lib/touchque/cross_app_access.rb +204 -0
- data/lib/touchque/crypto.rb +215 -0
- data/lib/touchque/errors.rb +246 -0
- data/lib/touchque/flow.rb +362 -0
- data/lib/touchque/hosted_login.rb +401 -0
- data/lib/touchque/http.rb +127 -0
- data/lib/touchque/login_session.rb +86 -0
- data/lib/touchque/org_agent.rb +398 -0
- data/lib/touchque/rack/relay.rb +75 -0
- data/lib/touchque/rails/guard.rb +132 -0
- data/lib/touchque/rails/hosted_login.rb +143 -0
- data/lib/touchque/rails/sessions_controller.rb +12 -0
- data/lib/touchque/railtie.rb +34 -0
- data/lib/touchque/relay.rb +533 -0
- data/lib/touchque/resources/actions.rb +212 -0
- data/lib/touchque/resources/agents.rb +251 -0
- data/lib/touchque/resources/device_risk.rb +130 -0
- data/lib/touchque/resources/identity.rb +331 -0
- data/lib/touchque/resources/login.rb +325 -0
- data/lib/touchque/resources/mobile.rb +115 -0
- data/lib/touchque/resources/people.rb +237 -0
- data/lib/touchque/resources/qr_pairings.rb +82 -0
- data/lib/touchque/resources/safe_guard.rb +87 -0
- data/lib/touchque/resources/webhook.rb +177 -0
- data/lib/touchque/safe_approval.rb +283 -0
- data/lib/touchque/security_events.rb +162 -0
- data/lib/touchque/util.rb +260 -0
- data/lib/touchque/version.rb +5 -0
- data/lib/touchque.rb +124 -0
- 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
|