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,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe WorkOS::Session do
4
+ let(:client_id) { 'test_client_id' }
5
+ let(:cookie_password) { 'test_very_long_cookie_password__' }
6
+ let(:session_data) { 'test_session_data' }
7
+ let(:jwks_url) { 'https://api.workos.com/sso/jwks/client_123' }
8
+ let(:jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), { kid: 'sso_oidc_key_pair_123', use: 'sig', alg: 'RS256' }) }
9
+ let(:jwks_hash) { { keys: [jwk.export] }.to_json }
10
+
11
+ before do
12
+ allow(Net::HTTP).to receive(:get).and_return(jwks_hash)
13
+ end
14
+
15
+ describe 'initialize' do
16
+ let(:user_management) { instance_double('UserManagement') }
17
+
18
+ before do
19
+ allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url)
20
+ end
21
+
22
+ describe 'JWKS caching' do
23
+ before do
24
+ WorkOS::Cache.clear
25
+ end
26
+
27
+ it 'caches and returns JWKS' do
28
+ expect(Net::HTTP).to receive(:get).once
29
+ session1 = WorkOS::Session.new(
30
+ user_management: user_management,
31
+ client_id: client_id,
32
+ session_data: session_data,
33
+ cookie_password: cookie_password,
34
+ )
35
+
36
+ session2 = WorkOS::Session.new(
37
+ user_management: user_management,
38
+ client_id: client_id,
39
+ session_data: session_data,
40
+ cookie_password: cookie_password,
41
+ )
42
+
43
+ expect(session1.jwks.map(&:export)).to eq(session2.jwks.map(&:export))
44
+ end
45
+
46
+ it 'fetches JWKS from remote when cache is expired' do
47
+ expect(Net::HTTP).to receive(:get).twice
48
+ session1 = WorkOS::Session.new(
49
+ user_management: user_management,
50
+ client_id: client_id,
51
+ session_data: session_data,
52
+ cookie_password: cookie_password,
53
+ )
54
+
55
+ allow(Time).to receive(:now).and_return(Time.now + 301)
56
+
57
+ session2 = WorkOS::Session.new(
58
+ user_management: user_management,
59
+ client_id: client_id,
60
+ session_data: session_data,
61
+ cookie_password: cookie_password,
62
+ )
63
+
64
+ expect(session1.jwks.map(&:export)).to eq(session2.jwks.map(&:export))
65
+ end
66
+ end
67
+
68
+ it 'raises an error if cookie_password is nil or empty' do
69
+ expect do
70
+ WorkOS::Session.new(
71
+ user_management: user_management,
72
+ client_id: client_id,
73
+ session_data: session_data,
74
+ cookie_password: nil,
75
+ )
76
+ end.to raise_error(ArgumentError, 'cookiePassword is required')
77
+
78
+ expect do
79
+ WorkOS::Session.new(
80
+ user_management: user_management,
81
+ client_id: client_id,
82
+ session_data: session_data,
83
+ cookie_password: '',
84
+ )
85
+ end.to raise_error(ArgumentError, 'cookiePassword is required')
86
+ end
87
+
88
+ it 'initializes with valid parameters' do
89
+ session = WorkOS::Session.new(
90
+ user_management: user_management,
91
+ client_id: client_id,
92
+ session_data: session_data,
93
+ cookie_password: cookie_password,
94
+ )
95
+ expect(session.user_management).to eq(user_management)
96
+ expect(session.client_id).to eq(client_id)
97
+ expect(session.session_data).to eq(session_data)
98
+ expect(session.cookie_password).to eq(cookie_password)
99
+ expect(session.jwks.map(&:export)).to eq(JSON.parse(jwks_hash, symbolize_names: true)[:keys])
100
+ expect(session.jwks_algorithms).to eq(['RS256'])
101
+ end
102
+ end
103
+
104
+ describe '.authenticate' do
105
+ let(:user_management) { instance_double('UserManagement') }
106
+ let(:payload) do
107
+ {
108
+ sid: 'session_id',
109
+ org_id: 'org_id',
110
+ role: 'role',
111
+ roles: ['role'],
112
+ permissions: ['read'],
113
+ exp: Time.now.to_i + 3600,
114
+ }
115
+ end
116
+ let(:valid_access_token) { JWT.encode(payload, jwk.signing_key, jwk[:alg], { kid: jwk[:kid] }) }
117
+ let(:session_data) do
118
+ WorkOS::Session.seal_data({
119
+ access_token: valid_access_token,
120
+ user: 'user',
121
+ impersonator: 'impersonator',
122
+ }, cookie_password,)
123
+ end
124
+
125
+ before do
126
+ allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url)
127
+ end
128
+
129
+ it 'returns NO_SESSION_COOKIE_PROVIDED if session_data is nil' do
130
+ session = WorkOS::Session.new(
131
+ user_management: user_management,
132
+ client_id: client_id,
133
+ session_data: nil,
134
+ cookie_password: cookie_password,
135
+ )
136
+ result = session.authenticate
137
+ expect(result).to eq({ authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' })
138
+ end
139
+
140
+ it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do
141
+ session = WorkOS::Session.new(
142
+ user_management: user_management,
143
+ client_id: client_id,
144
+ session_data: 'invalid_data',
145
+ cookie_password: cookie_password,
146
+ )
147
+ result = session.authenticate
148
+ expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' })
149
+ end
150
+
151
+ it 'returns INVALID_JWT if access_token is invalid' do
152
+ invalid_session_data = WorkOS::Session.seal_data({ access_token: 'invalid_token' }, cookie_password)
153
+ session = WorkOS::Session.new(
154
+ user_management: user_management,
155
+ client_id: client_id,
156
+ session_data: invalid_session_data,
157
+ cookie_password: cookie_password,
158
+ )
159
+ result = session.authenticate
160
+ expect(result).to eq({ authenticated: false, reason: 'INVALID_JWT' })
161
+ end
162
+
163
+ it 'returns INVALID_JWT without token data when session is expired' do
164
+ session = WorkOS::Session.new(
165
+ user_management: user_management,
166
+ client_id: client_id,
167
+ session_data: session_data,
168
+ cookie_password: cookie_password,
169
+ )
170
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
171
+ allow(Time).to receive(:now).and_return(Time.at(9_999_999_999))
172
+ result = session.authenticate
173
+ expect(result).to eq({ authenticated: false, reason: 'INVALID_JWT' })
174
+ end
175
+
176
+ it 'returns INVALID_JWT with full token data when session is expired and include_expired is true' do
177
+ session = WorkOS::Session.new(
178
+ user_management: user_management,
179
+ client_id: client_id,
180
+ session_data: session_data,
181
+ cookie_password: cookie_password,
182
+ )
183
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
184
+ allow(Time).to receive(:now).and_return(Time.at(9_999_999_999))
185
+ result = session.authenticate(include_expired: true)
186
+ expect(result).to eq({
187
+ authenticated: false,
188
+ session_id: 'session_id',
189
+ organization_id: 'org_id',
190
+ role: 'role',
191
+ roles: ['role'],
192
+ permissions: ['read'],
193
+ feature_flags: nil,
194
+ entitlements: nil,
195
+ user: 'user',
196
+ impersonator: 'impersonator',
197
+ reason: 'INVALID_JWT',
198
+ })
199
+ end
200
+
201
+ it 'authenticates successfully with valid session_data' do
202
+ session = WorkOS::Session.new(
203
+ user_management: user_management,
204
+ client_id: client_id,
205
+ session_data: session_data,
206
+ cookie_password: cookie_password,
207
+ )
208
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
209
+ result = session.authenticate
210
+ expect(result).to eq({
211
+ authenticated: true,
212
+ session_id: 'session_id',
213
+ organization_id: 'org_id',
214
+ role: 'role',
215
+ roles: ['role'],
216
+ permissions: ['read'],
217
+ feature_flags: nil,
218
+ entitlements: nil,
219
+ user: 'user',
220
+ impersonator: 'impersonator',
221
+ reason: nil,
222
+ })
223
+ end
224
+
225
+ it 'merges custom claims from claim_extractor block' do
226
+ custom_payload = payload.merge(custom_claim: 'custom_value', another_claim: 123)
227
+ custom_access_token = JWT.encode(custom_payload, jwk.signing_key, jwk[:alg], { kid: jwk[:kid] })
228
+ custom_session_data = WorkOS::Session.seal_data({
229
+ access_token: custom_access_token,
230
+ user: 'user',
231
+ impersonator: 'impersonator',
232
+ }, cookie_password,)
233
+ session = WorkOS::Session.new(
234
+ user_management: user_management,
235
+ client_id: client_id,
236
+ session_data: custom_session_data,
237
+ cookie_password: cookie_password,
238
+ )
239
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
240
+ result = session.authenticate do |jwt|
241
+ { my_custom_claim: jwt['custom_claim'], my_other_claim: jwt['another_claim'] }
242
+ end
243
+ expect(result[:authenticated]).to be true
244
+ expect(result[:my_custom_claim]).to eq('custom_value')
245
+ expect(result[:my_other_claim]).to eq(123)
246
+ end
247
+
248
+ describe 'with entitlements' do
249
+ let(:payload) do
250
+ {
251
+ sid: 'session_id',
252
+ org_id: 'org_id',
253
+ role: 'role',
254
+ roles: ['role'],
255
+ permissions: ['read'],
256
+ entitlements: ['billing'],
257
+ exp: Time.now.to_i + 3600,
258
+ }
259
+ end
260
+
261
+ it 'includes entitlements in the result' do
262
+ session = WorkOS::Session.new(
263
+ user_management: user_management,
264
+ client_id: client_id,
265
+ session_data: session_data,
266
+ cookie_password: cookie_password,
267
+ )
268
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
269
+ result = session.authenticate
270
+ expect(result).to eq({
271
+ authenticated: true,
272
+ session_id: 'session_id',
273
+ organization_id: 'org_id',
274
+ role: 'role',
275
+ roles: ['role'],
276
+ permissions: ['read'],
277
+ entitlements: ['billing'],
278
+ feature_flags: nil,
279
+ user: 'user',
280
+ impersonator: 'impersonator',
281
+ reason: nil,
282
+ })
283
+ end
284
+ end
285
+
286
+ describe 'with feature flags' do
287
+ let(:payload) do
288
+ {
289
+ sid: 'session_id',
290
+ org_id: 'org_id',
291
+ role: 'role',
292
+ roles: ['role'],
293
+ permissions: ['read'],
294
+ feature_flags: ['new_feature_enabled'],
295
+ exp: Time.now.to_i + 3600,
296
+ }
297
+ end
298
+
299
+ it 'includes feature flags in the result' do
300
+ session = WorkOS::Session.new(
301
+ user_management: user_management,
302
+ client_id: client_id,
303
+ session_data: session_data,
304
+ cookie_password: cookie_password,
305
+ )
306
+ allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true)
307
+ result = session.authenticate
308
+ expect(result).to eq({
309
+ authenticated: true,
310
+ session_id: 'session_id',
311
+ organization_id: 'org_id',
312
+ role: 'role',
313
+ roles: ['role'],
314
+ permissions: ['read'],
315
+ entitlements: nil,
316
+ feature_flags: ['new_feature_enabled'],
317
+ user: 'user',
318
+ impersonator: 'impersonator',
319
+ reason: nil,
320
+ })
321
+ end
322
+ end
323
+ end
324
+
325
+ describe '.refresh' do
326
+ let(:user_management) { instance_double('UserManagement') }
327
+ let(:refresh_token) { 'test_refresh_token' }
328
+ let(:session_data) { WorkOS::Session.seal_data({ refresh_token: refresh_token, user: 'user' }, cookie_password) }
329
+ let(:auth_response) { double('AuthResponse', sealed_session: 'new_sealed_session') }
330
+
331
+ before do
332
+ allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url)
333
+ allow(user_management).to receive(:authenticate_with_refresh_token).and_return(auth_response)
334
+ end
335
+
336
+ it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do
337
+ session = WorkOS::Session.new(
338
+ user_management: user_management,
339
+ client_id: client_id,
340
+ session_data: 'invalid_data',
341
+ cookie_password: cookie_password,
342
+ )
343
+ result = session.refresh
344
+ expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' })
345
+ end
346
+
347
+ it 'refreshes the session successfully with valid session_data' do
348
+ session = WorkOS::Session.new(
349
+ user_management: user_management,
350
+ client_id: client_id,
351
+ session_data: session_data,
352
+ cookie_password: cookie_password,
353
+ )
354
+ result = session.refresh
355
+ expect(result).to eq({
356
+ authenticated: true,
357
+ sealed_session: 'new_sealed_session',
358
+ session: auth_response,
359
+ reason: nil,
360
+ })
361
+ end
362
+ end
363
+
364
+ describe '.get_logout_url' do
365
+ let(:session) do
366
+ WorkOS::Session.new(
367
+ user_management: WorkOS::UserManagement,
368
+ client_id: client_id,
369
+ session_data: session_data,
370
+ cookie_password: cookie_password,
371
+ )
372
+ end
373
+
374
+ context 'when authentication is successful' do
375
+ before do
376
+ allow(session).to receive(:authenticate).and_return({
377
+ authenticated: true,
378
+ session_id: 'session_123abc',
379
+ reason: nil,
380
+ })
381
+ end
382
+
383
+ it 'returns the logout URL' do
384
+ expect(session.get_logout_url).to eq('https://api.workos.com/user_management/sessions/logout?session_id=session_123abc')
385
+ end
386
+
387
+ context 'when given a return_to URL' do
388
+ it 'returns the logout URL with the return_to parameter' do
389
+ expect(session.get_logout_url(return_to: 'https://example.com/signed-out')).to eq(
390
+ 'https://api.workos.com/user_management/sessions/logout?session_id=session_123abc&return_to=https%3A%2F%2Fexample.com%2Fsigned-out',
391
+ )
392
+ end
393
+ end
394
+ end
395
+
396
+ context 'when authentication fails' do
397
+ before do
398
+ allow(session).to receive(:authenticate).and_return({
399
+ authenticated: false,
400
+ reason: 'Invalid session',
401
+ })
402
+ end
403
+
404
+ it 'raises an error' do
405
+ expect { session.get_logout_url }.to raise_error(
406
+ RuntimeError, 'Failed to extract session ID for logout URL: Invalid session',
407
+ )
408
+ end
409
+ end
410
+ end
411
+
412
+ describe 'custom encryptor' do
413
+ let(:user_management) { instance_double('UserManagement') }
414
+ let(:custom_encryptor) do
415
+ Class.new do
416
+ def seal(data, _key)
417
+ "CUSTOM:#{JSON.generate(data)}"
418
+ end
419
+
420
+ def unseal(sealed_data, _key)
421
+ json = sealed_data.sub('CUSTOM:', '')
422
+ JSON.parse(json, symbolize_names: true)
423
+ end
424
+ end.new
425
+ end
426
+
427
+ before do
428
+ allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url)
429
+ end
430
+
431
+ it 'uses custom encryptor for seal_data' do
432
+ sealed = WorkOS::Session.seal_data({ foo: 'bar' }, 'key', encryptor: custom_encryptor)
433
+ expect(sealed).to start_with('CUSTOM:')
434
+ end
435
+
436
+ it 'uses custom encryptor for unseal_data' do
437
+ sealed = 'CUSTOM:{"foo":"bar"}'
438
+ unsealed = WorkOS::Session.unseal_data(sealed, 'key', encryptor: custom_encryptor)
439
+ expect(unsealed).to eq({ foo: 'bar' })
440
+ end
441
+
442
+ it 'accepts custom encryptor in initialize' do
443
+ session = WorkOS::Session.new(
444
+ user_management: user_management,
445
+ client_id: client_id,
446
+ session_data: session_data,
447
+ cookie_password: cookie_password,
448
+ encryptor: custom_encryptor,
449
+ )
450
+ expect(session.encryptor).to eq(custom_encryptor)
451
+ end
452
+
453
+ it 'defaults to AesGcm encryptor when none provided' do
454
+ session = WorkOS::Session.new(
455
+ user_management: user_management,
456
+ client_id: client_id,
457
+ session_data: session_data,
458
+ cookie_password: cookie_password,
459
+ )
460
+ expect(session.encryptor).to be_a(WorkOS::Encryptors::AesGcm)
461
+ end
462
+
463
+ it 'raises ArgumentError for invalid encryptor' do
464
+ expect do
465
+ WorkOS::Session.new(
466
+ user_management: user_management,
467
+ client_id: client_id,
468
+ session_data: session_data,
469
+ cookie_password: cookie_password,
470
+ encryptor: Object.new,
471
+ )
472
+ end.to raise_error(ArgumentError, /must respond to/)
473
+ end
474
+ end
475
+ end
@@ -281,7 +281,8 @@ describe WorkOS::SSO do
281
281
  described_class.authorization_url(**args)
