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,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # An organization's AI agent on Touchque SSO (Organization → AI agents in the
5
+ # dashboard): it gets 5-minute tokens for one of the organization's apps, proving who
6
+ # it is with its own key (private_key_jwt) or with the token its platform already
7
+ # gives it (GitHub Actions, GitLab, Google Cloud, Azure, Kubernetes), and binds each
8
+ # token to a DPoP key made when it starts.
9
+ #
10
+ # agent = Touchque::OrgAgent.new(agent_id: "oagt_...", private_key: ENV["TQ_ORG_AGENT_KEY"])
11
+ # headers = agent.authorization("GET", "https://payments.acme.com/invoices", resource: "tqapp_...")
12
+ # # A GitHub Actions job (permissions: id-token: write), with no key to store:
13
+ # agent = Touchque::OrgAgent.new(agent_id: "oagt_...", assertion: Touchque::OrgAgent.github_actions_token("touchque"))
14
+ class OrgAgent
15
+ attr_reader :agent_id
16
+
17
+ # The token a GitHub Actions job may ask for (the job needs permissions: id-token: write).
18
+ def self.github_actions_token(audience = "touchque")
19
+ lambda do
20
+ url = ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_URL", nil)
21
+ bearer = ENV.fetch("ACTIONS_ID_TOKEN_REQUEST_TOKEN", nil)
22
+ raise ConfigError, "Not in a GitHub Actions job with `permissions: id-token: write`" if url.to_s.empty? || bearer.to_s.empty?
23
+
24
+ _, text, = Transport.call("GET", "#{url}#{url.include?("?") ? "&" : "?"}audience=#{Util.seg(audience)}", headers: { "Authorization" => "Bearer #{bearer}" })
25
+ value = Util.parse_json(text)&.dig("value")
26
+ raise ConfigError, "GitHub gave no token" if value.to_s.empty?
27
+
28
+ value
29
+ end
30
+ end
31
+
32
+ # A token the platform writes to a file and renews (a Kubernetes projected service account token...).
33
+ def self.file_token(path)
34
+ -> { File.read(path).strip }
35
+ end
36
+
37
+ # agent_id: TQ_ORG_AGENT_ID. issuer: TQ_SSO_URL (https://sso.touchque.com). private_key:
38
+ # a JWK (Hash or JSON) or an EC PEM (TQ_ORG_AGENT_KEY), or assertion: a callable
39
+ # returning the platform's token.
40
+ def initialize(agent_id: nil, issuer: nil, private_key: nil, kid: nil, assertion: nil, resource: nil, scope: nil, timeout: 10)
41
+ agent_id ||= ENV.fetch("TQ_ORG_AGENT_ID", "")
42
+ raise ConfigError, "Set TQ_ORG_AGENT_ID (or agent_id:): the oagt_... id from Organization → AI agents" unless agent_id.match?(/\Aoagt_[a-z2-9]{20}\z/)
43
+
44
+ @agent_id = agent_id
45
+ @issuer = (issuer || ENV["TQ_SSO_URL"].then { |v| v.to_s.empty? ? nil : v } || "https://sso.touchque.com").chomp("/")
46
+ Util.assert_safe_url!(@issuer, "issuer")
47
+ if assertion
48
+ raise ConfigError, "assertion must be callable (it returns the platform's token)" unless assertion.respond_to?(:call)
49
+
50
+ @assertion = assertion
51
+ else
52
+ private_key ||= ENV.fetch("TQ_ORG_AGENT_KEY", nil)
53
+ raise ConfigError, "Pass private_key: (or set TQ_ORG_AGENT_KEY), or assertion: with the platform's token" if private_key.to_s.empty?
54
+
55
+ @key = SigningKey.load(private_key, "Touchque::OrgAgent")
56
+ jwk = private_key.is_a?(Hash) ? Util.stringify(private_key) : (private_key.to_s.lstrip.start_with?("{") ? Util.parse_json(private_key) : nil)
57
+ @kid = kid || (jwk.is_a?(Hash) && jwk["kid"]) || AgentKeys.thumbprint(@key.public_jwk)
58
+ end
59
+ # The DPoP key: made here, never leaves this process.
60
+ @dpop_key = SigningKey.new_es256
61
+ @resource = resource
62
+ @scope = scope
63
+ @timeout = timeout
64
+ @tokens = {}
65
+ @lock = Mutex.new
66
+ end
67
+
68
+ # A DPoP proof (RFC 9449) for one request: with the token, for a call to the app.
69
+ def dpop_proof(method, url, access_token = nil)
70
+ uri = URI.parse(url)
71
+ htu = "#{uri.scheme}://#{uri.host}#{uri.port == uri.default_port ? "" : ":#{uri.port}"}#{uri.path}"
72
+ claims = { "htm" => method.to_s.upcase, "htu" => htu, "iat" => Util.now, "jti" => Util.random_hex(16) }
73
+ claims["ath"] = AgentKeys.access_token_hash(access_token) if access_token
74
+ @dpop_key.jws({ "typ" => "dpop+jwt", "jwk" => @dpop_key.public_jwk }, claims)
75
+ end
76
+
77
+ # A token for one app (renewed 30 seconds before it expires):
78
+ # {"accessToken", "tokenType" => "DPoP", "expiresIn", "scope"}.
79
+ def get_token(resource: nil, scope: nil)
80
+ resource ||= @resource
81
+ scope ||= @scope
82
+ key = "#{resource} #{scope}"
83
+ @lock.synchronize do
84
+ cached = @tokens[key]
85
+ return cached[:token] if cached && cached[:expires_at] - Util.now > 30
86
+
87
+ token = token_of(post("/sso/token", { "grant_type" => "client_credentials", "resource" => resource, "scope" => scope }, dpop: true))
88
+ @tokens[key] = { token: token, expires_at: Util.now + token["expiresIn"] }
89
+ token
90
+ end
91
+ end
92
+
93
+ # A token for a member, with their own access token for one of the agent's apps
94
+ # (RFC 8693): sub is the member, act the agent.
95
+ def exchange_token(subject_token, resource: nil, scope: nil)
96
+ token_of(post("/sso/token", {
97
+ "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" => subject_token,
98
+ "subject_token_type" => "urn:ietf:params:oauth:token-type:access_token",
99
+ "resource" => resource || @resource, "scope" => scope || @scope
100
+ }, dpop: true))
101
+ end
102
+
103
+ # The token vault: a member's own access token at another service (Google, Microsoft
104
+ # 365, GitHub...) they connected on Touchque, for a connection that names this agent.
105
+ # APIError invalid_grant: the member has not connected it (or disconnected, or left).
106
+ def get_connection_token(subject_token, connection, scope: nil)
107
+ data = post("/sso/token", {
108
+ "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" => subject_token,
109
+ "subject_token_type" => "urn:ietf:params:oauth:token-type:access_token",
110
+ "requested_token_type" => "urn:touchque:params:oauth:token-type:connection-access-token",
111
+ "connection" => connection, "scope" => scope
112
+ }, dpop: true)
113
+ { "accessToken" => data["access_token"].to_s, "tokenType" => "Bearer", "expiresIn" => data["expires_in"]&.to_i,
114
+ "scope" => data["scope"].to_s, "connection" => data["connection"] || connection }
115
+ end
116
+
117
+ # Asks to open a shared account (its key, from Organization → AI agents → Access) for
118
+ # a member: {"askId", "openUrl", "status", "expiresIn"}. The member opens openUrl in
119
+ # the browser where their Touchque extension is paired; the agent never sees the
120
+ # password. +message+ (at most 64 characters) is shown marked unverified.
121
+ def ask_to_open_shared_account(account, login_hint, message = nil)
122
+ data = post("/sso/agents/shared-accounts/asks", { "account" => account, "login_hint" => login_hint, "binding_message" => message }, dpop: false)
123
+ { "askId" => data["ask_id"].to_s, "openUrl" => data["open_url"].to_s, "status" => data["status"].to_s, "expiresIn" => (data["expires_in"] || 600).to_i }
124
+ end
125
+
126
+ # Where an ask stands: {"status", "reason", "caseCode"}; never anything of the account.
127
+ def shared_account_ask_status(ask_id)
128
+ data = post("/sso/agents/shared-accounts/asks/#{Util.seg(ask_id)}", {}, dpop: false)
129
+ { "status" => data["status"].to_s, "reason" => data["reason"], "caseCode" => data["case_code"] }
130
+ end
131
+
132
+ # Polls an ask until it ends: opened, refused, declined or expired.
133
+ def wait_for_shared_account(ask, interval: 3.0)
134
+ deadline = Util.now + ask["expiresIn"].to_i + 5
135
+ loop do
136
+ s = shared_account_ask_status(ask["askId"])
137
+ return s if !%w[waiting_member asked_phone].include?(s["status"]) || Util.now > deadline
138
+
139
+ sleep interval
140
+ end
141
+ end
142
+
143
+ # Asks a member to approve on their phone (CIBA): {"authReqId", "expiresIn", "interval",
144
+ # "bindingCode"}. Show bindingCode in the conversation.
145
+ def ask_member(login_hint, binding_message: nil, authorization_details: nil, resource: nil, scope: nil, requested_expiry: nil)
146
+ data = post("/sso/bc-authorize", {
147
+ "login_hint" => login_hint, "binding_message" => binding_message,
148
+ "resource" => resource || @resource, "scope" => scope || @scope,
149
+ "authorization_details" => authorization_details && Util.json(Util.stringify(authorization_details)),
150
+ "requested_expiry" => requested_expiry&.to_s
151
+ }, dpop: false)
152
+ out = { "authReqId" => data["auth_req_id"], "expiresIn" => (data["expires_in"] || 300).to_i, "interval" => (data["interval"] || 5).to_i }
153
+ out["bindingCode"] = data["binding_code"] if data["binding_code"]
154
+ out
155
+ end
156
+
157
+ # The member's answer (polling at the interval, slower when asked): the token once they
158
+ # approve; APIError access_denied or expired_token otherwise, TimeoutError after it expired.
159
+ def wait_for_member(request)
160
+ interval = [request["interval"].to_i, 1].max
161
+ deadline = Util.now + request["expiresIn"].to_i + 5
162
+ while Util.now < deadline
163
+ sleep interval
164
+ begin
165
+ return token_of(post("/sso/token", { "grant_type" => "urn:openid:params:grant-type:ciba", "auth_req_id" => request["authReqId"] }, dpop: true))
166
+ rescue APIError => e
167
+ next if e.code == "authorization_pending"
168
+
169
+ if e.code == "slow_down"
170
+ interval += 5
171
+ next
172
+ end
173
+ raise
174
+ end
175
+ end
176
+ raise TimeoutError, "The member did not answer in time"
177
+ end
178
+
179
+ # The two headers for one call to the app: {"Authorization" => "DPoP ...", "DPoP" => proof}.
180
+ def authorization(method, url, resource: nil, scope: nil)
181
+ token = get_token(resource: resource, scope: scope)["accessToken"]
182
+ { "Authorization" => "DPoP #{token}", "DPoP" => dpop_proof(method, url, token) }
183
+ end
184
+
185
+ private
186
+
187
+ def client_assertion
188
+ return @assertion.call.to_s if @assertion
189
+
190
+ now = Util.now
191
+ @key.jws({ "typ" => "JWT", "kid" => @kid },
192
+ { "iss" => @agent_id, "sub" => @agent_id, "aud" => "#{@issuer}/sso/token", "iat" => now, "exp" => now + 60, "jti" => Util.random_hex(16) })
193
+ end
194
+
195
+ # POSTs to the SSO with the agent's client assertion (and, at /sso/token, a DPoP proof).
196
+ def post(path, fields, dpop:)
197
+ url = @issuer + path
198
+ form = { "client_id" => @agent_id, "client_assertion_type" => Agent::ASSERTION_TYPE, "client_assertion" => client_assertion }.merge(fields)
199
+ headers = { "Content-Type" => "application/x-www-form-urlencoded" }
200
+ headers["DPoP"] = dpop_proof("POST", url) if dpop
201
+ status, text, = Transport.call("POST", url, headers: headers, body: Transport.form(form), timeout: @timeout)
202
+ data = Util.parse_json(text)
203
+ data = {} unless data.is_a?(Hash)
204
+ raise Transport.oauth_failure(status, data) unless status == 200
205
+
206
+ data
207
+ end
208
+
209
+ def token_of(data)
210
+ token = { "accessToken" => data["access_token"], "tokenType" => "DPoP", "expiresIn" => (data["expires_in"] || 300).to_i, "scope" => data["scope"].to_s }
211
+ token["authorizationDetails"] = data["authorization_details"] if data["authorization_details"].is_a?(Array)
212
+ token
213
+ end
214
+ end
215
+
216
+ # For an MCP server whose authorization server is Touchque SSO (Organization → MCP
217
+ # servers in the dashboard; issuer https://sso.touchque.com/mcp):
218
+ # protected_resource_metadata is its RFC 9728 document (serve it at metadata_paths);
219
+ # verify checks a request's token (signed by Touchque, this issuer, audience = this
220
+ # server, not expired, and for a DPoP-bound token a fresh proof); require_tool refuses
221
+ # with 403 insufficient_scope naming the scope a tool needs.
222
+ #
223
+ # auth = Touchque::McpAuth.new(issuer: "https://sso.touchque.com/mcp", resource: "https://mcp.acme.com/mcp",
224
+ # tools: { "search_tickets" => "tickets:read" })
225
+ # claims = auth.verify(request.request_method, request.url, request.headers)
226
+ # auth.require_tool(claims, tool_name)
227
+ class McpAuth
228
+ def initialize(issuer:, resource:, tools: {}, resource_name: nil, clock_skew: 60, fetch: nil)
229
+ if issuer.to_s.empty? || resource.to_s.empty?
230
+ raise ConfigError, "McpAuth needs issuer (Touchque SSO's MCP issuer) and resource (this server's URL)"
231
+ end
232
+
233
+ @issuer = issuer.chomp("/")
234
+ @resource = resource
235
+ @tools = Util.stringify(tools || {})
236
+ @resource_name = resource_name
237
+ @skew = clock_skew
238
+ @fetch = fetch || method(:get_json)
239
+ @keys = {}
240
+ @keys_at = 0
241
+ @tried_at = 0
242
+ @proofs = {}
243
+ @lock = Mutex.new
244
+ end
245
+
246
+ # Where to serve protected_resource_metadata.
247
+ def metadata_paths
248
+ path = URI.parse(@resource).path.to_s.chomp("/")
249
+ path.empty? ? ["/.well-known/oauth-protected-resource"] : ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource#{path}"]
250
+ end
251
+
252
+ def metadata_url
253
+ u = URI.parse(@resource)
254
+ "#{u.scheme}://#{u.host}#{u.port == u.default_port ? "" : ":#{u.port}"}#{metadata_paths.last}"
255
+ end
256
+
257
+ def protected_resource_metadata
258
+ doc = {
259
+ "resource" => @resource, "authorization_servers" => [@issuer], "bearer_methods_supported" => ["header"],
260
+ "scopes_supported" => @tools.values.uniq, "dpop_signing_alg_values_supported" => %w[ES256 EdDSA]
261
+ }
262
+ doc["resource_name"] = @resource_name if @resource_name
263
+ doc
264
+ end
265
+
266
+ # A request's token, checked: {"sub", "email", "clientId", "scopes", "orgId",
267
+ # "expiresAt", "dpopBound", "claims"}. +headers+: a Hash (any key case) or anything
268
+ # answering [] (Rack/Rails headers).
269
+ def verify(method, url, headers)
270
+ get = header_getter(headers)
271
+ scheme, token = get.call("authorization").to_s.strip.split(" ", 2)
272
+ token = token.to_s.strip
273
+ raise refusal(401, "missing_token", "A Touchque access token is required") unless %w[bearer dpop].include?(scheme.to_s.downcase) && token.count(".") == 2
274
+
275
+ claims = claims_of(token)
276
+ jkt = claims.dig("cnf", "jkt")
277
+ if jkt
278
+ raise refusal(401, "invalid_token", "This token is bound to a DPoP key: send it as DPoP with a proof") unless scheme.downcase == "dpop"
279
+
280
+ check_proof(get.call("dpop"), method, url, token, jkt)
281
+ end
282
+ {
283
+ "sub" => claims["sub"].to_s, "email" => claims["email"], "clientId" => claims["client_id"].to_s,
284
+ "scopes" => claims["scope"].to_s.split, "orgId" => claims["org_id"], "expiresAt" => claims["exp"].to_i,
285
+ "dpopBound" => !jkt.nil?, "claims" => claims
286
+ }
287
+ end
288
+
289
+ def require_scope(claims, scope)
290
+ raise refusal(403, "insufficient_scope", "This needs the #{scope} scope", scope) unless claims["scopes"].include?(scope)
291
+ end
292
+
293
+ def require_tool(claims, tool)
294
+ scope = @tools[tool.to_s]
295
+ raise refusal(403, "insufficient_scope", "No scope is set for the tool #{tool}") if scope.to_s.empty?
296
+
297
+ require_scope(claims, scope)
298
+ end
299
+
300
+ private
301
+
302
+ def refusal(status, code, message, scope = nil)
303
+ parts = ["resource_metadata=\"#{metadata_url}\""]
304
+ unless code == "missing_token"
305
+ parts << "error=\"#{code}\"" << "error_description=\"#{message.tr('"', "'")}\""
306
+ parts << "scope=\"#{scope}\"" if scope
307
+ end
308
+ McpAuthError.new(status, code, message, "Bearer #{parts.join(", ")}")
309
+ end
310
+
311
+ def header_getter(headers)
312
+ if headers.is_a?(Hash)
313
+ lower = headers.to_h { |k, v| [k.to_s.downcase, v] }
314
+ ->(name) { lower[name] }
315
+ else
316
+ ->(name) { headers[name] }
317
+ end
318
+ end
319
+
320
+ def claims_of(token)
321
+ parts = JWS.split(token)
322
+ raise refusal(401, "invalid_token", "The token is malformed") unless parts
323
+
324
+ head, claims, input, sig = parts
325
+ raise refusal(401, "invalid_token", "Not a Touchque access token") unless head["typ"] == "at+jwt" && %w[RS256 ES256].include?(head["alg"])
326
+
327
+ key = begin
328
+ key_for(head["kid"].to_s)
329
+ rescue StandardError
330
+ raise refusal(401, "invalid_token", "The authorization server's keys could not be read")
331
+ end
332
+ raise refusal(401, "invalid_token", "The token signature does not verify") unless key && JWS.verify(head["alg"], key, input, sig)
333
+ raise refusal(401, "invalid_token", "The token is from another issuer") unless claims["iss"].to_s.chomp("/") == @issuer
334
+ raise refusal(401, "invalid_token", "The token is for another server") unless Array(claims["aud"]).include?(@resource)
335
+ raise refusal(401, "invalid_token", "The token expired") unless claims["exp"].is_a?(Numeric) && claims["exp"] + @skew > Util.now
336
+
337
+ claims
338
+ end
339
+
340
+ def load_keys
341
+ u = URI.parse(@issuer)
342
+ origin = "#{u.scheme}://#{u.host}#{u.port == u.default_port ? "" : ":#{u.port}"}"
343
+ meta = @fetch.call("#{origin}/.well-known/oauth-authorization-server#{u.path.to_s.chomp("/")}")
344
+ raise "the authorization server metadata does not match the issuer" unless meta["issuer"].to_s.chomp("/") == @issuer && meta["jwks_uri"]
345
+
346
+ @keys = (@fetch.call(meta["jwks_uri"])["keys"] || []).each_with_object({}) { |k, h| h[k["kid"]] = k if k["kid"] }
347
+ @keys_at = Util.now
348
+ end
349
+
350
+ def key_for(kid)
351
+ @lock.synchronize do
352
+ load_keys if @keys_at.zero? || Util.now - @keys_at > 600
353
+ if !@keys.key?(kid) && Util.now - @tried_at > 60
354
+ @tried_at = Util.now
355
+ load_keys
356
+ end
357
+ @keys[kid]
358
+ end
359
+ end
360
+
361
+ def check_proof(proof, method, url, token, jkt)
362
+ reject = ->(why) { refusal(401, "invalid_dpop_proof", "DPoP proof: #{why}") }
363
+ raise reject.call("missing") if proof.to_s.count(".") != 2
364
+
365
+ parts = JWS.split(proof)
366
+ raise reject.call("malformed") unless parts
367
+
368
+ head, claims, input, sig = parts
369
+ jwk = head["jwk"]
370
+ unless head["typ"] == "dpop+jwt" && jwk.is_a?(Hash) && !jwk.key?("d") && %w[EC OKP].include?(jwk["kty"]) && AgentKeys.thumbprint(jwk) == jkt
371
+ raise reject.call("not the bound key")
372
+ end
373
+ raise reject.call("signature") unless JWS.verify(head["alg"].to_s, jwk, input, sig)
374
+
375
+ u = URI.parse(url)
376
+ htu = "#{u.scheme}://#{u.host}#{u.port == u.default_port ? "" : ":#{u.port}"}#{u.path}"
377
+ raise reject.call("htm/htu") unless claims["htm"] == method.to_s.upcase && claims["htu"] == htu
378
+ raise reject.call("iat") if (claims["iat"].to_i - Util.now).abs > @skew
379
+ raise reject.call("ath") unless claims["ath"] == AgentKeys.access_token_hash(token)
380
+
381
+ @lock.synchronize do
382
+ now = Util.now
383
+ @proofs.delete_if { |_, until_at| until_at < now }
384
+ jti = claims["jti"].to_s
385
+ raise reject.call("jti used") if jti.empty? || @proofs.key?(jti)
386
+
387
+ @proofs[jti] = now + 2 * @skew
388
+ end
389
+ end
390
+
391
+ def get_json(url)
392
+ status, text, = Transport.call("GET", url, timeout: 5)
393
+ raise "#{url} answered #{status}" if status >= 400
394
+
395
+ Util.parse_json(text) || {}
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Touchque
6
+ module Rack
7
+ # The relay as a Rack app: mount it wherever your frontend (@touchque/web) expects it.
8
+ #
9
+ # # Rails, config/routes.rb
10
+ # mount Touchque::Rack::Relay.new(
11
+ # get_user_id: ->(req) { req.session["user_email"] },
12
+ # get_login_user: ->(req) { req.session["password_verified_user"] },
13
+ # on_authenticated: ->(req, response, info) { req.session["user_email"] = info["externalUsername"] }
14
+ # ) => "/touchque"
15
+ #
16
+ # # Sinatra / plain Rack
17
+ # map("/touchque") { run Touchque::Rack::Relay.new(get_user_id: ->(req) { req.session["user"] }) }
18
+ #
19
+ # The callbacks get the Rack::Request (its session is the app's session). The client IP
20
+ # is Rack::Request#ip: configure trusted proxies when you run behind a load balancer.
21
+ # See Touchque::RelayHandler for the routes and options.
22
+ class Relay
23
+ MAX_BODY_BYTES = 64 * 1024
24
+
25
+ def initialize(client: nil, **options)
26
+ @client = client
27
+ @options = options
28
+ @handler = nil
29
+ @lock = Mutex.new
30
+ end
31
+
32
+ def call(env)
33
+ req = ::Rack::Request.new(env)
34
+ body = {}
35
+ if req.post? && req.media_type == "application/json"
36
+ raw = req.body&.read(MAX_BODY_BYTES + 1).to_s
37
+ return json(413, { "error" => "body_too_large" }) if raw.bytesize > MAX_BODY_BYTES
38
+
39
+ parsed = raw.empty? ? {} : Util.parse_json(raw)
40
+ return json(400, { "error" => "invalid_json" }) if parsed.nil?
41
+
42
+ body = parsed.is_a?(Hash) ? parsed : {}
43
+ end
44
+ res = handler.handle(
45
+ method: req.request_method, path: req.path_info, body: body, ip: req.ip.to_s, user_agent: req.user_agent,
46
+ header: ->(name) { env["HTTP_#{name.upcase.tr("-", "_")}"] }, request: req
47
+ )
48
+ return json(404, { "error" => "not_found" }) unless res
49
+
50
+ headers = {}
51
+ res.headers.each do |(name, value)|
52
+ key = name.downcase
53
+ headers[key] = headers.key?(key) ? join(headers[key], value) : value
54
+ end
55
+ json(res.status, res.body, headers)
56
+ end
57
+
58
+ private
59
+
60
+ # Built on the first request, so Touchque.client can be configured after the routes load.
61
+ def handler
62
+ @handler || @lock.synchronize { @handler ||= RelayHandler.new(@client || Touchque.client, **@options) }
63
+ end
64
+
65
+ def join(previous, value)
66
+ # Rack 3 takes several values as an Array; Rack 2 as lines.
67
+ ::Rack.release.to_f >= 3 ? Array(previous) + [value] : "#{previous}\n#{value}"
68
+ end
69
+
70
+ def json(status, body, headers = {})
71
+ [status, { "content-type" => "application/json; charset=utf-8" }.merge(headers), [Util.json(body)]]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ module Rails
5
+ # One line per protected action:
6
+ #
7
+ # class TransfersController < ApplicationController
8
+ # include Touchque::Rails::Guard
9
+ # require_touchque "SEND_MONEY", only: :create,
10
+ # details: -> { { "Amount" => params.require(:amount), "To" => params.require(:to) } }
11
+ #
12
+ # def create
13
+ # touchque_approval # the approval, once the user approved on their phone
14
+ # end
15
+ # end
16
+ #
17
+ # Until the user approves this answers 202 {"touchque": step, "token": "..."}; your page
18
+ # shows the step and sends the same request again with header X-Touchque-Token (the
19
+ # @touchque/web loop). Once approved the action runs ONCE. Refusals answer 403 / 408 /
20
+ # 423 / 429 with Retry-After. Options: user: (default: current_user's email, id or
21
+ # username; for your login action, whoever just passed your password check), details:,
22
+ # reference_id:, context: (procs run in the controller, or method names),
23
+ # silent_sign_in: (true or { max_age_seconds: }), enroll:, and before_action's
24
+ # only: / except: / if: / unless:.
25
+ module Guard
26
+ def self.included(base)
27
+ base.extend(ClassMethods)
28
+ end
29
+
30
+ module ClassMethods
31
+ def require_touchque(action, user: nil, details: nil, reference_id: nil, context: nil, silent_sign_in: false, enroll: true, **filter_options)
32
+ Touchque::Guard.assert_silent_sign_in!(silent_sign_in, "require_touchque")
33
+ options = { user: user, details: details, reference_id: reference_id, context: context, silent_sign_in: silent_sign_in, enroll: enroll }
34
+ before_action(**filter_options) { touchque_guard!(action, options) }
35
+ end
36
+ end
37
+
38
+ # The approval of this request (set by require_touchque), or nil.
39
+ def touchque_approval
40
+ request.env["touchque.approval"]
41
+ end
42
+
43
+ private
44
+
45
+ def touchque_guard!(action, options)
46
+ header = ->(name) { request.headers[name] }
47
+ input = Touchque::Guard.input_from_headers(header)
48
+ silent = options[:silent_sign_in]
49
+ result = Touchque::Guard.run(
50
+ Touchque.client, touchque_value(options[:user]) || touchque_default_user, action,
51
+ details: touchque_value(options[:details]), reference_id: touchque_value(options[:reference_id]),
52
+ ip: request.remote_ip, user_agent: request.user_agent, token: input[:token], offline: input[:offline],
53
+ code: input[:code], code_type: input[:code_type], enroll: options[:enroll],
54
+ browser_proof: Touchque::Guard.silent_on?(silent) ? header.call(Touchque::Guard::BROWSER_HEADER) : nil,
55
+ silent_sign_in: silent, context: touchque_value(options[:context])
56
+ )
57
+ if result[:approved]
58
+ request.env["touchque.approval"] = result[:approved]
59
+ return
60
+ end
61
+ response.headers["Cache-Control"] = "no-store"
62
+ retry_after = result[:body].dig("touchque", "retryAfter")
63
+ response.headers["Retry-After"] = retry_after.to_s if retry_after
64
+ render json: result[:body], status: result[:status]
65
+ end
66
+
67
+ def touchque_value(option)
68
+ case option
69
+ when Proc then instance_exec(&option)
70
+ when Symbol then send(option)
71
+ else option
72
+ end
73
+ end
74
+
75
+ # The signed-in user: current_user's email, id or username.
76
+ def touchque_default_user
77
+ user = respond_to?(:current_user, true) ? send(:current_user) : nil
78
+ return nil unless user
79
+
80
+ %i[email id username].each do |m|
81
+ value = user.respond_to?(m) ? user.public_send(m) : nil
82
+ return value.to_s unless value.nil? || value.to_s.empty?
83
+ end
84
+ nil
85
+ end
86
+ end
87
+
88
+ # Verified webhooks in a controller:
89
+ #
90
+ # class TouchqueWebhooksController < ActionController::API
91
+ # include Touchque::Rails::Webhook
92
+ # verify_touchque_webhook
93
+ #
94
+ # def create
95
+ # EndSession.call(touchque_webhook["requestId"]) if touchque_webhook["event"] == Touchque::Resources::Webhook::EVENT_APPROVAL_REVOKED
96
+ # head :ok
97
+ # end
98
+ # end
99
+ #
100
+ # A bad signature or an old timestamp answers 401 before your action; a replay of a
101
+ # delivery already accepted answers 200 without running it again (Touchque.replay_cache:
102
+ # in-process by default; set one over Redis when you run several processes).
103
+ module Webhook
104
+ def self.included(base)
105
+ base.extend(ClassMethods)
106
+ base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
107
+ end
108
+
109
+ module ClassMethods
110
+ def verify_touchque_webhook(tolerance_seconds: Touchque::Resources::Webhook::DEFAULT_TOLERANCE_SECONDS, **filter_options)
111
+ before_action(**filter_options) { touchque_verify_webhook!(tolerance_seconds) }
112
+ end
113
+ end
114
+
115
+ # The verified payload of this request.
116
+ def touchque_webhook
117
+ @touchque_webhook
118
+ end
119
+
120
+ private
121
+
122
+ def touchque_verify_webhook!(tolerance_seconds)
123
+ @touchque_webhook = Touchque.client.webhook.verify(request.raw_post, request.headers["x-signature"],
124
+ tolerance_seconds: tolerance_seconds, replay_cache: Touchque.replay_cache)
125
+ rescue Touchque::WebhookReplayError
126
+ render json: { received: true, duplicate: true }, status: :ok
127
+ rescue Touchque::WebhookSignatureError
128
+ render json: { error: "invalid_signature" }, status: :unauthorized
129
+ end
130
+ end
131
+ end
132
+ end