workos 1.5.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) 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/client.rb +3 -6
  7. data/lib/workos/connection.rb +1 -1
  8. data/lib/workos/directory.rb +2 -2
  9. data/lib/workos/directory_sync.rb +29 -0
  10. data/lib/workos/errors.rb +4 -0
  11. data/lib/workos/organization.rb +4 -1
  12. data/lib/workos/organizations.rb +18 -4
  13. data/lib/workos/profile.rb +7 -2
  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/audit_trail_spec.rb +2 -0
  25. data/spec/lib/workos/directory_sync_spec.rb +45 -10
  26. data/spec/lib/workos/organizations_spec.rb +2 -0
  27. data/spec/lib/workos/passwordless_spec.rb +2 -0
  28. data/spec/lib/workos/portal_spec.rb +2 -0
  29. data/spec/lib/workos/sso_spec.rb +4 -0
  30. data/spec/lib/workos/webhooks_spec.rb +190 -0
  31. data/spec/spec_helper.rb +3 -0
  32. data/spec/support/fixtures/vcr_cassettes/{organization/update_invalid.yml → directory_sync/get_directory_with_invalid_id.yml} +35 -25
  33. data/spec/support/fixtures/vcr_cassettes/directory_sync/get_directory_with_valid_id.yml +84 -0
  34. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_after.yml +34 -22
  35. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_before.yml +36 -22
  36. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_domain.yml +30 -19
  37. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_limit.yml +31 -20
  38. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_no_options.yml +39 -21
  39. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_search.yml +32 -20
  40. data/spec/support/fixtures/vcr_cassettes/organization/create.yml +1 -1
  41. data/spec/support/fixtures/vcr_cassettes/organization/get.yml +1 -1
  42. data/spec/support/fixtures/vcr_cassettes/organization/list.yml +4 -4
  43. data/spec/support/fixtures/vcr_cassettes/organization/update.yml +1 -1
  44. data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
  45. data/spec/support/profile.txt +1 -1
  46. data/spec/support/shared_examples/client_spec.rb +16 -0
  47. data/spec/support/webhook_payload.txt +1 -0
  48. metadata +16 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae5493072c3719c3a6475116ed51d7955e072fb9499a9a40b231482ffd9db082
4
- data.tar.gz: 451ba6d7ac9542bf9cda07fe9703035fb8d58d0b1e6b4beceb4b6e7273e3b3f6
3
+ metadata.gz: 739e4734f5caa4b4c8180e125a206106242fea489c1dccc19f1bc45ad514bc7e
4
+ data.tar.gz: '029c0a8ef19f9b9b933a73eec53a9770851ea2a338a81060c040b72a06e39189'
5
5
  SHA512:
6
- metadata.gz: d5a7b8b11117bf140ee16cd1f390cd4c94cb9dba75c5c1668cc558290a2619e016c6e59f47ae2a13d5483e61078d79731bee619643ca6ec9640464b0e96a3d17
7
- data.tar.gz: 1534875a20f5410c6b9f9a9680cb9da1ad95247308765a3dc8917f3ffde2c2e1535ae56f022051207f69295d678f621a94b8b9f9ac8ea70812ec3f4108d6edef
6
+ metadata.gz: 18631e0e0cdc5033b7df32d3ff95e2eeadcd7babd6c3b11063e174006ed3e8abd7769bfe4b0c828967e79b724ce0d8a79243960b5f2922eeb242051b56395b41
7
+ data.tar.gz: 2a2b2d48b1410ad0fc86943a168d584234fd5c01ca5ee3e3b4b3117f9924ddc4199d55d10d338b1f07daa193edc0144312ec6271a68c65b89ec8fa2243064aa0
data/.rubocop.yml CHANGED
@@ -9,7 +9,7 @@ Layout/LineLength:
9
9
  - 'VCR\.use_cassette'
10
10
  - '(\A|\s)/.*?/'
11
11
  Metrics/BlockLength:
12
- ExcludedMethods: ['describe', 'context']
12
+ ExcludedMethods: ['describe', 'context', 'before']
13
13
  Metrics/MethodLength:
14
14
  Max: 15
15
15
  Metrics/ModuleLength:
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.0.1
1
+ 3.0.2
@@ -66,10 +66,10 @@ blocks:
66
66
  - sem-version ruby 2.7.3
67
67
  - bundle install
68
68
  - bundle exec rspec
69
- - name: Ruby 3.0.1
69
+ - name: Ruby 3.0.2
70
70
  commands:
71
71
  - checkout
72
- - sem-version ruby 3.0.1
72
+ - sem-version ruby 3.0.2
73
73
  - bundle install
74
74
  - bundle exec rspec
75
75
  promotions:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- workos (1.5.0)
