fingerly 0.1.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/LICENSE +202 -0
- data/README.md +402 -0
- data/lib/fingerly/client.rb +251 -0
- data/lib/fingerly/config.rb +146 -0
- data/lib/fingerly/errors.rb +95 -0
- data/lib/fingerly/models.rb +182 -0
- data/lib/fingerly/proxy.rb +430 -0
- data/lib/fingerly/redacted.rb +52 -0
- data/lib/fingerly/transport.rb +344 -0
- data/lib/fingerly/version.rb +6 -0
- data/lib/fingerly/webhooks.rb +126 -0
- data/lib/fingerly.rb +27 -0
- data/sig/fingerly.rbs +213 -0
- metadata +61 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module Fingerly
|
|
10
|
+
# One HTTP exchange, bounded end to end.
|
|
11
|
+
#
|
|
12
|
+
# Net::HTTP has +open_timeout+, +read_timeout+ and +write_timeout+, but each
|
|
13
|
+
# bounds a single operation: a server that answers and then dribbles a byte
|
|
14
|
+
# every second would satisfy all three forever. The socket's timeouts are
|
|
15
|
+
# shrunk to whatever is left of the deadline before each operation and each
|
|
16
|
+
# body chunk, and one Timeout around the whole exchange covers what those
|
|
17
|
+
# cannot reach: Net::HTTP reads the status line and every header line with
|
|
18
|
+
# the full read timeout each, so a server trickling header lines would
|
|
19
|
+
# otherwise hold the call for as long as it liked. Under a fiber scheduler
|
|
20
|
+
# Timeout hands the wait to the scheduler.
|
|
21
|
+
#
|
|
22
|
+
# Idle connections live in one pool per process, keyed by destination.
|
|
23
|
+
# Net::HTTP cannot be used by two threads or fibers at once, so each exchange
|
|
24
|
+
# checks a connection out and hands it back when it is done. A thread-local
|
|
25
|
+
# or fiber-local connection would be reused only by the same thread or
|
|
26
|
+
# fiber: under Falcon, which runs each client connection in its own fiber,
|
|
27
|
+
# that meant a new upstream socket per visitor, stranded until garbage
|
|
28
|
+
# collection. A pool shared by everything reuses a socket whoever asks next.
|
|
29
|
+
#
|
|
30
|
+
# Connections are recycled by age, because one held for the life of a
|
|
31
|
+
# process pins whatever DNS answered first, and dropped after sitting idle,
|
|
32
|
+
# because the other end will eventually close it without telling anybody.
|
|
33
|
+
# Keying by destination rather than by transport instance means an
|
|
34
|
+
# application that builds a client per request still reuses sockets.
|
|
35
|
+
#
|
|
36
|
+
# Nothing here needs an asynchronous twin. Net::HTTP has honoured the fiber
|
|
37
|
+
# scheduler since Ruby 3.0, so under Falcon or the async gem these same
|
|
38
|
+
# blocking reads yield to the scheduler instead of holding the thread.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
class Transport
|
|
42
|
+
include Redacted
|
|
43
|
+
|
|
44
|
+
# A response body is small; anything larger did not come from this API,
|
|
45
|
+
# and is refused rather than buffered.
|
|
46
|
+
MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
# How long a pooled connection may live before it is reopened, so DNS is
|
|
49
|
+
# asked again at least this often.
|
|
50
|
+
RECYCLE_AFTER = 60
|
|
51
|
+
|
|
52
|
+
# How long a connection may sit idle in the pool and still be reused. Well
|
|
53
|
+
# under the idle timeouts servers and load balancers apply, so a reused
|
|
54
|
+
# socket is one the other end has not yet given up on.
|
|
55
|
+
IDLE_TIMEOUT = 15
|
|
56
|
+
|
|
57
|
+
# Idle connections kept per destination. Enough that a burst of a few
|
|
58
|
+
# hundred concurrent requests hands its sockets back rather than closing
|
|
59
|
+
# them; never more open at rest than this.
|
|
60
|
+
MAX_IDLE = 256
|
|
61
|
+
|
|
62
|
+
Result = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
63
|
+
|
|
64
|
+
# @param origin [String] scheme and authority, without a trailing slash
|
|
65
|
+
# @param timeout [Numeric] the deadline for one exchange, in seconds
|
|
66
|
+
# @param configure_http [Proc, nil] receives the Net::HTTP before it starts
|
|
67
|
+
def initialize(origin:, timeout:, configure_http: nil)
|
|
68
|
+
@uri = URI.parse(origin)
|
|
69
|
+
@timeout = timeout
|
|
70
|
+
@configure_http = configure_http
|
|
71
|
+
# A caller-supplied configure_http may set anything at all, so two
|
|
72
|
+
# transports share a connection only when neither has one.
|
|
73
|
+
@pool_key = [@uri.scheme, @uri.host, @uri.port, configure_http&.object_id].freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Performs one request and reads the whole response.
|
|
77
|
+
#
|
|
78
|
+
# @param method [Class] a Net::HTTPRequest subclass
|
|
79
|
+
# @param path [String] the path and query to request
|
|
80
|
+
# @param headers [Hash{String=>String}] headers to send
|
|
81
|
+
# @param body [String, nil] the request body
|
|
82
|
+
# @return [Result]
|
|
83
|
+
# @raise [TimeoutError] if the deadline passes at any point
|
|
84
|
+
# @raise [ConnectionError] if the API cannot be reached
|
|
85
|
+
def call(method, path, headers, body = nil)
|
|
86
|
+
request = method.new(path, headers)
|
|
87
|
+
request.body = body if body
|
|
88
|
+
# Net::HTTP's own retry would resend on a connection error; reuse is
|
|
89
|
+
# handled here instead, where an idempotent request can be told from one
|
|
90
|
+
# that must never be sent twice.
|
|
91
|
+
idempotent = method == Net::HTTP::Get
|
|
92
|
+
|
|
93
|
+
# One budget for the whole call, so reopening a closed connection cannot
|
|
94
|
+
# quietly buy a second timeout's worth of time.
|
|
95
|
+
Timeout.timeout(@timeout, DeadlineExceeded) do
|
|
96
|
+
perform(request, deadline: now + @timeout, idempotent: idempotent)
|
|
97
|
+
end
|
|
98
|
+
rescue DeadlineExceeded
|
|
99
|
+
# perform has already discarded the interrupted connection.
|
|
100
|
+
raise TimeoutError, "The Fingerly request timed out"
|
|
101
|
+
rescue Timeout::Error => e
|
|
102
|
+
# Net::OpenTimeout, ReadTimeout and WriteTimeout all derive from it.
|
|
103
|
+
raise TimeoutError, "The Fingerly request timed out (#{e.class})"
|
|
104
|
+
rescue SocketError, IOError, SystemCallError, OpenSSL::OpenSSLError, Zlib::Error,
|
|
105
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError => e
|
|
106
|
+
# Every network failure arrives as the SDK's own error, never as a raw
|
|
107
|
+
# runtime one. The class names the cause; nothing from the body does.
|
|
108
|
+
raise ConnectionError, "Could not reach the Fingerly API (#{e.class})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Closes the idle connections kept for this destination.
|
|
112
|
+
def close
|
|
113
|
+
Transport.drain(@pool_key).each { |entry| discard(entry.http) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One entry per idle connection: the Net::HTTP, when it was opened, and
|
|
117
|
+
# when it was last handed back.
|
|
118
|
+
Idle = Struct.new(:http, :opened_at, :released_at)
|
|
119
|
+
|
|
120
|
+
@idle = {}
|
|
121
|
+
@lock = Mutex.new
|
|
122
|
+
@pid = Process.pid
|
|
123
|
+
@swept_at = nil
|
|
124
|
+
|
|
125
|
+
class << self
|
|
126
|
+
# @api private
|
|
127
|
+
# @return [Array(Idle, Array<Idle>)] the most recently used usable
|
|
128
|
+
# connection, or nil, and the stale ones the caller must close
|
|
129
|
+
def checkout(key, now)
|
|
130
|
+
@lock.synchronize do
|
|
131
|
+
list = idle_for(key)
|
|
132
|
+
stale = prune(list, now)
|
|
133
|
+
[list.pop, stale]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @api private
|
|
138
|
+
# @return [Array(Boolean, Array<Idle>)] whether the pool kept it, and the
|
|
139
|
+
# stale ones the caller must close
|
|
140
|
+
def checkin(key, entry, now)
|
|
141
|
+
@lock.synchronize do
|
|
142
|
+
list = idle_for(key)
|
|
143
|
+
stale = prune(list, now)
|
|
144
|
+
kept = list.size < MAX_IDLE
|
|
145
|
+
list.push(entry) if kept
|
|
146
|
+
# After the push, so a sweep cannot drop the list just appended to.
|
|
147
|
+
[kept, stale + sweep(now)]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @api private
|
|
152
|
+
def drain(key)
|
|
153
|
+
@lock.synchronize { idle_for(key).slice!(0..) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @api private
|
|
157
|
+
# @return [Integer] idle connections held for a destination
|
|
158
|
+
def idle_count(key)
|
|
159
|
+
@lock.synchronize { idle_for(key).size }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def idle_for(key)
|
|
165
|
+
# A forked child must never write to a socket its parent also holds.
|
|
166
|
+
# Dropping the references closes nothing the parent still uses.
|
|
167
|
+
unless @pid == Process.pid
|
|
168
|
+
@idle = {}
|
|
169
|
+
@pid = Process.pid
|
|
170
|
+
end
|
|
171
|
+
@idle[key] ||= []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A destination nobody asks for again would otherwise keep its sockets
|
|
175
|
+
# for ever: each +configure_http+ proc is its own key, so a client built
|
|
176
|
+
# per request with an inline proc strands one socket per request.
|
|
177
|
+
#
|
|
178
|
+
# @return [Array<Idle>] the stale connections the caller must close
|
|
179
|
+
def sweep(now)
|
|
180
|
+
return [] if @swept_at && now - @swept_at < IDLE_TIMEOUT
|
|
181
|
+
|
|
182
|
+
@swept_at = now
|
|
183
|
+
stale = []
|
|
184
|
+
@idle.delete_if do |_key, list|
|
|
185
|
+
stale.concat(prune(list, now))
|
|
186
|
+
list.empty?
|
|
187
|
+
end
|
|
188
|
+
stale
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The oldest entries sit at the front; take them while they are stale.
|
|
192
|
+
def prune(list, now)
|
|
193
|
+
stale = []
|
|
194
|
+
stale << list.shift while list.first && stale?(list.first, now)
|
|
195
|
+
stale
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def stale?(entry, now)
|
|
199
|
+
now - entry.released_at > IDLE_TIMEOUT || now - entry.opened_at > RECYCLE_AFTER
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def redacted_fields
|
|
206
|
+
[["origin", "#{@uri.scheme}://#{@uri.host}:#{@uri.port}"], ["timeout", @timeout]]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def perform(request, deadline:, idempotent:)
|
|
210
|
+
entry, reused = connection
|
|
211
|
+
begin
|
|
212
|
+
result = exchange(entry.http, request, deadline)
|
|
213
|
+
rescue IOError, SystemCallError, Net::HTTPBadResponse
|
|
214
|
+
discard(entry.http)
|
|
215
|
+
# A connection the other end closed while it was idle fails on its
|
|
216
|
+
# next use. Reopening and sending again is recovery, not a retry, so
|
|
217
|
+
# it happens only for a reused connection and only for a request that
|
|
218
|
+
# may safely arrive twice. A timeout is deliberately not in this list:
|
|
219
|
+
# a slow server is not a broken connection.
|
|
220
|
+
raise unless reused && idempotent
|
|
221
|
+
|
|
222
|
+
entry = fresh
|
|
223
|
+
begin
|
|
224
|
+
result = exchange(entry.http, request, deadline)
|
|
225
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
226
|
+
discard(entry.http)
|
|
227
|
+
raise
|
|
228
|
+
end
|
|
229
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
230
|
+
# Anything else, the overall deadline included, leaves the connection
|
|
231
|
+
# in an unknown state, so it is never handed to another caller.
|
|
232
|
+
discard(entry.http)
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
release(entry)
|
|
236
|
+
result
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def exchange(http, request, deadline)
|
|
240
|
+
apply_timeouts(http, deadline)
|
|
241
|
+
http.start unless http.started?
|
|
242
|
+
apply_timeouts(http, deadline)
|
|
243
|
+
|
|
244
|
+
# The result is captured rather than returned from the block. Net::HTTP
|
|
245
|
+
# decides whether the connection may be kept alive after the block ends,
|
|
246
|
+
# and a non-local exit would skip that: the next request on this thread
|
|
247
|
+
# would then be written to a socket the server had already said it was
|
|
248
|
+
# closing.
|
|
249
|
+
result = nil
|
|
250
|
+
http.request(request) do |response|
|
|
251
|
+
# Refused on the declared size before a byte is buffered, and on the
|
|
252
|
+
# real size while reading, whichever comes first.
|
|
253
|
+
too_large! if response.content_length.to_i > MAX_BODY_BYTES
|
|
254
|
+
body = +""
|
|
255
|
+
response.read_body do |chunk|
|
|
256
|
+
body << chunk
|
|
257
|
+
too_large! if body.bytesize > MAX_BODY_BYTES
|
|
258
|
+
|
|
259
|
+
# Shrink the socket's own timeout to what is left of the deadline, so
|
|
260
|
+
# a stalled body cannot outlive it.
|
|
261
|
+
apply_timeouts(http, deadline)
|
|
262
|
+
end
|
|
263
|
+
declared = response.content_length
|
|
264
|
+
if declared && body.bytesize != declared
|
|
265
|
+
raise ConnectionError, "The Fingerly API response was cut short"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
result = Result.new(
|
|
269
|
+
status: response.code.to_i, headers: response.to_hash, body: body
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
result
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def too_large!
|
|
276
|
+
raise Error, "The Fingerly API returned more than #{MAX_BODY_BYTES} bytes"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @raise [TimeoutError] when nothing is left of the deadline
|
|
280
|
+
def apply_timeouts(http, deadline)
|
|
281
|
+
left = deadline - now
|
|
282
|
+
raise TimeoutError, "The Fingerly request timed out" if left <= 0
|
|
283
|
+
|
|
284
|
+
http.open_timeout = left
|
|
285
|
+
http.read_timeout = left
|
|
286
|
+
http.write_timeout = left
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# @return [Array(Idle, Boolean)] a connection and whether it was reused
|
|
290
|
+
def connection
|
|
291
|
+
loop do
|
|
292
|
+
entry, stale = Transport.checkout(@pool_key, now)
|
|
293
|
+
stale.each { |old| discard(old.http) }
|
|
294
|
+
return [fresh, false] if entry.nil?
|
|
295
|
+
return [entry, true] if entry.http.started?
|
|
296
|
+
|
|
297
|
+
discard(entry.http)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def release(entry)
|
|
302
|
+
return discard(entry.http) unless entry.http.started?
|
|
303
|
+
|
|
304
|
+
entry.released_at = now
|
|
305
|
+
kept, stale = Transport.checkin(@pool_key, entry, now)
|
|
306
|
+
stale.each { |old| discard(old.http) }
|
|
307
|
+
discard(entry.http) unless kept
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def fresh
|
|
311
|
+
Idle.new(build, now, now)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def build
|
|
315
|
+
# hostname, not host: host keeps an IPv6 literal's brackets, which
|
|
316
|
+
# Net::HTTP would then try to resolve as a name.
|
|
317
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port)
|
|
318
|
+
http.use_ssl = @uri.scheme == "https"
|
|
319
|
+
http.max_retries = 0
|
|
320
|
+
# Net::HTTP accepts a body shorter than its content-length by default,
|
|
321
|
+
# which would pass a response cut short upstream on as complete.
|
|
322
|
+
http.ignore_eof = false
|
|
323
|
+
# Net::HTTP reconnects by itself after this long idle; keep it in step
|
|
324
|
+
# with the pool's own idea of a usable connection.
|
|
325
|
+
http.keep_alive_timeout = IDLE_TIMEOUT
|
|
326
|
+
@configure_http&.call(http)
|
|
327
|
+
http
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def discard(http)
|
|
331
|
+
http.finish if http.started?
|
|
332
|
+
rescue IOError
|
|
333
|
+
# Already closed by the other end; nothing left to release.
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def now
|
|
337
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Raised by the overall deadline; never escapes {#call}.
|
|
341
|
+
class DeadlineExceeded < StandardError; end
|
|
342
|
+
private_constant :DeadlineExceeded
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Fingerly
|
|
7
|
+
# Webhook signature verification.
|
|
8
|
+
#
|
|
9
|
+
# Stateless and module-level: verifying a delivery is one HMAC over bytes you
|
|
10
|
+
# already have, so it needs no client.
|
|
11
|
+
module Webhooks
|
|
12
|
+
# How far a delivery's timestamp may be from now, in seconds, before it is
|
|
13
|
+
# refused as a replay.
|
|
14
|
+
DEFAULT_TOLERANCE = 300
|
|
15
|
+
|
|
16
|
+
# A lowercase hex digest, exactly as Fingerly sends it. Anchored with \A
|
|
17
|
+
# and \z because Ruby's ^ and $ match at every line boundary.
|
|
18
|
+
SIGNATURE = /\Asha256=[a-f0-9]{64}\z/
|
|
19
|
+
# Whole unsigned seconds, and nothing else.
|
|
20
|
+
TIMESTAMP = /\A[0-9]+\z/
|
|
21
|
+
|
|
22
|
+
PREFIX = "sha256="
|
|
23
|
+
|
|
24
|
+
# Signatures read from one header. A rotation needs two; anything beyond
|
|
25
|
+
# this is not from Fingerly, and is not worth comparing one by one.
|
|
26
|
+
MAX_SIGNATURES = 32
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Reports whether a delivery came from Fingerly and is recent.
|
|
31
|
+
#
|
|
32
|
+
# payload = request.body.read
|
|
33
|
+
# ok = Fingerly::Webhooks.verify(
|
|
34
|
+
# secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"),
|
|
35
|
+
# payload: payload,
|
|
36
|
+
# timestamp: request.get_header("HTTP_X_FINGERLY_TIMESTAMP"),
|
|
37
|
+
# signature: request.get_header("HTTP_X_FINGERLY_SIGNATURE"),
|
|
38
|
+
# )
|
|
39
|
+
#
|
|
40
|
+
# Verification proves authenticity and freshness. It does not deduplicate,
|
|
41
|
+
# so persist processed {WebhookEvent#id} values as well.
|
|
42
|
+
#
|
|
43
|
+
# @param secret [String] the endpoint's signing secret
|
|
44
|
+
# @param payload [String] the exact bytes received, before any JSON
|
|
45
|
+
# parsing: re-serialising them changes the digest
|
|
46
|
+
# @param timestamp [String] the +x-fingerly-timestamp+ header
|
|
47
|
+
# @param signature [String] the +x-fingerly-signature+ header, which
|
|
48
|
+
# carries several comma-separated signatures during a secret rotation.
|
|
49
|
+
# Any one of the first {MAX_SIGNATURES} matching is accepted.
|
|
50
|
+
# @param tolerance [Numeric] the replay window in seconds
|
|
51
|
+
# @param now [Time, nil] the instant to measure freshness against.
|
|
52
|
+
# Override it only in tests.
|
|
53
|
+
# @return [Boolean] true when a signature matches and the timestamp is
|
|
54
|
+
# recent. Malformed or missing input returns false rather than raising.
|
|
55
|
+
def verify(secret:, payload:, timestamp:, signature:,
|
|
56
|
+
tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
57
|
+
return false unless secret.is_a?(String) && !secret.empty?
|
|
58
|
+
return false unless payload.is_a?(String)
|
|
59
|
+
return false unless timestamp.is_a?(String) && signature.is_a?(String)
|
|
60
|
+
|
|
61
|
+
# Header values are whatever the sender wrote. As bytes, a value that is
|
|
62
|
+
# not valid in its encoding simply fails to match, where as a string it
|
|
63
|
+
# would raise from the regular expressions below.
|
|
64
|
+
timestamp = timestamp.b
|
|
65
|
+
signature = signature.b
|
|
66
|
+
# Check freshness before spending an HMAC on an obvious replay.
|
|
67
|
+
return false unless fresh?(timestamp, now, tolerance)
|
|
68
|
+
|
|
69
|
+
expected = sign(secret, timestamp, payload)
|
|
70
|
+
# The limit keeps a huge header from being split in full; whatever lies
|
|
71
|
+
# past the last candidate is ignored rather than compared.
|
|
72
|
+
candidates = signature.split(",", MAX_SIGNATURES + 1).first(MAX_SIGNATURES)
|
|
73
|
+
candidates.any? { |candidate| matches?(candidate, expected) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parses a delivery's envelope.
|
|
77
|
+
#
|
|
78
|
+
# Verify the bytes before calling this: parsing an unverified body is
|
|
79
|
+
# trusting whoever sent it.
|
|
80
|
+
#
|
|
81
|
+
# @param payload [String] the raw delivery body
|
|
82
|
+
# @return [WebhookEvent]
|
|
83
|
+
# @raise [Error] if the body is not a Fingerly webhook envelope
|
|
84
|
+
def parse(payload)
|
|
85
|
+
parsed = JSON.parse(payload.to_s)
|
|
86
|
+
raise Error, "Not a Fingerly webhook envelope" unless parsed.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
WebhookEvent.new(parsed)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raise Error, "Not a Fingerly webhook envelope"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Whether the signed time is within tolerance of now.
|
|
94
|
+
# @api private
|
|
95
|
+
def fresh?(timestamp, now, tolerance)
|
|
96
|
+
return false unless TIMESTAMP.match?(timestamp)
|
|
97
|
+
return false unless tolerance.is_a?(Numeric) && !tolerance.negative?
|
|
98
|
+
|
|
99
|
+
moment = (now || Time.now).to_i
|
|
100
|
+
(moment - Integer(timestamp, 10)).abs <= tolerance
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Computes the expected digest.
|
|
104
|
+
#
|
|
105
|
+
# Fingerly signs the timestamp, a literal dot, then the raw body bytes.
|
|
106
|
+
# Both are forced to binary first: a body that is not valid UTF-8 would
|
|
107
|
+
# otherwise raise on concatenation rather than simply fail to verify.
|
|
108
|
+
#
|
|
109
|
+
# @api private
|
|
110
|
+
def sign(secret, timestamp, payload)
|
|
111
|
+
signed = timestamp.b
|
|
112
|
+
signed += "."
|
|
113
|
+
signed += payload.b
|
|
114
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Compares one candidate signature in constant time.
|
|
118
|
+
# @api private
|
|
119
|
+
def matches?(candidate, expected)
|
|
120
|
+
value = candidate.strip
|
|
121
|
+
return false unless SIGNATURE.match?(value)
|
|
122
|
+
|
|
123
|
+
OpenSSL.fixed_length_secure_compare(value.delete_prefix(PREFIX), expected)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/fingerly.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fingerly/version"
|
|
4
|
+
require_relative "fingerly/errors"
|
|
5
|
+
require_relative "fingerly/redacted"
|
|
6
|
+
require_relative "fingerly/config"
|
|
7
|
+
require_relative "fingerly/models"
|
|
8
|
+
require_relative "fingerly/transport"
|
|
9
|
+
require_relative "fingerly/client"
|
|
10
|
+
require_relative "fingerly/webhooks"
|
|
11
|
+
require_relative "fingerly/proxy"
|
|
12
|
+
|
|
13
|
+
# Server-side Fingerly SDK.
|
|
14
|
+
#
|
|
15
|
+
# It reads stored identification events with a secret key, verifies signed
|
|
16
|
+
# webhooks, and serves the browser SDK's requests from a route on your own
|
|
17
|
+
# domain. It has no runtime dependencies.
|
|
18
|
+
#
|
|
19
|
+
# require "fingerly"
|
|
20
|
+
#
|
|
21
|
+
# fingerly = Fingerly::Client.new(secret_key: ENV.fetch("FINGERLY_SECRET_KEY"))
|
|
22
|
+
# event = fingerly.events.get(request_id)
|
|
23
|
+
#
|
|
24
|
+
# Secret keys and webhook secrets belong in server-side code only. A browser
|
|
25
|
+
# must never see either.
|
|
26
|
+
module Fingerly
|
|
27
|
+
end
|