descope 1.0.6 → 1.0.7

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/.github/workflows/publish-gem.yaml +10 -3
  4. data/.gitignore +2 -0
  5. data/.ruby-version +1 -1
  6. data/Gemfile +7 -7
  7. data/Gemfile.lock +68 -55
  8. data/README.md +159 -51
  9. data/examples/ruby-on-rails-api/descope/Gemfile +8 -8
  10. data/examples/ruby-on-rails-api/descope/Gemfile.lock +1 -1
  11. data/examples/ruby-on-rails-api/descope/package-lock.json +187 -131
  12. data/examples/ruby-on-rails-api/descope/package.json +1 -1
  13. data/examples/ruby-on-rails-api/descope/yarn.lock +182 -84
  14. data/lib/descope/api/v1/auth/enchantedlink.rb +3 -1
  15. data/lib/descope/api/v1/auth/magiclink.rb +3 -1
  16. data/lib/descope/api/v1/auth/otp.rb +3 -1
  17. data/lib/descope/api/v1/auth/password.rb +6 -2
  18. data/lib/descope/api/v1/auth/totp.rb +3 -1
  19. data/lib/descope/api/v1/auth.rb +47 -12
  20. data/lib/descope/api/v1/management/common.rb +20 -5
  21. data/lib/descope/api/v1/management/sso_application.rb +236 -0
  22. data/lib/descope/api/v1/management/sso_settings.rb +2 -24
  23. data/lib/descope/api/v1/management/user.rb +151 -13
  24. data/lib/descope/api/v1/management.rb +2 -0
  25. data/lib/descope/api/v1/session.rb +37 -4
  26. data/lib/descope/mixins/common.rb +1 -0
  27. data/lib/descope/mixins/http.rb +60 -9
  28. data/lib/descope/mixins/initializer.rb +2 -1
  29. data/lib/descope/mixins/logging.rb +12 -4
  30. data/lib/descope/version.rb +1 -1
  31. data/spec/descope/api/v1/auth_spec.rb +29 -0
  32. data/spec/descope/api/v1/auth_token_extraction_spec.rb +126 -0
  33. data/spec/descope/api/v1/session_refresh_spec.rb +98 -0
  34. data/spec/factories/user.rb +1 -1
  35. data/spec/integration/lib.descope/api/v1/auth/enchantedlink_spec.rb +1 -1
  36. data/spec/integration/lib.descope/api/v1/auth/magiclink_spec.rb +1 -1
  37. data/spec/integration/lib.descope/api/v1/auth/otp_spec.rb +1 -1
  38. data/spec/integration/lib.descope/api/v1/auth/session_spec.rb +49 -0
  39. data/spec/integration/lib.descope/api/v1/auth/totp_spec.rb +1 -1
  40. data/spec/integration/lib.descope/api/v1/management/access_key_spec.rb +3 -0
  41. data/spec/integration/lib.descope/api/v1/management/audit_spec.rb +5 -3
  42. data/spec/integration/lib.descope/api/v1/management/authz_spec.rb +2 -0
  43. data/spec/integration/lib.descope/api/v1/management/flow_spec.rb +3 -1
  44. data/spec/integration/lib.descope/api/v1/management/permissions_spec.rb +4 -2
  45. data/spec/integration/lib.descope/api/v1/management/project_spec.rb +2 -0
  46. data/spec/integration/lib.descope/api/v1/management/roles_spec.rb +2 -0
  47. data/spec/integration/lib.descope/api/v1/management/user_spec.rb +55 -6
  48. data/spec/lib.descope/api/v1/auth/enchantedlink_spec.rb +11 -2
  49. data/spec/lib.descope/api/v1/auth/password_spec.rb +10 -1
  50. data/spec/lib.descope/api/v1/auth_spec.rb +167 -5
  51. data/spec/lib.descope/api/v1/cookie_domain_fix_integration_spec.rb +245 -0
  52. data/spec/lib.descope/api/v1/management/sso_application_spec.rb +217 -0
  53. data/spec/lib.descope/api/v1/management/sso_settings_spec.rb +2 -2
  54. data/spec/lib.descope/api/v1/management/user_spec.rb +134 -46
  55. data/spec/lib.descope/api/v1/session_spec.rb +119 -6
  56. data/spec/lib.descope/mixins/http_spec.rb +218 -0
  57. data/spec/support/client_config.rb +0 -1
  58. data/spec/support/utils.rb +6 -0
  59. metadata +13 -8
