workos 1.5.1 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/.ruby-version +1 -1
  4. data/.semaphore/semaphore.yml +2 -2
  5. data/Gemfile.lock +3 -3
  6. data/lib/workos/connection.rb +1 -1
  7. data/lib/workos/directory.rb +2 -2
  8. data/lib/workos/directory_sync.rb +29 -0
  9. data/lib/workos/errors.rb +4 -0
  10. data/lib/workos/organization.rb +4 -1
  11. data/lib/workos/organizations.rb +18 -4
  12. data/lib/workos/profile.rb +7 -2
  13. data/lib/workos/sso.rb +38 -15
  14. data/lib/workos/types/connection_struct.rb +1 -1
  15. data/lib/workos/types/directory_struct.rb +2 -2
  16. data/lib/workos/types/organization_struct.rb +1 -0
  17. data/lib/workos/types/profile_struct.rb +1 -0
  18. data/lib/workos/types/webhook_struct.rb +14 -0
  19. data/lib/workos/types.rb +1 -0
  20. data/lib/workos/version.rb +1 -1
  21. data/lib/workos/webhook.rb +47 -0
  22. data/lib/workos/webhooks.rb +168 -0
  23. data/lib/workos.rb +3 -0
  24. data/spec/lib/workos/directory_sync_spec.rb +43 -10
  25. data/spec/lib/workos/sso_spec.rb +142 -2
  26. data/spec/lib/workos/webhooks_spec.rb +190 -0
  27. data/spec/support/fixtures/vcr_cassettes/{organization/update_invalid.yml → directory_sync/get_directory_with_invalid_id.yml} +35 -25
  28. data/spec/support/fixtures/vcr_cassettes/directory_sync/get_directory_with_valid_id.yml +84 -0
  29. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_after.yml +34 -22
  30. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_before.yml +36 -22
  31. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_domain.yml +30 -19
  32. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_limit.yml +31 -20
  33. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_no_options.yml +39 -21
  34. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_search.yml +32 -20
  35. data/spec/support/fixtures/vcr_cassettes/organization/create.yml +1 -1
  36. data/spec/support/fixtures/vcr_cassettes/organization/get.yml +1 -1
  37. data/spec/support/fixtures/vcr_cassettes/organization/list.yml +4 -4
  38. data/spec/support/fixtures/vcr_cassettes/organization/update.yml +1 -1
  39. data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
  40. data/spec/support/profile.txt +1 -1
  41. data/spec/support/webhook_payload.txt +1 -0
  42. metadata +14 -5
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require 'openssl'
5
+
6
+ module WorkOS
7
+ # The Webhooks module provides convenience methods for working with the WorkOS webhooks.
8
+ # You'll need to extract the signature header and payload from the webhook request
9
+ # sig_header = request.headers['WorkOS-Signature']
10
+ # payload = request.body.read
11
+ #
12
+ # The secret is the Webhook Secret from your WorkOS Dashboard
13
+ # The tolerance is for the timestamp validation
14
+ #
15
+ module Webhooks
16
+ class << self
17
+ extend T::Sig
18
+
19
+ DEFAULT_TOLERANCE = 180
20
+
21
+ # Initializes an Event object from a JSON payload
22
+ # rubocop:disable Layout/LineLength
23
+ #
24
+ # @param [String] payload The payload from the webhook sent by WorkOS. This is the RAW_POST_DATA of the request.
25
+ # @param [String] sig_header The signature from the webhook sent by WorkOS.
26
+ # @param [String] secret The webhook secret from the WorkOS dashboard.
27
+ # @param [Integer] tolerance The time tolerance in seconds for the webhook.
28
+ #
29
+ # @example
30
+ # WorkOS::Webhooks.construct_event(
31
+ # payload: "{"id": "wh_123","data":{"id":"directory_user_01FAEAJCR3ZBZ30D8BD1924TVG","state":"active","emails":[{"type":"work","value":"blair@foo-corp.com","primary":true}],"idp_id":"00u1e8mutl6wlH3lL4x7","object":"directory_user","username":"blair@foo-corp.com","last_name":"Lunceford","first_name":"Blair","directory_id":"directory_01F9M7F68PZP8QXP8G7X5QRHS7","raw_attributes":{"name":{"givenName":"Blair","familyName":"Lunceford","middleName":"Elizabeth","honorificPrefix":"Ms."},"title":"Developer Success Engineer","active":true,"emails":[{"type":"work","value":"blair@foo-corp.com","primary":true}],"groups":[],"locale":"en-US","schemas":["urn:ietf:params:scim:schemas:core:2.0:User","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],"userName":"blair@foo-corp.com","addresses":[{"region":"CO","primary":true,"locality":"Steamboat Springs","postalCode":"80487"}],"externalId":"00u1e8mutl6wlH3lL4x7","displayName":"Blair Lunceford","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User":{"manager":{"value":"2","displayName":"Kathleen Chung"},"division":"Engineering","department":"Customer Success"}}},"event":"dsync.user.created"}",
32
+ # sig_header: 't=1626125972272, v1=80f7ab7efadc306eb5797c588cee9410da9be4416782b497bf1e1bf4175fb928',
33
+ # secret: 'LJlTiC19GmCKWs8AE0IaOQcos',
34
+ # )
35
+ #
36
+ # => #<WorkOS::Webhook:0x00007fa64b980910 @event="dsync.user.created", @data={:id=>"directory_user_01FAEAJCR3ZBZ30D8BD1924TVG", :state=>"active", :emails=>[{:type=>"work", :value=>"blair@foo-corp.com", :primary=>true}], :idp_id=>"00u1e8mutl6wlH3lL4x7", :object=>"directory_user", :username=>"blair@foo-corp.com", :last_name=>"Lunceford", :first_name=>"Blair", :directory_id=>"directory_01F9M7F68PZP8QXP8G7X5QRHS7", :raw_attributes=>{:name=>{:givenName=>"Blair", :familyName=>"Lunceford", :middleName=>"Elizabeth", :honorificPrefix=>"Ms."}, :title=>"Developer Success Engineer", :active=>true, :emails=>[{:type=>"work", :value=>"blair@foo-corp.com", :primary=>true}], :groups=>[], :locale=>"en-US", :schemas=>["urn:ietf:params:scim:schemas:core:2.0:User", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], :userName=>"blair@foo-corp.com", :addresses=>[{:region=>"CO", :primary=>true, :locality=>"Steamboat Springs", :postalCode=>"80487"}], :externalId=>"00u1e8mutl6wlH3lL4x7", :displayName=>"Blair Lunceford", :"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"=>{:manager=>{:value=>"2", :displayName=>"Kathleen Chung"}, :division=>"Engineering", :department=>"Customer Success"}}}>
37
+ #
38
+ # @return [WorkOS::Webhook]
39
+ # rubocop:enable Layout/LineLength
40
+ sig do
41
+ params(
42
+ payload: String,
43
+ sig_header: String,
44
+ secret: String,
45
+ tolerance: Integer,
46
+ ).returns(WorkOS::Webhook)
47
+ end
48
+ def construct_event(
49
+ payload:,
50
+ sig_header:,
51
+ secret:,
52
+ tolerance: DEFAULT_TOLERANCE
53
+ )
54
+ verify_header(payload: payload, sig_header: sig_header, secret: secret, tolerance: tolerance)
55
+ WorkOS::Webhook.new(payload)
56
+ end
57
+
58
+ private
59
+
60
+ sig do
61
+ params(
62
+ payload: String,
63
+ sig_header: String,
64
+ secret: String,
65
+ tolerance: Integer,
66
+ ).returns(T::Boolean)
67
+ end
68
+ # rubocop:disable Metrics/MethodLength
69
+ def verify_header(
70
+ payload:,
71
+ sig_header:,
72
+ secret:,
73
+ tolerance: DEFAULT_TOLERANCE
74
+ )
75
+ begin
76
+ timestamp, signature_hash = get_timestamp_and_signature_hash(sig_header: sig_header)
77
+ rescue StandardError
78
+ raise WorkOS::SignatureVerificationError.new(
79
+ message: 'Unable to extract timestamp and signature hash from header',
80
+ )
81
+ end
82
+
83
+ if signature_hash.empty?
84
+ raise WorkOS::SignatureVerificationError.new(
85
+ message: 'No signature hash found with expected scheme v1',
86
+ )
87
+ end
88
+
89
+ if timestamp < Time.now - tolerance
90
+ raise WorkOS::SignatureVerificationError.new(
91
+ message: 'Timestamp outside the tolerance zone',
92
+ )
93
+ end
94
+
95
+ expected_sig = compute_signature(timestamp: timestamp, payload: payload, secret: secret)
96
+ unless secure_compare(str_a: expected_sig, str_b: signature_hash)
97
+ raise WorkOS::SignatureVerificationError.new(
98
+ message: 'Signature hash does not match the expected signature hash for payload',
99
+ )
100
+ end
101
+
102
+ true
103
+ end
104
+ # rubocop:enable Metrics/MethodLength
105
+
106
+ sig do
107
+ params(
108
+ sig_header: String,
109
+ ).returns(T::Array[T.untyped])
110
+ end
111
+ def get_timestamp_and_signature_hash(
112
+ sig_header:
113
+ )
114
+ timestamp, signature_hash = sig_header.split(', ')
115
+
116
+ if timestamp.nil? || signature_hash.nil?
117
+ raise WorkOS::SignatureVerificationError.new(
118
+ message: 'Unable to extract timestamp and signature hash from header',
119
+ )
120
+ end
121
+
122
+ timestamp = timestamp.sub('t=', '')
123
+ signature_hash = signature_hash.sub('v1=', '')
124
+
125
+ [Time.at(timestamp.to_i), signature_hash]
126
+ end
127
+
128
+ sig do
129
+ params(
130
+ timestamp: Time,
131
+ payload: String,
132
+ secret: String,
133
+ ).returns(String)
134
+ end
135
+ def compute_signature(
136
+ timestamp:,
137
+ payload:,
138
+ secret:
139
+ )
140
+ unhashed_string = "#{timestamp.to_i}.#{payload}"
141
+ digest = OpenSSL::Digest.new('sha256')
142
+ OpenSSL::HMAC.hexdigest(digest, secret, unhashed_string)
143
+ end
144
+
145
+ # Constant time string comparison to prevent timing attacks
146
+ # Code borrowed from ActiveSupport
147
+ sig do
148
+ params(
149
+ str_a: String,
150
+ str_b: String,
151
+ ).returns(T::Boolean)
152
+ end
153
+ def secure_compare(
154
+ str_a:,
155
+ str_b:
156
+ )
157
+ return false unless str_a.bytesize == str_b.bytesize
158
+
159
+ l = T.unsafe(str_a.unpack("C#{str_a.bytesize}"))
160
+
161
+ res = 0
162
+ str_b.each_byte { |byte| res |= byte ^ l.shift }
163
+
164
+ res.zero?
165
+ end
166
+ end
167
+ end
168
+ end
data/lib/workos.rb CHANGED
@@ -42,11 +42,14 @@ module WorkOS
42
42
  autoload :ProfileAndToken, 'workos/profile_and_token'