4
+ workos (2.0.0)
5
5
  sorbet-runtime (~> 0.5)
6
6
 
7
7
  GEM
@@ -60,7 +60,7 @@ GEM
60
60
  simplecov_json_formatter (0.1.2)
61
61
  sorbet (0.5.6388)
62
62
  sorbet-static (= 0.5.6388)
63
- sorbet-runtime (0.5.9035)
63
+ sorbet-runtime (0.5.9300)
64
64
  sorbet-static (0.5.6388-universal-darwin-14)
65
65
  sorbet-static (0.5.6388-universal-darwin-15)
66
66
  sorbet-static (0.5.6388-universal-darwin-16)
@@ -93,4 +93,4 @@ DEPENDENCIES
93
93
  yard
94
94
 
95
95
  BUNDLED WITH
96
- 2.2.16
96
+ 2.2.22
data/lib/workos/client.rb CHANGED
@@ -9,12 +9,9 @@ module WorkOS
9
9
 
10
10
  sig { returns(Net::HTTP) }
11
11
  def client
12
- return @client if defined?(@client)
13
-
14
- @client = Net::HTTP.new(WorkOS::API_HOSTNAME, 443)
15
- @client.use_ssl = true
16
-
17
- @client
12
+ Net::HTTP.new(WorkOS::API_HOSTNAME, 443).tap do |http_client|
13
+ http_client.use_ssl = true
14
+ end
18
15
  end
19
16
 
20
17
  sig do
@@ -21,7 +21,7 @@ module WorkOS
21
21
  @name = T.let(raw.name, String)
22
22
  @connection_type = T.let(raw.connection_type, String)
23
23
  @domains = T.let(raw.domains, Array)
24
- @organization_id = T.let(raw.organization_id, String)
24
+ @organization_id = raw.organization_id
25
25
  @state = T.let(raw.state, String)
26
26
  @status = T.let(raw.status, String)
27
27
  @created_at = T.let(raw.created_at, String)
@@ -17,10 +17,10 @@ module WorkOS
17
17
 
18
18
  @id = T.let(raw.id, String)
19
19
  @name = T.let(raw.name, String)
20
- @domain = T.let(raw.domain, String)
20
+ @domain = raw.domain
21
21
  @type = T.let(raw.type, String)
22
22
  @state = T.let(raw.state, String)
23
- @organization_id = T.let(raw.organization_id, String)
23
+ @organization_id = raw.organization_id
24
24
  @created_at = T.let(raw.created_at, String)
25
25
  @updated_at = T.let(raw.updated_at, String)
26
26
  end
@@ -53,6 +53,35 @@ module WorkOS
53
53
  )
54
54
  end
55
55
 
56
+ # Retrieve directory.
57
+ #
58
+ # @param [String] id Directory unique identifier
59
+ #
60
+ # @example
61
+ # WorkOS::SSO.get_directory(id: 'directory_01FK17DWRHH7APAFXT5B52PV0W')
62
+ # => #<WorkOS::Directory:0x00007fb6e4193d20
63
+ # @id="directory_01FK17DWRHH7APAFXT5B52PV0W",
64
+ # @name="Foo Corp",
65
+ # @domain="foo-corp.com",
66
+ # @type="okta scim v2.0",
67
+ # @state="linked",
68
+ # @organization_id="org_01F6Q6TFP7RD2PF6J03ANNWDKV",
69
+ # @created_at="2021-10-27T15:55:47.856Z",
70
+ # @updated_at="2021-10-27T16:03:43.990Z"
71
+ #
72
+ # @return [WorkOS::Directory]
73
+ sig { params(id: String).returns(WorkOS::Directory) }
74
+ def get_directory(id:)
75
+ request = get_request(
76
+ auth: true,
77
+ path: "/directories/#{id}",
78
+ )
79
+
80
+ response = execute_request(request: request)
81
+
82
+ WorkOS::Directory.new(response.body)
83
+ end
84
+
56
85
  # Retrieve directory groups.
57
86
  #
58
87
  # @param [Hash] options An options hash
data/lib/workos/errors.rb CHANGED
@@ -55,4 +55,8 @@ module WorkOS
55
55
  # InvalidRequestError is raised when a request is initiated with invalid
56
56
  # parameters.
57
57
  class InvalidRequestError < WorkOSError; end
58
+
59
+ # SignatureVerificationError is raised when the signature verification for a
60
+ # webhook fails
61
+ class SignatureVerificationError < WorkOSError; end
58
62
  end
@@ -8,7 +8,7 @@ module WorkOS
8
8
  class Organization
9
9
  extend T::Sig
10
10
 
11
- attr_accessor :id, :domains, :name, :created_at, :updated_at
11
+ attr_accessor :id, :domains, :name, :allow_profiles_outside_organization, :created_at, :updated_at
12
12
 
