workos 5.3.0 → 6.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +1 -1
  3. data/.github/workflows/ci.yml +2 -4
  4. data/.github/workflows/lint-pr-title.yml +20 -0
  5. data/.github/workflows/release-please.yml +25 -0
  6. data/.github/workflows/release.yml +22 -25
  7. data/.gitignore +1 -0
  8. data/.release-please-manifest.json +3 -0
  9. data/.rubocop.yml +11 -8
  10. data/.rubocop_todo.yml +94 -0
  11. data/.ruby-version +1 -1
  12. data/CHANGELOG.md +15 -0
  13. data/Gemfile.lock +32 -18
  14. data/Rakefile +8 -0
  15. data/context7.json +4 -0
  16. data/lib/workos/authentication_response.rb +32 -4
  17. data/lib/workos/cache.rb +94 -0
  18. data/lib/workos/client.rb +9 -1
  19. data/lib/workos/directory_sync.rb +1 -1
  20. data/lib/workos/directory_user.rb +31 -3
  21. data/lib/workos/encryptors/aes_gcm.rb +49 -0
  22. data/lib/workos/encryptors.rb +9 -0
  23. data/lib/workos/errors.rb +4 -0
  24. data/lib/workos/feature_flag.rb +34 -0
  25. data/lib/workos/mfa.rb +0 -1
  26. data/lib/workos/oauth_tokens.rb +29 -0
  27. data/lib/workos/organization.rb +14 -1
  28. data/lib/workos/organization_membership.rb +5 -1
  29. data/lib/workos/organizations.rb +87 -3
  30. data/lib/workos/profile.rb +10 -2
  31. data/lib/workos/refresh_authentication_response.rb +29 -2
  32. data/lib/workos/role.rb +38 -0
  33. data/lib/workos/session.rb +187 -0
  34. data/lib/workos/sso.rb +3 -24
  35. data/lib/workos/types/intent.rb +3 -1
  36. data/lib/workos/types/provider.rb +1 -1
  37. data/lib/workos/types/widget_scope.rb +15 -0
  38. data/lib/workos/types.rb +1 -0
  39. data/lib/workos/user.rb +7 -1
  40. data/lib/workos/user_management/session.rb +57 -0
  41. data/lib/workos/user_management.rb +213 -45
  42. data/lib/workos/version.rb +1 -1
  43. data/lib/workos/widgets.rb +46 -0
  44. data/lib/workos.rb +8 -0
  45. data/release-please-config.json +12 -0
  46. data/spec/lib/workos/cache_spec.rb +94 -0
  47. data/spec/lib/workos/directory_user_spec.rb +13 -3
  48. data/spec/lib/workos/encryptors/aes_gcm_spec.rb +41 -0
  49. data/spec/lib/workos/organizations_spec.rb +258 -1
  50. data/spec/lib/workos/portal_spec.rb +30 -0
  51. data/spec/lib/workos/role_spec.rb +142 -0
  52. data/spec/lib/workos/session_spec.rb +475 -0
  53. data/spec/lib/workos/sso_spec.rb +106 -5
  54. data/spec/lib/workos/user_management_spec.rb +496 -1
  55. data/spec/lib/workos/widgets_spec.rb +73 -0
  56. data/spec/support/fixtures/vcr_cassettes/directory_sync/get_user.yml +1 -1
  57. data/spec/support/fixtures/vcr_cassettes/organization/create_with_external_id.yml +83 -0
  58. data/spec/support/fixtures/vcr_cassettes/organization/list_organization_feature_flags.yml +78 -0
  59. data/spec/support/fixtures/vcr_cassettes/organization/list_organization_roles.yml +82 -0
  60. data/spec/support/fixtures/vcr_cassettes/organization/update_with_external_id.yml +78 -0
  61. data/spec/support/fixtures/vcr_cassettes/organization/update_with_external_id_null.yml +78 -0
  62. data/spec/support/fixtures/vcr_cassettes/organization/update_with_stripe_customer_id.yml +78 -0
  63. data/spec/support/fixtures/vcr_cassettes/organization/update_without_name.yml +85 -0
  64. data/spec/support/fixtures/vcr_cassettes/portal/generate_link_certificate_renewal.yml +72 -0
  65. data/spec/support/fixtures/vcr_cassettes/portal/generate_link_domain_verification.yml +72 -0
  66. data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
  67. data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_code/valid_with_oauth_tokens.yml +82 -0
  68. data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_password/unverified.yml +82 -0
  69. data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml +79 -78
  70. data/spec/support/fixtures/vcr_cassettes/user_management/create_organization_membership/valid_multiple_roles.yml +76 -0
  71. data/spec/support/fixtures/vcr_cassettes/user_management/create_user_with_external_id.yml +77 -0
  72. data/spec/support/fixtures/vcr_cassettes/user_management/get_user.yml +1 -1
  73. data/spec/support/fixtures/vcr_cassettes/user_management/list_sessions/valid.yml +38 -0
  74. data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/accepted.yml +83 -0
  75. data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/expired.yml +83 -0
  76. data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/invalid.yml +83 -0
  77. data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/revoked.yml +83 -0
  78. data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/valid.yml +83 -0
  79. data/spec/support/fixtures/vcr_cassettes/user_management/reset_password/valid.yml +1 -1
  80. data/spec/support/fixtures/vcr_cassettes/user_management/update_organization_membership/valid_multiple_roles.yml +76 -0
  81. data/spec/support/fixtures/vcr_cassettes/user_management/update_user/email.yml +82 -0
  82. data/spec/support/fixtures/vcr_cassettes/user_management/update_user/locale.yml +76 -0
  83. data/spec/support/fixtures/vcr_cassettes/user_management/update_user/valid.yml +2 -2
  84. data/spec/support/fixtures/vcr_cassettes/user_management/update_user_external_id_null.yml +77 -0
  85. data/spec/support/fixtures/vcr_cassettes/widgets/get_token.yml +82 -0
  86. data/spec/support/fixtures/vcr_cassettes/widgets/get_token_invalid_organization_id.yml +74 -0
  87. data/spec/support/fixtures/vcr_cassettes/widgets/get_token_invalid_user_id.yml +74 -0
  88. data/spec/support/profile.txt +1 -1
  89. data/workos.gemspec +7 -3
  90. metadata +132 -10
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'uri'
5
+ require 'net/http'
6
+ require 'encryptor'
7
+ require 'securerandom'
8
+ require 'json'
9
+ require 'uri'
10
+
11
+ module WorkOS
12
+ # The Session class provides helper methods for working with WorkOS sessions
13
+ # This class is not meant to be instantiated in a user space, and is instantiated internally but exposed.
14
+ class Session
15
+ attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id, :encryptor
16
+
17
+ def initialize(user_management:, client_id:, session_data:, cookie_password:, encryptor: nil)
18
+ raise ArgumentError, 'cookiePassword is required' if cookie_password.nil? || cookie_password.empty?
19
+
20
+ @encryptor = encryptor || WorkOS::Encryptors::AesGcm.new
21
+ validate_encryptor!(@encryptor)
22
+
23
+ @user_management = user_management
24
+ @cookie_password = cookie_password
25
+ @session_data = session_data
26
+ @client_id = client_id
27
+
28
+ @jwks = Cache.fetch("jwks_#{client_id}", expires_in: 5 * 60) do
29
+ create_remote_jwk_set(URI(@user_management.get_jwks_url(client_id)))
30
+ end
31
+ @jwks_algorithms = @jwks.map { |key| key[:alg] }.compact.uniq
32
+ end
33
+
34
+ # Authenticates the user based on the session data
35
+ # @param include_expired [Boolean] If true, returns decoded token data even when expired (default: false)
36
+ # @param block [Proc] Optional block to call to extract additional claims from the decoded JWT
37
+ # @return [Hash] A hash containing the authentication response and a reason if the authentication failed
38
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
39
+ def authenticate(include_expired: false, &claim_extractor)
40
+ return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' } if @session_data.nil?
41
+
42
+ begin
43
+ session = Session.unseal_data(@session_data, @cookie_password, encryptor: @encryptor)
44
+ rescue StandardError
45
+ return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' }
46
+ end
47
+
48
+ return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:access_token]
49
+
50
+ begin
51
+ decoded = JWT.decode(
52
+ session[:access_token],
53
+ nil,
54
+ true,
55
+ algorithms: @jwks_algorithms,
56
+ jwks: @jwks,
57
+ verify_expiration: false,
58
+ ).first
59
+
60
+ expired = decoded['exp'] && decoded['exp'] < Time.now.to_i
61
+
62
+ # Early return for expired tokens when not including expired data (backward compatible)
63
+ return { authenticated: false, reason: 'INVALID_JWT' } if expired && !include_expired
64
+
65
+ # Return full data for valid tokens or when include_expired is true
66
+ result = {
67
+ authenticated: !expired,
68
+ session_id: decoded['sid'],
69
+ organization_id: decoded['org_id'],
70
+ role: decoded['role'],
71
+ roles: decoded['roles'],
72
+ permissions: decoded['permissions'],
73
+ entitlements: decoded['entitlements'],
74
+ feature_flags: decoded['feature_flags'],
75
+ user: session[:user],
76
+ impersonator: session[:impersonator],
77
+ reason: expired ? 'INVALID_JWT' : nil,
78
+ }
79
+ result.merge!(claim_extractor.call(decoded)) if block_given?
80
+ result
81
+ rescue JWT::DecodeError
82
+ { authenticated: false, reason: 'INVALID_JWT' }
83
+ rescue StandardError => e
84
+ { authenticated: false, reason: e.message }
85
+ end
86
+ end
87
+
88
+ # Refreshes the session data using the refresh token stored in the session data
89
+ # @param options [Hash] Options for refreshing the session
90
+ # @option options [String] :cookie_password The password to use for unsealing the session data
91
+ # @option options [String] :organization_id The organization ID to use for refreshing the session
92
+ # @return [Hash] A hash containing a new sealed session, the authentication response,
93
+ # and a reason if the refresh failed
94
+ def refresh(options = nil)
95
+ cookie_password = options.nil? || options[:cookie_password].nil? ? @cookie_password : options[:cookie_password]
96
+
97
+ begin
98
+ session = Session.unseal_data(@session_data, cookie_password, encryptor: @encryptor)
99
+ rescue StandardError
100
+ return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' }
101
+ end
102
+
103
+ return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:refresh_token] && session[:user]
104
+
105
+ begin
106
+ auth_response = @user_management.authenticate_with_refresh_token(
107
+ client_id: @client_id,
108
+ refresh_token: session[:refresh_token],
109
+ organization_id: options.nil? || options[:organization_id].nil? ? nil : options[:organization_id],
110
+ session: { seal_session: true, cookie_password: cookie_password, encryptor: @encryptor },
111
+ )
112
+
113
+ @session_data = auth_response.sealed_session
114
+ @cookie_password = cookie_password
115
+
116
+ {
117
+ authenticated: true,
118
+ sealed_session: auth_response.sealed_session,
119
+ session: auth_response,
120
+ reason: nil,
121
+ }
122
+ rescue StandardError => e
123
+ { authenticated: false, reason: e.message }
124
+ end
125
+ end
126
+ # rubocop:enable Metrics/AbcSize
127
+ # rubocop:enable Metrics/CyclomaticComplexity
128
+ # rubocop:enable Metrics/PerceivedComplexity
129
+
130
+ # Returns a URL to redirect the user to for logging out
131
+ # @param return_to [String] The URL to redirect the user to after logging out
132
+ # @return [String] The URL to redirect the user to for logging out
133
+ def get_logout_url(return_to: nil)
134
+ auth_response = authenticate
135
+
136
+ unless auth_response[:authenticated]
137
+ raise "Failed to extract session ID for logout URL: #{auth_response[:reason]}"
138
+ end
139
+
140
+ @user_management.get_logout_url(session_id: auth_response[:session_id], return_to: return_to)
141
+ end
142
+
143
+ # Encrypts and seals data using the provided encryptor (defaults to AES-256-GCM)
144
+ # @param data [Hash] The data to seal
145
+ # @param key [String] The key to use for encryption
146
+ # @param encryptor [Object] Optional encryptor that responds to #seal(data, key)
147
+ # @return [String] The sealed data
148
+ def self.seal_data(data, key, encryptor: nil)
149
+ enc = encryptor || WorkOS::Encryptors::AesGcm.new
150
+ enc.seal(data, key)
151
+ end
152
+
153
+ # Decrypts and unseals data using the provided encryptor (defaults to AES-256-GCM)
154
+ # @param sealed_data [String] The sealed data to unseal
155
+ # @param key [String] The key to use for decryption
156
+ # @param encryptor [Object] Optional encryptor that responds to #unseal(sealed_data, key)
157
+ # @return [Hash] The unsealed data
158
+ def self.unseal_data(sealed_data, key, encryptor: nil)
159
+ enc = encryptor || WorkOS::Encryptors::AesGcm.new
160
+ enc.unseal(sealed_data, key)
161
+ end
162
+
163
+ private
164
+
165
+ def validate_encryptor!(enc)
166
+ return if enc.respond_to?(:seal) && enc.respond_to?(:unseal)
167
+
168
+ raise ArgumentError, 'encryptor must respond to #seal(data, key) and #unseal(sealed_data, key)'
169
+ end
170
+
171
+ # Creates a JWKS set from a remote JWKS URL
172
+ # @param uri [URI] The URI of the JWKS
173
+ # @return [JWT::JWK::Set] The JWKS set
174
+ def create_remote_jwk_set(uri)
175
+ # Fetch the JWKS from the remote URL
176
+ response = Net::HTTP.get(uri)
177
+
178
+ jwks_hash = JSON.parse(response)
179
+ jwks = JWT::JWK::Set.new(jwks_hash)
180
+
181
+ # filter jwks so it only returns the keys where 'use' is equal to 'sig'
182
+ jwks.keys.select! { |key| key[:use] == 'sig' }
183
+
184
+ jwks
185
+ end
186
+ end
187
+ end
data/lib/workos/sso.rb CHANGED
@@ -120,8 +120,9 @@ module WorkOS
120
120
  code: code,