43
43
  autoload :SSO, 'workos/sso'
44
44
  autoload :DirectoryUser, 'workos/directory_user'
45
+ autoload :Webhook, 'workos/webhook'
46
+ autoload :Webhooks, 'workos/webhooks'
45
47
 
46
48
  # Errors
47
49
  autoload :APIError, 'workos/errors'
48
50
  autoload :AuthenticationError, 'workos/errors'
49
51
  autoload :InvalidRequestError, 'workos/errors'
52
+ autoload :SignatureVerificationError, 'workos/errors'
50
53
 
51
54
  # Remove WORKOS_KEY at some point in the future. Keeping it here now for
52
55
  # backwards compatibility.
@@ -15,7 +15,7 @@ describe WorkOS::DirectorySync do
15
15
  VCR.use_cassette 'directory_sync/list_directories/with_no_options' do
16
16
  directories = described_class.list_directories
17
17
 
18
- expect(directories.data.size).to eq(3)
18
+ expect(directories.data.size).to eq(10)
19
19
  expect(directories.list_metadata).to eq(expected_metadata)
20
20
  end
21
21
  end
@@ -46,7 +46,7 @@ describe WorkOS::DirectorySync do
46
46
  context 'with search option' do
47
47
  it 'forms the proper request to the API' do
48
48
  request_args = [
49
- '/directories?search=Foo',
49
+ '/directories?search=Testing',
50
50
  'Content-Type' => 'application/json'
51
51
  ]