@@ -4,17 +4,21 @@ require 'spec_helper'
4
4
 
5
5
  describe Descope::Api::V1::Management::User do
6
6
  before(:all) do
7
+ raise 'DESCOPE_MANAGEMENT_KEY is not set' if ENV['DESCOPE_MANAGEMENT_KEY'].nil?
8
+
9
+ @client = DescopeClient.new(Configuration.config)
10
+
7
11
  @password = SpecUtils.generate_password
8
12
  @new_password = SpecUtils.generate_password
9
13
  @user = build(:user)
10
- @client = DescopeClient.new(Configuration.config)
14
+
11
15
  include Descope::Mixins::Common::DeliveryMethod
12
16
  end
13
17
 
14
18
  after(:all) do
15
19
  all_users = @client.search_all_users
16
20
  all_users['users'].each do |user|
17
- if user['middleName'] == 'Ruby SDK User'
21
+ if user['middleName'] == "#{SpecUtils.build_prefix}Ruby-SDK-User"
18
22
  puts "Deleting ruby spec test user #{user['loginIds'][0]}"
19
23
  @client.delete_user(user['loginIds'][0])
20
24
  end
@@ -58,6 +62,41 @@ describe Descope::Api::V1::Management::User do
58
62
  expect(updated_user['first_name']).to eq(created_user[updated_first_name])
59
63
  end
60
64
 
65
+ it 'should patch a user' do
66
+ user = build(:user)
67
+ role_name = 'some-new-role'
68
+
69
+ # ensure no roles exist with that name
70
+ all_roles = @client.load_all_roles
71
+ all_roles['roles'].each do |role|
72
+ @client.delete_role(name: role['name']) if role['name'] == role_name
73
+ end
74
+
75
+ @client.create_role(name: role_name)
76
+ @client.create_user(**user)['user']
77
+ updated_first_name = 'new name'
78
+ updated_given_name = 'new given name'
79
+ update_phone_number = "+1#{Faker::Number.number(digits: 10)}"
80
+ updated_role_names = [role_name]
81
+ updated_middle_name = 'new middle name'
82
+ updated_user = @client.patch_user(
83
+ **user,
84
+ name: updated_first_name,
85
+ given_name: updated_given_name,
86
+ phone: update_phone_number,
87
+ role_names: updated_role_names,
88
+ middle_name: updated_middle_name
89
+ )['user']
90
+
91
+ puts "updated_user #{updated_user}"
92
+
93
+ expect(updated_user['name']).to eq(updated_first_name)
94
+ expect(updated_user['givenName']).to eq(updated_given_name)
95
+ expect(updated_user['phone']).to eq(update_phone_number)
96
+ expect(updated_user['roleNames']).to eq(updated_role_names)
97
+ expect(updated_user['middleName']).to eq(updated_middle_name)
98
+ end
99
+
61
100
  it 'should delete a user' do
62
101
  user = build(:user)
63
102
  created_user = @client.create_user(**user)['user']
@@ -77,18 +116,28 @@ describe Descope::Api::V1::Management::User do
77
116
  users = FactoryBot.build_list(:user, 5)
78
117
  @client.create_batch_users(users)
79
118
  all_users = @client.search_all_users
80
- sdk_users = all_users['users'].select { |user| user['middleName'] == 'Ruby SDK User' }
119
+ sdk_users = all_users['users'].select { |user| user['middleName'] == "#{SpecUtils.build_prefix}Ruby-SDK-User" }
81
120
  expect(sdk_users.length).to be >= 5