282
282
  end.to raise_error(
283
283
  ArgumentError,
284
- 'Okta is not a valid value. `provider` must be in ["GitHubOAuth", "GoogleOAuth", "MicrosoftOAuth"]',
284
+ 'Okta is not a valid value. `provider` must be in ' \
285
+ '["AppleOAuth", "GitHubOAuth", "GoogleOAuth", "MicrosoftOAuth"]',
285
286
  )
286
287
  end
287
288
  end
@@ -301,8 +302,15 @@ describe WorkOS::SSO do
301
302
  id: 'prof_01EEJTY9SZ1R350RB7B73SNBKF',
302
303
  idp_id: '116485463307139932699',
303
304
  last_name: 'Loblaw',
305
+ role: {
306
+ slug: 'member',
307
+ },
308
+ roles: [{
309
+ slug: 'member',
310
+ }],
304
311
  groups: nil,
305
312
  organization_id: 'org_01FG53X8636WSNW2WEKB2C31ZB',
313
+ custom_attributes: {},
306
314
  raw_attributes: {
307
315
  email: 'bob.loblaw@workos.com',
308
316
  family_name: 'Loblaw',
@@ -372,8 +380,17 @@ describe WorkOS::SSO do
372
380
  id: 'prof_01DRA1XNSJDZ19A31F183ECQW5',
373
381
  idp_id: '00u1klkowm8EGah2H357',
374
382
  last_name: 'Demo',
383
+ role: {
384
+ slug: 'admin',
385
+ },
386
+ roles: [{
387
+ slug: 'admin',
388
+ }],
375
389
  groups: %w[Admins Developers],
376
390
  organization_id: 'org_01FG53X8636WSNW2WEKB2C31ZB',
391
+ custom_attributes: {
392
+ license: 'professional',
393
+ },
377
394
  raw_attributes: {
378
395
  email: 'demo@workos-okta.com',
379
396
  first_name: 'WorkOS',
@@ -381,6 +398,7 @@ describe WorkOS::SSO do
381
398
  idp_id: '00u1klkowm8EGah2H357',
382
399
  last_name: 'Demo',
383
400
  groups: %w[Admins Developers],
401
+ license: 'professional',
384
402
  },
385
403
  }
