workos 1.3.0 → 1.6.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/Gemfile.lock +3 -3
  4. data/lib/workos/client.rb +3 -6
  5. data/lib/workos/connection.rb +9 -1
  6. data/lib/workos/directory.rb +9 -1
  7. data/lib/workos/directory_user.rb +4 -1
  8. data/lib/workos/errors.rb +4 -0
  9. data/lib/workos/organization.rb +10 -1
  10. data/lib/workos/organizations.rb +18 -4
  11. data/lib/workos/profile.rb +7 -2
  12. data/lib/workos/types/connection_struct.rb +2 -0
  13. data/lib/workos/types/directory_struct.rb +2 -0
  14. data/lib/workos/types/directory_user_struct.rb +1 -0
  15. data/lib/workos/types/organization_struct.rb +3 -0
  16. data/lib/workos/types/profile_struct.rb +1 -0
  17. data/lib/workos/types/provider_enum.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 +21 -19
  26. data/spec/lib/workos/organizations_spec.rb +13 -11
  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 +17 -13
  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/directory_sync/get_user.yml +40 -16
  33. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_after.yml +3 -3
  34. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_before.yml +3 -3
  35. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_domain.yml +1 -1
  36. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_limit.yml +2 -2
  37. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_no_options.yml +3 -3
  38. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_directories/with_search.yml +1 -1
  39. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_users/with_after.yml +128 -28
  40. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_users/with_before.yml +31 -18
  41. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_users/with_directory.yml +136 -35
  42. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_users/with_group.yml +128 -18
  43. data/spec/support/fixtures/vcr_cassettes/directory_sync/list_users/with_limit.yml +131 -17
  44. data/spec/support/fixtures/vcr_cassettes/organization/create.yml +28 -16
  45. data/spec/support/fixtures/vcr_cassettes/organization/get.yml +27 -16
  46. data/spec/support/fixtures/vcr_cassettes/organization/list.yml +29 -14
  47. data/spec/support/fixtures/vcr_cassettes/organization/update.yml +27 -16
  48. data/spec/support/fixtures/vcr_cassettes/sso/get_connection_with_valid_id.yml +28 -16
  49. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_after.yml +25 -15
  50. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_before.yml +28 -15
  51. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_connection_type.yml +31 -14
  52. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_domain.yml +27 -13
  53. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_limit.yml +24 -15
  54. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_no_options.yml +30 -14
  55. data/spec/support/fixtures/vcr_cassettes/sso/list_connections/with_organization_id.yml +28 -14
  56. data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
  57. data/spec/support/profile.txt +1 -1
  58. data/spec/support/shared_examples/client_spec.rb +16 -0
  59. data/spec/support/webhook_payload.txt +1 -0
  60. metadata +12 -5
  61. data/spec/support/fixtures/vcr_cassettes/organization/update_invalid.yml +0 -73
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82fc158535f666670ec2f6f71edd889c24be6a3c9488ffad6c4822f45b9f6ece
4
- data.tar.gz: d613fb8b9284124c6720c5f95320a205a1e061291a353befa629a0ee1f74151b
3
+ metadata.gz: dd493c247c202074fa3d93d6f4db6f1cb4cffb19b63f140eeb37a3236255be1a
4
+ data.tar.gz: a266a358383a5242299a2df002b04486d6ef1203f0d3448736b6d5904d657643
5
5
  SHA512:
6
- metadata.gz: da3e7172612b651a5fd36854deaa480c1bb27032fae2d202bf16eefbfb3914659caa5ce47b1ea8cdac8d8c6cb4995497843ff48f800d870da52ad32f20427323
7
- data.tar.gz: ab046cdf757eb36e1711c34a56764bfa74265888c605d40a681e75e6184290198cf8249212e6ae316016e72107f7a34f9135330380ac24394c731cc934f0795b
6
+ metadata.gz: d05f397238b7e94d6f8e7e513523de96c87da44042c65638dbe8ce947076b5383f38491997a887a30e05a0c47e4193178dd39f0e4c62b5f14ebb1fcfb40b3299
7
+ data.tar.gz: 28158f92efaff774bbfaaf02e9be76552dd94bfebb996515824e278faf4011b4996846990074e30694f4306ba26c2cb76440294a020e5832b8bf25c3c504ccb3
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/Gemfile.lock CHANGED
@@ -1,13 +1,13 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- workos (1.3.0)
4
+ workos (1.6.0)
5
5
  sorbet-runtime (~> 0.5)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- addressable (2.7.0)