52
52
 
@@ -57,10 +57,11 @@ describe WorkOS::DirectorySync do
57
57
 
58
58
  VCR.use_cassette 'directory_sync/list_directories/with_search' do
59
59
  directories = described_class.list_directories(
60
- search: 'Foo',
60
+ search: 'Testing',
61
61
  )
62
62
 
63
- expect(directories.data.size).to eq(1)
63
+ expect(directories.data.size).to eq(2)
64
+ expect(directories.data[0].name).to include('Testing')
64
65
  end
65
66
  end
66
67
  end
@@ -68,7 +69,7 @@ describe WorkOS::DirectorySync do
68
69
  context 'with the before option' do
69
70
  it 'forms the proper request to the API' do
70
71
  request_args = [
71
- '/directories?before=before-id',
72
+ '/directories?before=directory_01FGCPNV312FHFRCX0BYWHVSE1',
72
73
  'Content-Type' => 'application/json'
73
74
  ]
74
75
 
@@ -79,10 +80,10 @@ describe WorkOS::DirectorySync do
79
80
 
80
81
  VCR.use_cassette 'directory_sync/list_directories/with_before' do
81
82
  directories = described_class.list_directories(
82
- before: 'before-id',
83
+ before: 'directory_01FGCPNV312FHFRCX0BYWHVSE1',
83
84
  )