13
13
  sig { params(json: String).void }
14
14
  def initialize(json)
@@ -16,6 +16,7 @@ module WorkOS
16
16
 
17
17
  @id = T.let(raw.id, String)
18
18
  @name = T.let(raw.name, String)
19
+ @allow_profiles_outside_organization = T.let(raw.allow_profiles_outside_organization, T::Boolean)
19
20
  @domains = T.let(raw.domains, Array)
20
21
  @created_at = T.let(raw.created_at, String)
21
22
  @updated_at = T.let(raw.updated_at, String)
@@ -25,6 +26,7 @@ module WorkOS
25
26
  {
26
27
  id: id,
27
28
  name: name,
29
+ allow_profiles_outside_organization: allow_profiles_outside_organization,
28
30
  domains: domains,
29
31
  created_at: created_at,
30
32
  updated_at: updated_at,
@@ -44,6 +46,7 @@ module WorkOS
44
46
  WorkOS::Types::OrganizationStruct.new(
45
47
  id: hash[:id],
46
48
  name: hash[:name],
49
+ allow_profiles_outside_organization: hash[:allow_profiles_outside_organization],
47
50
  domains: hash[:domains],
48
51
  created_at: hash[:created_at],
49
52
  updated_at: hash[:updated_at],
@@ -80,16 +80,23 @@ module WorkOS
80
80
  # @param [Array<String>] domains List of domains that belong to the
81
81
  # organization
82
82
  # @param [String] name A unique, descriptive name for the organization
83
+ # @param [Boolean, nil] allow_profiles_outside_organization Whether Connections
84
+ # within the Organization allow profiles that are outside of the Organization's configured User Email Domains.
83
85
  sig do
84
86
  params(
85
87
  domains: T::Array[String],
86
88
  name: String,
89
+ allow_profiles_outside_organization: T.nilable(T::Boolean),
87
90
  ).returns(WorkOS::Organization)
88
91
  end
89
- def create_organization(domains:, name:)
92
+ def create_organization(domains:, name:, allow_profiles_outside_organization: nil)
90
93
  request = post_request(
91
94
  auth: true,
92
- body: { domains: domains, name: name },
95
+ body: {
96
+ domains: domains,
97
+ name: name,
98
+ allow_profiles_outside_organization: allow_profiles_outside_organization,
99
+ },
93
100
  path: '/organizations',
94
101
  )
95
102
 
@@ -105,17 +112,24 @@ module WorkOS
105
112
  # @param [Array<String>] domains List of domains that belong to the
106
113
  # organization
107
114
  # @param [String] name A unique, descriptive name for the organization
115
+ # @param [Boolean, nil] allow_profiles_outside_organization Whether Connections
116
+ # within the Organization allow profiles that are outside of the Organization's configured User Email Domains.
108
117
  sig do
109
118
  params(
110
119
  organization: String,
111
120
  domains: T::Array[String],
112
121
  name: String,
122
+ allow_profiles_outside_organization: T.nilable(T::Boolean),
113
123
  ).returns(WorkOS::Organization)
114
124
  end
115
- def update_organization(organization:, domains:, name:)
125
+ def update_organization(organization:, domains:, name:, allow_profiles_outside_organization: nil)
116
126
  request = put_request(
117
127
  auth: true,
118
- body: { domains: domains, name: name },
128
+ body: {
129
+ domains: domains,
130
+ name: name,
131
+ allow_profiles_outside_organization: allow_profiles_outside_organization,
132
+ },
119
133
  path: "/organizations/#{organization}",
120
134
  )
121
135
 
@@ -11,9 +11,10 @@ module WorkOS
11
11
  extend T::Sig
12
12
 
13
13
  sig { returns(String) }
14
- attr_accessor :id, :email, :first_name, :last_name, :connection_id,
15
- :connection_type, :idp_id, :raw_attributes
14
+ attr_accessor :id, :email, :first_name, :last_name, :organization_id,
15
+ :connection_id, :connection_type, :idp_id, :raw_attributes
16
16
 
17
+ # rubocop:disable Metrics/AbcSize
17
18
  sig { params(profile_json: String).void }
18
19
  def initialize(profile_json)
19
20
  raw = parse_json(profile_json)
@@ -22,11 +23,13 @@ module WorkOS
22
23
  @email = T.let(raw.email, String)
23
24
  @first_name = raw.first_name
24
25
  @last_name = raw.last_name
26
+ @organization_id = raw.organization_id
25
27
  @connection_id = T.let(raw.connection_id, String)
26
28
  @connection_type = T.let(raw.connection_type, String)
27
29
  @idp_id = raw.idp_id
28
30
  @raw_attributes = raw.raw_attributes
29
31
  end
32
+ # rubocop:enable Metrics/AbcSize
30
33
 
31
34
  sig { returns(String) }
32
35
  def full_name
@@ -39,6 +42,7 @@ module WorkOS
39
42
  email: email,
40
43
  first_name: first_name,
41
44
  last_name: last_name,
45
+ organization_id: organization_id,
42
46
  connection_id: connection_id,
43
47
  connection_type: connection_type,
44
48
  idp_id: idp_id,
@@ -57,6 +61,7 @@ module WorkOS
57
61
  email: hash[:email],
58
62
  first_name: hash[:first_name],
59
63
  last_name: hash[:last_name],
64
+ organization_id: hash[:organization_id],
60
65
  connection_id: hash[:connection_id],
61
66
  connection_type: hash[:connection_type],
62
67
  idp_id: hash[:idp_id],
@@ -10,7 +10,7 @@ module WorkOS
10
10
  const :name, String
11
11
  const :connection_type, String
12
12
  const :domains, T::Array[T.untyped]
13
- const :organization_id, String
13
+ const :organization_id, T.nilable(String)
14
14
  const :state, String
15
15
  const :status, String
16
16
  const :created_at, String
@@ -8,10 +8,10 @@ module WorkOS
8
8
  class DirectoryStruct < T::Struct
9
9
  const :id, String
10
10
  const :name, String
11
- const :domain, String
11
+ const :domain, T.nilable(String)
12
12
  const :type, String
13
13
  const :state, String
14
- const :organization_id, String
14
+ const :organization_id, T.nilable(String)
15
15
  const :created_at, String
16
16
  const :updated_at, String
17
17
  end
@@ -8,6 +8,7 @@ module WorkOS
8
8
  class OrganizationStruct < T::Struct
9
9
  const :id, String
10
10
  const :name, String
11
+ const :allow_profiles_outside_organization, T::Boolean
11
12
  const :domains, T::Array[T.untyped]
12
13
  const :created_at, String
13
14
  const :updated_at, String
@@ -10,6 +10,7 @@ module WorkOS
10
10
  const :email, String
11
11
  const :first_name, T.nilable(String)
12
12
  const :last_name, T.nilable(String)
13
+ const :organization_id, T.nilable(String)
13
14
  const :connection_id, String
14
15
  const :connection_type, String
15
16
  const :idp_id, T.nilable(String)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module WorkOS
5
+ module Types
6
+ # This WebhookStruct acts as a typed interface
7
+ # for the Webhook class
8
+ class WebhookStruct < T::Struct
9
+ const :id, String
10
+ const :event, String
11
+ const :data, T::Hash[Symbol, Object]
12
+ end
13
+ end
14
+ end
data/lib/workos/types.rb CHANGED
@@ -15,5 +15,6 @@ module WorkOS
15
15
  require_relative 'types/profile_struct'
16
16
  require_relative 'types/provider_enum'
17
17
  require_relative 'types/directory_user_struct'
18
+ require_relative 'types/webhook_struct'
18
19
  end
19
20
  end
@@ -2,5 +2,5 @@
2
2
  # typed: strong
3
3
 
4
4
  module WorkOS
5
- VERSION = '1.5.0'
5
+ VERSION = '2.0.0'
6
6
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ module WorkOS
5
+ # The Webhook class provides a lightweight wrapper around
6
+ # a WorkOS Webhook resource. This class is not meant to be instantiated
7
+ # in user space, and is instantiated internally but exposed.
8
+ class Webhook
9
+ extend T::Sig
10
+
11
+ attr_accessor :id, :event, :data
12
+
13
+ sig { params(json: String).void }
14
+ def initialize(json)
15
+ raw = parse_json(json)
16
+
17
+ @id = T.let(raw.id, String)
18
+ @event = T.let(raw.event, String)
19
+ @data = raw.data
20
+ end
21
+
22
+ def to_json(*)
23
+ {
24
+ id: id,
25
+ event: event,
26
+ data: data,
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ sig do
33
+ params(
34
+ json_string: String,
35
+ ).returns(WorkOS::Types::WebhookStruct)
36
+ end
37
+ def parse_json(json_string)
38
+ hash = JSON.parse(json_string, symbolize_names: true)
39
+
40
+ WorkOS::Types::WebhookStruct.new(
41
+ id: hash[:id],
42
+ event: hash[:event],
43
+ data: hash[:data],
44
+ )
45
+ end
46
+ end
47
+ end
@@ -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.
@@ -2,6 +2,8 @@
2
2
  # typed: false
3
3
 
4
4
  describe WorkOS::AuditTrail do
5
+ it_behaves_like 'client'
6
+
5
7
  describe '.create_event' do
6
8
  context 'with valid event payload' do
7
9
  let(:valid_event) do