10
+ addressable (2.8.0)
11
11
  public_suffix (>= 2.0.2, < 5.0)
12
12
  ast (2.4.2)
13
13
  codecov (0.2.12)
@@ -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.6433)
63
+ sorbet-runtime (0.5.9094)
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)
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
@@ -10,8 +10,9 @@ module WorkOS
10
10
  extend T::Sig
11
11
 
12
12
  attr_accessor :id, :name, :connection_type, :domains, :organization_id,
13
- :state, :status
13
+ :state, :status, :created_at, :updated_at
14
14
 
15
+ # rubocop:disable Metrics/AbcSize
15
16
  sig { params(json: String).void }
16
17
  def initialize(json)
17
18
  raw = parse_json(json)
@@ -23,7 +24,10 @@ module WorkOS
23
24
  @organization_id = T.let(raw.organization_id, String)
24
25
  @state = T.let(raw.state, String)
25
26
  @status = T.let(raw.status, String)
27
+ @created_at = T.let(raw.created_at, String)
28
+ @updated_at = T.let(raw.updated_at, String)
26
29
  end
30
+ # rubocop:enable Metrics/AbcSize
27
31
 
28
32
  def to_json(*)
29
33
  {
@@ -34,6 +38,8 @@ module WorkOS
34
38
  organization_id: organization_id,
35
39
  state: state,
36
40
  status: status,
41
+ created_at: created_at,
42
+ updated_at: updated_at,
37
43
  }
38
44
  end
39
45
 
@@ -51,6 +57,8 @@ module WorkOS
51
57
  organization_id: hash[:organization_id],
52
58
  state: hash[:state],
53
59
  status: hash[:status],
60
+ created_at: hash[:created_at],
61
+ updated_at: hash[:updated_at],
54
62
  )
55
63
  end
56
64
  end
@@ -8,8 +8,9 @@ module WorkOS
8
8
  class Directory
9
9
  extend T::Sig
10
10
 
11
- attr_accessor :id, :domain, :name, :type, :state, :organization_id
11
+ attr_accessor :id, :domain, :name, :type, :state, :organization_id, :created_at, :updated_at
12
12
 
13
+ # rubocop:disable Metrics/AbcSize
13
14
  sig { params(json: String).void }
14
15
  def initialize(json)
15
16
  raw = parse_json(json)
@@ -20,7 +21,10 @@ module WorkOS
20
21
  @type = T.let(raw.type, String)
21
22
  @state = T.let(raw.state, String)
22
23
  @organization_id = T.let(raw.organization_id, String)
24
+ @created_at = T.let(raw.created_at, String)
25
+ @updated_at = T.let(raw.updated_at, String)
23
26
  end
27
+ # rubocop:enable Metrics/AbcSize
24
28
 
25
29
  def to_json(*)
26
30
  {
@@ -30,6 +34,8 @@ module WorkOS
30
34
  type: type,
31
35
  state: state,
32
36
  organization_id: organization_id,
37
+ created_at: created_at,
38
+ updated_at: updated_at,
33
39
  }
34
40
  end
35
41
 
@@ -50,6 +56,8 @@ module WorkOS
50
56
  type: hash[:type],
51
57
  state: hash[:state],
52
58
  organization_id: hash[:organization_id],
59
+ created_at: hash[:created_at],
60
+ updated_at: hash[:updated_at],
53
61
  )
54
62
  end
55
63
  end
@@ -9,7 +9,7 @@ module WorkOS
9
9
  extend T::Sig
