workos 1.5.1 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.ruby-version +1 -1
- data/.semaphore/semaphore.yml +2 -2
- data/Gemfile.lock +3 -3
- data/lib/workos/connection.rb +1 -1
- data/lib/workos/directory.rb +2 -2
- data/lib/workos/directory_sync.rb +29 -0
- data/lib/workos/errors.rb +4 -0
- data/lib/workos/organization.rb +4 -1
- data/lib/workos/organizations.rb +18 -4
- data/lib/workos/profile.rb +7 -2
- data/lib/workos/sso.rb +38 -15
- data/lib/workos/types/connection_struct.rb +1 -1
- data/lib/workos/types/directory_struct.rb +2 -2
- data/lib/workos/types/organization_struct.rb +1 -0
- data/lib/workos/types/profile_struct.rb +1 -0
- data/lib/workos/types/webhook_struct.rb +14 -0
- data/lib/workos/types.rb +1 -0
- data/lib/workos/version.rb +1 -1
- data/lib/workos/webhook.rb +47 -0
- data/lib/workos/webhooks.rb +168 -0
- data/lib/workos.rb +3 -0
- data/spec/lib/workos/directory_sync_spec.rb +43 -10
- data/spec/lib/workos/sso_spec.rb +142 -2
- data/spec/lib/workos/webhooks_spec.rb +190 -0
- data/spec/support/fixtures/vcr_cassettes/{organization/update_invalid.yml → directory_sync/get_directory_with_invalid_id.yml} +35 -25
- data/spec/support/fixtures/vcr_cassettes/directory_sync/get_directory_with_valid_id.yml +84 -0
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_after.yml +34 -22
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_before.yml +36 -22
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_domain.yml +30 -19
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_limit.yml +31 -20
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_no_options.yml +39 -21
- data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_search.yml +32 -20
- data/spec/support/fixtures/vcr_cassettes/organization/create.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/organization/get.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/organization/list.yml +4 -4
- data/spec/support/fixtures/vcr_cassettes/organization/update.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
- data/spec/support/profile.txt +1 -1
- data/spec/support/webhook_payload.txt +1 -0
- 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(
|
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=
|
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: '
|
60
|
+
search: 'Testing',
|
61
61
|
)
|
62
62
|
|
63
|
-
expect(directories.data.size).to eq(
|
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=
|
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: '
|
83
|
+
before: 'directory_01FGCPNV312FHFRCX0BYWHVSE1',
|
83
84
|
)
|
84
85
|
|
85
|
-
expect(directories.data.size).to eq(
|
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=
|
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: '
|
104
|
+
directories = described_class.list_directories(after: 'directory_01FGCPNV312FHFRCX0BYWHVSE1')
|
104
105
|
|
105
|
-
expect(directories.data.size).to eq(
|
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
|
data/spec/lib/workos/sso_spec.rb
CHANGED
@@ -109,7 +109,145 @@ describe WorkOS::SSO do
|
|
109
109
|
end
|
110
110
|
end
|
111
111
|
|
112
|
-
context 'with
|
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
|
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
|