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,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
|