workos 1.3.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
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