foil-server 0.3.5 → 0.4.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 +4 -4
- data/README.md +24 -29
- data/lib/foil/server/client.rb +6 -155
- data/lib/foil/server/version.rb +1 -1
- data/lib/foil/server/webhooks.rb +71 -0
- data/lib/foil/server.rb +8 -6
- data/spec/README.md +14 -28
- data/spec/fixtures/api/organizations/api-key-create.json +1 -0
- data/spec/fixtures/api/organizations/api-key-list.json +1 -0
- data/spec/fixtures/api/organizations/api-key-revoke.json +1 -0
- data/spec/fixtures/api/organizations/api-key-rotate.json +1 -0
- data/spec/fixtures/api/organizations/api-key-update.json +1 -0
- data/spec/fixtures/api/sessions/detail.json +8 -1
- data/spec/fixtures/manifest.json +0 -85
- data/spec/fixtures/webhooks/signature.json +9 -0
- data/spec/openapi.json +1683 -6333
- metadata +4 -22
- data/lib/foil/server/gate_delivery.rb +0 -332
- data/spec/fixtures/api/gate/agent-token-revoke.json +0 -3
- data/spec/fixtures/api/gate/agent-token-verify.json +0 -12
- data/spec/fixtures/api/gate/login-session-consume.json +0 -10
- data/spec/fixtures/api/gate/login-session-create.json +0 -12
- data/spec/fixtures/api/gate/registry-detail.json +0 -45
- data/spec/fixtures/api/gate/registry-list.json +0 -47
- data/spec/fixtures/api/gate/service-create.json +0 -49
- data/spec/fixtures/api/gate/service-detail.json +0 -49
- data/spec/fixtures/api/gate/service-disable.json +0 -49
- data/spec/fixtures/api/gate/service-update.json +0 -49
- data/spec/fixtures/api/gate/services-list.json +0 -51
- data/spec/fixtures/api/gate/session-ack.json +0 -10
- data/spec/fixtures/api/gate/session-create.json +0 -13
- data/spec/fixtures/api/gate/session-poll.json +0 -36
- data/spec/fixtures/gate-delivery/approved-webhook-payload.valid.json +0 -19
- data/spec/fixtures/gate-delivery/delivery-request.json +0 -9
- data/spec/fixtures/gate-delivery/env-policy.json +0 -40
- data/spec/fixtures/gate-delivery/vector.v1.json +0 -28
- data/spec/fixtures/gate-delivery/webhook-signature.json +0 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 142ee8a11ee7b38b933aab55b22188e9f2a6ef73186097a5ea708d93fa0be2b8
|
|
4
|
+
data.tar.gz: cbcf119648d0de7699f30ff12894ed6303e7a6c1a874178db73e6a706a828b6a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 899d02338303288f518178bb8c1a72e815dd2372ef428534fe3c36a2574a09eecb021164ebda90f90439d74ce0bdd112d5d2301ddf393dd837fe4bf18e91103a
|
|
7
|
+
data.tar.gz: c88c199301d6c99dc1e612824b3d96caddaec9fd70b1dee9abc31f29ac071e75fcfba2d4bc55055ee7c2f75b8ca394b2bd8e01d0d7cc3eeacb5b779b2dc4465c
|
data/README.md
CHANGED
|
@@ -4,16 +4,14 @@
|
|
|
4
4
|

|
|
5
5
|