84
85
 
85
- expect(directories.data.size).to eq(3)
86
+ expect(directories.data.size).to eq(6)
86
87
  end
87
88
  end
88
89
  end
@@ -90,7 +91,7 @@ describe WorkOS::DirectorySync do
90
91
  context 'with the after option' do
91
92
  it 'forms the proper request to the API' do
92
93
  request_args = [
93
- '/directories?after=after-id',
94
+ '/directories?after=directory_01FGCPNV312FHFRCX0BYWHVSE1',
94
95
  'Content-Type' => 'application/json'
95
96
  ]
96
97
 
@@ -100,9 +101,9 @@ describe WorkOS::DirectorySync do
100
101
  and_return(expected_request)
101
102
 
102
103
  VCR.use_cassette 'directory_sync/list_directories/with_after' do
103
- directories = described_class.list_directories(after: 'after-id')
104
+ directories = described_class.list_directories(after: 'directory_01FGCPNV312FHFRCX0BYWHVSE1')
104
105
 
105
- expect(directories.data.size).to eq(3)
106
+ expect(directories.data.size).to eq(4)
106
107
  end
107
108
  end
108
109
  end
@@ -142,6 +143,38 @@ describe WorkOS::DirectorySync do
142
143
  end
143
144
  end
144
145
 
146
+ describe '.get_directory' do
147
+ context 'with a valid id' do
148
+ it 'gets the directory details' do
149
+ VCR.use_cassette('directory_sync/get_directory_with_valid_id') do
150
+ directory = WorkOS::DirectorySync.get_directory(
151
+ id: 'directory_01FK17DWRHH7APAFXT5B52PV0W',
152
+ )
153
+
154
+ expect(directory.id).to eq('directory_01FK17DWRHH7APAFXT5B52PV0W')
155
+ expect(directory.name).to eq('Testing Active Attribute')
156
+ expect(directory.domain).to eq('example.me')
157
+ expect(directory.type).to eq('azure scim v2.0')
158
+ expect(directory.state).to eq('linked')
159
+ expect(directory.organization_id).to eq('org_01F6Q6TFP7RD2PF6J03ANNWDKV')
160
+ end
161
+ end
162
+ end
163
+
164
+ context 'with an invalid id' do
165
+ it 'raises an error' do
166
+ VCR.use_cassette('directory_sync/get_directory_with_invalid_id') do
167
+ expect do
168
+ WorkOS::DirectorySync.get_directory(id: 'invalid')
169
+ end.to raise_error(
170
+ WorkOS::APIError,
171
+ "Status 404, Directory not found: 'invalid'. - request ID: ",
172
+ )
173
+ end
174
+ end
175
+ end
176
+ end
177
+
145
178
  describe '.list_groups' do
146
179
  context 'with no options' do
147
180
  it 'raises an error' do
@@ -109,7 +109,145 @@ describe WorkOS::SSO do
109
109
  end
110
110
  end
111
111
 