386
404
 
@@ -404,10 +422,73 @@ describe WorkOS::SSO do
404
422
  expect do
405
423
  described_class.profile_and_token(**args)
406
424
  end.to raise_error(
407
- WorkOS::APIError,
408
- 'error: some error, error_description: some error description - request ID: request-id',
425
+ WorkOS::UnprocessableEntityError,
426
+ 'Status 422, some error - request ID: request-id',
409
427
  )
410
428
  end
429
+
430
+ it 'raises an exception with proper error object attributes' do
431
+ expect do
432
+ described_class.profile_and_token(**args)
433
+ end.to raise_error(WorkOS::UnprocessableEntityError)
434
+ end
435
+
436
+ it 'includes proper error attributes' do
437
+ error = begin
438
+ described_class.profile_and_token(**args)
439
+ rescue WorkOS::UnprocessableEntityError => e
440
+ e
441
+ end
442
+
443
+ expect(error.http_status).to eq(422)
444
+ expect(error.request_id).to eq('request-id')
445
+ expect(error.error).to eq('some error')
446
+ expect(error.message).to include('some error')
447
+ end
448
+ end
449
+
450
+ context 'with detailed field validation errors' do
451
+ before do
452
+ stub_request(:post, 'https://api.workos.com/sso/token').
453
+ with(headers: headers, body: request_body).
454
+ to_return(
455
+ headers: { 'X-Request-ID' => 'request-id' },
456
+ status: 422,
457
+ body: {
458
+ "message": 'Validation failed',
459
+ "code": 'invalid_request_parameters',
460
+ "errors": [
461
+ {
462
+ "field": 'code',
463
+ "code": 'missing_required_parameter',
464
+ "message": 'The code parameter is required',
465
+ }
466
+ ],
467
+ }.to_json,
468
+ )
469
+ end
470
+
471
+ it 'raises an exception with detailed field errors' do
472
+ expect do
473
+ described_class.profile_and_token(**args)
474
+ end.to raise_error(WorkOS::UnprocessableEntityError)
475
+ end
476
+
477
+ it 'includes detailed field error attributes' do
478
+ error = begin
479
+ described_class.profile_and_token(**args)
480
+ rescue WorkOS::UnprocessableEntityError => e
481
+ e
482
+ end
483
+
484
+ expect(error.http_status).to eq(422)
485
+ expect(error.request_id).to eq('request-id')
486
+ expect(error.code).to eq('invalid_request_parameters')
487
+ expect(error.errors).not_to be_nil
488
+ expect(error.errors).to include('code: missing_required_parameter')
489
+ expect(error.message).to include('Validation failed')
490
+ expect(error.message).to include('(code: missing_required_parameter)')
491
+ end
411
492
  end