82
121
  end
83
122
 
84
123
  it 'should create a test user' do
85
124
  @client.delete_all_test_users
125
+ # ensure no roles exist with that name
126
+ role_name = 'some-new-role'
127
+ all_roles = @client.load_all_roles
128
+ all_roles['roles'].each do |role|
129
+ @client.delete_role(name: role['name']) if role['name'] == role_name
130
+ end
86
131
  sleep 5
132
+
87
133
  user_args = build(:user)
88
134
  test_user = @client.create_test_user(**user_args)['user']
89
- test_users = @client.search_all_users(test_users_only: true)['users']
135
+ @client.create_role(name: role_name)
136
+ @client.user_add_roles(login_id: test_user['loginIds'][0], role_names: [role_name])
137
+ test_users = @client.search_all_test_users(role_names: [role_name])['users']
90
138
  expect(test_users.length).to be >= 1
91
139
  expect(test_users[0]['loginIds'][0]).to eq(test_user['loginIds'][0])
140
+ expect(test_users[0]['roleNames']).to eq([role_name])
92
141
  end
93
142
 
94
143
  it 'should update user status' do
@@ -191,8 +240,8 @@ describe Descope::Api::V1::Management::User do
191
240
  new_password = SpecUtils.generate_password
192
241
  @client.set_password(login_id: user['loginIds'][0], password: new_password)
193
242
  @client.password_sign_in(login_id: user['loginIds'][0], password:)
194
- rescue Descope::ServerError => e
195
- expect(e.message).to match(/"errorCode":"E062909"/)
243
+ rescue Descope::Unauthorized => e
244
+ expect(e.message).to match(/"Invalid signin credentials"/)
196
245
  end
197
246
  end
198
247
 
@@ -51,7 +51,7 @@ describe Descope::Api::V1::EnchantedLink do
51
51
 
52
52
  it 'is expected to validate refresh token and not raise an error with refresh token and valid login options' do
53
53
  expect do
54
- @instance.send(:validate_refresh_token_provided, { mfa: true, stepup: true }, 'some-token')
54
+ @instance.send(:validate_refresh_token_provided, { mfa: true, stepup: true }, 'some-token')
55
55
  end.not_to raise_error
56
56
  end
57
57
 
@@ -148,7 +148,16 @@ describe Descope::Api::V1::EnchantedLink do
148
148
  end
149
149
 
150
150
  it 'is expected to get session by pending ref with enchanted link' do
151
- jwt_response = { 'fake': 'response' }
151
+ jwt_response = {
152
+ 'sessionJwt' => 'fake_session_jwt',
153
+ 'refreshJwt' => 'fake_refresh_jwt',
154
+ 'cookies' => {
155
+ 'refresh_token' => 'fake_refresh_cookie'
156
+ }
157
+ }
158
+ allow(@instance).to receive(:post).with(
159
+ GET_SESSION_ENCHANTEDLINK_AUTH_PATH, { pendingRef: 'pendingRef' }
160
+ ).and_return(jwt_response)
152
161
  allow(@instance).to receive(:generate_jwt_response).and_return(jwt_response)
153
162
 
154
163
  expect do
@@ -43,9 +43,18 @@ describe Descope::Api::V1::Password do
43
43
  end
44
44
 
45
45
  it 'is expected to sign in with password' do
46
+ response_body = {
47
+ 'sessionJwt' => 'fake_session_jwt',
48
+ 'refreshJwt' => 'fake_refresh_jwt',
49
+ 'cookies' => {
50
+ 'refresh_token' => 'fake_refresh_cookie'
51
+ }
52
+ }
53
+
46
54
  expect(@instance).to receive(:post).with(
47
55
  SIGN_IN_PASSWORD_PATH, { loginId: 'test', password: 's3cr3t', ssoAppId: nil }
48
- )
56
+ ).and_return(response_body)
57
+
49
58
  # stub the jwt_get_unverified_header method to return the kid of the public key created above