112
- context 'with neither connection, domain, or provider' do
112
+ context 'with a domain' do
113
+ let(:args) do
114
+ {
115
+ domain: 'foo.com',
116
+ client_id: 'workos-proj-123',
117
+ redirect_uri: 'foo.com/auth/callback',
118
+ state: {
119
+ next_page: '/dashboard/edit',
120
+ }.to_s,
121
+ }
122
+ end
123
+ it 'returns a valid URL' do
124
+ authorization_url = described_class.authorization_url(**args)
125
+
126
+ expect(URI.parse(authorization_url)).to be_a URI
127
+ end
128
+
129
+ it 'returns the expected hostname' do
130
+ authorization_url = described_class.authorization_url(**args)
131
+
132
+ expect(URI.parse(authorization_url).host).to eq(WorkOS::API_HOSTNAME)
133
+ end
134
+
135
+ it 'returns the expected query string' do
136
+ authorization_url = described_class.authorization_url(**args)
137
+
138
+ expect(URI.parse(authorization_url).query).to eq(
139
+ 'client_id=workos-proj-123&redirect_uri=foo.com%2Fauth%2Fcallback' \
140
+ '&response_type=code&state=%7B%3Anext_page%3D%3E%22%2Fdashboard%2F' \
141
+ 'edit%22%7D&domain=foo.com',
142
+ )
143
+ end
144
+ end
145
+
146
+ context 'with a domain_hint' do
147
+ let(:args) do
148
+ {
149
+ connection: 'connection_123',
150
+ domain_hint: 'foo.com',
151
+ client_id: 'workos-proj-123',
152
+ redirect_uri: 'foo.com/auth/callback',
153
+ state: {
154
+ next_page: '/dashboard/edit',
155
+ }.to_s,
156
+ }
157
+ end
158
+ it 'returns a valid URL' do
159
+ authorization_url = described_class.authorization_url(**args)
160
+
161
+ expect(URI.parse(authorization_url)).to be_a URI
162
+ end
163
+
164
+ it 'returns the expected hostname' do
165
+ authorization_url = described_class.authorization_url(**args)
166
+
167
+ expect(URI.parse(authorization_url).host).to eq(WorkOS::API_HOSTNAME)
168
+ end
169
+
170
+ it 'returns the expected query string' do
171
+ authorization_url = described_class.authorization_url(**args)
172
+
173
+ expect(URI.parse(authorization_url).query).to eq(
174
+ 'client_id=workos-proj-123&redirect_uri=foo.com%2Fauth%2Fcallback' \
175
+ '&response_type=code&state=%7B%3Anext_page%3D%3E%22%2Fdashboard%2' \
176
+ 'Fedit%22%7D&domain_hint=foo.com&connection=connection_123',
177
+ )
178
+ end
179
+ end
180
+
181
+ context 'with a login_hint' do
182
+ let(:args) do
183
+ {
184
+ connection: 'connection_123',
185
+ login_hint: 'foo@workos.com',
186
+ client_id: 'workos-proj-123',
187
+ redirect_uri: 'foo.com/auth/callback',
188
+ state: {
189
+ next_page: '/dashboard/edit',
190
+ }.to_s,
191
+ }
192
+ end
193
+ it 'returns a valid URL' do
194
+ authorization_url = described_class.authorization_url(**args)
195
+
196
+ expect(URI.parse(authorization_url)).to be_a URI
197
+ end
198
+
199
+ it 'returns the expected hostname' do
200
+ authorization_url = described_class.authorization_url(**args)
201
+
202
+ expect(URI.parse(authorization_url).host).to eq(WorkOS::API_HOSTNAME)
203
+ end
204
+
205
+ it 'returns the expected query string' do
206
+ authorization_url = described_class.authorization_url(**args)
207
+
208
+ expect(URI.parse(authorization_url).query).to eq(
209
+ 'client_id=workos-proj-123&redirect_uri=foo.com%2Fauth%2Fcallback' \
210
+ '&response_type=code&state=%7B%3Anext_page%3D%3E%22%2Fdashboard%2' \
211
+ 'Fedit%22%7D&login_hint=foo%40workos.com&connection=connection_123',
212
+ )
213
+ end
214
+ end
215
+
216
+ context 'with an organization' do
217
+ let(:args) do
218
+ {
219
+ organization: 'org_123',
220
+ client_id: 'workos-proj-123',
221
+ redirect_uri: 'foo.com/auth/callback',
222
+ state: {
223
+ next_page: '/dashboard/edit',
224
+ }.to_s,
225
+ }
226
+ end
227
+ it 'returns a valid URL' do
228
+ authorization_url = described_class.authorization_url(**args)
229
+
230
+ expect(URI.parse(authorization_url)).to be_a URI
231
+ end
232
+
233
+ it 'returns the expected hostname' do
234
+ authorization_url = described_class.authorization_url(**args)
235
+
236
+ expect(URI.parse(authorization_url).host).to eq(WorkOS::API_HOSTNAME)
237
+ end
238
+
239
+ it 'returns the expected query string' do
240
+ authorization_url = described_class.authorization_url(**args)
241
+
242
+ expect(URI.parse(authorization_url).query).to eq(
243
+ 'client_id=workos-proj-123&redirect_uri=foo.com%2Fauth%2Fcallback' \
244
+ '&response_type=code&state=%7B%3Anext_page%3D%3E%22%2Fdashboard%2F' \
245
+ 'edit%22%7D&organization=org_123',
246
+ )
247
+ end
248
+ end
249
+
250
+ context 'with neither connection, domain, provider, or organization' do
113
251
  let(:args) do
