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,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Action types (LOGIN, SEND_MONEY...): what a user is asked to approve. Define them
|
|
6
|
+
# from code at start-up instead of clicking them into the dashboard.
|
|
7
|
+
class ActionTypes
|
|
8
|
+
# The Second Thought questions an action type can ask (at most two).
|
|
9
|
+
SECOND_THOUGHT_QUESTIONS = %w[someone_guiding safe_account remote_access asked_for_code support_call
|
|
10
|
+
met_online promised_return know_recipient sim_in_hand].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(http)
|
|
13
|
+
@http = http
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Creates the action type, or updates its name, description, critical flag and
|
|
17
|
+
# Second Thought questions ({questions: [...], locale: "tr" | "en"}; questions: []
|
|
18
|
+
# turns them off; nil keeps what the dashboard has). Never re-enables a type an
|
|
19
|
+
# admin disabled.
|
|
20
|
+
def define(type, name: nil, description: nil, critical: nil, second_thought: nil)
|
|
21
|
+
body = { "type" => type, "name" => name || type }
|
|
22
|
+
body["description"] = description unless description.nil?
|
|
23
|
+
body["critical"] = critical unless critical.nil?
|
|
24
|
+
body["secondThought"] = Util.stringify(second_thought) unless second_thought.nil?
|
|
25
|
+
@http.post("/action-types", body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def list
|
|
29
|
+
@http.get("/action-types")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Touchque Actions: your own code at points of the approval pipeline (before the push
|
|
34
|
+
# is sent, the silent sign-in decision, after an approval, fraud reports, device and
|
|
35
|
+
# recovery events). Touchque runs it in a sandbox; this resource deploys, tests,
|
|
36
|
+
# replays and observes it.
|
|
37
|
+
#
|
|
38
|
+
# tq.actions.deploy("night-alert", trigger: "approval.decided",
|
|
39
|
+
# template: { id: "alert", params: { channel: "slack", when: { countries: ["TR"] } } })
|
|
40
|
+
#
|
|
41
|
+
# Action code itself is JavaScript (exports.onExecute = async (event, api) => {...}),
|
|
42
|
+
# whatever language your server is written in.
|
|
43
|
+
class Actions
|
|
44
|
+
SLUG = /\A[a-z0-9][a-z0-9-]{1,62}\z/.freeze
|
|
45
|
+
TEMPLATE = /\A[a-z_]{1,64}\z/.freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :secrets
|
|
48
|
+
|
|
49
|
+
def initialize(http)
|
|
50
|
+
@http = http
|
|
51
|
+
@secrets = ActionSecrets.new(http)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Creates the Action or adds a version and makes it live (idempotent). A version
|
|
55
|
+
# asking for a loosening capability (skip_number_match, override_block,
|
|
56
|
+
# allow_silent) waits for an admin's phone approval: then "deployed" is false and
|
|
57
|
+
# "approval" => {"required" => true}.
|
|
58
|
+
def deploy(slug, trigger: nil, code: nil, template: nil, name: nil, secrets: nil, allowed_hosts: nil,
|
|
59
|
+
capabilities: nil, on_failure: nil, order: nil, enabled: nil, activate: nil)
|
|
60
|
+
path = slug_path(slug)
|
|
61
|
+
raise ConfigError, "actions.deploy() needs code or template" if code.nil? && template.nil?
|
|
62
|
+
|
|
63
|
+
body = draft(trigger: trigger, code: code, template: template, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
|
|
64
|
+
body["name"] = name unless name.nil?
|
|
65
|
+
body["onFailure"] = on_failure unless on_failure.nil?
|
|
66
|
+
body["order"] = order unless order.nil?
|
|
67
|
+
body["enabled"] = enabled unless enabled.nil?
|
|
68
|
+
body["activate"] = activate unless activate.nil?
|
|
69
|
+
@http.put(path, body)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def list
|
|
73
|
+
@http.get("/actions")["actions"] || []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The Action, the active version's code and its versions.
|
|
77
|
+
def get(slug)
|
|
78
|
+
@http.get(slug_path(slug))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def version(slug, number)
|
|
82
|
+
@http.get("#{slug_path(slug)}/versions/#{Integer(number)}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Rename, turn on or off, reorder, change on_failure.
|
|
86
|
+
def update(slug, name: nil, enabled: nil, order: nil, on_failure: nil)
|
|
87
|
+
body = { "name" => name, "enabled" => enabled, "order" => order, "onFailure" => on_failure }.reject { |_, v| v.nil? }
|
|
88
|
+
res = @http.patch(slug_path(slug), body)
|
|
89
|
+
res["action"] || res
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def delete(slug)
|
|
93
|
+
@http.delete(slug_path(slug))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Back to +version+, or to the newest runnable version before the active one.
|
|
97
|
+
def rollback(slug, version: nil)
|
|
98
|
+
@http.post("#{slug_path(slug)}/rollback", version.nil? ? {} : { "version" => version })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Runs a draft (or the deployed Action +slug+) once on a recorded event, your own
|
|
102
|
+
# event or the trigger's sample. Sends nothing.
|
|
103
|
+
def test(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil,
|
|
104
|
+
event_id: nil, event: nil, network: nil)
|
|
105
|
+
body = draft(trigger: trigger, code: code, template: template, slug: slug, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
|
|
106
|
+
body["eventId"] = event_id unless event_id.nil?
|
|
107
|
+
body["event"] = Util.stringify(event) unless event.nil?
|
|
108
|
+
body["network"] = network unless network.nil?
|
|
109
|
+
@http.post("/actions/test", body)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Replays a draft against recent real decisions: how many it would have changed
|
|
113
|
+
# ("changed", "transitions" such as "push→number_match" => 12), notifications,
|
|
114
|
+
# errors and timing. +replaces+: the deployed Action it would replace.
|
|
115
|
+
def dry_run(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil,
|
|
116
|
+
replaces: nil, on_failure: nil, limit: nil, since: nil)
|
|
117
|
+
body = draft(trigger: trigger, code: code, template: template, slug: slug, secrets: secrets, allowed_hosts: allowed_hosts, capabilities: capabilities)
|
|
118
|
+
body["replaces"] = replaces unless replaces.nil?
|
|
119
|
+
body["onFailure"] = on_failure unless on_failure.nil?
|
|
120
|
+
body["limit"] = limit unless limit.nil?
|
|
121
|
+
body["since"] = since.respond_to?(:iso8601) ? since.iso8601 : since unless since.nil?
|
|
122
|
+
@http.post("/actions/dry-run", body)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Recorded events (newest first, kept 30 days); use an id with test(event_id:).
|
|
126
|
+
def events(trigger: nil, limit: nil)
|
|
127
|
+
@http.get("/actions/events", { "trigger" => trigger, "limit" => limit })["events"] || []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Live runs: decision, notifications, console output, errors.
|
|
131
|
+
def logs(slug, limit: nil, before: nil)
|
|
132
|
+
before = before.iso8601 if before.respond_to?(:iso8601)
|
|
133
|
+
@http.get("#{slug_path(slug)}/logs", { "limit" => limit, "before" => before })["logs"] || []
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# +window+: 1h, 24h (default), 7d or 30d.
|
|
137
|
+
def metrics(slug, window: nil)
|
|
138
|
+
@http.get("#{slug_path(slug)}/metrics", { "window" => window })
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Every trigger with its decisions, capabilities, notification types and a sample event.
|
|
142
|
+
def triggers
|
|
143
|
+
@http.get("/actions/triggers")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def templates
|
|
147
|
+
@http.get("/actions/templates")["templates"] || []
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A template as plain code (to read, or to start a code Action from).
|
|
151
|
+
def render_template(id, trigger, params = {})
|
|
152
|
+
raise ConfigError, "Unknown template id" unless id.to_s.match?(TEMPLATE)
|
|
153
|
+
|
|
154
|
+
@http.post("/actions/templates/#{id}/render", { "trigger" => trigger, "params" => Util.stringify(params || {}) })
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def slug_path(slug)
|
|
160
|
+
raise ConfigError, "An Action slug is 2-63 lowercase letters, digits or - (e.g. \"night-alert\")" unless slug.to_s.match?(SLUG)
|
|
161
|
+
|
|
162
|
+
"/actions/#{slug}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def draft(trigger: nil, code: nil, template: nil, slug: nil, secrets: nil, allowed_hosts: nil, capabilities: nil)
|
|
166
|
+
body = {}
|
|
167
|
+
body["trigger"] = trigger unless trigger.nil?
|
|
168
|
+
body["slug"] = slug unless slug.nil?
|
|
169
|
+
body["code"] = code unless code.nil?
|
|
170
|
+
body["template"] = Util.stringify(template) unless template.nil?
|
|
171
|
+
body["secrets"] = secrets unless secrets.nil?
|
|
172
|
+
body["allowedHosts"] = allowed_hosts unless allowed_hosts.nil?
|
|
173
|
+
body["capabilities"] = capabilities unless capabilities.nil?
|
|
174
|
+
body
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Secrets your Actions read (event.secrets.NAME) or notify with. A value is encrypted
|
|
179
|
+
# and never readable again through the API.
|
|
180
|
+
class ActionSecrets
|
|
181
|
+
NAME = /\A[A-Z][A-Z0-9_]{0,63}\z/.freeze
|
|
182
|
+
|
|
183
|
+
def initialize(http)
|
|
184
|
+
@http = http
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def set(name, value)
|
|
188
|
+
path = path(name)
|
|
189
|
+
raise ConfigError, "A secret value is a non-empty string" if value.to_s.empty?
|
|
190
|
+
|
|
191
|
+
@http.put(path, { "value" => value })
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Names only.
|
|
195
|
+
def list
|
|
196
|
+
@http.get("/actions/secrets")["secrets"] || []
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def delete(name)
|
|
200
|
+
@http.delete(path(name))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def path(name)
|
|
206
|
+
raise ConfigError, "A secret name is capital letters, digits or _ (e.g. SLACK_WEBHOOK_URL)" unless name.to_s.match?(NAME)
|
|
207
|
+
|
|
208
|
+
"/actions/secrets/#{name}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# AI agents as identities of their own: register an agent with its PUBLIC key, give
|
|
6
|
+
# it an accountable owner and the action types it may ask approvals for, suspend,
|
|
7
|
+
# revoke or stop every agent at once. The agent itself never gets your API key: it
|
|
8
|
+
# signs in with its own key (Touchque::Agent).
|
|
9
|
+
#
|
|
10
|
+
# keys = Touchque::AgentKeys.generate # where the agent runs
|
|
11
|
+
# agent = tq.agents.create("Invoice bot", ["PAY_INVOICE"], public_key: keys["publicJwk"])
|
|
12
|
+
class Agents
|
|
13
|
+
attr_reader :vault
|
|
14
|
+
|
|
15
|
+
def initialize(http)
|
|
16
|
+
@http = http
|
|
17
|
+
@vault = AgentVault.new(http)
|
|
18
|
+
@jwks = nil
|
|
19
|
+
@jwks_at = 0
|
|
20
|
+
@audience = nil
|
|
21
|
+
@lock = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Uses an agent's approval exactly once, right before you execute: checks the
|
|
25
|
+
# expectation against the token first (nothing is used on a mismatch), then
|
|
26
|
+
# Touchque checks the token and marks it used, and the answer is checked again.
|
|
27
|
+
def redeem_approval(token, user: nil, type: nil, authorization_details: nil)
|
|
28
|
+
expect = { user: user, type: type, authorization_details: authorization_details }
|
|
29
|
+
ApprovalToken.assert_matches!(ApprovalToken.decode(token)["claims"], **expect)
|
|
30
|
+
approval = @http.post("/agents/approvals/redeem", { "token" => token })["approval"] || {}
|
|
31
|
+
ApprovalToken.assert_matches!({ "sub" => approval["externalUsername"], "type" => approval["type"],
|
|
32
|
+
"authorization_details" => approval["authorizationDetails"] }, **expect)
|
|
33
|
+
approval
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The aud of this workspace's approval tokens (touchque:workspace:<id>), from
|
|
37
|
+
# GET /agents/approval-audience; asked once, then kept.
|
|
38
|
+
def approval_audience
|
|
39
|
+
@lock.synchronize do
|
|
40
|
+
unless @audience
|
|
41
|
+
audience = (@http.get("/agents/approval-audience") || {})["audience"]
|
|
42
|
+
raise ApprovalMismatchError, "Touchque did not name this workspace's approval audience" if !audience.is_a?(String) || audience.empty?
|
|
43
|
+
|
|
44
|
+
@audience = audience
|
|
45
|
+
end
|
|
46
|
+
@audience
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Verifies an approval token offline (Touchque's published keys, expiry, aud, the
|
|
51
|
+
# expectation). aud must be this workspace's: audience defaults to approval_audience,
|
|
52
|
+
# and a token without aud or for another workspace is refused. It does not make
|
|
53
|
+
# the token single-use: redeem_approval does.
|
|
54
|
+
def verify_approval_token(token, audience: nil, user: nil, type: nil, authorization_details: nil)
|
|
55
|
+
audience = approval_audience if audience.nil? || audience.to_s.empty?
|
|
56
|
+
expect = { audience: audience, user: user, type: type, authorization_details: authorization_details }
|
|
57
|
+
ApprovalToken.verify_with(token, jwks(false), **expect)
|
|
58
|
+
rescue ApprovalMismatchError => e
|
|
59
|
+
raise unless e.message.include?("unknown key")
|
|
60
|
+
|
|
61
|
+
ApprovalToken.verify_with(token, jwks(true), **expect)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Registers an agent (needs the agents:write scope). owner_email: the accountable
|
|
65
|
+
# member (default the API key's creator; the agent stops when they leave).
|
|
66
|
+
def create(name, allowed_types, public_key: nil, public_keys: nil, description: nil, owner_email: nil, platform: nil, review_in_days: nil)
|
|
67
|
+
body = { "name" => name, "allowedTypes" => allowed_types.to_a }
|
|
68
|
+
body["description"] = description unless description.nil?
|
|
69
|
+
body["ownerEmail"] = owner_email unless owner_email.nil?
|
|
70
|
+
body["platform"] = platform unless platform.nil?
|
|
71
|
+
body["reviewInDays"] = review_in_days unless review_in_days.nil?
|
|
72
|
+
body["publicKey"] = public_only(public_key) unless public_key.nil?
|
|
73
|
+
body["publicKeys"] = public_keys.map { |k| public_only(k) } unless public_keys.nil?
|
|
74
|
+
@http.post("/agents", body)["agent"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# {"agents" => [...], "killSwitch" => {...}}.
|
|
78
|
+
def list(status: nil)
|
|
79
|
+
@http.get("/agents", { "status" => status })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get(agent_id)
|
|
83
|
+
@http.get(path(agent_id))["agent"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Changes, hands over (owner_email) or renews (review_in_days) an agent.
|
|
87
|
+
def update(agent_id, name: nil, description: nil, owner_email: nil, platform: nil, allowed_types: nil, review_in_days: nil)
|
|
88
|
+
body = { "name" => name, "description" => description, "ownerEmail" => owner_email, "platform" => platform,
|
|
89
|
+
"allowedTypes" => allowed_types, "reviewInDays" => review_in_days }.reject { |_, v| v.nil? }
|
|
90
|
+
@http.patch(path(agent_id), body)["agent"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Adds a key (rotation: add the new one, move the agent to it, then remove the old one).
|
|
94
|
+
def add_key(agent_id, public_key)
|
|
95
|
+
@http.post("#{path(agent_id)}/keys", { "publicKey" => public_only(public_key) })["agent"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Removes a key; tokens the agent got with it stop at once.
|
|
99
|
+
def remove_key(agent_id, kid)
|
|
100
|
+
@http.delete("#{path(agent_id)}/keys/#{Util.seg(kid)}")["agent"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Stops an agent at once (its tokens too) until resumed.
|
|
104
|
+
def suspend(agent_id, reason: nil)
|
|
105
|
+
@http.post("#{path(agent_id)}/suspend", Util.compact({ "reason" => reason }))["agent"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resume(agent_id)
|
|
109
|
+
@http.post("#{path(agent_id)}/resume", {})["agent"]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Revokes an agent for good (the record stays for the audit trail).
|
|
113
|
+
def revoke(agent_id)
|
|
114
|
+
@http.delete(path(agent_id))["agent"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Sets the agent's budgets, amounts, recipients and background rule (a full
|
|
118
|
+
# replacement): requestsPer10Min, requestsPerUserPer10Min, amountPerRequest /
|
|
119
|
+
# amountPerDay ({"TRY" => "1000.00"}), background (non_critical | never),
|
|
120
|
+
# newRecipients (ask | deny), recipientAllowlist, fourEyes, requirePasskeyAbove
|
|
121
|
+
# ({"TRY" => "10000.00"}: over it the person approves with a passkey on your page,
|
|
122
|
+
# never on the phone or with a mandate).
|
|
123
|
+
def set_policy(agent_id, policy)
|
|
124
|
+
@http.put("#{path(agent_id)}/policy", { "policy" => Util.stringify(policy) })["agent"]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The workspace's agent events for a SIEM, oldest first: { "events", "next", "hasMore" }.
|
|
128
|
+
# Keep "next" and pass it as +after+; the first call reads from +since+ (ISO 8601;
|
|
129
|
+
# default the last 24 hours).
|
|
130
|
+
def list_events(after: nil, since: nil, limit: nil)
|
|
131
|
+
query = { "after" => after, "since" => since, "limit" => limit }.compact
|
|
132
|
+
@http.get("/agents/events", query.empty? ? nil : query)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The workspace's policy: every agent's floor (an agent's own policy can only make it stricter).
|
|
136
|
+
def workspace_policy
|
|
137
|
+
@http.get("/agents/workspace-policy")["policy"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The name every other Touchque SDK uses (getWorkspacePolicy); workspace_policy stays.
|
|
141
|
+
alias get_workspace_policy workspace_policy
|
|
142
|
+
|
|
143
|
+
# Sets the workspace's policy (a full replacement; a field left out sets no floor).
|
|
144
|
+
def set_workspace_policy(policy)
|
|
145
|
+
@http.put("/agents/workspace-policy", { "policy" => Util.stringify(policy) })["policy"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The people who stopped this agent asking them (reason not_me, coached or rejections).
|
|
149
|
+
def list_blocks(agent_id)
|
|
150
|
+
@http.get("#{path(agent_id)}/blocks")["blocks"] || []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Lets the agent ask this person again (check with them first).
|
|
154
|
+
def lift_block(agent_id, user)
|
|
155
|
+
@http.delete("#{path(agent_id)}/blocks/#{Util.seg(user)}")
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# An agent's mandates, optionally one person's.
|
|
160
|
+
def list_mandates(agent_id, user: nil)
|
|
161
|
+
@http.get("#{path(agent_id)}/mandates", { "externalUsername" => user })["mandates"] || []
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Ends an agent's mandate at once.
|
|
165
|
+
def revoke_mandate(agent_id, mandate_id)
|
|
166
|
+
@http.post("#{path(agent_id)}/mandates/#{Util.seg(mandate_id)}/revoke", {})
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Stops (true) or restarts (false) every agent of the workspace at once.
|
|
170
|
+
def set_kill_switch(engaged, reason: nil)
|
|
171
|
+
body = { "engaged" => engaged }
|
|
172
|
+
body["reason"] = reason if reason
|
|
173
|
+
@http.put("/agents/kill-switch", body)["killSwitch"]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def path(agent_id)
|
|
179
|
+
"/agents/#{Util.seg(agent_id)}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Refuses a private key before it leaves this process.
|
|
183
|
+
def public_only(jwk)
|
|
184
|
+
jwk = Util.stringify(jwk)
|
|
185
|
+
if jwk.key?("d")
|
|
186
|
+
raise ConfigError, "Pass the agent's PUBLIC key: this one has private parts (d). The private key must stay where the agent runs."
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
jwk
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def jwks(force)
|
|
193
|
+
@lock.synchronize do
|
|
194
|
+
if force || @jwks.nil? || Util.now - @jwks_at > 300
|
|
195
|
+
@jwks = @http.get("/agents/jwks.json")
|
|
196
|
+
@jwks_at = Util.now
|
|
197
|
+
end
|
|
198
|
+
@jwks
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The token vault for your agents (tq.agents.vault): after your own OAuth flow with a
|
|
204
|
+
# person, deposit their tokens; Touchque keeps them sealed and refreshes them, and the
|
|
205
|
+
# agents a connection names get a short-lived access token
|
|
206
|
+
# (Touchque::Agent#get_connection_token), never the refresh token.
|
|
207
|
+
class AgentVault
|
|
208
|
+
def initialize(http)
|
|
209
|
+
@http = http
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def list_connections
|
|
213
|
+
@http.get("/agents/vault/connections")["connections"] || []
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Adds or replaces a connection; client_secret is kept when left out of a replacement.
|
|
217
|
+
# provider: google, microsoft, github, facebook, linkedin, x or oidc.
|
|
218
|
+
def put_connection(name, provider:, client_id:, client_secret: nil, tenant: nil, issuer: nil, agent_ids: nil)
|
|
219
|
+
body = { "provider" => provider, "clientId" => client_id }
|
|
220
|
+
body.merge!(Util.compact({ "clientSecret" => client_secret, "tenant" => tenant, "issuer" => issuer }))
|
|
221
|
+
body["agentIds"] = agent_ids.to_a unless agent_ids.nil?
|
|
222
|
+
@http.put("/agents/vault/connections/#{Util.seg(name)}", body)["connection"]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Removes it, and every person's tokens kept for it.
|
|
226
|
+
def remove_connection(name)
|
|
227
|
+
@http.delete("/agents/vault/connections/#{Util.seg(name)}")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Keeps a person's tokens (replacing earlier ones) after your own OAuth flow with them.
|
|
231
|
+
def deposit(connection, user, access_token:, refresh_token: nil, expires_in: nil, scope: nil)
|
|
232
|
+
body = { "accessToken" => access_token }
|
|
233
|
+
body["refreshToken"] = refresh_token unless refresh_token.nil?
|
|
234
|
+
body["expiresIn"] = expires_in unless expires_in.nil?
|
|
235
|
+
body["scope"] = scope unless scope.nil?
|
|
236
|
+
@http.put("/agents/vault/accounts/#{Util.seg(connection)}/#{Util.seg(user)}", body)["account"]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A person's accounts in the vault (never a token).
|
|
240
|
+
def list_accounts(user)
|
|
241
|
+
@http.get("/agents/vault/accounts", { "externalUsername" => user })["accounts"] || []
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Deletes a person's tokens: agents get nothing more for them there.
|
|
245
|
+
def remove_account(connection, user)
|
|
246
|
+
@http.delete("/agents/vault/accounts/#{Util.seg(connection)}/#{Util.seg(user)}")
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Device risk (a paid add-on, off until your workspace turns it on): a visitor ID for
|
|
6
|
+
# every browser on your sign-up, password-reset, sign-in or approval page. The page
|
|
7
|
+
# identifies with @touchque/web (deviceRisk.identify({ siteKey, purpose }) gives
|
|
8
|
+
# { visitorId, requestId, confidence }) and sends you the requestId. Never trust the
|
|
9
|
+
# page's copy: read the identification here (get_event) or check it for an account
|
|
10
|
+
# (check). Touchque keeps keyed hashes only, with a key of your workspace alone, and its
|
|
11
|
+
# risk engine decides.
|
|
12
|
+
#
|
|
13
|
+
# Every signal only ever asks for more: "stepUp" means an extra check on this page (an
|
|
14
|
+
# emailed code, a passkey, a number-matched approval), never a refusal. For sign-ins,
|
|
15
|
+
# pass the requestId to login.request / risk.check as device_risk_request_id: instead.
|
|
16
|
+
# Collect it only on these pages and name it in your privacy notice.
|
|
17
|
+
#
|
|
18
|
+
# With the script tag (device.js), the form arrives with touchque_request_id:
|
|
19
|
+
#
|
|
20
|
+
# r = tq.device_risk.check(params[:touchque_request_id], event: "sign_in", external_username: email)
|
|
21
|
+
#
|
|
22
|
+
# Pass it as it came. Missing, empty, made up or expired, it is still a verdict
|
|
23
|
+
# ("report" missing, unknown, expired; the signal device_identification_missing), never an
|
|
24
|
+
# exception: a page without the tag, or with it blocked, only gets the visitor a step-up.
|
|
25
|
+
class DeviceRisk
|
|
26
|
+
# The pages a device report may come from.
|
|
27
|
+
EVENTS = %w[sign_in sign_up password_reset approval].freeze
|
|
28
|
+
# What the page had sent: ok, replayed, or no usable identification.
|
|
29
|
+
REPORTS = %w[ok replayed missing unknown expired invalid].freeze
|
|
30
|
+
REQUEST_ID = /\Adre_[A-Za-z0-9_-]{22}\z/
|
|
31
|
+
|
|
32
|
+
def initialize(http)
|
|
33
|
+
@http = http
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reads an answer, failing toward asking: "stepUp" is true unless it says exactly
|
|
37
|
+
# false; junk signals are dropped.
|
|
38
|
+
def self.parse_check(raw)
|
|
39
|
+
body = raw.is_a?(Hash) ? raw : {}
|
|
40
|
+
signals = (body["signals"].is_a?(Array) ? body["signals"] : []).filter_map do |s|
|
|
41
|
+
next unless s.is_a?(Hash) && s["code"].is_a?(String)
|
|
42
|
+
|
|
43
|
+
item = { "code" => s["code"], "text" => s["text"].is_a?(String) ? s["text"] : "",
|
|
44
|
+
"strength" => %w[passkey none].include?(s["strength"]) ? s["strength"] : "number_match" }
|
|
45
|
+
item["enforced"] = s["enforced"] if [true, false].include?(s["enforced"])
|
|
46
|
+
item["evidence"] = s["evidence"] if s["evidence"].is_a?(Hash)
|
|
47
|
+
item
|
|
48
|
+
end
|
|
49
|
+
out = {
|
|
50
|
+
"report" => REPORTS.include?(body["report"]) ? body["report"] : "invalid",
|
|
51
|
+
"requestId" => body["requestId"].is_a?(String) ? body["requestId"] : nil,
|
|
52
|
+
"visitorId" => body["visitorId"].is_a?(String) ? body["visitorId"] : nil,
|
|
53
|
+
"confidence" => body["confidence"].is_a?(Numeric) ? body["confidence"] : nil,
|
|
54
|
+
"visitor" => body["visitor"].is_a?(Hash) ? body["visitor"] : nil,
|
|
55
|
+
"counts" => body["counts"].is_a?(Hash) ? body["counts"] : nil,
|
|
56
|
+
# The address (never coordinates), the visitor's counts, one answer to "a bot?" (detected | suspected | not_detected).
|
|
57
|
+
"network" => body["network"].is_a?(Hash) ? body["network"] : nil,
|
|
58
|
+
"velocity" => body["velocity"].is_a?(Hash) ? body["velocity"] : nil,
|
|
59
|
+
"bot" => body["bot"].is_a?(Hash) ? body["bot"] : nil,
|
|
60
|
+
"signals" => signals,
|
|
61
|
+
"stepUp" => body["stepUp"] != false,
|
|
62
|
+
"wouldStepUp" => body["wouldStepUp"] == true,
|
|
63
|
+
"requires" => %w[number_match passkey].include?(body["requires"]) ? body["requires"] : "none",
|
|
64
|
+
"mode" => body["mode"] == "enforce" ? "enforce" : "shadow",
|
|
65
|
+
# The engine's state for this check; "unavailable" when the risk engine could not be reached and the verdict is a safe default.
|
|
66
|
+
"engine" => body["engine"].is_a?(String) ? body["engine"] : nil
|
|
67
|
+
}
|
|
68
|
+
out["caseCode"] = body["caseCode"] if body["caseCode"].is_a?(String)
|
|
69
|
+
out
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Checks the page's identification for an account: binds it (one account per
|
|
73
|
+
# identification, 15 minutes) and asks the risk engine. Pass the form field as it came:
|
|
74
|
+
# nil, empty or made up is still a verdict ("report" missing, unknown), never an
|
|
75
|
+
# exception. 409 device_risk_off until your workspace turns Device risk on.
|
|
76
|
+
# device_risk: instead of request_id, the page's report (deviceRisk.collect).
|
|
77
|
+
#
|
|
78
|
+
# r = tq.device_risk.check(params[:touchque_request_id], event: "sign_up", external_username: email, client_ip: request.remote_ip)
|
|
79
|
+
# ask_for_email_code if r["stepUp"] # never refuse on it alone
|
|
80
|
+
def check(request_id = nil, event: "sign_in", external_username: nil, client_ip: nil, device_risk: nil)
|
|
81
|
+
raise ConfigError, "device_risk.check(): event must be one of #{EVENTS.join(", ")}" unless EVENTS.include?(event)
|
|
82
|
+
|
|
83
|
+
body = {}
|
|
84
|
+
if request_id.is_a?(String) && !request_id.empty?
|
|
85
|
+
body["requestId"] = request_id[0, 64]
|
|
86
|
+
elsif !device_risk.to_s.empty?
|
|
87
|
+
body["deviceRisk"] = device_risk
|
|
88
|
+
end
|
|
89
|
+
body["event"] = event
|
|
90
|
+
body["externalUsername"] = external_username unless external_username.to_s.empty?
|
|
91
|
+
body["clientIp"] = client_ip unless client_ip.to_s.empty?
|
|
92
|
+
self.class.parse_check(@http.post("/device-risk/check", body))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
SEARCH_FILTERS = { from: "from", to: "to", signal: "signal", kind: "kind", origin: "origin", ip: "ip", account: "account",
|
|
96
|
+
visitor_id: "visitorId", request_id: "requestId", cursor: "cursor" }.freeze
|
|
97
|
+
|
|
98
|
+
# Your workspace's identifications, newest first, one page at a time
|
|
99
|
+
# (GET /device-risk/events): a date range (from:/to: YYYY-MM-DD or ISO times; 7 days by
|
|
100
|
+
# default, at most 90, within your retention) and any of the filters. ip: is an address
|
|
101
|
+
# or a CIDR range and is never returned. Pass "nextCursor" back as cursor: until it is
|
|
102
|
+
# nil; do not page by moving the dates. A search that would read too much is a 422
|
|
103
|
+
# search_too_broad: narrow the range or add a filter.
|
|
104
|
+
# { "events" => [...], "nextCursor" => String | nil, "from" => String, "to" => String }.
|
|
105
|
+
def search_events(step_up: nil, limit: nil, **filters)
|
|
106
|
+
unknown = filters.keys - SEARCH_FILTERS.keys
|
|
107
|
+
raise ConfigError, "device_risk.search_events() does not know #{unknown.join(', ')}" unless unknown.empty?
|
|
108
|
+
raise ConfigError, "device_risk.search_events() limit must be 1 to 100" unless limit.nil? || (limit.is_a?(Integer) && limit.between?(1, 100))
|
|
109
|
+
|
|
110
|
+
params = {}
|
|
111
|
+
filters.each { |k, v| params[SEARCH_FILTERS[k]] = v if v.is_a?(String) && !v.empty? }
|
|
112
|
+
params["stepUp"] = step_up ? "true" : "false" if [true, false].include?(step_up)
|
|
113
|
+
params["limit"] = limit.to_s if limit
|
|
114
|
+
raw = @http.get("/device-risk/events", params) || {}
|
|
115
|
+
cursor = raw["nextCursor"]
|
|
116
|
+
{ "events" => raw["events"].is_a?(Array) ? raw["events"] : [],
|
|
117
|
+
"nextCursor" => cursor.is_a?(String) && !cursor.empty? ? cursor : nil,
|
|
118
|
+
"from" => raw["from"].to_s, "to" => raw["to"].to_s }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The trusted identification behind a request_id: the visitor, how sure, what the page
|
|
122
|
+
# showed, the risk engine's verdict. Read-only.
|
|
123
|
+
def get_event(request_id)
|
|
124
|
+
raise ConfigError, "device_risk.get_event() needs a request_id (dre_...)" unless request_id.is_a?(String) && REQUEST_ID.match?(request_id)
|
|
125
|
+
|
|
126
|
+
@http.get("/device-risk/events/#{request_id}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|