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,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # Touchque Login (hosted sign-in) for your app: an OpenID Connect client for your
5
+ # tenant (acme.login.touchque.com or your own domain). PKCE (S256) is always used.
6
+ # ID tokens are checked against the tenant's JWKS (RS256, issuer, audience, expiry,
7
+ # nonce, at_hash); your API checks access tokens with verify_access_token. In Rails,
8
+ # Touchque::Rails::HostedLogin wires the routes for you.
9
+ #
10
+ # login = Touchque::HostedLogin.new(domain: "acme.login.touchque.com", client_id: "...", client_secret: "...",
11
+ # redirect_uri: "https://app/callback")
12
+ # req = login.authorization_url # keep state, nonce, code_verifier; redirect to req["url"]
13
+ # tokens = login.handle_callback(request.url, state: req["state"], nonce: req["nonce"], code_verifier: req["codeVerifier"])
14
+ # tokens["claims"]["sub"]
15
+ class HostedLogin
16
+ # requested_token_type for the token vault (get_connection_token).
17
+ CONNECTION_TOKEN_TYPE = "urn:touchque:params:oauth:token-type:connection-access-token"
18
+
19
+ attr_reader :issuer, :client_id, :redirect_uri
20
+
21
+ # +transport+: ->(method, url, headers, body) { [status, text] } (tests).
22
+ def initialize(domain:, client_id:, client_secret: nil, redirect_uri: nil, scope: "openid profile email", audience: nil,
23
+ clock_tolerance: 60, transport: nil)
24
+ raise HostedLoginError.new("config", "domain is required") if domain.to_s.empty?
25
+ raise HostedLoginError.new("config", "client_id is required") if client_id.to_s.empty?
26
+
27
+ raw = domain.chomp("/")
28
+ @issuer = raw.match?(%r{\Ahttps?://}) ? raw : "https://#{raw}"
29
+ unless Util.https_or_loopback?(@issuer)
30
+ raise HostedLoginError.new("config", "Refusing a plaintext http:// domain: codes, the client secret and tokens would be sent in the clear. Use https:// (http:// only on localhost).")
31
+ end
32
+ @client_id = client_id
33
+ @client_secret = client_secret
34
+ @redirect_uri = redirect_uri
35
+ @scope = scope
36
+ @audience = audience
37
+ @clock_tolerance = clock_tolerance
38
+ @transport = transport || lambda { |method, url, headers, body|
39
+ status, text, = Transport.call(method, url, headers: headers, body: body)
40
+ [status, text]
41
+ }
42
+ @keys = {}
43
+ @keys_at = 0
44
+ @lock = Mutex.new
45
+ end
46
+
47
+ # Never shows the client secret (logs, consoles, error reporters).
48
+ def inspect
49
+ "#<#{self.class.name} issuer=#{@issuer.inspect} client_id=#{@client_id.inspect}>"
50
+ end
51
+
52
+ # The URL to send the browser to, and the values to keep until the callback:
53
+ # {"url", "state", "nonce", "codeVerifier"}.
54
+ def authorization_url(state: nil, nonce: nil, code_verifier: nil, redirect_uri: nil, scope: nil, audience: nil, prompt: nil,
55
+ login_hint: nil, screen_hint: nil, ui_locales: nil, max_age: nil, connection: nil, organization: nil,
56
+ invitation: nil, acr_values: nil, response_mode: nil, extra_params: {})
57
+ redirect = redirect_uri || @redirect_uri
58
+ raise HostedLoginError.new("config", "redirect_uri is required") if redirect.to_s.empty?
59
+
60
+ state ||= random(32)
61
+ nonce ||= random(32)
62
+ code_verifier ||= random(48)
63
+ params = [
64
+ ["response_type", "code"], ["client_id", @client_id], ["redirect_uri", redirect], ["scope", scope || @scope],
65
+ ["state", state], ["nonce", nonce], ["code_challenge", Util.b64url(Util.sha256(code_verifier))], ["code_challenge_method", "S256"]
66
+ ]
67
+ { "audience" => audience || @audience, "prompt" => prompt, "login_hint" => login_hint, "screen_hint" => screen_hint,
68
+ "ui_locales" => ui_locales, "max_age" => max_age&.to_s, "connection" => connection, "organization" => organization,
69
+ "invitation" => invitation, "acr_values" => acr_values, "response_mode" => response_mode }.each do |k, v|
70
+ params << [k, v.to_s] unless v.nil? || v.to_s.empty?
71
+ end
72
+ (extra_params || {}).each { |k, v| params << [k.to_s, v.to_s] }
73
+ query = params.map { |k, v| "#{Util.seg(k)}=#{Util.seg(v)}" }.join("&")
74
+ { "url" => "#{@issuer}/authorize?#{query}", "state" => state, "nonce" => nonce, "codeVerifier" => code_verifier }
75
+ end
76
+
77
+ # Checks state (and iss), exchanges the code with the PKCE verifier, verifies the ID
78
+ # token. +callback+: the callback URL, its query string, or its params (a Hash).
79
+ def handle_callback(callback, state:, nonce:, code_verifier:, redirect_uri: nil, max_age: nil)
80
+ p = self.class.params_of(callback)
81
+ if p["state"].to_s.empty? || state.to_s.empty? || !Util.secure_compare(state, p["state"])
82
+ raise HostedLoginError.new("state_mismatch", "The state does not match this sign-in")
83
+ end
84
+ # RFC 9207: a response that carries iss (even an empty one) must name this issuer, the one the
85
+ # ID token is checked against. Servers that do not send iss yet are accepted.
86
+ if p.key?("iss") && p["iss"] != @issuer
87
+ raise HostedLoginError.new("issuer_mismatch", p["iss"].empty? ? "The response has an empty iss" : "The response came from #{p["iss"]}")
88
+ end
89
+ raise HostedLoginError.new(p["error"], p["error_description"]) unless p["error"].to_s.empty?
90
+ raise HostedLoginError.new("invalid_request", "The callback has no code") if p["code"].to_s.empty?
91
+
92
+ redirect = redirect_uri || @redirect_uri
93
+ raise HostedLoginError.new("config", "redirect_uri is required") if redirect.to_s.empty?
94
+
95
+ tokens = token("grant_type" => "authorization_code", "code" => p["code"], "redirect_uri" => redirect, "code_verifier" => code_verifier)
96
+ raise HostedLoginError.new("invalid_token", "No ID token: ask for the openid scope") if tokens["idToken"].to_s.empty?
97
+
98
+ tokens.merge("claims" => verify_id_token(tokens["idToken"], nonce: nonce, access_token: tokens["accessToken"], max_age: max_age))
99
+ end
100
+
101
+ # A new access token and a rotated refresh token: keep the new one.
102
+ def refresh(refresh_token, scope: nil)
103
+ tokens = token("grant_type" => "refresh_token", "refresh_token" => refresh_token, "scope" => scope)
104
+ return tokens if tokens["idToken"].to_s.empty?
105
+
106
+ tokens.merge("claims" => verify_id_token(tokens["idToken"], access_token: tokens["accessToken"]))
107
+ end
108
+
109
+ # A token for the My Account API (audience https://<domain>/me/) from the refresh token
110
+ # a first-party app holds. The refresh token rotates: keep the returned one. Scopes:
111
+ # read:me, update:me, manage:me:security, delete:me.
112
+ def my_account_token(refresh_token, scope: "read:me update:me")
113
+ token("grant_type" => "refresh_token", "refresh_token" => refresh_token, "audience" => "#{@issuer}/me/", "scope" => scope)
114
+ end
115
+
116
+ # The My Account API for the person holding +access_token+.
117
+ def my_account(access_token)
118
+ MyAccount.new(@issuer, access_token, transport: @transport)
119
+ end
120
+
121
+ # The hosted account page (profile; security, devices or privacy).
122
+ def account_url(section = nil)
123
+ "#{@issuer}/account#{section ? "/#{section}" : ""}"
124
+ end
125
+
126
+ # Machine to machine: an access token for your API with the app's own credentials.
127
+ def client_credentials(audience, scope: nil)
128
+ token("grant_type" => "client_credentials", "audience" => audience, "scope" => scope)
129
+ end
130
+
131
+ # RFC 8693: a signed-in user's access token, traded for one to call +audience+ on
132
+ # their behalf (sub = the user, act.sub = this app).
133
+ def exchange_token(subject_token, audience, scope: nil)
134
+ token("grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" => subject_token,
135
+ "subject_token_type" => "urn:ietf:params:oauth:token-type:access_token", "audience" => audience, "scope" => scope)
136
+ end
137
+
138
+ # Token vault: the provider's own access token (Google, Microsoft, GitHub...) for the
139
+ # signed-in user, from a connection that stores tokens. Touchque refreshes it; the
140
+ # refresh token never leaves. HostedLoginError invalid_grant when nothing is stored.
141
+ def get_connection_token(user_access_token, connection, scope: nil)
142
+ token("grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" => user_access_token,
143
+ "subject_token_type" => "urn:ietf:params:oauth:token-type:access_token", "requested_token_type" => CONNECTION_TOKEN_TYPE,
144
+ "connection" => connection, "scope" => scope)
145
+ end
146
+
147
+ # CIBA: asks the user (email or usr_ id) to approve on their phone:
148
+ # {"authReqId", "expiresAt", "interval", "bindingCode"}.
149
+ def ask_user(login_hint, scope, audience: nil, binding_message: nil, authorization_details: nil, requested_expiry: nil)
150
+ status, body = post("/bc-authorize", {
151
+ "login_hint" => login_hint, "scope" => scope, "audience" => audience, "binding_message" => binding_message,
152
+ "authorization_details" => authorization_details && Util.json(Util.stringify(authorization_details)),
153
+ "requested_expiry" => requested_expiry&.to_s
154
+ })
155
+ raise error(status, body) if status >= 400
156
+
157
+ d = Util.parse_json(body) || {}
158
+ { "authReqId" => d["auth_req_id"].to_s, "expiresAt" => Util.now + d["expires_in"].to_i, "interval" => (d["interval"] || 5).to_i,
159
+ "bindingCode" => d["binding_code"] }
160
+ end
161
+
162
+ # Polls until the user answers: the tokens, or HostedLoginError access_denied / expired_token.
163
+ def wait_for_user(request)
164
+ interval = request["interval"].to_i
165
+ loop do
166
+ raise HostedLoginError.new("expired_token", "The user did not answer in time") if Util.now > request["expiresAt"]
167
+
168
+ sleep interval
169
+ begin
170
+ return token("grant_type" => "urn:openid:params:grant-type:ciba", "auth_req_id" => request["authReqId"])
171
+ rescue HostedLoginError => e
172
+ if e.error_code == "slow_down"
173
+ interval += 5
174
+ elsif e.error_code != "authorization_pending"
175
+ raise
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ def revoke(token_value)
182
+ status, body = post("/oauth/revoke", { "token" => token_value })
183
+ raise error(status, body) if status >= 400
184
+
185
+ nil
186
+ end
187
+
188
+ def userinfo(access_token)
189
+ status, body = @transport.call("GET", "#{@issuer}/userinfo", { "Authorization" => "Bearer #{access_token}", "Accept" => "application/json" }, nil)
190
+ raise error(status, body) if status >= 400
191
+
192
+ Util.parse_json(body) || {}
193
+ end
194
+
195
+ def logout_url(id_token_hint: nil, post_logout_redirect_uri: nil, state: nil)
196
+ p = { "client_id" => @client_id, "id_token_hint" => id_token_hint, "post_logout_redirect_uri" => post_logout_redirect_uri, "state" => state }
197
+ "#{@issuer}/oidc/logout?" + p.reject { |_, v| v.nil? || v.empty? }.map { |k, v| "#{k}=#{Util.seg(v)}" }.join("&")
198
+ end
199
+
200
+ # For your API: signature, issuer, audience, expiry, typ at+jwt, scopes.
201
+ def verify_access_token(token_value, audience:, required_scopes: [])
202
+ payload = verify_jwt(token_value, "at+jwt")
203
+ raise HostedLoginError.new("invalid_token", "The token is for another audience") unless Array(payload["aud"]).include?(audience)
204
+
205
+ missing = required_scopes - payload["scope"].to_s.split(" ")
206
+ raise HostedLoginError.new("insufficient_scope", "Missing scope: #{missing.join(" ")}", 403) unless missing.empty?
207
+
208
+ payload
209
+ end
210
+
211
+ def verify_id_token(id_token, nonce: nil, access_token: nil, max_age: nil)
212
+ payload = verify_jwt(id_token, nil)
213
+ auds = Array(payload["aud"])
214
+ raise HostedLoginError.new("invalid_token", "The ID token is for another app") unless auds.include?(@client_id)
215
+ raise HostedLoginError.new("invalid_token", "azp does not name this app") if auds.length > 1 && payload["azp"] != @client_id
216
+ if nonce && !(payload["nonce"].is_a?(String) && Util.secure_compare(nonce, payload["nonce"]))
217
+ raise HostedLoginError.new("nonce_mismatch", "The nonce does not match this sign-in")
218
+ end
219
+ if access_token && payload["at_hash"].is_a?(String)
220
+ expected = Util.b64url(Util.sha256(access_token)[0, 16])
221
+ raise HostedLoginError.new("invalid_token", "at_hash does not match the access token") unless Util.secure_compare(expected, payload["at_hash"])
222
+ end
223
+ if max_age && payload["auth_time"].is_a?(Integer) && Util.now - payload["auth_time"] > max_age + @clock_tolerance
224
+ raise HostedLoginError.new("login_required", "The sign-in is older than max_age")
225
+ end
226
+
227
+ payload
228
+ end
229
+
230
+ # The query or form params of a callback URL, a query string or a Hash.
231
+ def self.params_of(callback)
232
+ # A nil value (a framework's missing field) counts as no field.
233
+ return Util.stringify(callback).compact.transform_values(&:to_s) if callback.is_a?(Hash)
234
+
235
+ text = callback.to_s
236
+ text = text.split("?", 2)[1].to_s if text.include?("?")
237
+ URI.decode_www_form(text.split("#", 2)[0].to_s).to_h
238
+ end
239
+
240
+ private
241
+
242
+ def random(bytes)
243
+ Util.b64url(SecureRandom.random_bytes(bytes))
244
+ end
245
+
246
+ def post(path, fields)
247
+ headers = { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" }
248
+ fields = fields.reject { |_, v| v.nil? || v.to_s.empty? }
249
+ if @client_secret
250
+ basic = "#{Util.seg(@client_id)}:#{Util.seg(@client_secret)}"
251
+ headers["Authorization"] = "Basic #{[basic].pack("m0")}"
252
+ else
253
+ fields = fields.merge("client_id" => @client_id)
254
+ end
255
+ @transport.call("POST", "#{@issuer}#{path}", headers, URI.encode_www_form(fields))
256
+ end
257
+
258
+ def error(status, body)
259
+ d = Util.parse_json(body.to_s) || {}
260
+ HostedLoginError.new(d["error"] || "http_#{status}", d["error_description"], status)
261
+ end
262
+
263
+ def token(fields)
264
+ status, body = post("/oauth/token", fields)
265
+ raise error(status, body) if status >= 400
266
+
267
+ d = Util.parse_json(body) || {}
268
+ {
269
+ "accessToken" => d["access_token"].to_s, "tokenType" => d["token_type"] || "Bearer", "expiresAt" => Util.now + d["expires_in"].to_i,
270
+ "scope" => d["scope"].to_s, "idToken" => d["id_token"], "refreshToken" => d["refresh_token"], "claims" => {},
271
+ "authorizationDetails" => d["authorization_details"].is_a?(Array) ? d["authorization_details"] : nil,
272
+ "issuedTokenType" => d["issued_token_type"], "connection" => d["connection"].is_a?(String) ? d["connection"] : nil
273
+ }
274
+ end
275
+
276
+ def load_keys(force)
277
+ @lock.synchronize do
278
+ age = Util.now - @keys_at
279
+ return if !force && !@keys.empty? && age < 3600
280
+ return if force && age < 30
281
+
282
+ status, body = @transport.call("GET", "#{@issuer}/.well-known/jwks.json", { "Accept" => "application/json" }, nil)
283
+ raise HostedLoginError.new("jwks_unavailable", "JWKS answered #{status}") if status >= 400
284
+
285
+ @keys = ((Util.parse_json(body) || {})["keys"] || []).each_with_object({}) do |k, h|
286
+ h[k["kid"]] = k if k["kty"] == "RSA" && k["kid"] && k["n"] && k["e"] && (k["use"] || "sig") == "sig"
287
+ end
288
+ @keys_at = Util.now
289
+ end
290
+ end
291
+
292
+ def verify_jwt(token_value, typ)
293
+ parts = JWS.split(token_value)
294
+ raise HostedLoginError.new("invalid_token", "not a JWT") unless parts
295
+
296
+ header, payload, input, sig = parts
297
+ raise HostedLoginError.new("invalid_token", "Unexpected alg") unless header["alg"] == "RS256"
298
+ raise HostedLoginError.new("invalid_token", "Wrong token type") if typ && header["typ"].to_s.downcase != typ
299
+
300
+ kid = header["kid"].to_s
301
+ load_keys(false)
302
+ load_keys(true) unless @keys.key?(kid)
303
+ raise HostedLoginError.new("invalid_token", "Unknown signing key") unless @keys.key?(kid)
304
+ raise HostedLoginError.new("invalid_token", "Bad signature") unless JWS.verify("RS256", @keys[kid], input, sig)
305
+
306
+ now = Util.now
307
+ raise HostedLoginError.new("invalid_token", "Wrong issuer") unless payload["iss"] == @issuer
308
+ raise HostedLoginError.new("invalid_token", "The token expired") unless payload["exp"].is_a?(Numeric) && now <= payload["exp"] + @clock_tolerance
309
+ raise HostedLoginError.new("invalid_token", "The token is from the future") if payload["iat"].is_a?(Numeric) && payload["iat"] > now + @clock_tolerance
310
+
311
+ payload
312
+ end
313
+ end
314
+
315
+ # The Touchque Login My Account API (/me/v1) from your server: the signed-in customer's
316
+ # own profile, sign-in methods, devices, apps and data, called with their access token
317
+ # for audience https://<domain>/me/. Security changes, the data export and deletion need
318
+ # a recent sign-in; when it is too old a MyAccountError with step_up? is raised.
319
+ class MyAccount
320
+ def initialize(domain, access_token, transport: nil)
321
+ raise MyAccountError.new("config", 0, "domain and access_token are required") if domain.to_s.empty? || access_token.to_s.empty?
322
+
323
+ raw = domain.chomp("/")
324
+ @issuer = raw.match?(%r{\Ahttps?://}) ? raw : "https://#{raw}"
325
+ unless Util.https_or_loopback?(@issuer)
326
+ raise MyAccountError.new("config", 0, "Refusing a plaintext http:// domain: the access token would be sent in the clear. Use https:// (http:// only on localhost).")
327
+ end
328
+ @access_token = access_token
329
+ @transport = transport || lambda { |method, url, headers, body|
330
+ status, text, = Transport.call(method, url, headers: headers, body: body)
331
+ [status, text]
332
+ }
333
+ end
334
+
335
+ # Never shows the access token (logs, consoles, error reporters).
336
+ def inspect
337
+ "#<#{self.class.name} issuer=#{@issuer.inspect}>"
338
+ end
339
+
340
+ # The hosted account page, optionally at security, devices or privacy.
341
+ def account_page_url(section = nil)
342
+ "#{@issuer}/account#{section ? "/#{section}" : ""}"
343
+ end
344
+
345
+ def get = call("GET", "")
346
+ def get_profile = call("GET", "/profile")
347
+
348
+ # name, givenName, familyName, nickname, locale, userMetadata (merged; nil removes a key).
349
+ def update_profile(changes) = call("PATCH", "/profile", changes)
350
+ def authentication_methods = call("GET", "/authentication-methods")
351
+ def rename_passkey(id, label) = call("PATCH", "/authentication-methods/passkeys/#{Util.seg(id)}", { "label" => label }) && nil
352
+ # Needs a recent sign-in.
353
+ def remove_passkey(id) = call("DELETE", "/authentication-methods/passkeys/#{Util.seg(id)}") && nil
354
+ # Needs a recent sign-in.
355
+ def unlink_touchque_app = call("DELETE", "/authentication-methods/touchque") && nil
356
+ # Email codes as a second step. Needs a recent sign-in.
357
+ def set_email_codes(enabled) = call("PUT", "/authentication-methods/email", { "enabled" => enabled })
358
+ # Texts a code to a new number for text-message codes. Needs a recent sign-in.
359
+ def start_sms_enrollment(phone) = call("POST", "/authentication-methods/sms", { "phone" => phone })
360
+ # The code from that text: text-message codes are on. Needs a recent sign-in.
361
+ def verify_sms_enrollment(code) = call("POST", "/authentication-methods/sms/verify", { "code" => code })
362
+ # Needs a recent sign-in.
363
+ def remove_sms_codes = call("DELETE", "/authentication-methods/sms") && nil
364
+ # Needs a recent sign-in.
365
+ def unlink_identity(id) = call("DELETE", "/identities/#{Util.seg(id)}") && nil
366
+ # Apps stop using a linked account for the person (token vault: the identity's appAccess id); signing in with it stays.
367
+ def stop_app_access(id) = call("DELETE", "/app-access/#{Util.seg(id)}") && nil
368
+ # Sets or changes the password; other sessions end. Needs a recent sign-in.
369
+ def set_password(password) = call("PUT", "/password", { "password" => password })
370
+ # Needs a recent sign-in and another way to sign in.
371
+ def remove_password = call("DELETE", "/password") && nil
372
+ # A confirmation link goes to the new address. Needs a recent sign-in.
373
+ def change_email(email) = call("POST", "/email", { "email" => email })
374
+ def send_verification_email = call("POST", "/email/verification")
375
+ def sessions = call("GET", "/sessions")
376
+ def end_session(id) = call("DELETE", "/sessions/#{Util.seg(id)}") && nil
377
+ def end_other_sessions = call("DELETE", "/sessions")
378
+ def apps = call("GET", "/apps")
379
+ def revoke_app(client_id) = call("DELETE", "/apps/#{Util.seg(client_id)}")
380
+ def consents = call("GET", "/consents")
381
+ def set_marketing(granted) = call("PUT", "/consents/marketing", { "granted" => granted })
382
+ # Everything about the person (KVKK/GDPR). Needs a recent sign-in.
383
+ def export_data = call("GET", "/export")
384
+ # Erases the account at once. Needs delete:me and a recent sign-in.
385
+ def delete_account = call("DELETE", "")
386
+
387
+ private
388
+
389
+ def call(method, path, body = nil)
390
+ headers = { "Authorization" => "Bearer #{@access_token}", "Accept" => "application/json" }
391
+ headers["Content-Type"] = "application/json" unless body.nil?
392
+ status, text = @transport.call(method, "#{@issuer}/me/v1#{path}", headers, body.nil? ? nil : Util.json(Util.stringify(body)))
393
+ return {} if status == 204
394
+
395
+ d = Util.parse_json(text.to_s) || {}
396
+ raise MyAccountError.new(d["error"] || "http_#{status}", status, d["error_description"], d["max_age"]&.to_i) if status >= 400
397
+
398
+ d
399
+ end
400
+ end
401
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Touchque
8
+ # Every partner API call: signed with HMAC-SHA256 over
9
+ # "METHOD:pathWithQuery:timestamp:nonce:sha256(body)", sent once, never redirected.
10
+ class HTTP
11
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
12
+ # What a request that never got a usable answer raises (every Errno, a garbled
13
+ # answer, TLS): NetworkError for the caller.
14
+ NETWORK_ERRORS = [Timeout::Error, SystemCallError, SocketError, OpenSSL::SSL::SSLError, IOError,
15
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError].freeze
16
+
17
+ attr_reader :base_url, :api_secret
18
+
19
+ def initialize(api_key:, api_secret:, base_url:, timeout:)
20
+ @api_key = api_key
21
+ @api_secret = api_secret
22
+ @base_url = base_url
23
+ @timeout = timeout
24
+ @uri = URI.parse(base_url)
25
+ end
26
+
27
+ # Never shows the API key or secret (logs, consoles, error reporters).
28
+ def inspect
29
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
30
+ end
31
+
32
+ def get(path, params = nil)
33
+ send_request("GET", path + self.class.build_query(params), nil)
34
+ end
35
+
36
+ def post(path, body = {})
37
+ send_request("POST", path, body || {})
38
+ end
39
+
40
+ def put(path, body = {})
41
+ send_request("PUT", path, body || {})
42
+ end
43
+
44
+ def patch(path, body = {})
45
+ send_request("PATCH", path, body || {})
46
+ end
47
+
48
+ def delete(path)
49
+ send_request("DELETE", path, nil)
50
+ end
51
+
52
+ # A signed GET whose answer is returned as text (an export file).
53
+ def get_text(path)
54
+ send_request("GET", path, nil, raw: true)
55
+ end
56
+
57
+ # Deterministic query string: keys sorted, values URL-encoded, "?"-prefixed (or "").
58
+ def self.build_query(params)
59
+ return "" if params.nil?
60
+
61
+ pairs = params.map { |k, v| [k.to_s, v] }.reject { |_, v| v.nil? }.sort_by(&:first)
62
+ return "" if pairs.empty?
63
+
64
+ "?" + pairs.map { |k, v| "#{Util.seg(k)}=#{Util.seg(v.to_s)}" }.join("&")
65
+ end
66
+
67
+ def self.sign(secret, method, path_with_query, body, timestamp, nonce)
68
+ message = "#{method.upcase}:#{path_with_query}:#{timestamp}:#{nonce}:#{Util.sha256_hex(body)}"
69
+ Util.hmac_hex(secret, message)
70
+ end
71
+
72
+ private
73
+
74
+ def send_request(method, path_with_query, body, raw: false)
75
+ body_text = body.nil? ? "" : Util.json(Util.stringify(body))
76
+ timestamp = Util.now_ms.to_s
77
+ nonce = Util.random_hex(16)
78
+ klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put,
79
+ "PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete }.fetch(method)
80
+ req = klass.new(@uri.path.chomp("/") + path_with_query)
81
+ req["x-api-key"] = @api_key
82
+ req["x-signature"] = self.class.sign(@api_secret, method, path_with_query, body_text, timestamp, nonce)
83
+ req["x-timestamp"] = timestamp
84
+ req["x-nonce"] = nonce
85
+ req["Accept"] = "application/json"
86
+ req["User-Agent"] = "touchque-ruby-sdk/#{VERSION}"
87
+ unless body.nil?
88
+ req["Content-Type"] = "application/json"
89
+ req.body = body_text
90
+ end
91
+ res = perform(req)
92
+ text = res.body.to_s.dup.force_encoding("UTF-8")
93
+ status = res.code.to_i
94
+ return text if raw && status < 300
95
+
96
+ data = Util.parse_json(text)
97
+ if status >= 300
98
+ data = {} unless data.is_a?(Hash)
99
+ if data["retryAfter"].nil? && res["Retry-After"].to_s.match?(/\A\d+\z/)
100
+ data["retryAfter"] = res["Retry-After"].to_i
101
+ end
102
+ if status < 400
103
+ # An API endpoint never redirects; one that does is not Touchque's.
104
+ raise APIError.new(status, data, "Touchque: unexpected redirect (HTTP #{status}); redirects are never followed.")
105
+ end
106
+ raise APIError.for(status, data)
107
+ end
108
+ data.nil? ? {} : data
109
+ end
110
+
111
+ def perform(req)
112
+ http = Net::HTTP.new(@uri.host, @uri.port)
113
+ http.use_ssl = @uri.scheme == "https"
114
+ http.open_timeout = @timeout
115
+ http.read_timeout = @timeout
116
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
117
+ res = http.request(req)
118
+ if res["Content-Length"].to_i > MAX_RESPONSE_BYTES || res.body.to_s.bytesize > MAX_RESPONSE_BYTES
119
+ raise NetworkError, "the answer is larger than 10 MB"
120
+ end
121
+
122
+ res
123
+ rescue *NETWORK_ERRORS => e
124
+ raise NetworkError, e.message
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # Encrypted cookies for apps that use Touchque Login without a session store: the
5
+ # sign-in in progress (state, nonce, PKCE verifier, where to go back) and the
6
+ # signed-in session (user claims, tokens). AES-256-GCM with a key derived from your
7
+ # secret; values too big for one cookie are split across several (name.0, name.1...).
8
+ # The same format as the Node SDK's sealSession, so both can read each other's cookies.
9
+ module LoginSession
10
+ CHUNK = 3800
11
+ MAX_CHUNKS = 5
12
+
13
+ module_function
14
+
15
+ def key_of(secret, purpose)
16
+ raise Error, "The cookie secret must be at least 32 characters" unless secret.is_a?(String) && secret.length >= 32
17
+
18
+ Util.sha256("touchque-login:#{purpose}:#{secret}")
19
+ end
20
+
21
+ def seal(value, secret, purpose, max_age_seconds)
22
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
23
+ cipher.key = key_of(secret, purpose)
24
+ iv = cipher.random_iv
25
+ cipher.auth_data = ""
26
+ enc = cipher.update(Util.json({ "v" => value, "exp" => Util.now + max_age_seconds })) + cipher.final
27
+ Util.b64url(iv + cipher.auth_tag + enc)
28
+ end
29
+
30
+ # The value, or nil when it is missing, tampered with or expired.
31
+ def unseal(sealed, secret, purpose)
32
+ return nil if sealed.nil? || sealed.empty?
33
+
34
+ raw = Util.b64url_decode(sealed)
35
+ return nil if raw.bytesize < 28
36
+
37
+ decipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
38
+ decipher.key = key_of(secret, purpose)
39
+ decipher.iv = raw[0, 12]
40
+ decipher.auth_tag = raw[12, 16]
41
+ decipher.auth_data = ""
42
+ body = Util.parse_json((decipher.update(raw[28..]) + decipher.final).force_encoding("UTF-8"))
43
+ return nil unless body.is_a?(Hash) && body["exp"].is_a?(Numeric) && body["exp"] >= Util.now
44
+
45
+ body["v"]
46
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
47
+ nil
48
+ end
49
+
50
+ # Cookie name => value pairs storing +sealed+ under +name+ (split when long); nil
51
+ # values delete. +existing+: the names of the cookies the browser sent.
52
+ def cookie_values(name, sealed, existing)
53
+ old = existing.select { |k| k == name || k.start_with?("#{name}.") }
54
+ return old.to_h { |k| [k, nil] } if sealed.nil?
55
+ return { name => sealed }.merge((old - [name]).to_h { |k| [k, nil] }) if sealed.length <= CHUNK
56
+
57
+ chunks = (sealed.length.to_f / CHUNK).ceil
58
+ raise Error, "The session is too large for cookies" if chunks > MAX_CHUNKS
59
+
60
+ out = (0...chunks).to_h { |i| ["#{name}.#{i}", sealed[i * CHUNK, CHUNK]] }
61
+ (old - out.keys).each { |k| out[k] = nil }
62
+ out
63
+ end
64
+
65
+ # The sealed value stored under +name+, joined back from its chunks.
66
+ def read(name, cookies)
67
+ return cookies[name] if cookies[name] && !cookies[name].empty?
68
+
69
+ joined = +""
70
+ MAX_CHUNKS.times do |i|
71
+ part = cookies["#{name}.#{i}"]
72
+ break if part.nil? || part.empty?
73
+
74
+ joined << part
75
+ end
76
+ joined.empty? ? nil : joined
77
+ end
78
+
79
+ # A path inside this app to return to after sign-in (never another site).
80
+ def safe_return_to(value, fallback = "/")
81
+ return fallback unless value.is_a?(String) && value.start_with?("/") && !value.start_with?("//", "/\\") && value.length <= 2000
82
+
83
+ value
84
+ end
85
+ end
86
+ end