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