workos 1.5.1 → 2.1.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/.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
|