121
121
  }
122
122
 
123
- response = client.request(post_request(path: '/sso/token', body: body))
124
- check_and_raise_profile_and_token_error(response: response)
123
+ response = execute_request(
124
+ request: post_request(path: '/sso/token', body: body),
125
+ )
125
126
 
126
127
  WorkOS::ProfileAndToken.new(response.body)
127
128
  end
@@ -229,28 +230,6 @@ module WorkOS
229
230
  raise ArgumentError, "#{provider} is not a valid value." \
230
231
  " `provider` must be in #{PROVIDERS}"
231
232
  end
232
-
233
- def check_and_raise_profile_and_token_error(response:)
234
- begin
235
- body = JSON.parse(response.body)
236
- return if body['access_token'] && body['profile']
237
-
238
- message = body['message']
239
- error = body['error']
240
- error_description = body['error_description']
241
- request_id = response['x-request-id']
242
- rescue StandardError
243
- message = 'Something went wrong'
244
- end
245
-
246
- raise APIError.new(
247
- message: message,
248
- error: error,
249
- error_description: error_description,
250
- http_status: nil,
251
- request_id: request_id,
252
- )
253
- end
254
233
  end
255
234
  end
256
235
  end
@@ -6,11 +6,13 @@ module WorkOS
6
6
  # intents while generating an Admin Portal link.