|
|
6
6
|
|
|
7
|
-
The Foil Ruby library provides convenient access to the Foil API from applications written in Ruby. It includes a client for Sessions, visitor fingerprints, Organizations, Organization API key management,
|
|
7
|
+
The Foil Ruby library provides convenient access to the Foil API from applications written in Ruby. It includes a client for Sessions, visitor fingerprints, Organizations, Organization API key management, webhook endpoints, and sealed token verification.
|
|
8
8
|
|
|
9
9
|
The library also provides:
|
|
10
10
|
|
|
11
11
|
- a fast configuration path using `FOIL_SECRET_KEY`
|
|
12
12
|
- lazy helpers for cursor-based pagination
|
|
13
13
|
- structured API errors and built-in sealed token verification
|
|
14
|
-
- webhook endpoint management, test sends,
|
|
15
|
-
- public, bearer-token, and secret-key auth modes for Gate flows
|
|
16
|
-
- Gate delivery/webhook helpers
|
|
14
|
+
- webhook endpoint management, test sends, event delivery history, and webhook signature verification
|
|
17
15
|
|
|
18
16
|
## Documentation
|
|
19
17
|
|
|
@@ -33,7 +31,7 @@ bundle add foil-server
|
|
|
33
31
|
|
|
34
32
|
## Usage
|
|
35
33
|
|
|
36
|
-
Use `FOIL_SECRET_KEY` or `secret_key
|
|
34
|
+
Use `FOIL_SECRET_KEY` or `secret_key:`:
|
|
37
35
|
|
|
38
36
|
```ruby
|
|
39
37
|
require "foil/server"
|
|
@@ -98,7 +96,7 @@ endpoint = client.webhooks.create_endpoint(
|
|
|
98
96
|
"org_0123456789abcdefghjkmnpqrs",
|
|
99
97
|
name: "Production alerts",
|
|
100
98
|
url: "https://example.com/foil/webhook",
|
|
101
|
-
event_types: ["session.result.persisted"
|
|
99
|
+
event_types: ["session.result.persisted"]
|
|
102
100
|
)
|
|
103
101
|
|
|
104
102
|
events = client.webhooks.list_events(
|
|
@@ -110,37 +108,34 @@ events = client.webhooks.list_events(
|
|
|
110
108
|
puts events.items.first[:webhook_deliveries].first[:status]
|
|
111
109
|
```
|
|
112
110
|
|
|
113
|
-
|
|
111
|
+
#### Verifying webhook deliveries
|
|
114
112
|
|
|
115
|
-
|
|
116
|
-
delivery_key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
|
|
113
|
+
Every webhook delivery is signed with your endpoint's signing secret. Verify the `X-Foil-Timestamp` and `X-Foil-Signature` headers against the raw request body before trusting the payload:
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
```ruby
|
|
116
|
+
valid = Foil::Server.verify_webhook_signature(
|
|
117
|
+
secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
|
|
118
|
+
timestamp: request.headers["X-Foil-Timestamp"],
|
|
119
|
+
raw_body: raw_body,
|
|
120
|
+
signature: request.headers["X-Foil-Signature"]
|
|
123
121
|
)
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
key_pair = Foil::Server::GateDelivery.create_delivery_key_pair
|
|
132
|
-
response = Foil::Server::GateDelivery.create_gate_approved_webhook_response(
|
|
133
|
-
delivery: key_pair[:delivery],
|
|
134
|
-
outputs: {
|
|
135
|
-
"FOIL_PUBLISHABLE_KEY" => "pk_live_...",
|
|
136
|
-
"FOIL_SECRET_KEY" => "sk_live_..."
|
|
137
|
-
}
|
|
123
|
+
# Verify and parse in one step. Raises ArgumentError if the signature is invalid or expired.
|
|
124
|
+
event = Foil::Server.verify_and_parse_webhook_event(
|
|
125
|
+
secret: ENV.fetch("FOIL_WEBHOOK_SECRET"),
|
|
126
|
+
timestamp: request.headers["X-Foil-Timestamp"],
|
|
127
|
+
raw_body: raw_body,
|
|
128
|
+
signature: request.headers["X-Foil-Signature"]
|
|
138
129
|
)
|
|
139
|
-
payload = Foil::Server::GateDelivery.decrypt_gate_delivery_envelope(key_pair[:private_key], response[:encrypted_delivery])
|
|
140
130
|
|
|
141
|
-
puts
|
|
131
|
+
puts event[:data] if event[:type] == "session.result.persisted"
|
|
132
|
+
|
|
133
|
+
# Parse a payload you have already verified.
|
|
134
|
+
parsed = Foil::Server.parse_webhook_event(raw_body)
|
|
142
135
|
```
|
|
143
136
|
|
|
137
|
+
Signatures older than five minutes are rejected by default. Pass `max_age_seconds:` to change the tolerance.
|
|
138
|
+
|
|
144
139
|
### Error handling
|
|
145
140
|
|
|
146
141
|
```ruby
|
data/lib/foil/server/client.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Foil
|
|
|
10
10
|
DEFAULT_TIMEOUT = 30
|
|
11
11
|
SDK_CLIENT_HEADER = "foil-server-ruby/0.1.0".freeze
|
|
12
12
|
|
|
13
|
-
attr_reader :sessions, :fingerprints, :organizations, :
|
|
13
|
+
attr_reader :sessions, :fingerprints, :organizations, :webhooks, :timeout
|
|
14
14
|
|
|
15
15
|
def initialize(secret_key: ENV["FOIL_SECRET_KEY"], base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, user_agent: nil, transport: nil)
|
|
16
16
|
@secret_key = secret_key
|
|
@@ -22,11 +22,10 @@ module Foil
|
|
|
22
22
|
@sessions = SessionsResource.new(self)
|
|
23
23
|
@fingerprints = FingerprintsResource.new(self)
|
|
24
24
|
@organizations = OrganizationsResource.new(self)
|
|
25
|
-
@gate = GateResource.new(self)
|
|
26
25
|
@webhooks = WebhooksResource.new(self)
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
def request_json(method, path, query: {}, body: nil, expect_content: true
|
|
28
|
+
def request_json(method, path, query: {}, body: nil, expect_content: true)
|
|
30
29
|
url = build_url(path, query)
|
|
31
30
|
headers = {
|
|
32
31
|
"Accept" => "application/json",
|
|
@@ -34,7 +33,7 @@ module Foil
|
|
|
34
33
|
}
|
|
35
34
|
headers["User-Agent"] = @user_agent if @user_agent
|
|
36
35
|
headers["Content-Type"] = "application/json" if body
|
|
37
|
-
apply_auth_headers(headers
|
|
36
|
+
apply_auth_headers(headers)
|
|
38
37
|
|
|
39
38
|
status, response_headers, response_body =
|
|
40
39
|
if @transport
|
|
@@ -126,21 +125,10 @@ module Foil
|
|
|
126
125
|
end
|
|
127
126
|
private :deep_symbolize
|
|
128
127
|
|
|
129
|
-
def apply_auth_headers(headers
|
|
130
|
-
|
|
131
|
-
case kind
|
|
132
|
-
when :none
|
|
133
|
-
headers
|
|
134
|
-
when :bearer
|
|
135
|
-
token = auth[:token]
|
|
136
|
-
raise ConfigurationError, "Missing bearer token for this Foil request." if token.nil? || token.empty?
|
|
128
|
+
def apply_auth_headers(headers)
|
|
129
|
+
raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if @secret_key.nil? || @secret_key.empty?
|
|
137
130
|
|
|
138
|
-
|
|
139
|
-
else
|
|
140
|
-
raise ConfigurationError, "Missing Foil secret key. Pass secret_key explicitly or set FOIL_SECRET_KEY." if @secret_key.nil? || @secret_key.empty?
|
|
141
|
-
|
|
142
|
-
headers["Authorization"] = "Bearer #{@secret_key}"
|
|
143
|
-
end
|
|
131
|
+
headers["Authorization"] = "Bearer #{@secret_key}"
|
|
144
132
|
end
|
|
145
133
|
private :apply_auth_headers
|
|
146
134
|
end
|
|
@@ -300,82 +288,6 @@ module Foil
|
|
|
300
288
|
end
|
|
301
289
|
end
|
|
302
290
|
|
|
303
|
-
class GateResource < BaseResource
|
|
304
|
-
attr_reader :registry, :services, :sessions, :login_sessions, :agent_tokens
|
|
305
|
-
|
|
306
|
-
def initialize(client)
|
|
307
|
-
super(client)
|
|
308
|
-
@registry = GateRegistryResource.new(client)
|
|
309
|
-
@services = GateServicesResource.new(client)
|
|
310
|
-
@sessions = GateSessionsResource.new(client)
|
|
311
|
-
@login_sessions = GateLoginSessionsResource.new(client)
|
|
312
|
-
@agent_tokens = GateAgentTokensResource.new(client)
|
|
313
|
-
end
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
class GateRegistryResource < BaseResource
|
|
317
|
-
def list
|
|
318
|
-
@client.request_json("GET", "/v1/gate/registry", auth: { kind: :none })[:data]
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def get(service_id)
|
|
322
|
-
@client.request_json("GET", "/v1/gate/registry/#{CGI.escape(service_id)}", auth: { kind: :none })[:data]
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
class GateServicesResource < BaseResource
|
|
327
|
-
def list
|
|
328
|
-
@client.request_json("GET", "/v1/gate/services")[:data]
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
def get(service_id)
|
|
332
|
-
@client.request_json("GET", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def create(id:, name:, description:, website:, webhook_endpoint_id:, discoverable: nil, dashboard_login_url: nil, env_vars: nil, docs_url: nil, sdks: nil, branding: nil, consent: nil)
|
|
336
|
-
@client.request_json("POST", "/v1/gate/services", body: compact({
|
|
337
|
-
id: id,
|
|
338
|
-
discoverable: discoverable,
|
|
339
|
-
name: name,
|
|
340
|
-
description: description,
|
|
341
|
-
website: website,
|
|
342
|
-
dashboard_login_url: dashboard_login_url,
|
|
343
|
-
webhook_endpoint_id: webhook_endpoint_id,
|
|
344
|
-
env_vars: env_vars,
|
|
345
|
-
docs_url: docs_url,
|
|
346
|
-
sdks: sdks,
|
|
347
|
-
branding: branding,
|
|
348
|
-
consent: consent
|
|
349
|
-
}))[:data]
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
def update(service_id, discoverable: nil, name: nil, description: nil, website: nil, dashboard_login_url: nil, webhook_endpoint_id: nil, env_vars: nil, docs_url: nil, sdks: nil, branding: nil, consent: nil)
|
|
353
|
-
@client.request_json("PATCH", "/v1/gate/services/#{CGI.escape(service_id)}", body: compact({
|
|
354
|
-
discoverable: discoverable,
|
|
355
|
-
name: name,
|
|
356
|
-
description: description,
|
|
357
|
-
website: website,
|
|
358
|
-
dashboard_login_url: dashboard_login_url,
|
|
359
|
-
webhook_endpoint_id: webhook_endpoint_id,
|
|
360
|
-
env_vars: env_vars,
|
|
361
|
-
docs_url: docs_url,
|
|
362
|
-
sdks: sdks,
|
|
363
|
-
branding: branding,
|
|
364
|
-
consent: consent
|
|
365
|
-
}))[:data]
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def disable(service_id)
|
|
369
|
-
@client.request_json("DELETE", "/v1/gate/services/#{CGI.escape(service_id)}")[:data]
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
private
|
|
373
|
-
|
|
374
|
-
def compact(hash)
|
|
375
|
-
hash.reject { |_key, value| value.nil? }
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
291
|
class WebhooksResource < BaseResource
|
|
380
292
|
def list_endpoints(organization_id)
|
|
381
293
|
payload = @client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/webhooks/endpoints")
|
|
@@ -419,66 +331,5 @@ module Foil
|
|
|
419
331
|
@client.request_json("GET", "/v1/organizations/#{CGI.escape(organization_id)}/events/#{CGI.escape(event_id)}")[:data]
|
|
420
332
|
end
|
|
421
333
|
end
|
|
422
|
-
|
|
423
|
-
class GateSessionsResource < BaseResource
|
|
424
|
-
def create(service_id:, account_name:, delivery:, metadata: nil)
|
|
425
|
-
body = {
|
|
426
|
-
service_id: service_id,
|
|
427
|
-
account_name: account_name,
|
|
428
|
-
delivery: delivery
|
|
429
|
-
}
|
|
430
|
-
body[:metadata] = metadata unless metadata.nil?
|
|
431
|
-
|
|
432
|
-
@client.request_json("POST", "/v1/gate/sessions", body: body, auth: { kind: :none })[:data]
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def poll(gate_session_id, poll_token:)
|
|
436
|
-
@client.request_json(
|
|
437
|
-
"GET",
|
|
438
|
-
"/v1/gate/sessions/#{CGI.escape(gate_session_id)}",
|
|
439
|
-
auth: { kind: :bearer, token: poll_token }
|
|
440
|
-
)[:data]
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
def acknowledge(gate_session_id, poll_token:, ack_token:)
|
|
444
|
-
@client.request_json(
|
|
445
|
-
"POST",
|
|
446
|
-
"/v1/gate/sessions/#{CGI.escape(gate_session_id)}/ack",
|
|
447
|
-
body: { ack_token: ack_token },
|
|
448
|
-
auth: { kind: :bearer, token: poll_token }
|
|
449
|
-
)[:data]
|
|
450
|
-
end
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
class GateLoginSessionsResource < BaseResource
|
|
454
|
-
def create(service_id:, agent_token:)
|
|
455
|
-
@client.request_json(
|
|
456
|
-
"POST",
|
|
457
|
-
"/v1/gate/login-sessions",
|
|
458
|
-
body: { service_id: service_id },
|
|
459
|
-
auth: { kind: :bearer, token: agent_token }
|
|
460
|
-
)[:data]
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
def consume(code:)
|
|
464
|
-
@client.request_json("POST", "/v1/gate/login-sessions/consume", body: { code: code })[:data]
|
|
465
|
-
end
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
class GateAgentTokensResource < BaseResource
|
|
469
|
-
def verify(agent_token:)
|
|
470
|
-
@client.request_json("POST", "/v1/gate/agent-tokens/verify", body: { agent_token: agent_token })[:data]
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
def revoke(agent_token:)
|
|
474
|
-
@client.request_json(
|
|
475
|
-
"POST",
|
|
476
|
-
"/v1/gate/agent-tokens/revoke",
|
|
477
|
-
body: { agent_token: agent_token },
|
|
478
|
-
expect_content: false
|
|
479
|
-
)
|
|
480
|
-
nil
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
334
|
end
|
|
484
335
|
end
|
data/lib/foil/server/version.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Foil
|
|
5
|
+
module Server
|
|
6
|
+
module Webhooks
|
|
7
|
+
WEBHOOK_EVENT_TYPES = %w[
|
|
8
|
+
session.result.persisted
|
|
9
|
+
webhook.test
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def verify_webhook_signature(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
15
|
+
return false if secret.to_s.empty?
|
|
16
|
+
|
|
17
|
+
parsed_timestamp = Integer(timestamp)
|
|
18
|
+
current = now_seconds || Time.now.to_i
|
|
19
|
+
return false if (current - parsed_timestamp).abs > max_age_seconds
|
|
20
|
+
|
|
21
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
22
|
+
secure_compare(expected, signature.to_s)
|
|
23
|
+
rescue ArgumentError, TypeError
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse_webhook_event(raw_body)
|
|
28
|
+
envelope = symbolize(JSON.parse(raw_body))
|
|
29
|
+
raise ArgumentError, "webhook event envelope must be an object" unless envelope.is_a?(Hash)
|
|
30
|
+
raise ArgumentError, "webhook event object must be webhook_event" unless envelope[:object] == "webhook_event"
|
|
31
|
+
raise ArgumentError, "webhook event id is required" if envelope[:id].to_s.empty?
|
|
32
|
+
raise ArgumentError, "webhook event type is required" if envelope[:type].to_s.empty?
|
|
33
|
+
raise ArgumentError, "unsupported webhook event type: #{envelope[:type]}" unless WEBHOOK_EVENT_TYPES.include?(envelope[:type])
|
|
34
|
+
raise ArgumentError, "webhook event created timestamp is required" if envelope[:created].to_s.empty?
|
|
35
|
+
raise ArgumentError, "webhook event data must be an object" unless envelope[:data].is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
envelope
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify_and_parse_webhook_event(secret:, timestamp:, raw_body:, signature:, max_age_seconds: 300, now_seconds: nil)
|
|
41
|
+
unless verify_webhook_signature(secret: secret, timestamp: timestamp, raw_body: raw_body, signature: signature, max_age_seconds: max_age_seconds, now_seconds: now_seconds)
|
|
42
|
+
raise ArgumentError, "Invalid Foil webhook signature"
|
|
43
|
+
end
|
|
44
|
+
parse_webhook_event(raw_body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def symbolize(value)
|
|
48
|
+
case value
|
|
49
|
+
when Array
|
|
50
|
+
value.map { |item| symbolize(item) }
|
|
51
|
+
when Hash
|
|
52
|
+
value.each_with_object({}) do |(key, item), memo|
|
|
53
|
+
memo[key.to_sym] = symbolize(item)
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :symbolize
|
|
60
|
+
|
|
61
|
+
def secure_compare(left, right)
|
|
62
|
+
return false unless left.bytesize == right.bytesize
|
|
63
|
+
|
|
64
|
+
result = 0
|
|
65
|
+
left.bytes.zip(right.bytes) { |a, b| result |= a ^ b }
|
|
66
|
+
result.zero?
|
|
67
|
+
end
|
|
68
|
+
private_class_method :secure_compare
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/foil/server.rb
CHANGED
|
@@ -3,7 +3,7 @@ require_relative "server/errors"
|
|
|
3
3
|
require_relative "server/crypto_support"
|
|
4
4
|
require_relative "server/types"
|
|
5
5
|
require_relative "server/sealed_token"
|
|
6
|
-
require_relative "server/
|
|
6
|
+
require_relative "server/webhooks"
|
|
7
7
|
require_relative "server/client"
|
|
8
8
|
|
|
9
9
|
module Foil
|
|
@@ -18,14 +18,16 @@ module Foil
|
|
|
18
18
|
SealedToken.safe_verify_foil_token(sealed_token, secret_key)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def
|
|
22
|
-
|
|
21
|
+
def verify_webhook_signature(**options)
|
|
22
|
+
Webhooks.verify_webhook_signature(**options)
|
|
23
|
+
end
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
def parse_webhook_event(raw_body)
|
|
26
|
+
Webhooks.parse_webhook_event(raw_body)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
+
def verify_and_parse_webhook_event(**options)
|
|
30
|
+
Webhooks.verify_and_parse_webhook_event(**options)
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
end
|
data/spec/README.md
CHANGED
|
@@ -6,7 +6,7 @@ It defines:
|
|
|
6
6
|
|
|
7
7
|
- the supported server API surface
|
|
8
8
|
- the shared sealed token verification behavior
|
|
9
|
-
- the shared
|
|
9
|
+
- the shared webhook signature verification behavior
|
|
10
10
|
- golden fixtures for success, error, pagination, and helper flows
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
@@ -17,11 +17,6 @@ Server SDKs include only customer-facing APIs:
|
|
|
17
17
|
- `/v1/fingerprints`
|
|
18
18
|
- `/v1/organizations`
|
|
19
19
|
- `/v1/organizations/:organizationId/api-keys`
|
|
20
|
-
- `/v1/gate/registry`
|
|
21
|
-
- `/v1/gate/services`
|
|
22
|
-
- `/v1/gate/sessions`
|
|
23
|
-
- `/v1/gate/login-sessions`
|
|
24
|
-
- `/v1/gate/agent-tokens/*`
|
|
25
20
|
|
|
26
21
|
Server SDKs do **not** include:
|
|
27
22
|
|
|
@@ -53,24 +48,13 @@ Every server SDK should expose these top-level capabilities:
|
|
|
53
48
|
- list
|
|
54
49
|
- revoke
|
|
55
50
|
- rotate
|
|
56
|
-
- Gate
|
|
57
|
-
- registry list/get
|
|
58
|
-
- services list/get/create/update/disable
|
|
59
|
-
- sessions create/poll/acknowledge
|
|
60
|
-
- login sessions create/consume
|
|
61
|
-
- agent tokens verify/revoke
|
|
62
51
|
- sealed token helpers
|
|
63
52
|
- strict verify
|
|
64
53
|
- safe verify
|
|
65
|
-
-
|
|
66
|
-
- generate delivery keypair
|
|
67
|
-
- import/export delivery private key
|
|
68
|
-
- validate delivery request
|
|
69
|
-
- encrypt/decrypt delivery envelopes
|
|
70
|
-
- validate approved webhook payload
|
|
54
|
+
- webhook helpers
|
|
71
55
|
- verify webhook signature
|
|
72
|
-
-
|
|
73
|
-
-
|
|
56
|
+
- parse webhook event
|
|
57
|
+
- verify and parse webhook event
|
|
74
58
|
|
|
75
59
|
## Shared Defaults
|
|
76
60
|
|
|
@@ -78,7 +62,6 @@ Every SDK should default to:
|
|
|
78
62
|
|
|
79
63
|
- `base_url = https://api.usefoil.com`
|
|
80
64
|
- `secret_key = env(FOIL_SECRET_KEY)`
|
|
81
|
-
- support for public, bearer-token, and secret-key auth as required by the Gate surface
|
|
82
65
|
- request timeout support
|
|
83
66
|
- no automatic retries
|
|
84
67
|
|
|
@@ -120,15 +103,17 @@ Use both:
|
|
|
120
103
|
|
|
121
104
|
to validate correctness and failure behavior.
|
|
122
105
|
|
|
123
|
-
##
|
|
106
|
+
## Webhook Signature Verification
|
|
124
107
|
|
|
125
|
-
|
|
108
|
+
Every Foil webhook delivery carries `X-Foil-Timestamp` and `X-Foil-Signature`. The signature is the lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed with the endpoint's signing secret. SDK helpers must:
|
|
126
109
|
|
|
127
|
-
-
|
|
128
|
-
-
|
|
129
|
-
-
|
|
130
|
-
-
|
|
131
|
-
-
|
|
110
|
+
- compute the HMAC over the raw request body bytes
|
|
111
|
+
- compare signatures in constant time
|
|
112
|
+
- reject a timestamp more than 300 seconds from the current time by default
|
|
113
|
+
- reject an empty signing secret
|
|
114
|
+
- parse only the event types in the spec's `WebhookDeliveryEventType` schema and reject any other type
|
|
115
|
+
|
|
116
|
+
Use `fixtures/webhooks/signature.json` to validate the valid, tampered, expired and malformed cases.
|
|
132
117
|
|
|
133
118
|
## Sync Model
|
|
134
119
|
|
|
@@ -156,5 +141,6 @@ When changing any server SDK:
|
|
|
156
141
|
- `next_cursor`
|
|
157
142
|
- preserve structured API errors
|
|
158
143
|
- keep sealed token golden-vector coverage
|
|
144
|
+
- keep webhook signature fixture coverage
|
|
159
145
|
- keep one live smoke suite per SDK
|
|
160
146
|
- only update the vendored SDK `spec/` copies or the monorepo submodule pointer after the relevant CI is green
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"id": "sid_0123456789abcdefghjkmnpqrs",
|
|
5
5
|
"created_at": "2026-03-24T20:00:00.000Z",
|
|
6
6
|
"client_user_id": "user_123",
|
|
7
|
+
"behavior": null,
|
|
7
8
|
"decision": {
|
|
8
9
|
"event_id": "evt_23456789abcdefghjkmnpqrstv",
|
|
9
10
|
"automation_status": "automated",
|
|
@@ -198,6 +199,10 @@
|
|
|
198
199
|
"id": "vid_456789abcdefghjkmnpqrstvwx",
|
|
199
200
|
"confidence": 93,
|
|
200
201
|
"identified_at": "2026-03-24T20:00:01.000Z",
|
|
202
|
+
"resolution": {
|
|
203
|
+
"method": "continuity",
|
|
204
|
+
"basis": "deterministic"
|
|
205
|
+
},
|
|
201
206
|
"lifecycle": {
|
|
202
207
|
"first_seen_at": "2026-03-01T18:22:11.000Z",
|
|
203
208
|
"last_seen_at": "2026-03-24T20:00:05.000Z",
|
|
@@ -257,7 +262,8 @@
|
|
|
257
262
|
"name": "Chromium",
|
|
258
263
|
"version": "123.0.0.0",
|
|
259
264
|
"major_version": "123",
|
|
260
|
-
"engine": "blink"
|
|
265
|
+
"engine": "blink",
|
|
266
|
+
"app_name": null
|
|
261
267
|
},
|
|
262
268
|
"device": {
|
|
263
269
|
"form_factor": "desktop",
|
|
@@ -347,6 +353,7 @@
|
|
|
347
353
|
],
|
|
348
354
|
"client_telemetry": {
|
|
349
355
|
"navigator": {
|
|
356
|
+
"model": null,
|
|
350
357
|
"platform": "macOS",
|
|
351
358
|
"vendor": "Google Inc.",
|
|
352
359
|
"hardware_concurrency": null,
|