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,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Touchque
9
+ # Small helpers shared by the SDK (no third-party gems).
10
+ module Util
11
+ module_function
12
+
13
+ def b64url(bytes)
14
+ [bytes].pack("m0").tr("+/", "-_").delete("=")
15
+ end
16
+
17
+ def b64url_decode(text)
18
+ s = text.to_s.tr("-_", "+/")
19
+ s += "=" * ((4 - s.length % 4) % 4)
20
+ s.unpack1("m0")
21
+ rescue ArgumentError
22
+ raise ArgumentError, "malformed base64url"
23
+ end
24
+
25
+ def b64_decode(text)
26
+ text.to_s.unpack1("m0")
27
+ end
28
+
29
+ def sha256(data)
30
+ OpenSSL::Digest::SHA256.digest(data)
31
+ end
32
+
33
+ def sha256_hex(data)
34
+ OpenSSL::Digest::SHA256.hexdigest(data)
35
+ end
36
+
37
+ def hmac_hex(key, data)
38
+ OpenSSL::HMAC.hexdigest("SHA256", key, data)
39
+ end
40
+
41
+ def hmac(key, data)
42
+ OpenSSL::HMAC.digest("SHA256", key, data)
43
+ end
44
+
45
+ # Constant-time string comparison.
46
+ def secure_compare(a, b)
47
+ a = a.to_s.b
48
+ b = b.to_s.b
49
+ return false unless a.bytesize == b.bytesize
50
+
51
+ OpenSSL.fixed_length_secure_compare(a, b)
52
+ end
53
+
54
+ def random_hex(bytes = 16)
55
+ SecureRandom.hex(bytes)
56
+ end
57
+
58
+ # A path segment, percent-encoded (encodeURIComponent / rawurlencode).
59
+ def seg(value)
60
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
61
+ end
62
+
63
+ # JSON text, compact, non-ASCII kept as is (like JSON.stringify).
64
+ def json(value)
65
+ JSON.generate(value)
66
+ end
67
+
68
+ def parse_json(text)
69
+ return nil if text.nil? || text.strip.empty?
70
+
71
+ JSON.parse(text)
72
+ rescue JSON::ParserError
73
+ nil
74
+ end
75
+
76
+ # Keys as strings, deeply (symbol keys from Ruby callers become the API's names).
77
+ def stringify(value)
78
+ case value
79
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
80
+ when Array then value.map { |v| stringify(v) }
81
+ when Symbol then value.to_s
82
+ else value
83
+ end
84
+ end
85
+
86
+ # A hash without nil values (and, for strings, without empty ones).
87
+ def compact(hash)
88
+ hash.reject { |_, v| v.nil? || (v.is_a?(String) && v.empty?) }
89
+ end
90
+
91
+ # The bytes JSON.stringify would write for a string (quotes included).
92
+ def js_string(str)
93
+ out = +'"'
94
+ str.to_s.each_char do |c|
95
+ out << case c
96
+ when '"' then '\\"'
97
+ when "\\" then "\\\\"
98
+ when "\b" then "\\b"
99
+ when "\f" then "\\f"
100
+ when "\n" then "\\n"
101
+ when "\r" then "\\r"
102
+ when "\t" then "\\t"
103
+ else
104
+ c.ord < 0x20 ? format("\\u%04x", c.ord) : c
105
+ end
106
+ end
107
+ out << '"'
108
+ end
109
+
110
+ # JSON text without insignificant whitespace; strings and numbers kept byte for byte.
111
+ def compact_json(text)
112
+ out = +""
113
+ in_string = false
114
+ escaped = false
115
+ text.each_char do |c|
116
+ if in_string
117
+ out << c
118
+ if escaped
119
+ escaped = false
120
+ elsif c == "\\"
121
+ escaped = true
122
+ elsif c == '"'
123
+ in_string = false
124
+ end
125
+ elsif c == '"'
126
+ in_string = true
127
+ out << c
128
+ elsif !" \t\n\r".include?(c)
129
+ out << c
130
+ end
131
+ end
132
+ out
133
+ end
134
+
135
+ # The members of a top-level JSON object as [key, raw value text] pairs, in order.
136
+ def top_level_members(text)
137
+ s = text.strip
138
+ raise JSON::ParserError, "not an object" unless s.start_with?("{")
139
+
140
+ members = []
141
+ i = 1
142
+ loop do
143
+ i = skip_ws(s, i)
144
+ break if s[i] == "}"
145
+
146
+ key_end = scan_string(s, i)
147
+ key = JSON.parse("[#{s[i...key_end]}]")[0]
148
+ i = skip_ws(s, key_end)
149
+ raise JSON::ParserError, "expected :" unless s[i] == ":"
150
+
151
+ i = skip_ws(s, i + 1)
152
+ value_end = scan_value(s, i)
153
+ members << [key, s[i...value_end]]
154
+ i = skip_ws(s, value_end)
155
+ if s[i] == ","
156
+ i += 1
157
+ elsif s[i] == "}"
158
+ break
159
+ else
160
+ raise JSON::ParserError, "expected , or }"
161
+ end
162
+ end
163
+ members
164
+ end
165
+
166
+ def skip_ws(s, i)
167
+ i += 1 while i < s.length && " \t\n\r".include?(s[i])
168
+ i
169
+ end
170
+
171
+ def scan_string(s, i)
172
+ raise JSON::ParserError, "expected a string" unless s[i] == '"'
173
+
174
+ i += 1
175
+ while i < s.length
176
+ case s[i]
177
+ when "\\" then i += 2
178
+ when '"' then return i + 1
179
+ else i += 1
180
+ end
181
+ end
182
+ raise JSON::ParserError, "unterminated string"
183
+ end
184
+
185
+ def scan_value(s, i)
186
+ case s[i]
187
+ when '"' then scan_string(s, i)
188
+ when "{", "["
189
+ depth = 0
190
+ while i < s.length
191
+ case s[i]
192
+ when '"'
193
+ i = scan_string(s, i)
194
+ next
195
+ when "{", "[" then depth += 1
196
+ when "}", "]"
197
+ depth -= 1
198
+ return i + 1 if depth.zero?
199
+ end
200
+ i += 1
201
+ end
202
+ raise JSON::ParserError, "unterminated value"
203
+ else
204
+ j = i
205
+ j += 1 while j < s.length && !",}".include?(s[j])
206
+ j -= 1 while j > i && " \t\n\r".include?(s[j - 1])
207
+ j
208
+ end
209
+ end
210
+
211
+ # True for https, and for plaintext http only on this machine (localhost, 127.0.0.1,
212
+ # ::1, *.localhost: local development).
213
+ def https_or_loopback?(url)
214
+ uri = URI.parse(url.to_s)
215
+ return true if uri.scheme == "https"
216
+
217
+ host = uri.hostname.to_s.downcase
218
+ uri.scheme == "http" && (%w[localhost 127.0.0.1 ::1].include?(host) || host.end_with?(".localhost"))
219
+ rescue URI::InvalidURIError
220
+ false
221
+ end
222
+
223
+ # Allow plaintext http only for localhost / loopback (local development).
224
+ def assert_safe_url!(url, name)
225
+ uri = URI.parse(url.to_s)
226
+ return if uri.scheme == "https"
227
+
228
+ if uri.scheme == "http"
229
+ return if %w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host.to_s.downcase)
230
+
231
+ raise ConfigError, "Refusing a plaintext http:// #{name} for \"#{uri.host}\". The API key and request signature would be sent in the clear: use https://."
232
+ end
233
+ raise ConfigError, "#{name} must be http(s): #{url}"
234
+ rescue URI::InvalidURIError
235
+ raise ConfigError, "#{name} is not a valid URL: #{url}"
236
+ end
237
+
238
+ def now
239
+ Time.now.to_i
240
+ end
241
+
242
+ def now_ms
243
+ (Time.now.to_f * 1000).to_i
244
+ end
245
+
246
+ # Unix seconds of an ISO 8601 timestamp, or nil.
247
+ def parse_time(value)
248
+ return nil unless value.is_a?(String) && !value.empty?
249
+
250
+ require "time"
251
+ Time.iso8601(value).to_f
252
+ rescue ArgumentError
253
+ begin
254
+ Time.parse(value).to_f
255
+ rescue ArgumentError
256
+ nil
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ VERSION = "5.0.0"
5
+ end
data/lib/touchque.rb ADDED
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "touchque/version"
4
+ require_relative "touchque/errors"
5
+ require_relative "touchque/util"
6
+ require_relative "touchque/http"
7
+ require_relative "touchque/crypto"
8
+ require_relative "touchque/approvals"
9
+ require_relative "touchque/code_reply"
10
+ require_relative "touchque/safe_approval"
11
+ require_relative "touchque/resources/login"
12
+ require_relative "touchque/resources/actions"
13
+ require_relative "touchque/resources/people"
14
+ require_relative "touchque/resources/agents"
15
+ require_relative "touchque/resources/identity"
16
+ require_relative "touchque/resources/mobile"
17
+ require_relative "touchque/resources/qr_pairings"
18
+ require_relative "touchque/resources/device_risk"
19
+ require_relative "touchque/resources/safe_guard"
20
+ require_relative "touchque/resources/webhook"
21
+ require_relative "touchque/flow"
22
+ require_relative "touchque/client"
23
+ require_relative "touchque/relay"
24
+ require_relative "touchque/agent"
25
+ require_relative "touchque/org_agent"
26
+ require_relative "touchque/cross_app_access"
27
+ require_relative "touchque/security_events"
28
+ require_relative "touchque/agent_card"
29
+ require_relative "touchque/hosted_login"
30
+ require_relative "touchque/login_session"
31
+
32
+ # Touchque: push approvals with number matching, passkeys, silent sign-ins, offline
33
+ # codes, verified webhooks, Touchque Actions, AI agent approvals, Touchque Login and the
34
+ # server side of the Mobile SDK.
35
+ #
36
+ # # TQ_API_KEY=tq_... TQ_API_SECRET=...
37
+ # step = Touchque.client.start("SEND_MONEY", "jane@acme.com", details: { "Amount" => "250 EUR" })
38
+ module Touchque
39
+ @lock = Mutex.new
40
+
41
+ class << self
42
+ attr_writer :client, :replay_cache, :hosted_login, :login_secret
43
+
44
+ # Where the SDK reports its own problems: a misconfigured guard, a failed on_challenge,
45
+ # a relay without get_login_user. nil (the default) logs nothing; set any Logger
46
+ # (Touchque.logger = Rails.logger). Lines carry an error's class and message, never
47
+ # request data.
48
+ attr_accessor :logger
49
+
50
+ # @api private: one of the SDK's own problems (:warn or :error) to Touchque.logger, if any.
51
+ # A logger that raises never breaks the request.
52
+ def log(level, message)
53
+ logger&.public_send(level, message)
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ # Options for Touchque.client (api_key:, api_secret:, base_url:, timeout:); anything
59
+ # left out comes from the environment.
60
+ def configure(**options)
61
+ @lock.synchronize do
62
+ @client_options = options
63
+ @client = nil
64
+ end
65
+ end
66
+
67
+ # The shared client, built on first use from configure or the environment.
68
+ def client
69
+ @client || @lock.synchronize { @client ||= Client.new(**(@client_options || {})) }
70
+ end
71
+
72
+ # Webhook jtis already accepted (in-process by default; set one over Redis when you
73
+ # run several processes: anything answering check_and_set(jti, ttl_seconds)).
74
+ def replay_cache
75
+ @replay_cache || @lock.synchronize { @replay_cache ||= MemoryReplayCache.new }
76
+ end
77
+
78
+ # Touchque Login settings: domain:, client_id:, client_secret:, scope:, audience:,
79
+ # redirect_uri:, callback_path:, login_path:, post_logout_redirect_uri:, session_max_age:.
80
+ def login_options=(options)
81
+ @login_options = Util.stringify(options || {}).transform_keys(&:to_sym)
82
+ @hosted_login = nil
83
+ end
84
+
85
+ def login_options
86
+ @login_options || {}
87
+ end
88
+
89
+ # The Touchque Login client of login_options.
90
+ def hosted_login
91
+ @hosted_login || @lock.synchronize do
92
+ @hosted_login ||= begin
93
+ o = login_options
94
+ raise ConfigError, "Set config.touchque.login (domain:, client_id:) or Touchque.login_options" if o[:domain].to_s.empty?
95
+
96
+ HostedLogin.new(domain: o[:domain], client_id: o[:client_id], client_secret: o[:client_secret],
97
+ scope: o[:scope] || "openid profile email", audience: o[:audience],
98
+ redirect_uri: o[:redirect_uri].to_s.match?(%r{\Ahttps?://}) ? o[:redirect_uri] : nil)
99
+ end
100
+ end
101
+ end
102
+
103
+ # The secret that encrypts the Touchque Login cookies (at least 32 characters).
104
+ def login_secret
105
+ secret = @login_secret.respond_to?(:call) ? @login_secret.call : @login_secret
106
+ raise ConfigError, "Set Touchque.login_secret (32+ characters) for the Touchque Login cookies" if secret.to_s.length < 32
107
+
108
+ secret
109
+ end
110
+ end
111
+
112
+ # The relay as a Rack app (Sinatra, plain Rack), loaded on first use: Rack is not a
113
+ # dependency of the gem.
114
+ module Rack
115
+ autoload :Relay, File.expand_path("touchque/rack/relay", __dir__)
116
+ end
117
+ end
118
+
119
+ if defined?(Rails::Railtie)
120
+ require_relative "touchque/rack/relay"
121
+ require_relative "touchque/rails/guard"
122
+ require_relative "touchque/rails/hosted_login"
123
+ require_relative "touchque/railtie"
124
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: touchque
3
+ version: !ruby/object:Gem::Version
4
+ version: 5.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Touchque
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server SDK for Touchque Authenticator and Touchque Login, with Rails
13
+ integration (Railtie, controller concerns) and a Rack relay for @touchque/web.
14
+ email:
15
+ - developer@touchque.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/touchque.rb
24
+ - lib/touchque/agent.rb
25
+ - lib/touchque/agent_card.rb
26
+ - lib/touchque/approvals.rb
27
+ - lib/touchque/client.rb
28
+ - lib/touchque/code_reply.rb
29
+ - lib/touchque/cross_app_access.rb
30
+ - lib/touchque/crypto.rb
31
+ - lib/touchque/errors.rb
32
+ - lib/touchque/flow.rb
33
+ - lib/touchque/hosted_login.rb
34
+ - lib/touchque/http.rb
35
+ - lib/touchque/login_session.rb
36
+ - lib/touchque/org_agent.rb
37
+ - lib/touchque/rack/relay.rb
38
+ - lib/touchque/rails/guard.rb
39
+ - lib/touchque/rails/hosted_login.rb
40
+ - lib/touchque/rails/sessions_controller.rb
41
+ - lib/touchque/railtie.rb
42
+ - lib/touchque/relay.rb
43
+ - lib/touchque/resources/actions.rb
44
+ - lib/touchque/resources/agents.rb
45
+ - lib/touchque/resources/device_risk.rb
46
+ - lib/touchque/resources/identity.rb
47
+ - lib/touchque/resources/login.rb
48
+ - lib/touchque/resources/mobile.rb
49
+ - lib/touchque/resources/people.rb
50
+ - lib/touchque/resources/qr_pairings.rb
51
+ - lib/touchque/resources/safe_guard.rb
52
+ - lib/touchque/resources/webhook.rb
53
+ - lib/touchque/safe_approval.rb
54
+ - lib/touchque/security_events.rb
55
+ - lib/touchque/util.rb
56
+ - lib/touchque/version.rb
57
+ homepage: https://touchque.com
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ source_code_uri: https://github.com/Touchque/touchque-ruby
62
+ changelog_uri: https://github.com/Touchque/touchque-ruby/blob/main/CHANGELOG.md
63
+ documentation_uri: https://support.touchque.com
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.1'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.23
80
+ specification_version: 4
81
+ summary: 'Official Ruby SDK for Touchque: push approvals with number matching, passkeys,
82
+ silent sign-ins, webhooks, Actions, AI agents and Touchque Login.'
83
+ test_files: []