50
59
  allow(@instance).to receive(:generate_jwt_response).and_return({})
51
60
  expect { @instance.password_sign_in(login_id: 'test', password: 's3cr3t') }.not_to raise_error
@@ -249,7 +249,13 @@ describe Descope::Api::V1::Auth do
249
249
  end
250
250
 
251
251
  it 'is expected to select tenant' do
252
- jwt_response = { 'fake': 'response' }
252
+ jwt_response = {
253
+ 'sessionJwt' => 'fake_session_jwt',
254
+ 'refreshJwt' => 'fake_refresh_jwt',
255
+ 'cookies' => {
256
+ 'refresh_token' => 'fake_refresh_cookie'
257
+ }
258
+ }
253
259
 
254
260
  expect(@instance).to receive(:post).with(
255
261
  SELECT_TENANT_PATH, { tenantId: 'tenant123' }, {}, 'refresh-token'
@@ -390,20 +396,26 @@ describe Descope::Api::V1::Auth do
390
396
  end
391
397
 
392
398
  it 'is expected to successfully exchange access key without login_options' do
393
- jwt_response = { 'fake': 'response' }
399
+ jwt_response = {
400
+ 'sessionJwt' => 'fake_session_jwt',
401
+ 'refreshJwt' => 'fake_refresh_jwt'
402
+ }
394
403
  access_key = 'abc'
395
404
 
396
405
  expect(@instance).to receive(:post).with(
397
406
  EXCHANGE_AUTH_ACCESS_KEY_PATH, { loginOptions: {}, audience: 'IT' }, {}, access_key
398
407
  ).and_return(jwt_response)
399
408
 
400
- allow(@instance).to receive(:generate_jwt_response).and_return(jwt_response)
409
+ allow(@instance).to receive(:generate_auth_info).and_return(jwt_response)
401
410
 
402
411
  expect { @instance.exchange_access_key(access_key:, audience: 'IT') }.not_to raise_error
403
412
  end
404
413
 
405
414
  it 'is expected to successfully exchange access key with login_options' do
406
- jwt_response = { 'fake': 'response' }
415
+ jwt_response = {
416
+ 'sessionJwt' => 'fake_session_jwt',
417
+ 'refreshJwt' => 'fake_refresh_jwt'
418
+ }
407
419
  access_key = 'abc'
408
420
 
409
421
  expect(@instance).to receive(:post).with(
@@ -413,9 +425,159 @@ describe Descope::Api::V1::Auth do
413
425
  access_key
414
426
  ).and_return(jwt_response)
415
427
 
416
- allow(@instance).to receive(:generate_jwt_response).and_return(jwt_response)
428
+ allow(@instance).to receive(:generate_auth_info).and_return(jwt_response)
417
429
 
418
430
  expect { @instance.exchange_access_key(access_key:, login_options: { customClaims: { k1: 'v1' } }, audience: 'IT') }.not_to raise_error
419
431
  end
420
432
  end
433
+
434
+ describe '#generate_auth_info cookie handling enhancements' do
435
+ let(:audience) { nil }
436
+ let(:session_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.session_sig' }
437
+ let(:refresh_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.refresh_sig' }
438
+
439
+ let(:mock_token_validation) do
440
+ {
441
+ 'iss' => 'https://api.descope.com/P2abcde12345',
442
+ 'sub' => 'U2abcde12345',
443
+ 'permissions' => ['read', 'write'],
444
+ 'roles' => ['admin'],
445
+ 'tenants' => { 'tenant1' => { 'permissions' => ['read'] } }
446
+ }
447
+ end
448
+
449
+ before do
450
+ allow(@instance).to receive(:validate_token).and_return(mock_token_validation)
451
+ end
452
+
453
+ context 'when session token is in cookies (custom domain scenario)' do
454
+ let(:response_body) do
455
+ {
456
+ 'userId' => 'test123',
457
+ 'cookieExpiration' => 1640704758,
458
+ 'cookieDomain' => 'dev.lulukuku.com',
459
+ 'cookies' => {
460
+ 'DS' => session_jwt, # Session token in cookies
461
+ 'DSR' => refresh_jwt # Refresh token in cookies
462
+ }
463
+ }
464
+ end
465
+
466
+ it 'extracts session token from cookies when not in response body' do
467
+ result = @instance.send(:generate_auth_info, response_body, nil, true, audience)
468
+
469
+ expect(result['sessionToken']).to eq(mock_token_validation)
470
+ expect(result['refreshSessionToken']).to eq(mock_token_validation)
471
+ end
472
+
473
+ it 'validates session token from cookies' do
474
+ expect(@instance).to receive(:validate_token).with(session_jwt, audience).and_return(mock_token_validation)
475
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return(mock_token_validation)
476
+
477
+ @instance.send(:generate_auth_info, response_body, nil, true, audience)
478
+ end
479
+
480
+ it 'includes permissions and roles from cookie tokens' do
481
+ result = @instance.send(:generate_auth_info, response_body, nil, true, audience)
482
+
483
+ expect(result['permissions']).to eq(['read', 'write'])
484
+ expect(result['roles']).to eq(['admin'])
485
+ expect(result['tenants']).to eq({ 'tenant1' => { 'permissions' => ['read'] } })
486
+ end
487
+ end
488
+
489
+ context 'when session token is in response body and refresh token in cookies' do
490
+ let(:response_body) do
491
+ {
492
+ 'sessionJwt' => session_jwt, # Session token in response body
493
+ 'userId' => 'test123',
494
+ 'cookies' => {
495
+ 'DSR' => refresh_jwt # Only refresh token in cookies
496
+ }
497
+ }
498
+ end
499
+
500
+ it 'uses session token from response body and refresh token from cookies' do
501
+ expect(@instance).to receive(:validate_token).with(session_jwt, audience).and_return(mock_token_validation)
502
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return(mock_token_validation)
503
+
504
+ result = @instance.send(:generate_auth_info, response_body, nil, true, audience)
505
+
506
+ expect(result['sessionToken']).to eq(mock_token_validation)
507
+ expect(result['refreshSessionToken']).to eq(mock_token_validation)
508
+ end
509
+ end
510
+
511
+ context 'when refresh token is passed as parameter' do
512
+ let(:response_body) do
513
+ {
514
+ 'userId' => 'test123',
515
+ 'cookies' => {
516
+ 'DS' => session_jwt # Only session token in cookies
517
+ }
518
+ }
519
+ end
520
+
521
+ it 'uses passed refresh token when not in response body or cookies' do
522
+ expect(@instance).to receive(:validate_token).with(session_jwt, audience).and_return(mock_token_validation)
523
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return(mock_token_validation)
524
+
525
+ result = @instance.send(:generate_auth_info, response_body, refresh_jwt, true, audience)
526
+
527
+ expect(result['sessionToken']).to eq(mock_token_validation)
528
+ expect(result['refreshSessionToken']).to eq(mock_token_validation)
529
+ end
530
+ end
531
+
532
+ context 'error handling for missing tokens' do
533
+ let(:response_body) do
534
+ {
535
+ 'userId' => 'test123',
536
+ 'cookieExpiration' => 1640704758,
537
+ 'cookies' => {} # No tokens anywhere
538
+ }
539
+ end
540
+
541
+ it 'raises helpful error when no refresh token is found' do
542
+ expect {
543
+ @instance.send(:generate_auth_info, response_body, nil, true, audience)
544
+ }.to raise_error(Descope::AuthException, /Could not find refreshJwt in response body \/ cookies \/ passed in refresh_token/)
545
+ end
546
+ end
547
+
548
+ context 'backward compatibility' do
549
+ let(:traditional_response_body) do
550
+ {
551
+ 'sessionJwt' => session_jwt,
552
+ 'refreshJwt' => refresh_jwt,
553
+ 'userId' => 'test123'
554
+ }
555
+ end
556
+
557
+ it 'continues to work with traditional response body tokens' do
558
+ expect(@instance).to receive(:validate_token).with(session_jwt, audience).and_return(mock_token_validation)
559
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return(mock_token_validation)
560
+
561
+ result = @instance.send(:generate_auth_info, traditional_response_body, nil, true, audience)
562
+
563
+ expect(result['sessionToken']).to eq(mock_token_validation)
564
+ expect(result['refreshSessionToken']).to eq(mock_token_validation)
565
+ end
566
+
567
+ it 'works with same-domain cookies (existing RestClient behavior)' do
568
+ response_with_restclient_cookies = {
569
+ 'userId' => 'test123',
570
+ 'cookies' => {
571
+ 'DSR' => refresh_jwt
572
+ }
573
+ }
574
+
575
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return(mock_token_validation)
576
+
577
+ result = @instance.send(:generate_auth_info, response_with_restclient_cookies, nil, false, audience)
578
+
579
+ expect(result['refreshSessionToken']).to eq(mock_token_validation)
580
+ end
581
+ end
582
+ end
421
583
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Cookie Domain Fix Integration' do
6
+ before(:all) do
7
+ dummy_instance = DummyClass.new
8
+ dummy_instance.extend(Descope::Api::V1::Session)
9
+ dummy_instance.extend(Descope::Api::V1::Auth)
10
+ dummy_instance.extend(Descope::Mixins::HTTP)
11
+ dummy_instance.extend(Descope::Mixins::Common::EndpointsV1)
12
+ @instance = dummy_instance
13
+ end
14
+
15
+ describe 'refresh_session with custom domain cookies' do
16
+ let(:refresh_token) { 'test_refresh_token' }
17
+ let(:audience) { nil }
18
+
19
+ let(:session_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbS9QMmFiY2RlMTIzNDUiLCJzdWIiOiJVMmFiY2RlMTIzNDUifQ.session_signature' }
20
+ let(:refresh_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbS9QMmFiY2RlMTIzNDUiLCJzdWIiOiJVMmFiY2RlMTIzNDUifQ.refresh_signature' }
21
+
22
+ context 'when Descope is configured for cookie-only tokens with custom domain' do
23
+ let(:api_response_body) do
24
+ # Response body without sessionJwt/refreshJwt (cookie-only configuration)
25
+ {
26
+ 'userId' => 'test123',
27
+ 'cookieExpiration' => 1640704758,
28
+ 'cookieDomain' => 'dev.lulukuku.com',
29
+ 'cookiePath' => '/'
30
+ }
31
+ end
32
+
33
+ let(:set_cookie_headers) do
34
+ [
35
+ "DS=#{session_jwt}; Path=/; Domain=dev.lulukuku.com; HttpOnly; Secure; SameSite=None",
36
+ "DSR=#{refresh_jwt}; Path=/; Domain=dev.lulukuku.com; HttpOnly; Secure; SameSite=None; Max-Age=2592000"
37
+ ]
38
+ end
39
+
40
+ let(:mock_response) do
41
+ double('response').tap do |response|
42
+ allow(response).to receive(:code).and_return(200)
43
+ allow(response).to receive(:body).and_return(api_response_body.to_json)
44
+ allow(response).to receive(:cookies).and_return({}) # RestClient filters out custom domain cookies
45
+ allow(response).to receive(:headers).and_return({ 'set-cookie' => set_cookie_headers })
46
+ end
47
+ end
48
+
49
+ before do
50
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
51
+ allow(@instance).to receive(:validate_token).and_return({
52
+ 'iss' => 'https://api.descope.com/P2abcde12345',
53
+ 'sub' => 'U2abcde12345',
54
+ 'permissions' => [],
55
+ 'roles' => [],
56
+ 'tenants' => {}
57
+ })
58
+ allow(@instance).to receive(:call).and_return(mock_response)
59
+ end
60
+
61
+ it 'successfully extracts tokens from Set-Cookie headers' do
62
+ result = @instance.refresh_session(refresh_token: refresh_token, audience: audience)
63
+ expect(result).to be_a(Hash)
64
+ expect(result['sessionToken']).to be_a(Hash)
65
+ expect(result['sessionToken']['iss']).to eq('https://api.descope.com/P2abcde12345')
66
+ expect(result['sessionToken']['sub']).to eq('U2abcde12345')
67
+
68
+ expect(result['refreshSessionToken']).to be_a(Hash)
69
+ expect(result['refreshSessionToken']['iss']).to eq('https://api.descope.com/P2abcde12345')
70
+ expect(result['refreshSessionToken']['sub']).to eq('U2abcde12345')
71
+
72
+ expect(result['cookieData'][:domain]).to eq('dev.lulukuku.com')
73
+ end
74
+
75
+ it 'validates the extracted session token' do
76
+ expect(@instance).to receive(:validate_token).with(session_jwt, audience).and_return({
77
+ 'iss' => 'https://api.descope.com/P2abcde12345',
78
+ 'sub' => 'U2abcde12345'
79
+ })
80
+
81
+ @instance.refresh_session(refresh_token: refresh_token, audience: audience)
82
+ end
83
+
84
+ it 'validates the extracted refresh token' do
85
+ expect(@instance).to receive(:validate_token).with(refresh_jwt, audience).and_return({
86
+ 'iss' => 'https://api.descope.com/P2abcde12345',
87
+ 'sub' => 'U2abcde12345'
88
+ })
89
+
90
+ @instance.refresh_session(refresh_token: refresh_token, audience: audience)
91
+ end
92
+
93
+ it 'includes cookie metadata in response' do
94
+ result = @instance.refresh_session(refresh_token: refresh_token, audience: audience)
95
+ expect(result['cookieData'][:domain]).to eq('dev.lulukuku.com')
96
+ end
97
+ end
98
+
99
+ context 'when only refresh token is in cookies (partial custom domain)' do
100
+ let(:api_response_body) do
101
+ {
102
+ 'sessionJwt' => session_jwt, # Session token in response body
103
+ 'userId' => 'test123',
104
+ 'cookieExpiration' => 1640704758,
105
+ 'cookieDomain' => 'dev.lulukuku.com'
106
+ }
107
+ end
108
+
109
+ let(:set_cookie_headers) do
110
+ [
111
+ "DSR=#{refresh_jwt}; Path=/; Domain=dev.lulukuku.com; HttpOnly; Secure; Max-Age=2592000"
112
+ ]
113
+ end
114
+
115
+ let(:mock_response) do
116
+ double('response').tap do |response|
117
+ allow(response).to receive(:code).and_return(200)
118
+ allow(response).to receive(:body).and_return(api_response_body.to_json)
119
+ allow(response).to receive(:cookies).and_return({})
120
+ allow(response).to receive(:headers).and_return({ 'set-cookie' => set_cookie_headers })
121
+ end
122
+ end
123
+
124
+ before do
125
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
126
+ allow(@instance).to receive(:validate_token).and_return({
127
+ 'iss' => 'https://api.descope.com/P2abcde12345',
128
+ 'sub' => 'U2abcde12345',
129
+ 'permissions' => [],
130
+ 'roles' => [],
131
+ 'tenants' => {}
132
+ })
133
+ allow(@instance).to receive(:call).and_return(mock_response)
134
+ end
135
+
136
+ it 'handles mixed token sources (response body + custom domain cookies)' do
137
+ result = @instance.refresh_session(refresh_token: refresh_token, audience: audience)
138
+
139
+ expect(result).to be_a(Hash)
140
+ expect(result['sessionToken']).to_not be_nil
141
+ expect(result['refreshSessionToken']).to_not be_nil
142
+ end
143
+ end
144
+
145
+ context 'error handling for custom domain configurations' do
146
+ let(:api_response_body) do
147
+ {
148
+ 'userId' => 'test123',
149
+ 'cookieExpiration' => 1640704758,
150
+ 'cookieDomain' => 'dev.lulukuku.com'
151
+ }
152
+ end
153
+
154
+ let(:mock_response_no_cookies) do
155
+ double('response').tap do |response|
156
+ allow(response).to receive(:code).and_return(200)
157
+ allow(response).to receive(:body).and_return(api_response_body.to_json)
158
+ allow(response).to receive(:cookies).and_return({})
159
+ allow(response).to receive(:headers).and_return({}) # No Set-Cookie headers
160
+ end
161
+ end
162
+
163
+ before do
164
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
165
+ allow(@instance).to receive(:validate_token).and_return({
166
+ 'iss' => 'https://api.descope.com/P2abcde12345',
167
+ 'sub' => 'U2abcde12345'
168
+ })
169
+ allow(@instance).to receive(:call).and_return(mock_response_no_cookies)
170
+ end
171
+
172
+ it 'provides helpful error message when no tokens are found' do
173
+ result = @instance.refresh_session(refresh_token: refresh_token, audience: audience)
174
+ expect(result).to be_a(Hash)
175
+ expect(result['sessionToken']).to be_nil
176
+ expect(result['refreshSessionToken']).to_not be_nil
177
+ end
178
+ end
179
+ end
180
+
181
+ describe 'validate_and_refresh_session with custom domain cookies' do
182
+ let(:session_token) { 'expired_session_token' }
183
+ let(:refresh_token) { 'valid_refresh_token' }
184
+ let(:audience) { nil }
185
+
186
+ context 'when session is expired and refresh uses custom domain cookies' do
187
+ let(:refresh_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.signature' }
188
+
189
+ let(:api_response_body) do
190
+ {
191
+ 'userId' => 'test123',
192
+ 'cookieExpiration' => 1640704758,
193
+ 'cookieDomain' => 'dev.lulukuku.com'
194
+ }
195
+ end
196
+
197
+ let(:set_cookie_headers) do
198
+ [
199
+ "DS=new_session_jwt; Path=/; Domain=dev.lulukuku.com; HttpOnly; Secure",
200
+ "DSR=#{refresh_jwt}; Path=/; Domain=dev.lulukuku.com; HttpOnly; Secure; Max-Age=2592000"
201
+ ]
202
+ end
203
+
204
+ let(:mock_response) do
205
+ double('response').tap do |response|
206
+ allow(response).to receive(:code).and_return(200)
207
+ allow(response).to receive(:body).and_return(api_response_body.to_json)
208
+ allow(response).to receive(:cookies).and_return({})
209
+ allow(response).to receive(:headers).and_return({ 'set-cookie' => set_cookie_headers })
210
+ end
211
+ end
212
+
213
+ before do
214
+ # Mock session validation to fail (expired token)
215
+ allow(@instance).to receive(:validate_session).and_raise(Descope::AuthException.new('Token expired'))
216
+
217
+ # Mock refresh_session to work with custom domain cookies
218
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
219
+ allow(@instance).to receive(:validate_token).and_return({
220
+ 'iss' => 'https://api.descope.com/P2abcde12345',
221
+ 'sub' => 'U2abcde12345',
222
+ 'permissions' => [],
223
+ 'roles' => [],
224
+ 'tenants' => {}
225
+ })
226
+ allow(@instance).to receive(:call).and_return(mock_response)
227
+ end
228
+
229
+ it 'falls back to refresh_session when validate_session fails' do
230
+ expect(@instance).to receive(:refresh_session).with(
231
+ refresh_token: refresh_token,
232
+ audience: audience
233
+ ).and_call_original
234
+
235
+ result = @instance.validate_and_refresh_session(
236
+ session_token: session_token,
237
+ refresh_token: refresh_token,
238
+ audience: audience
239
+ )
240
+
241
+ expect(result).to be_a(Hash)
242
+ end
243
+ end
244
+ end
245
+ end