7
7
  module Intent
8
8
  AUDIT_LOGS = 'audit_logs'
9
+ CERTIFICATE_RENEWAL = 'certificate_renewal'
10
+ DOMAIN_VERIFICATION = 'domain_verification'
9
11
  DSYNC = 'dsync'
10
12
  LOG_STREAMS = 'log_streams'
11
13
  SSO = 'sso'
12
14
 
13
- ALL = [AUDIT_LOGS, DSYNC, LOG_STREAMS, SSO].freeze
15
+ ALL = [AUDIT_LOGS, CERTIFICATE_RENEWAL, DOMAIN_VERIFICATION, DSYNC, LOG_STREAMS, SSO].freeze
14
16
  end
15
17
  end
16
18
  end
@@ -10,7 +10,7 @@ module WorkOS
10
10
  Google = 'GoogleOAuth'
11
11
  Microsoft = 'MicrosoftOAuth'
12
12
 
13
- ALL = [GitHub, Google, Microsoft].freeze
13
+ ALL = [Apple, GitHub, Google, Microsoft].freeze
14
14
  end
15
15
  end
16
16
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WorkOS
4
+ module Types
5
+ # The WidgetScope constants are declarations of a fixed set of values for
6
+ # scopes while generating a widget token.
7
+ module WidgetScope
8
+ USERS_TABLE_MANAGE = 'widgets:users-table:manage'
9
+ SSO_MANAGE = 'widgets:sso:manage'
10
+ DOMAIN_VERIFICATION_MANAGE = 'widgets:domain-verification:manage'
11
+
12
+ ALL = [USERS_TABLE_MANAGE, SSO_MANAGE, DOMAIN_VERIFICATION_MANAGE].freeze
13
+ end
14
+ end
15
+ end
data/lib/workos/types.rb CHANGED
@@ -7,5 +7,6 @@ module WorkOS
7
7
  autoload :Intent, 'workos/types/intent'