412
493
 
413
494
  context 'with an expired code' do
@@ -428,11 +509,31 @@ describe WorkOS::SSO do
428
509
  expect do
429
510
  described_class.profile_and_token(**args)
430
511
  end.to raise_error(
431
- WorkOS::APIError,
432
- "error: invalid_grant, error_description: The code '01DVX3C5Z367SFHR8QNDMK7V24'" \
512
+ WorkOS::InvalidRequestError,
513
+ "Status 400, error: invalid_grant, error_description: The code '01DVX3C5Z367SFHR8QNDMK7V24'" \
433
514
  ' has expired or is invalid. - request ID: request-id',
434
515
  )
435
516
  end
517
+
518
+ it 'raises an exception with proper error object attributes' do
519
+ expect do
520
+ described_class.profile_and_token(**args)
521
+ end.to raise_error(WorkOS::InvalidRequestError)
522
+ end
523
+
524
+ it 'includes proper error attributes' do
525
+ error = begin
526
+ described_class.profile_and_token(**args)
527
+ rescue WorkOS::InvalidRequestError => e
528
+ e
529
+ end
530
+
531
+ expect(error.http_status).to eq(400)
532
+ expect(error.request_id).to eq('request-id')
533
+ expect(error.error).to eq('invalid_grant')
534
+ expect(error.error_description).to eq("The code '01DVX3C5Z367SFHR8QNDMK7V24' has expired or is invalid.")
535
+ expect(error.message).to include('invalid_grant')
536
+ end
436
537
  end
437
538
  end
438
539