10
10
 
11
11
  attr_accessor :id, :idp_id, :emails, :first_name, :last_name, :username, :state,
12
- :groups, :raw_attributes
12
+ :groups, :custom_attributes, :raw_attributes
13
13
 
14
14
  # rubocop:disable Metrics/AbcSize
15
15
  sig { params(json: String).void }
@@ -24,6 +24,7 @@ module WorkOS
24
24
  @username = raw.username
25
25
  @state = raw.state
26
26
  @groups = T.let(raw.groups, Array)
27
+ @custom_attributes = raw.custom_attributes
27
28
  @raw_attributes = raw.raw_attributes
28
29
  end
29
30
  # rubocop:enable Metrics/AbcSize
@@ -38,6 +39,7 @@ module WorkOS
38
39
  username: username,
39
40
  state: state,
40
41
  groups: groups,
42
+ custom_attributes: custom_attributes,
41
43
  raw_attributes: raw_attributes,
42
44
  }
43
45
  end
@@ -61,6 +63,7 @@ module WorkOS
61
63
  username: hash[:username],
62
64
  state: hash[:state],
63
65
  groups: hash[:groups],
66
+ custom_attributes: hash[:custom_attributes],
64
67
  raw_attributes: hash[:raw_attributes],
65
68
  )
66
69
  end
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
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,14 +16,20 @@ 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)
21
+ @created_at = T.let(raw.created_at, String)
22
+ @updated_at = T.let(raw.updated_at, String)
20
23
  end
21
24
 
22
25
  def to_json(*)
23
26
  {
24
27
  id: id,
25
28
  name: name,
29
+ allow_profiles_outside_organization: allow_profiles_outside_organization,
26
30
  domains: domains,
31
+ created_at: created_at,
32
+ updated_at: updated_at,
27
33
  }
28
34
  end
29
35
 
@@ -40,7 +46,10 @@ module WorkOS
40
46
  WorkOS::Types::OrganizationStruct.new(
41
47
  id: hash[:id],
42
48
  name: hash[:name],
49
+ allow_profiles_outside_organization: hash[:allow_profiles_outside_organization],
43
50
  domains: hash[:domains],
51
+ created_at: hash[:created_at],
52
+ updated_at: hash[:updated_at],
44
53
  )
45
54
  end
46
55
  end
@@ -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 = T.let(raw.organization_id, String)
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],
@@ -13,6 +13,8 @@ module WorkOS
13
13
  const :organization_id, String
14
14
  const :state, String
15
15
  const :status, String
16
+ const :created_at, String
17
+ const :updated_at, String
16
18
  end
17
19
  end
18
20
  end
@@ -12,6 +12,8 @@ module WorkOS
12
12
  const :type, String
13
13
  const :state, String
14
14
  const :organization_id, String
15
+ const :created_at, String
16
+ const :updated_at, String
15
17
  end
16
18
  end
17
19
  end
@@ -14,6 +14,7 @@ module WorkOS
14
14
  const :username, T.nilable(String)
15
15
  const :state, T.nilable(String)
16
16
  const :groups, T::Array[T.untyped]
17
+ const :custom_attributes, T::Hash[Symbol, T.untyped]
17
18
  const :raw_attributes, T::Hash[Symbol, Object]
18
19
  end
19
20
  end
@@ -8,7 +8,10 @@ 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]
13
+ const :created_at, String
14
+ const :updated_at, String
12
15
  end
13
16
  end
14
17
  end
@@ -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, String
13
14
  const :connection_id, String
14
15
  const :connection_type, String
15
16
  const :idp_id, T.nilable(String)
@@ -8,6 +8,7 @@ module WorkOS
8
8
  class Provider < T::Enum
9
9
  enums do
10
10
  Google = new('GoogleOAuth')
11
+ Microsoft = new('MicrosoftOAuth')
11
12
  end
12
13
  end
13
14
  end
@@ -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.3.0'
5
+ VERSION = '1.6.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