8
8
  autoload :ListStruct, 'workos/types/list_struct'
9
9
  autoload :PasswordlessSessionStruct, 'workos/types/passwordless_session_struct'
10
+ autoload :WidgetScope, 'workos/types/widget_scope'
10
11
  end
11
12
  end
data/lib/workos/user.rb CHANGED
@@ -8,7 +8,7 @@ module WorkOS
8
8
  include HashProvider
9
9
 
10
10
  attr_accessor :id, :email, :first_name, :last_name, :email_verified,
11
- :profile_picture_url, :created_at, :updated_at
11
+ :profile_picture_url, :external_id, :locale, :last_sign_in_at, :created_at, :updated_at
12
12
 
13
13
  def initialize(json)
14
14
  hash = JSON.parse(json, symbolize_names: true)
@@ -19,6 +19,9 @@ module WorkOS
19
19
  @last_name = hash[:last_name]
20
20
  @email_verified = hash[:email_verified]
21
21
  @profile_picture_url = hash[:profile_picture_url]
22
+ @external_id = hash[:external_id]
23
+ @locale = hash[:locale]
24
+ @last_sign_in_at = hash[:last_sign_in_at]
22
25
  @created_at = hash[:created_at]
23
26
  @updated_at = hash[:updated_at]
24
27
  end