114
252
  {
115
253
  client_id: 'workos-proj-123',
@@ -124,7 +262,7 @@ describe WorkOS::SSO do
124
262
  described_class.authorization_url(**args)
125
263
  end.to raise_error(
126
264
  ArgumentError,
127
- 'Either connection, domain, or provider is required.',
265
+ 'Either connection, domain, provider, or organization is required.',
128
266
  )
129
267
  end
130
268
  end
@@ -164,6 +302,7 @@ describe WorkOS::SSO do
164
302
  id: 'prof_01EEJTY9SZ1R350RB7B73SNBKF',
165
303
  idp_id: '116485463307139932699',
166
304
  last_name: 'Loblaw',
305
+ organization_id: 'org_01FG53X8636WSNW2WEKB2C31ZB',
167
306
  raw_attributes: {
168
307
  email: 'bob.loblaw@workos.com',
169
308
  family_name: 'Loblaw',
@@ -233,6 +372,7 @@ describe WorkOS::SSO do
233
372
  id: 'prof_01DRA1XNSJDZ19A31F183ECQW5',
234
373
  idp_id: '00u1klkowm8EGah2H357',
235
374
  last_name: 'Demo',
375
+ organization_id: 'org_01FG53X8636WSNW2WEKB2C31ZB',
236
376
  raw_attributes: {
237
377
  email: 'demo@workos-okta.com',
238
378
  first_name: 'WorkOS',
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+ # typed: false
3
+
4
+ require 'json'
5
+ require 'openssl'
6
+
7
+ describe WorkOS::Webhooks do
8
+ describe '.construct_event' do
9
+ before(:each) do
10
+ @payload = File.read("#{SPEC_ROOT}/support/webhook_payload.txt")
11
+ @secret = 'secret'
12
+ @timestamp = Time.at(Time.now.to_i * 1000)
13
+ unhashed_string = "#{@timestamp.to_i}.#{@payload}"
14
+ digest = OpenSSL::Digest.new('sha256')
15
+ @signature_hash = OpenSSL::HMAC.hexdigest(digest, @secret, unhashed_string)
16
+ @expectation = {
17
+ id: 'directory_user_01FAEAJCR3ZBZ30D8BD1924TVG',
18
+ state: 'active',
19
+ emails: [{
20
+ type: 'work',
21
+ value: 'blair@foo-corp.com',
22
+ primary: true,
23
+ }],
24
+ idp_id: '00u1e8mutl6wlH3lL4x7',
25
+ object: 'directory_user',
26
+ username: 'blair@foo-corp.com',
27
+ last_name: 'Lunceford',
28
+ first_name: 'Blair',
29
+ directory_id: 'directory_01F9M7F68PZP8QXP8G7X5QRHS7',
30
+ raw_attributes: {
31
+ name: {
32
+ givenName: 'Blair',
33
+ familyName: 'Lunceford',
34
+ middleName: 'Elizabeth',
35
+ honorificPrefix: 'Ms.',
36
+ },
37
+ title: 'Developer Success Engineer',
38
+ active: true,
39
+ emails: [{
40
+ type: 'work',
41
+ value: 'blair@foo-corp.com',
42
+ primary: true,
43
+ }],
44
+ groups: [],
45
+ locale: 'en-US',
46
+ schemas: [
47
+ 'urn:ietf:params:scim:schemas:core:2.0:User',
48
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'
49
+ ],
50
+ userName: 'blair@foo-corp.com',
51
+ addresses: [{
52
+ region: 'CO',
53
+ primary: true,
54
+ locality: 'Steamboat Springs',
55
+ postalCode: '80487',
56
+ }],
57
+ externalId: '00u1e8mutl6wlH3lL4x7',
58
+ displayName: 'Blair Lunceford',
59
+ "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
60
+ manager: {
61
+ value: '2',
62
+ displayName: 'Kathleen Chung',
63
+ },
64
+ division: 'Engineering',
65
+ department: 'Customer Success',
66
+ },
67
+ },
68
+ }
69
+ end
70
+
71
+ context 'with the correct payload, sig_header, and secret' do
72
+ it 'returns a webhook event' do
73
+ webhook = described_class.construct_event(
74
+ payload: @payload,
75
+ sig_header: "t=#{@timestamp.to_i}, v1=#{@signature_hash}",
76
+ secret: @secret,
77
+ )
78
+
79
+ expect(webhook.data).to eq(@expectation)
80
+ expect(webhook.event).to eq('dsync.user.created')
81
+ expect(webhook.id).to eq('wh_123')
82
+ end
83
+ end
84
+
85
+ context 'with the correct payload, sig_header, secret, and tolerance' do
86
+ it 'returns a webhook event' do
87
+ webhook = described_class.construct_event(
88
+ payload: @payload,
89
+ sig_header: "t=#{@timestamp.to_i}, v1=#{@signature_hash}",
90
+ secret: @secret,
91
+ tolerance: 300,
92
+ )
93
+
94
+ expect(webhook.data).to eq(@expectation)
95
+ expect(webhook.event).to eq('dsync.user.created')
96
+ expect(webhook.id).to eq('wh_123')
97
+ end
98
+ end
99
+
100
+ context 'with an empty header' do
101
+ it 'raises an error' do
102
+ expect do
103
+ described_class.construct_event(
104
+ payload: @payload,
105
+ sig_header: '',
106
+ secret: @secret,
107
+ )
108
+ end.to raise_error(
109
+ WorkOS::SignatureVerificationError,
110
+ 'Unable to extract timestamp and signature hash from header',
111
+ )
112
+ end
113
+ end
114
+
115
+ context 'with an empty signature hash' do
116
+ it 'raises an error' do
117
+ expect do
118
+ described_class.construct_event(
119
+ payload: @payload,
120
+ sig_header: "t=#{@timestamp.to_i}, v1=",
121
+ secret: @secret,
122
+ )
123
+ end.to raise_error(
124
+ WorkOS::SignatureVerificationError,
125
+ 'No signature hash found with expected scheme v1',
126
+ )
127
+ end
128
+ end
129
+
130
+ context 'with an incorrect signature hash' do
131
+ it 'raises an error' do
132
+ expect do
133
+ described_class.construct_event(
134
+ payload: @payload,
135
+ sig_header: "t=#{@timestamp.to_i}, v1=99999",
136
+ secret: @secret,
137
+ )
138
+ end.to raise_error(
139
+ WorkOS::SignatureVerificationError,
140
+ 'Signature hash does not match the expected signature hash for payload',
141
+ )
142
+ end
143
+ end
144
+
145
+ context 'with an incorrect payload' do
146
+ it 'raises an error' do
147
+ expect do
148
+ described_class.construct_event(
149
+ payload: 'invalid',
150
+ sig_header: "t=#{@timestamp.to_i}, v1=#{@signature_hash}",
151
+ secret: @secret,
152
+ )
153
+ end.to raise_error(
154
+ WorkOS::SignatureVerificationError,
155
+ 'Signature hash does not match the expected signature hash for payload',
156
+ )
157
+ end
158
+ end
159
+
160
+ context 'with an incorrect webhook secret' do
161
+ it 'raises an error' do
162
+ expect do
163
+ described_class.construct_event(
164
+ payload: @payload,
165
+ sig_header: "t=#{@timestamp.to_i}, v1=#{@signature_hash}",
166
+ secret: 'invalid',
167
+ )
168
+ end.to raise_error(
169
+ WorkOS::SignatureVerificationError,
170
+ 'Signature hash does not match the expected signature hash for payload',
171
+ )
172
+ end
173
+ end
174
+
175
+ context 'with a timestamp outside tolerance' do
176
+ it 'raises an error' do
177
+ expect do
178
+ described_class.construct_event(
179
+ payload: @payload,
180
+ sig_header: "t=9999, v1=#{@signature_hash}",
181
+ secret: @secret,
182
+ )
183
+ end.to raise_error(
184
+ WorkOS::SignatureVerificationError,
185
+ 'Timestamp outside the tolerance zone',
186
+ )
187
+ end
188
+ end
189
+ end
190
+ end