mobiscroll-connect 1.5.1 → 1.6.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/CHANGELOG.md +9 -1
- data/README.md +38 -1
- data/lib/mobiscroll/connect/client.rb +3 -2
- data/lib/mobiscroll/connect/config.rb +5 -2
- data/lib/mobiscroll/connect/errors.rb +13 -0
- data/lib/mobiscroll/connect/models.rb +46 -0
- data/lib/mobiscroll/connect/resources/webhooks.rb +61 -1
- data/lib/mobiscroll/connect/version.rb +1 -1
- data/lib/mobiscroll/connect/webhook_verification.rb +250 -0
- data/lib/mobiscroll/connect.rb +1 -0
- data/mobiscroll-connect.gemspec +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfda16db0b33649065d21a3df62ae7f3b6962adaa0e57fd11944b5e0edce780f
|
|
4
|
+
data.tar.gz: 476dd4536d5bae00d3e169d26c30bb98ac3ab24aa82fe4b4b65a47b55cb57bea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6ffcf4cddf3cfafe432cb53bff0ebda0b0f8af4fc9d113e18fd5540cddc3328fe32d40d47b5ce8922cb32e4e758198e24a804c939e4676aa842991ebe98334e5
|
|
7
|
+
data.tar.gz: 35f100a37e7fdc727c8bf824726c907100286d41b4738e7aa78fe2d4f9e9fe05bdcdeef296f3898ecbe14193842957132e1bc2ccddcb8ab86448015ff49a9232
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Mobiscroll Connect Ruby SDK (RubyGems: `mobiscroll-connect`) are documented in this file.
|
|
4
4
|
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this SDK
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this SDK's major.minor always matches the Connect API's (for example API 1.5.x → SDK 1.5.x): each release bumps only the patch, including releases with breaking changes. Patch numbers are independent per SDK, so this file covers the Ruby SDK only.
|
|
6
|
+
|
|
7
|
+
## [1.6.0] — 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `webhooks.verify_webhook` checks a delivery's Ed25519 signature and timestamp and returns the parsed `WebhookDelivery`, or raises `WebhookVerificationError`. It fetches and caches the public keys from `/.well-known/webhook-keys` and re-fetches them once before rejecting, so key rotations need no action.
|
|
12
|
+
- `webhook_public_key` client option: a pinned key, used only when the keys endpoint cannot be reached.
|
|
13
|
+
- `Mobiscroll::Connect.verify_webhook_signature` for verifying against keys you supply, with no fetching.
|
|
6
14
|
|
|
7
15
|
## [1.5.1] — 2026-09-22
|
|
8
16
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Ruby client for [Mobiscroll Connect](https://mobiscroll.com/connect), the calend
|
|
|
4
4
|
|
|
5
5
|
[](https://rubygems.org/gems/mobiscroll-connect)
|
|
6
6
|
|
|
7
|
-
**[RubyGems](https://rubygems.org/gems/mobiscroll-connect)** · **[Documentation](https://mobiscroll.com/docs/connect/ruby-sdk)** · **[Changelog](CHANGELOG.md)** · **[Source](https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby)**
|
|
7
|
+
**[RubyGems](https://rubygems.org/gems/mobiscroll-connect)** · **[Documentation](https://mobiscroll.com/docs/connect/ruby-sdk)** · **[Changelog](https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md)** · **[Source](https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby)**
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -98,6 +98,8 @@ The SDK automatically refreshes expired access tokens. When a request returns 40
|
|
|
98
98
|
|
|
99
99
|
Concurrent 401s share a single in-flight refresh — only one `POST /oauth/token` is ever issued per `Client` instance at a time.
|
|
100
100
|
|
|
101
|
+
**Running more than one instance.** The single in-flight refresh above is per `Client` instance. The SDK does not coordinate across processes — separate containers, cluster workers or serverless invocations each refresh from the tokens they hold in memory. Connect accepts concurrent refreshes of the same token, but refreshing with a copy that is two or more refreshes out of date revokes the user's authorization. Persist refreshed tokens to storage every instance reads, and call `client.set_credentials(...)` with the current tokens when a process starts a job or handles a request. See [Refreshing from several instances](https://mobiscroll.com/docs/connect/api/oauth#concurrent-refresh).
|
|
102
|
+
|
|
101
103
|
To persist refreshed tokens (e.g., back to a session or database):
|
|
102
104
|
|
|
103
105
|
```ruby
|
|
@@ -197,6 +199,40 @@ client.webhooks.unsubscribe_webhook(
|
|
|
197
199
|
)
|
|
198
200
|
```
|
|
199
201
|
|
|
202
|
+
### Verify webhook deliveries
|
|
203
|
+
|
|
204
|
+
Every delivery to your webhook URL is signed. `verify_webhook` checks the signature and the timestamp, then returns the parsed `WebhookDelivery`, or raises `WebhookVerificationError`. Pass the **raw** request body: a JSON body parser that runs first changes the bytes and every check fails.
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
post '/webhooks/mobiscroll' do
|
|
208
|
+
begin
|
|
209
|
+
delivery = client.webhooks.verify_webhook(request.body.read, request.env)
|
|
210
|
+
rescue Mobiscroll::Connect::WebhookVerificationError => e
|
|
211
|
+
# 503 lets Connect retry when the keys could not be loaded; 401 is final.
|
|
212
|
+
halt(e.reason == 'no_public_keys' ? 503 : 401)
|
|
213
|
+
end
|
|
214
|
+
handle_delivery(delivery)
|
|
215
|
+
status 204
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`headers` can be a Hash with any key casing, a Rack env (`HTTP_WEBHOOK_ID` keys), or Rails' `request.headers` (pass `request.raw_post` as the body in Rails).
|
|
220
|
+
|
|
221
|
+
The public keys are fetched from `https://connect.mobiscroll.com/.well-known/webhook-keys` on the first delivery and cached for the whole process as the endpoint's `Cache-Control` allows. If no signature matches, the keys are fetched again (at most once a minute) before the delivery is rejected, so key rotations need no action on your side.
|
|
222
|
+
|
|
223
|
+
If your handler cannot make outbound requests, pin the key. `webhook_public_key` is used only when the keys endpoint cannot be reached; it stops working when Mobiscroll retires that key, so you must replace it on every rotation.
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
client = Mobiscroll::Connect::Client.new(
|
|
227
|
+
client_id: ENV['MOBISCROLL_CLIENT_ID'],
|
|
228
|
+
client_secret: ENV['MOBISCROLL_CLIENT_SECRET'],
|
|
229
|
+
redirect_uri: ENV['MOBISCROLL_REDIRECT_URI'],
|
|
230
|
+
webhook_public_key: ENV['MOBISCROLL_WEBHOOK_PUBLIC_KEY'] # whpk_...
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
To check against keys you supply, with no fetching, call `Mobiscroll::Connect.verify_webhook_signature(raw_body, headers, ['whpk_...'])`. It raises `WebhookVerificationError` and returns `nil`. See [Verifying deliveries](https://mobiscroll.com/docs/connect/api/webhooks#verifying-deliveries).
|
|
235
|
+
|
|
200
236
|
## Error handling
|
|
201
237
|
|
|
202
238
|
All errors are subclasses of `Mobiscroll::Connect::Error`:
|
|
@@ -229,6 +265,7 @@ end
|
|
|
229
265
|
| `RateLimitError` | 429 | `retry_after` (seconds) |
|
|
230
266
|
| `ServerError` | 5xx | `status_code` |
|
|
231
267
|
| `NetworkError` | transport | `cause` |
|
|
268
|
+
| `WebhookVerificationError` | — (from `verify_webhook`) | `reason` |
|
|
232
269
|
|
|
233
270
|
## Minimal demo app
|
|
234
271
|
|
|
@@ -7,14 +7,15 @@ module Mobiscroll
|
|
|
7
7
|
|
|
8
8
|
def initialize(client_id:, client_secret:, redirect_uri:,
|
|
9
9
|
base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
10
|
-
on_tokens_refreshed: nil)
|
|
10
|
+
on_tokens_refreshed: nil, webhook_public_key: nil)
|
|
11
11
|
@config = Config.new(
|
|
12
12
|
client_id: client_id,
|
|
13
13
|
client_secret: client_secret,
|
|
14
14
|
redirect_uri: redirect_uri,
|
|
15
15
|
base_url: base_url,
|
|
16
16
|
timeout: timeout,
|
|
17
|
-
on_tokens_refreshed: on_tokens_refreshed
|
|
17
|
+
on_tokens_refreshed: on_tokens_refreshed,
|
|
18
|
+
webhook_public_key: webhook_public_key
|
|
18
19
|
)
|
|
19
20
|
@api_client = ApiClient.new(@config)
|
|
20
21
|
@auth = Resources::Auth.new(@config, @api_client)
|
|
@@ -6,12 +6,14 @@ module Mobiscroll
|
|
|
6
6
|
DEFAULT_TIMEOUT = 30
|
|
7
7
|
|
|
8
8
|
class Config
|
|
9
|
-
|
|
9
|
+
# `webhook_public_key` is a pinned `whpk_` key, used by Webhooks#verify_webhook only when
|
|
10
|
+
# the keys endpoint cannot be reached; it stops verifying once Mobiscroll retires that key.
|
|
11
|
+
attr_reader :client_id, :client_secret, :redirect_uri, :base_url, :timeout, :webhook_public_key
|
|
10
12
|
attr_accessor :on_tokens_refreshed
|
|
11
13
|
|
|
12
14
|
def initialize(client_id:, client_secret:, redirect_uri:,
|
|
13
15
|
base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
14
|
-
on_tokens_refreshed: nil)
|
|
16
|
+
on_tokens_refreshed: nil, webhook_public_key: nil)
|
|
15
17
|
raise Error, 'client_id is required' if client_id.nil? || client_id.empty?
|
|
16
18
|
raise Error, 'client_secret is required' if client_secret.nil? || client_secret.empty?
|
|
17
19
|
raise Error, 'redirect_uri is required' if redirect_uri.nil? || redirect_uri.empty?
|
|
@@ -22,6 +24,7 @@ module Mobiscroll
|
|
|
22
24
|
@base_url = base_url
|
|
23
25
|
@timeout = timeout
|
|
24
26
|
@on_tokens_refreshed = on_tokens_refreshed
|
|
27
|
+
@webhook_public_key = webhook_public_key
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -78,6 +78,19 @@ module Mobiscroll
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Raised when a webhook delivery fails verification. Respond with a 4xx and do not process it.
|
|
82
|
+
#
|
|
83
|
+
# +reason+ is one of 'missing_headers', 'invalid_timestamp', 'timestamp_out_of_tolerance',
|
|
84
|
+
# 'no_public_keys', 'no_matching_signature' or 'invalid_payload'.
|
|
85
|
+
class WebhookVerificationError < Error
|
|
86
|
+
attr_reader :reason
|
|
87
|
+
|
|
88
|
+
def initialize(message = 'Webhook verification failed', reason: nil)
|
|
89
|
+
super(message, code: 'WEBHOOK_VERIFICATION_ERROR')
|
|
90
|
+
@reason = reason
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
81
94
|
# Maps an HTTP response (status + body hash + headers) to the matching
|
|
82
95
|
# typed error. Returns nil for 2xx responses.
|
|
83
96
|
def self.map_response_error(status, body, headers)
|
|
@@ -242,6 +242,52 @@ module Mobiscroll
|
|
|
242
242
|
end
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
+
# A changed event in a webhook delivery: every CalendarEvent field plus `change_type`
|
|
246
|
+
# ('created', 'updated' or 'deleted').
|
|
247
|
+
WebhookEvent = Struct.new(*CalendarEvent.members, :change_type, keyword_init: true) do
|
|
248
|
+
def self.from_h(hash)
|
|
249
|
+
return nil if hash.nil?
|
|
250
|
+
|
|
251
|
+
new(**CalendarEvent.from_h(hash).to_h, change_type: hash['changeType'] || hash[:changeType])
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# `metadata` of a webhook delivery.
|
|
256
|
+
WebhookDeliveryMetadata = Struct.new(:channel_id, :event_count, :is_initial_sync, keyword_init: true) do
|
|
257
|
+
def self.from_h(hash)
|
|
258
|
+
return nil if hash.nil?
|
|
259
|
+
|
|
260
|
+
key = 'isInitialSync'
|
|
261
|
+
new(
|
|
262
|
+
channel_id: hash['channelId'] || hash[:channelId],
|
|
263
|
+
event_count: hash['eventCount'] || hash[:eventCount],
|
|
264
|
+
is_initial_sync: hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A verified webhook delivery, returned by Webhooks#verify_webhook. `change_type` is
|
|
270
|
+
# 'created', 'updated', 'deleted' or 'mixed'; `timestamp` is the ISO 8601 time Connect
|
|
271
|
+
# processed the change.
|
|
272
|
+
WebhookDelivery = Struct.new(
|
|
273
|
+
:provider, :user_id, :calendar_id, :events, :change_type, :timestamp, :metadata, keyword_init: true
|
|
274
|
+
) do
|
|
275
|
+
def self.from_h(hash)
|
|
276
|
+
return nil if hash.nil?
|
|
277
|
+
|
|
278
|
+
metadata = hash['metadata'] || hash[:metadata]
|
|
279
|
+
new(
|
|
280
|
+
provider: hash['provider'] || hash[:provider],
|
|
281
|
+
user_id: hash['userId'] || hash[:userId],
|
|
282
|
+
calendar_id: hash['calendarId'] || hash[:calendarId],
|
|
283
|
+
events: Array(hash['events'] || hash[:events]).filter_map { |e| WebhookEvent.from_h(e) if e.is_a?(Hash) },
|
|
284
|
+
change_type: hash['changeType'] || hash[:changeType],
|
|
285
|
+
timestamp: hash['timestamp'] || hash[:timestamp],
|
|
286
|
+
metadata: metadata.is_a?(Hash) ? WebhookDeliveryMetadata.from_h(metadata) : nil
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
245
291
|
# Response from Webhooks#unsubscribe_webhook. `success` is true whenever the local
|
|
246
292
|
# mapping was removed, even if the provider-side unsubscribe itself failed — `message`
|
|
247
293
|
# explains what happened in that case.
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'uri'
|
|
4
5
|
|
|
5
6
|
module Mobiscroll
|
|
6
7
|
module Connect
|
|
7
8
|
module Resources
|
|
8
9
|
class Webhooks
|
|
9
|
-
|
|
10
|
+
RETRYABLE_REASONS = %w[no_matching_signature no_public_keys].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(config, api_client)
|
|
13
|
+
@config = config
|
|
10
14
|
@api_client = api_client
|
|
11
15
|
end
|
|
12
16
|
|
|
@@ -36,6 +40,62 @@ module Mobiscroll
|
|
|
36
40
|
parsed = @api_client.post('/unsubscribe-webhook', body: JSON.generate(body))
|
|
37
41
|
UnsubscribeWebhookResponse.from_h(parsed)
|
|
38
42
|
end
|
|
43
|
+
|
|
44
|
+
# Verifies that a webhook delivery came from Mobiscroll Connect, and parses it.
|
|
45
|
+
#
|
|
46
|
+
# Fetches the public keys from `/.well-known/webhook-keys` on first use and caches them for
|
|
47
|
+
# the whole process, refreshing them as the endpoint's `Cache-Control` allows. When no
|
|
48
|
+
# signature matches, it re-fetches the keys once (at most once a minute) before rejecting,
|
|
49
|
+
# so a key rotation never rejects genuine deliveries. `webhook_public_key` from the config
|
|
50
|
+
# is used only when the endpoint cannot be reached.
|
|
51
|
+
#
|
|
52
|
+
# `payload` is the raw request body String, exactly as received; not a parsed Hash.
|
|
53
|
+
# `headers` is a Hash (any key casing) or a Rack env / Rails `request.headers`.
|
|
54
|
+
#
|
|
55
|
+
# Returns a WebhookDelivery. Raises WebhookVerificationError when the delivery is not
|
|
56
|
+
# genuine; respond with a 4xx.
|
|
57
|
+
#
|
|
58
|
+
# post '/webhooks/mobiscroll' do
|
|
59
|
+
# delivery = client.webhooks.verify_webhook(request.body.read, request.env)
|
|
60
|
+
# handle_delivery(delivery)
|
|
61
|
+
# status 204
|
|
62
|
+
# rescue Mobiscroll::Connect::WebhookVerificationError
|
|
63
|
+
# halt 401
|
|
64
|
+
# end
|
|
65
|
+
def verify_webhook(payload, headers)
|
|
66
|
+
store = WebhookKeyStore.for_url(URI.join(@config.base_url, WEBHOOK_KEYS_PATH).to_s)
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
Connect.verify_webhook_signature(payload, headers, keys_or_pinned(store.keys))
|
|
70
|
+
rescue WebhookVerificationError => e
|
|
71
|
+
raise unless RETRYABLE_REASONS.include?(e.reason) && store.can_refetch?
|
|
72
|
+
|
|
73
|
+
store.refresh
|
|
74
|
+
Connect.verify_webhook_signature(payload, headers, keys_or_pinned(store.keys))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
parse_delivery(payload)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The pinned key is never merged with fetched keys: after an emergency rotation a pinned
|
|
83
|
+
# retired key must stop verifying.
|
|
84
|
+
def keys_or_pinned(keys)
|
|
85
|
+
pinned = @config.webhook_public_key
|
|
86
|
+
keys.empty? && pinned && !pinned.empty? ? [pinned] : keys
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_delivery(payload)
|
|
90
|
+
parsed = begin
|
|
91
|
+
JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
|
|
92
|
+
rescue JSON::ParserError, EncodingError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
return WebhookDelivery.from_h(parsed) if parsed.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
raise WebhookVerificationError.new('Webhook payload is not a JSON object', reason: 'invalid_payload')
|
|
98
|
+
end
|
|
39
99
|
end
|
|
40
100
|
end
|
|
41
101
|
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
module Mobiscroll
|
|
9
|
+
module Connect
|
|
10
|
+
WEBHOOK_TOLERANCE_SECONDS = 300
|
|
11
|
+
WEBHOOK_KEYS_PATH = '/.well-known/webhook-keys'
|
|
12
|
+
|
|
13
|
+
# Verifies the Standard Webhooks `v1a` (Ed25519) signature of a Mobiscroll Connect delivery
|
|
14
|
+
# against the given public keys, without fetching anything.
|
|
15
|
+
#
|
|
16
|
+
# Use it with a pinned key in handlers that cannot make outbound requests. Otherwise prefer
|
|
17
|
+
# `client.webhooks.verify_webhook`, which fetches and refreshes the keys for you.
|
|
18
|
+
#
|
|
19
|
+
# `payload` is the raw request body String, exactly as received. `headers` is a Hash (any
|
|
20
|
+
# key casing, Array values take the first) or a Rack env / Rails `request.headers`, where
|
|
21
|
+
# `HTTP_WEBHOOK_ID`-style keys are matched too. `public_keys` are `whpk_` keys; the delivery
|
|
22
|
+
# is genuine if any signature verifies against any key. `now` is the current Unix time in
|
|
23
|
+
# seconds, defaulting to the system clock.
|
|
24
|
+
#
|
|
25
|
+
# Returns nil. Raises WebhookVerificationError when the delivery is not genuine or cannot
|
|
26
|
+
# be checked.
|
|
27
|
+
#
|
|
28
|
+
# Mobiscroll::Connect.verify_webhook_signature(
|
|
29
|
+
# request.body.read, request.env, [ENV.fetch('MOBISCROLL_WEBHOOK_PUBLIC_KEY')]
|
|
30
|
+
# )
|
|
31
|
+
def self.verify_webhook_signature(payload, headers, public_keys,
|
|
32
|
+
tolerance_seconds: WEBHOOK_TOLERANCE_SECONDS, now: nil)
|
|
33
|
+
WebhookSignature.verify(payload, headers, public_keys, tolerance_seconds: tolerance_seconds, now: now)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @api private
|
|
38
|
+
module WebhookSignature
|
|
39
|
+
PUBLIC_KEY_PREFIX = 'whpk_'
|
|
40
|
+
SIGNATURE_VERSION = 'v1a'
|
|
41
|
+
# DER SubjectPublicKeyInfo header for an Ed25519 key; `OpenSSL::PKey.new_raw_public_key`
|
|
42
|
+
# needs openssl gem 3.2+, which Ruby 3.2 does not bundle.
|
|
43
|
+
ED25519_SPKI_PREFIX = ['302a300506032b6570032100'].pack('H*').freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def verify(payload, headers, public_keys, tolerance_seconds:, now:)
|
|
48
|
+
unless payload.is_a?(String)
|
|
49
|
+
raise WebhookVerificationError.new(
|
|
50
|
+
'Pass the raw request body as a String, not a parsed object', reason: 'invalid_payload'
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
id = read_header(headers, 'webhook-id')
|
|
55
|
+
timestamp = read_header(headers, 'webhook-timestamp')
|
|
56
|
+
signature_header = read_header(headers, 'webhook-signature')
|
|
57
|
+
if [id, timestamp, signature_header].any? { |value| value.nil? || value.empty? }
|
|
58
|
+
raise WebhookVerificationError.new(
|
|
59
|
+
'Missing webhook-id, webhook-timestamp or webhook-signature header', reason: 'missing_headers'
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
check_timestamp(timestamp, tolerance_seconds, now || Time.now.to_i)
|
|
64
|
+
|
|
65
|
+
keys = Array(public_keys).filter_map { |key| parse_public_key(key) }
|
|
66
|
+
if keys.empty?
|
|
67
|
+
raise WebhookVerificationError.new('No valid webhook public keys available', reason: 'no_public_keys')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
signed_content = "#{id}.#{timestamp}.".b << payload.b
|
|
71
|
+
return if signature_header.split(/ /).any? { |entry| entry_matches?(entry, signed_content, keys) }
|
|
72
|
+
|
|
73
|
+
raise WebhookVerificationError.new('No webhook signature matched', reason: 'no_matching_signature')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def check_timestamp(timestamp, tolerance_seconds, now)
|
|
77
|
+
unless timestamp.match?(/\A\d+\z/)
|
|
78
|
+
raise WebhookVerificationError.new('Invalid webhook-timestamp header', reason: 'invalid_timestamp')
|
|
79
|
+
end
|
|
80
|
+
return if (now - timestamp.to_i).abs <= tolerance_seconds
|
|
81
|
+
|
|
82
|
+
raise WebhookVerificationError.new(
|
|
83
|
+
'Webhook timestamp is outside the allowed tolerance', reason: 'timestamp_out_of_tolerance'
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read_header(headers, name)
|
|
88
|
+
return nil unless headers.respond_to?(:each)
|
|
89
|
+
|
|
90
|
+
rack_name = "HTTP_#{name.upcase.tr('-', '_')}"
|
|
91
|
+
headers.each do |key, value|
|
|
92
|
+
next unless key.to_s.casecmp?(name) || key.to_s.casecmp?(rack_name)
|
|
93
|
+
|
|
94
|
+
value = value.first if value.is_a?(Array)
|
|
95
|
+
return value&.to_s&.b
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_public_key(value)
|
|
101
|
+
return nil unless value.is_a?(String)
|
|
102
|
+
|
|
103
|
+
raw = Base64.decode64(value.strip.delete_prefix(PUBLIC_KEY_PREFIX))
|
|
104
|
+
return nil unless raw.bytesize == 32
|
|
105
|
+
|
|
106
|
+
OpenSSL::PKey.read(ED25519_SPKI_PREFIX + raw)
|
|
107
|
+
rescue OpenSSL::PKey::PKeyError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def entry_matches?(entry, signed_content, keys)
|
|
112
|
+
version, separator, encoded = entry.partition(',')
|
|
113
|
+
return false if separator.empty? || version != SIGNATURE_VERSION
|
|
114
|
+
|
|
115
|
+
signature = Base64.decode64(encoded)
|
|
116
|
+
return false unless signature.bytesize == 64
|
|
117
|
+
|
|
118
|
+
keys.any? do |key|
|
|
119
|
+
key.verify(nil, signature, signed_content)
|
|
120
|
+
rescue OpenSSL::PKey::PKeyError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
private_constant :WebhookSignature
|
|
126
|
+
|
|
127
|
+
# Process-wide cache of the keys published at one keys URL, shared by every client instance.
|
|
128
|
+
# Follows the endpoint's `Cache-Control`, re-fetches at most once a minute, and keeps the last
|
|
129
|
+
# good keys when a fetch fails. Concurrent callers share one in-flight fetch.
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
class WebhookKeyStore
|
|
133
|
+
DEFAULT_MAX_AGE = 60 * 60
|
|
134
|
+
MAX_MAX_AGE = 24 * 60 * 60
|
|
135
|
+
MIN_REFETCH_INTERVAL = 60
|
|
136
|
+
FETCH_TIMEOUT = 10
|
|
137
|
+
|
|
138
|
+
@stores = {}
|
|
139
|
+
@stores_lock = Mutex.new
|
|
140
|
+
|
|
141
|
+
class << self
|
|
142
|
+
def for_url(url)
|
|
143
|
+
@stores_lock.synchronize { @stores[url] ||= new(url) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Clears every cached key set; for tests.
|
|
147
|
+
def reset!
|
|
148
|
+
@stores_lock.synchronize { @stores.clear }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def initialize(url)
|
|
153
|
+
@url = url
|
|
154
|
+
@keys = [].freeze
|
|
155
|
+
@fetched_at = nil
|
|
156
|
+
@last_attempt_at = nil
|
|
157
|
+
@max_age = DEFAULT_MAX_AGE
|
|
158
|
+
@in_flight = false
|
|
159
|
+
@lock = Mutex.new
|
|
160
|
+
@cond = ConditionVariable.new
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The current keys, fetching them first when they are missing or stale and a fetch is allowed.
|
|
164
|
+
def keys
|
|
165
|
+
fetch_if { |now| stale?(now) && can_refetch_at?(now) }
|
|
166
|
+
@lock.synchronize { @keys }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def can_refetch?
|
|
170
|
+
@lock.synchronize { can_refetch_at?(Time.now.to_f) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Fetches the keys unless a fetch was attempted in the last minute; joins one in flight.
|
|
174
|
+
def refresh
|
|
175
|
+
fetch_if { |now| can_refetch_at?(now) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def stale?(now)
|
|
181
|
+
@fetched_at.nil? || now - @fetched_at > @max_age
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def can_refetch_at?(now)
|
|
185
|
+
@last_attempt_at.nil? || now - @last_attempt_at >= MIN_REFETCH_INTERVAL
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Starts a fetch when the block allows it, or waits for the one already in flight; the
|
|
189
|
+
# decision and the in-flight flag are taken under one lock so callers never fetch twice.
|
|
190
|
+
def fetch_if
|
|
191
|
+
@lock.synchronize do
|
|
192
|
+
if @in_flight
|
|
193
|
+
@cond.wait(@lock) while @in_flight
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
now = Time.now.to_f
|
|
197
|
+
return unless yield(now)
|
|
198
|
+
|
|
199
|
+
@in_flight = true
|
|
200
|
+
@last_attempt_at = now
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
result = nil
|
|
204
|
+
begin
|
|
205
|
+
result = fetch_keys
|
|
206
|
+
ensure
|
|
207
|
+
@lock.synchronize do
|
|
208
|
+
if result
|
|
209
|
+
@keys, @max_age = result
|
|
210
|
+
@fetched_at = Time.now.to_f
|
|
211
|
+
end
|
|
212
|
+
@in_flight = false
|
|
213
|
+
@cond.broadcast
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Returns [keys, max_age], or nil when the fetch fails or yields no usable key. Uses a plain
|
|
219
|
+
# connection: the keys endpoint is public and must never go through the token-refresh path.
|
|
220
|
+
def fetch_keys
|
|
221
|
+
response = Faraday.new(request: { timeout: FETCH_TIMEOUT, open_timeout: FETCH_TIMEOUT }).get(@url) do |req|
|
|
222
|
+
req.headers['Accept'] = 'application/json'
|
|
223
|
+
end
|
|
224
|
+
return nil unless response.success?
|
|
225
|
+
|
|
226
|
+
parsed = JSON.parse(response.body.to_s)
|
|
227
|
+
keys = Array(parsed.is_a?(Hash) ? parsed['keys'] : nil).filter_map { |entry| usable_key(entry) }
|
|
228
|
+
keys.empty? ? nil : [keys.freeze, parse_max_age(response.headers['cache-control'])]
|
|
229
|
+
rescue StandardError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def usable_key(entry)
|
|
234
|
+
return nil unless entry.is_a?(Hash)
|
|
235
|
+
|
|
236
|
+
key = entry['key']
|
|
237
|
+
alg = entry['alg'].to_s
|
|
238
|
+
return nil unless key.is_a?(String) && key.start_with?(WebhookSignature::PUBLIC_KEY_PREFIX)
|
|
239
|
+
return nil unless alg.empty? || alg.casecmp?('ed25519')
|
|
240
|
+
|
|
241
|
+
key
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_max_age(cache_control)
|
|
245
|
+
match = /max-age=(\d+)/i.match(cache_control.to_s)
|
|
246
|
+
match ? [match[1].to_i, MAX_MAX_AGE].min : DEFAULT_MAX_AGE
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/lib/mobiscroll/connect.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'connect/provider'
|
|
|
6
6
|
require_relative 'connect/config'
|
|
7
7
|
require_relative 'connect/models'
|
|
8
8
|
require_relative 'connect/api_client'
|
|
9
|
+
require_relative 'connect/webhook_verification'
|
|
9
10
|
require_relative 'connect/resources/auth'
|
|
10
11
|
require_relative 'connect/resources/calendars'
|
|
11
12
|
require_relative 'connect/resources/events'
|
data/mobiscroll-connect.gemspec
CHANGED
|
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
|
|
23
23
|
spec.metadata = {
|
|
24
24
|
'homepage_uri' => spec.homepage,
|
|
25
|
-
'source_code_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks',
|
|
25
|
+
'source_code_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby',
|
|
26
26
|
'documentation_uri' => 'https://mobiscroll.com/docs/connect/ruby-sdk',
|
|
27
27
|
'bug_tracker_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/issues',
|
|
28
28
|
'changelog_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md',
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mobiscroll-connect
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mobiscroll
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -106,13 +106,14 @@ files:
|
|
|
106
106
|
- lib/mobiscroll/connect/resources/events.rb
|
|
107
107
|
- lib/mobiscroll/connect/resources/webhooks.rb
|
|
108
108
|
- lib/mobiscroll/connect/version.rb
|
|
109
|
+
- lib/mobiscroll/connect/webhook_verification.rb
|
|
109
110
|
- mobiscroll-connect.gemspec
|
|
110
111
|
homepage: https://mobiscroll.com/connect
|
|
111
112
|
licenses:
|
|
112
113
|
- MIT
|
|
113
114
|
metadata:
|
|
114
115
|
homepage_uri: https://mobiscroll.com/connect
|
|
115
|
-
source_code_uri: https://github.com/acidb/mobiscroll-connect-sdks
|
|
116
|
+
source_code_uri: https://github.com/acidb/mobiscroll-connect-sdks/tree/main/sdks/ruby
|
|
116
117
|
documentation_uri: https://mobiscroll.com/docs/connect/ruby-sdk
|
|
117
118
|
bug_tracker_uri: https://github.com/acidb/mobiscroll-connect-sdks/issues
|
|
118
119
|
changelog_uri: https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md
|