@@ -31,6 +34,9 @@ module WorkOS
31
34
  last_name: last_name,
32
35
  email_verified: email_verified,
33
36
  profile_picture_url: profile_picture_url,
37
+ external_id: external_id,
38
+ locale: locale,
39
+ last_sign_in_at: last_sign_in_at,
34
40
  created_at: created_at,
35
41
  updated_at: updated_at,
36
42
  }
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WorkOS
4
+ module UserManagement
5
+ # The Session class provides a lightweight wrapper around
6
+ # a WorkOS Session resource. This class is not meant to be instantiated
7
+ # in user space, and is instantiated internally but exposed.
8
+ class Session
9
+ include HashProvider
10
+ attr_accessor :id, :object, :user_id, :organization_id, :status, :auth_method,
11
+ :ip_address, :user_agent, :expires_at, :ended_at, :created_at, :updated_at
12
+
13
+ # rubocop:disable Metrics/AbcSize
14
+ def initialize(json)
15
+ hash = JSON.parse(json, symbolize_names: true)
16
+
17
+ @id = hash[:id]
18
+ @object = hash[:object]
19
+ @user_id = hash[:user_id]
20
+ @organization_id = hash[:organization_id]
21
+ @status = hash[:status]
22
+ @auth_method = hash[:auth_method]
23
+ @ip_address = hash[:ip_address]
24
+ @user_agent = hash[:user_agent]
25
+ @expires_at = hash[:expires_at]
26
+ @ended_at = hash[:ended_at]
27
+ @created_at = hash[:created_at]
28
+ @updated_at = hash[:updated_at]
29
+ end
30
+ # rubocop:enable Metrics/AbcSize
31
+
32
+ def to_json(*)
33
+ {
34
+ id: id,
35
+ object: object,
36
+ user_id: user_id,
37
+ organization_id: organization_id,
38
+ status: status,
39
+ auth_method: auth_method,
40
+ ip_address: ip_address,
41
+ user_agent: user_agent,
42
+ expires_at: expires_at,
43
+ ended_at: ended_at,
44
+ created_at: created_at,
45
+ updated_at: updated_at,
46
+ }
47
+ end
48
+
49
+ # Revoke this session
50
+ #
51
+ # @return [Bool] - returns `true` if successful
52
+ def revoke
53
+ WorkOS::UserManagement.revoke_session(session_id: id)
54
+ end
55
+ end
56
+ end
57
+ end