licensechain_ruby_sdk 1.0.1
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/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +60 -0
- data/.github/workflows/publish-rubygems.yml +24 -0
- data/.gitignore +96 -0
- data/CHANGELOG.md +26 -0
- data/Gemfile +16 -0
- data/LICENSE +95 -0
- data/README.md +492 -0
- data/ROADMAP.md +13 -0
- data/examples/basic_usage.rb +190 -0
- data/examples/jwks_only.rb +25 -0
- data/lib/licensechain/api_client.rb +139 -0
- data/lib/licensechain/client.rb +37 -0
- data/lib/licensechain/configuration.rb +41 -0
- data/lib/licensechain/enhanced_client.rb +320 -0
- data/lib/licensechain/errors.rb +15 -0
- data/lib/licensechain/license_assertion.rb +67 -0
- data/lib/licensechain/license_validator.rb +174 -0
- data/lib/licensechain/models.rb +354 -0
- data/lib/licensechain/services/license_service.rb +108 -0
- data/lib/licensechain/services/product_service.rb +49 -0
- data/lib/licensechain/services/user_service.rb +59 -0
- data/lib/licensechain/services/webhook_service.rb +74 -0
- data/lib/licensechain/utils.rb +211 -0
- data/lib/licensechain/webhook_handler.rb +157 -0
- data/lib/licensechain/webhook_verifier.rb +184 -0
- data/lib/licensechain_ruby_sdk/version.rb +3 -0
- data/lib/licensechain_ruby_sdk.rb +36 -0
- data/licensechain_ruby_sdk.gemspec +44 -0
- metadata +275 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module LicenseChain
|
|
2
|
+
module Services
|
|
3
|
+
class UserService
|
|
4
|
+
def initialize(client)
|
|
5
|
+
@client = client
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def create(email, name = nil, metadata = {})
|
|
9
|
+
validate_email(email)
|
|
10
|
+
|
|
11
|
+
data = {
|
|
12
|
+
email: email,
|
|
13
|
+
name: name,
|
|
14
|
+
password: 'ChangeMe123!',
|
|
15
|
+
metadata: Utils.sanitize_metadata(metadata)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
response = @client.post('/auth/register', data)
|
|
19
|
+
User.new(response[:user] || response[:data] || response)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get(user_id)
|
|
23
|
+
Utils.validate_not_empty(user_id, 'user_id')
|
|
24
|
+
response = @client.get('/auth/me')
|
|
25
|
+
User.new(response[:data] || response)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update(user_id, updates = {})
|
|
29
|
+
raise ValidationError, 'User update endpoint is not available in API v1'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(user_id)
|
|
33
|
+
raise ValidationError, 'User delete endpoint is not available in API v1'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def list(page = 1, limit = 10)
|
|
37
|
+
page, limit = Utils.validate_pagination(page, limit)
|
|
38
|
+
{
|
|
39
|
+
data: [],
|
|
40
|
+
total: 0,
|
|
41
|
+
page: page,
|
|
42
|
+
limit: limit
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stats
|
|
47
|
+
UserStats.new({ total: 0, active: 0, inactive: 0 })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate_email(email)
|
|
53
|
+
Utils.validate_not_empty(email, 'email')
|
|
54
|
+
raise ValidationError, 'Invalid email format' unless Utils.validate_email(email)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module LicenseChain
|
|
2
|
+
module Services
|
|
3
|
+
class WebhookService
|
|
4
|
+
def initialize(client)
|
|
5
|
+
@client = client
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def create(url, events, secret = nil)
|
|
9
|
+
validate_webhook_params(url, events)
|
|
10
|
+
|
|
11
|
+
data = {
|
|
12
|
+
url: url,
|
|
13
|
+
events: events,
|
|
14
|
+
secret: secret
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
response = @client.post('/webhooks', data)
|
|
18
|
+
Webhook.new(normalize_webhook_payload(response[:data] || response))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(webhook_id)
|
|
22
|
+
validate_uuid(webhook_id, 'webhook_id')
|
|
23
|
+
|
|
24
|
+
response = @client.get("/webhooks/#{webhook_id}")
|
|
25
|
+
Webhook.new(normalize_webhook_payload(response[:data] || response))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update(webhook_id, updates = {})
|
|
29
|
+
validate_uuid(webhook_id, 'webhook_id')
|
|
30
|
+
|
|
31
|
+
response = @client.put("/webhooks/#{webhook_id}", Utils.sanitize_metadata(updates))
|
|
32
|
+
Webhook.new(normalize_webhook_payload(response[:data] || response))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(webhook_id)
|
|
36
|
+
validate_uuid(webhook_id, 'webhook_id')
|
|
37
|
+
|
|
38
|
+
@client.delete("/webhooks/#{webhook_id}")
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list
|
|
43
|
+
response = @client.get('/webhooks')
|
|
44
|
+
(response[:data] || []).map { |webhook| Webhook.new(normalize_webhook_payload(webhook)) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_webhook_params(url, events)
|
|
50
|
+
Utils.validate_not_empty(url, 'url')
|
|
51
|
+
raise ValidationError, 'Events must be an array' unless events.is_a?(Array)
|
|
52
|
+
raise ValidationError, 'Events cannot be empty' if events.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_uuid(id, field_name)
|
|
56
|
+
Utils.validate_not_empty(id, field_name)
|
|
57
|
+
raise ValidationError, "Invalid #{field_name} format" unless Utils.validate_uuid(id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_webhook_payload(payload)
|
|
61
|
+
{
|
|
62
|
+
id: payload[:id],
|
|
63
|
+
app_id: payload[:app_id] || '',
|
|
64
|
+
url: payload[:url] || '',
|
|
65
|
+
events: payload[:events] || [],
|
|
66
|
+
secret: payload[:secret],
|
|
67
|
+
status: ((payload[:active].nil? || payload[:active]) ? 'active' : 'inactive'),
|
|
68
|
+
created_at: payload[:created_at] || payload[:createdAt],
|
|
69
|
+
updated_at: payload[:updated_at] || payload[:updatedAt]
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
module LicenseChain
|
|
2
|
+
module Utils
|
|
3
|
+
def self.validate_email(email)
|
|
4
|
+
email_regex = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
|
|
5
|
+
email_regex.match?(email)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def self.validate_license_key(license_key)
|
|
9
|
+
return false if license_key.nil? || license_key.length != 32
|
|
10
|
+
license_key.match?(/\A[A-Z0-9]+\z/)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.validate_uuid(uuid)
|
|
14
|
+
uuid_regex = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
15
|
+
uuid_regex.match?(uuid)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.validate_amount(amount)
|
|
19
|
+
amount.is_a?(Numeric) && amount > 0 && amount.finite?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.validate_currency(currency)
|
|
23
|
+
valid_currencies = %w[USD EUR GBP CAD AUD JPY CHF CNY]
|
|
24
|
+
valid_currencies.include?(currency.upcase)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.validate_status(status, allowed_statuses)
|
|
28
|
+
allowed_statuses.include?(status)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.sanitize_input(input)
|
|
32
|
+
return input unless input.is_a?(String)
|
|
33
|
+
input.gsub(/[<>&"']/, {
|
|
34
|
+
'<' => '<',
|
|
35
|
+
'>' => '>',
|
|
36
|
+
'&' => '&',
|
|
37
|
+
'"' => '"',
|
|
38
|
+
"'" => '''
|
|
39
|
+
})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.sanitize_metadata(metadata)
|
|
43
|
+
return metadata unless metadata.is_a?(Hash)
|
|
44
|
+
metadata.transform_values do |value|
|
|
45
|
+
if value.is_a?(String)
|
|
46
|
+
sanitize_input(value)
|
|
47
|
+
else
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.generate_license_key
|
|
54
|
+
charset = ('A'..'Z').to_a + ('0'..'9').to_a
|
|
55
|
+
(0...32).map { charset.sample }.join
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.generate_uuid
|
|
59
|
+
SecureRandom.uuid
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.format_timestamp(timestamp)
|
|
63
|
+
Time.at(timestamp).utc.iso8601
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse_timestamp(timestamp)
|
|
67
|
+
Time.parse(timestamp).to_i
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
raise ValidationError, "Invalid timestamp format: #{timestamp}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.validate_pagination(page, limit)
|
|
73
|
+
page = [page || 1, 1].max
|
|
74
|
+
limit = [[limit || 10, 1].max, 100].min
|
|
75
|
+
[page, limit]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.validate_date_range(start_date, end_date)
|
|
79
|
+
start_time = parse_timestamp(start_date)
|
|
80
|
+
end_time = parse_timestamp(end_date)
|
|
81
|
+
|
|
82
|
+
if start_time > end_time
|
|
83
|
+
raise ValidationError, "Start date must be before or equal to end date"
|
|
84
|
+
end
|
|
85
|
+
rescue ValidationError
|
|
86
|
+
raise
|
|
87
|
+
rescue => e
|
|
88
|
+
raise ValidationError, "Invalid date format: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.create_webhook_signature(payload, secret)
|
|
92
|
+
require 'openssl'
|
|
93
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
94
|
+
OpenSSL::HMAC.hexdigest(digest, secret, payload)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.verify_webhook_signature(payload, signature, secret)
|
|
98
|
+
expected_signature = create_webhook_signature(payload, secret)
|
|
99
|
+
require 'securerandom'
|
|
100
|
+
SecureRandom.secure_compare(signature, expected_signature)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.default_hwuid
|
|
104
|
+
require 'socket'
|
|
105
|
+
require 'rbconfig'
|
|
106
|
+
require 'digest'
|
|
107
|
+
raw = [
|
|
108
|
+
'licensechain',
|
|
109
|
+
'ruby',
|
|
110
|
+
Socket.gethostname,
|
|
111
|
+
RbConfig::CONFIG['host_os'],
|
|
112
|
+
RbConfig::CONFIG['host_cpu']
|
|
113
|
+
].join('|')
|
|
114
|
+
Digest::SHA256.hexdigest(raw)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.retry_with_backoff(max_retries = 3, initial_delay = 1.0)
|
|
118
|
+
delay = initial_delay
|
|
119
|
+
|
|
120
|
+
(0..max_retries).each do |attempt|
|
|
121
|
+
begin
|
|
122
|
+
return yield
|
|
123
|
+
rescue => e
|
|
124
|
+
if attempt == max_retries
|
|
125
|
+
raise e
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sleep(delay)
|
|
129
|
+
delay *= 2
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.format_bytes(bytes)
|
|
135
|
+
units = %w[B KB MB GB TB PB]
|
|
136
|
+
threshold = 1024
|
|
137
|
+
|
|
138
|
+
return "#{bytes} B" if bytes < threshold
|
|
139
|
+
|
|
140
|
+
size = bytes.to_f
|
|
141
|
+
unit_index = 0
|
|
142
|
+
|
|
143
|
+
while size >= threshold && unit_index < units.length - 1
|
|
144
|
+
size /= threshold
|
|
145
|
+
unit_index += 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
"#{size.round(1)} #{units[unit_index]}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.format_duration(seconds)
|
|
152
|
+
if seconds < 60
|
|
153
|
+
"#{seconds}s"
|
|
154
|
+
elsif seconds < 3600
|
|
155
|
+
minutes = seconds / 60
|
|
156
|
+
remaining_seconds = seconds % 60
|
|
157
|
+
"#{minutes}m #{remaining_seconds}s"
|
|
158
|
+
elsif seconds < 86400
|
|
159
|
+
hours = seconds / 3600
|
|
160
|
+
minutes = (seconds % 3600) / 60
|
|
161
|
+
"#{hours}h #{minutes}m"
|
|
162
|
+
else
|
|
163
|
+
days = seconds / 86400
|
|
164
|
+
hours = (seconds % 86400) / 3600
|
|
165
|
+
"#{days}d #{hours}h"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.capitalize_first(str)
|
|
170
|
+
return str if str.empty?
|
|
171
|
+
str[0].upcase + str[1..-1].downcase
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.to_snake_case(str)
|
|
175
|
+
str.gsub(/([A-Z])/, '_\1').downcase.gsub(/^_/, '')
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.to_pascal_case(str)
|
|
179
|
+
str.split('_').map(&:capitalize).join
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.truncate_string(str, max_length)
|
|
183
|
+
return str if str.length <= max_length
|
|
184
|
+
"#{str[0...max_length - 3]}..."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.remove_special_chars(str)
|
|
188
|
+
str.gsub(/[^a-zA-Z0-9\s]/, '')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.slugify(str)
|
|
192
|
+
str.downcase
|
|
193
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
194
|
+
.gsub(/\s+/, '-')
|
|
195
|
+
.gsub(/-+/, '-')
|
|
196
|
+
.gsub(/^-|-$/, '')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.validate_not_empty(str, field_name)
|
|
200
|
+
raise ValidationError, "#{field_name} cannot be empty" if str.nil? || str.strip.empty?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.validate_positive(number, field_name)
|
|
204
|
+
raise ValidationError, "#{field_name} must be positive" if number.nil? || number <= 0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.validate_range(number, min, max, field_name)
|
|
208
|
+
raise ValidationError, "#{field_name} must be between #{min} and #{max}" if number < min || number > max
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module LicenseChain
|
|
6
|
+
class WebhookHandler
|
|
7
|
+
attr_reader :secret, :tolerance
|
|
8
|
+
|
|
9
|
+
def initialize(secret, tolerance = 300)
|
|
10
|
+
@secret = secret
|
|
11
|
+
@tolerance = tolerance
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify_signature(payload, signature)
|
|
15
|
+
Utils.verify_webhook_signature(payload, signature, @secret)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify_timestamp(timestamp)
|
|
19
|
+
webhook_time = Time.parse(timestamp)
|
|
20
|
+
current_time = Time.now
|
|
21
|
+
time_diff = (current_time - webhook_time).abs
|
|
22
|
+
|
|
23
|
+
if time_diff > @tolerance
|
|
24
|
+
raise ValidationError, "Webhook timestamp too old: #{time_diff} seconds"
|
|
25
|
+
end
|
|
26
|
+
rescue ArgumentError => e
|
|
27
|
+
raise ValidationError, "Invalid timestamp format: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def verify_webhook(payload, signature, timestamp)
|
|
31
|
+
verify_timestamp(timestamp)
|
|
32
|
+
|
|
33
|
+
unless verify_signature(payload, signature)
|
|
34
|
+
raise AuthenticationError, "Invalid webhook signature"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def process_event(event_data)
|
|
39
|
+
payload = event_data[:data].to_json
|
|
40
|
+
verify_webhook(payload, event_data[:signature], event_data[:timestamp])
|
|
41
|
+
|
|
42
|
+
case event_data[:type]
|
|
43
|
+
when 'license.created'
|
|
44
|
+
handle_license_created(event_data)
|
|
45
|
+
when 'license.updated'
|
|
46
|
+
handle_license_updated(event_data)
|
|
47
|
+
when 'license.revoked'
|
|
48
|
+
handle_license_revoked(event_data)
|
|
49
|
+
when 'license.expired'
|
|
50
|
+
handle_license_expired(event_data)
|
|
51
|
+
when 'user.created'
|
|
52
|
+
handle_user_created(event_data)
|
|
53
|
+
when 'user.updated'
|
|
54
|
+
handle_user_updated(event_data)
|
|
55
|
+
when 'user.deleted'
|
|
56
|
+
handle_user_deleted(event_data)
|
|
57
|
+
when 'product.created'
|
|
58
|
+
handle_product_created(event_data)
|
|
59
|
+
when 'product.updated'
|
|
60
|
+
handle_product_updated(event_data)
|
|
61
|
+
when 'product.deleted'
|
|
62
|
+
handle_product_deleted(event_data)
|
|
63
|
+
when 'payment.completed'
|
|
64
|
+
handle_payment_completed(event_data)
|
|
65
|
+
when 'payment.failed'
|
|
66
|
+
handle_payment_failed(event_data)
|
|
67
|
+
when 'payment.refunded'
|
|
68
|
+
handle_payment_refunded(event_data)
|
|
69
|
+
else
|
|
70
|
+
puts "Unknown webhook event type: #{event_data[:type]}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def handle_license_created(event_data)
|
|
77
|
+
puts "License created: #{event_data[:id]}"
|
|
78
|
+
# Add custom logic for license created event
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_license_updated(event_data)
|
|
82
|
+
puts "License updated: #{event_data[:id]}"
|
|
83
|
+
# Add custom logic for license updated event
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_license_revoked(event_data)
|
|
87
|
+
puts "License revoked: #{event_data[:id]}"
|
|
88
|
+
# Add custom logic for license revoked event
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_license_expired(event_data)
|
|
92
|
+
puts "License expired: #{event_data[:id]}"
|
|
93
|
+
# Add custom logic for license expired event
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_user_created(event_data)
|
|
97
|
+
puts "User created: #{event_data[:id]}"
|
|
98
|
+
# Add custom logic for user created event
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_user_updated(event_data)
|
|
102
|
+
puts "User updated: #{event_data[:id]}"
|
|
103
|
+
# Add custom logic for user updated event
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_user_deleted(event_data)
|
|
107
|
+
puts "User deleted: #{event_data[:id]}"
|
|
108
|
+
# Add custom logic for user deleted event
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def handle_product_created(event_data)
|
|
112
|
+
puts "Product created: #{event_data[:id]}"
|
|
113
|
+
# Add custom logic for product created event
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_product_updated(event_data)
|
|
117
|
+
puts "Product updated: #{event_data[:id]}"
|
|
118
|
+
# Add custom logic for product updated event
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def handle_product_deleted(event_data)
|
|
122
|
+
puts "Product deleted: #{event_data[:id]}"
|
|
123
|
+
# Add custom logic for product deleted event
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_payment_completed(event_data)
|
|
127
|
+
puts "Payment completed: #{event_data[:id]}"
|
|
128
|
+
# Add custom logic for payment completed event
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle_payment_failed(event_data)
|
|
132
|
+
puts "Payment failed: #{event_data[:id]}"
|
|
133
|
+
# Add custom logic for payment failed event
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_payment_refunded(event_data)
|
|
137
|
+
puts "Payment refunded: #{event_data[:id]}"
|
|
138
|
+
# Add custom logic for payment refunded event
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
module WebhookEvents
|
|
143
|
+
LICENSE_CREATED = 'license.created'
|
|
144
|
+
LICENSE_UPDATED = 'license.updated'
|
|
145
|
+
LICENSE_REVOKED = 'license.revoked'
|
|
146
|
+
LICENSE_EXPIRED = 'license.expired'
|
|
147
|
+
USER_CREATED = 'user.created'
|
|
148
|
+
USER_UPDATED = 'user.updated'
|
|
149
|
+
USER_DELETED = 'user.deleted'
|
|
150
|
+
PRODUCT_CREATED = 'product.created'
|
|
151
|
+
PRODUCT_UPDATED = 'product.updated'
|
|
152
|
+
PRODUCT_DELETED = 'product.deleted'
|
|
153
|
+
PAYMENT_COMPLETED = 'payment.completed'
|
|
154
|
+
PAYMENT_FAILED = 'payment.failed'
|
|
155
|
+
PAYMENT_REFUNDED = 'payment.refunded'
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module LicenseChain
|
|
7
|
+
class WebhookVerifier
|
|
8
|
+
attr_reader :secret
|
|
9
|
+
|
|
10
|
+
def initialize(secret)
|
|
11
|
+
@secret = secret
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Verify webhook signature
|
|
15
|
+
def verify_signature(payload, signature, algorithm: "sha256")
|
|
16
|
+
expected_signature = generate_signature(payload, algorithm)
|
|
17
|
+
secure_compare(signature, expected_signature)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Parse and verify webhook payload
|
|
21
|
+
def parse_payload(payload, signature, algorithm: "sha256")
|
|
22
|
+
raise ValidationError, "Invalid signature" unless verify_signature(payload, signature, algorithm: algorithm)
|
|
23
|
+
|
|
24
|
+
JSON.parse(payload)
|
|
25
|
+
rescue JSON::ParserError => e
|
|
26
|
+
raise ValidationError, "Invalid JSON payload: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Generate signature for testing
|
|
30
|
+
def generate_signature(payload, algorithm = "sha256")
|
|
31
|
+
digest = case algorithm.downcase
|
|
32
|
+
when "sha1"
|
|
33
|
+
OpenSSL::Digest::SHA1
|
|
34
|
+
when "sha256"
|
|
35
|
+
OpenSSL::Digest::SHA256
|
|
36
|
+
when "sha512"
|
|
37
|
+
OpenSSL::Digest::SHA512
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
signature = OpenSSL::HMAC.hexdigest(digest.new, @secret, payload)
|
|
43
|
+
"#{algorithm}=#{signature}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Verify webhook event type
|
|
47
|
+
def verify_event_type(payload, expected_type)
|
|
48
|
+
event_type = payload.dig("type") || payload.dig("event")
|
|
49
|
+
event_type == expected_type
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Extract event data
|
|
53
|
+
def extract_event_data(payload)
|
|
54
|
+
{
|
|
55
|
+
id: payload["id"],
|
|
56
|
+
type: payload["type"] || payload["event"],
|
|
57
|
+
created_at: payload["created_at"],
|
|
58
|
+
data: payload["data"] || payload["object"]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Verify webhook timestamp (prevent replay attacks)
|
|
63
|
+
def verify_timestamp(payload, tolerance: 300) # 5 minutes
|
|
64
|
+
timestamp = payload["timestamp"] || payload["created_at"]
|
|
65
|
+
return true unless timestamp
|
|
66
|
+
|
|
67
|
+
event_time = Time.parse(timestamp)
|
|
68
|
+
current_time = Time.now
|
|
69
|
+
|
|
70
|
+
(current_time - event_time).abs <= tolerance
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def secure_compare(a, b)
|
|
78
|
+
return false if a.nil? || b.nil?
|
|
79
|
+
return false if a.length != b.length
|
|
80
|
+
|
|
81
|
+
result = 0
|
|
82
|
+
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
|
|
83
|
+
result == 0
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Webhook event handler
|
|
88
|
+
class WebhookHandler
|
|
89
|
+
attr_reader :verifier
|
|
90
|
+
|
|
91
|
+
def initialize(secret)
|
|
92
|
+
@verifier = WebhookVerifier.new(secret)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle(payload, signature, algorithm: "sha256")
|
|
96
|
+
# Parse and verify the payload
|
|
97
|
+
data = @verifier.parse_payload(payload, signature, algorithm: algorithm)
|
|
98
|
+
|
|
99
|
+
# Verify timestamp to prevent replay attacks
|
|
100
|
+
unless @verifier.verify_timestamp(data)
|
|
101
|
+
raise ValidationError, "Webhook timestamp is too old"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Extract event information
|
|
105
|
+
event_data = @verifier.extract_event_data(data)
|
|
106
|
+
|
|
107
|
+
# Route to appropriate handler
|
|
108
|
+
handle_event(event_data)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def handle_event(event_data)
|
|
114
|
+
case event_data[:type]
|
|
115
|
+
when "license.created"
|
|
116
|
+
handle_license_created(event_data)
|
|
117
|
+
when "license.updated"
|
|
118
|
+
handle_license_updated(event_data)
|
|
119
|
+
when "license.revoked"
|
|
120
|
+
handle_license_revoked(event_data)
|
|
121
|
+
when "license.expired"
|
|
122
|
+
handle_license_expired(event_data)
|
|
123
|
+
when "license.validated"
|
|
124
|
+
handle_license_validated(event_data)
|
|
125
|
+
when "app.created"
|
|
126
|
+
handle_app_created(event_data)
|
|
127
|
+
when "app.updated"
|
|
128
|
+
handle_app_updated(event_data)
|
|
129
|
+
when "app.deleted"
|
|
130
|
+
handle_app_deleted(event_data)
|
|
131
|
+
when "user.created"
|
|
132
|
+
handle_user_created(event_data)
|
|
133
|
+
when "user.updated"
|
|
134
|
+
handle_user_updated(event_data)
|
|
135
|
+
else
|
|
136
|
+
handle_unknown_event(event_data)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_license_created(event_data)
|
|
141
|
+
# Override in subclass
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_license_updated(event_data)
|
|
145
|
+
# Override in subclass
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def handle_license_revoked(event_data)
|
|
149
|
+
# Override in subclass
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def handle_license_expired(event_data)
|
|
153
|
+
# Override in subclass
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_license_validated(event_data)
|
|
157
|
+
# Override in subclass
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_app_created(event_data)
|
|
161
|
+
# Override in subclass
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_app_updated(event_data)
|
|
165
|
+
# Override in subclass
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def handle_app_deleted(event_data)
|
|
169
|
+
# Override in subclass
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handle_user_created(event_data)
|
|
173
|
+
# Override in subclass
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def handle_user_updated(event_data)
|
|
177
|
+
# Override in subclass
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_unknown_event(event_data)
|
|
181
|
+
# Override in subclass
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|