descope 1.0.6 → 1.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +51 -12
  3. data/.github/workflows/publish-gem.yaml +6 -26
  4. data/.github/workflows/release-please.yaml +36 -0
  5. data/.gitignore +5 -2
  6. data/.release-please-manifest.json +1 -1
  7. data/.ruby-version +1 -1
  8. data/CHANGELOG.md +21 -0
  9. data/Gemfile +8 -7
  10. data/Gemfile.lock +70 -56
  11. data/README.md +170 -51
  12. data/examples/ruby-on-rails-api/descope/Gemfile +8 -8
  13. data/examples/ruby-on-rails-api/descope/Gemfile.lock +1 -1
  14. data/examples/ruby-on-rails-api/descope/package-lock.json +203 -141
  15. data/examples/ruby-on-rails-api/descope/package.json +1 -1
  16. data/examples/ruby-on-rails-api/descope/yarn.lock +185 -87
  17. data/lib/descope/api/v1/auth/enchantedlink.rb +3 -1
  18. data/lib/descope/api/v1/auth/magiclink.rb +3 -1
  19. data/lib/descope/api/v1/auth/otp.rb +3 -1
  20. data/lib/descope/api/v1/auth/password.rb +6 -2
  21. data/lib/descope/api/v1/auth/totp.rb +3 -1
  22. data/lib/descope/api/v1/auth.rb +47 -12
  23. data/lib/descope/api/v1/management/common.rb +20 -5
  24. data/lib/descope/api/v1/management/sso_application.rb +236 -0
  25. data/lib/descope/api/v1/management/sso_settings.rb +2 -24
  26. data/lib/descope/api/v1/management/user.rb +151 -13
  27. data/lib/descope/api/v1/management.rb +2 -0
  28. data/lib/descope/api/v1/session.rb +37 -4
  29. data/lib/descope/mixins/common.rb +1 -0
  30. data/lib/descope/mixins/http.rb +60 -9
  31. data/lib/descope/mixins/initializer.rb +5 -2
  32. data/lib/descope/mixins/logging.rb +12 -4
  33. data/lib/descope/version.rb +1 -1
  34. data/spec/descope/api/v1/auth_spec.rb +29 -0
  35. data/spec/descope/api/v1/auth_token_extraction_spec.rb +126 -0
  36. data/spec/descope/api/v1/session_refresh_spec.rb +98 -0
  37. data/spec/factories/user.rb +1 -1
  38. data/spec/integration/lib.descope/api/v1/auth/enchantedlink_spec.rb +20 -22
  39. data/spec/integration/lib.descope/api/v1/auth/magiclink_spec.rb +6 -2
  40. data/spec/integration/lib.descope/api/v1/auth/otp_spec.rb +6 -2
  41. data/spec/integration/lib.descope/api/v1/auth/session_spec.rb +68 -0
  42. data/spec/integration/lib.descope/api/v1/auth/totp_spec.rb +6 -2
  43. data/spec/integration/lib.descope/api/v1/management/access_key_spec.rb +12 -1
  44. data/spec/integration/lib.descope/api/v1/management/audit_spec.rb +5 -3
  45. data/spec/integration/lib.descope/api/v1/management/authz_spec.rb +28 -5
  46. data/spec/integration/lib.descope/api/v1/management/flow_spec.rb +3 -1
  47. data/spec/integration/lib.descope/api/v1/management/permissions_spec.rb +22 -2
  48. data/spec/integration/lib.descope/api/v1/management/project_spec.rb +18 -2
  49. data/spec/integration/lib.descope/api/v1/management/roles_spec.rb +116 -36
  50. data/spec/integration/lib.descope/api/v1/management/user_spec.rb +74 -8
  51. data/spec/lib.descope/api/v1/auth/enchantedlink_spec.rb +11 -2
  52. data/spec/lib.descope/api/v1/auth/password_spec.rb +10 -1
  53. data/spec/lib.descope/api/v1/auth_spec.rb +167 -5
  54. data/spec/lib.descope/api/v1/cookie_domain_fix_integration_spec.rb +245 -0
  55. data/spec/lib.descope/api/v1/management/sso_application_spec.rb +217 -0
  56. data/spec/lib.descope/api/v1/management/sso_settings_spec.rb +2 -2
  57. data/spec/lib.descope/api/v1/management/user_spec.rb +134 -46
  58. data/spec/lib.descope/api/v1/session_spec.rb +119 -6
  59. data/spec/lib.descope/mixins/http_spec.rb +229 -0
  60. data/spec/support/client_config.rb +0 -1
  61. data/spec/support/utils.rb +21 -0
  62. metadata +14 -8
@@ -9,55 +9,69 @@ describe Descope::Api::V1::Management::User do
9
9
  @instance = dummy_instance
10
10
  end
11
11
 
12
- context '.create_user' do
12
+ context '.create_user_and_test_user' do
13
13
  it 'is expected to respond to a user create method' do
14
14
  expect(@instance).to respond_to(:create_user)
15
+ expect(@instance).to respond_to(:create_test_user)
15
16
  end
16
17
 
18
+ user_tenants_args = [
19
+ {
20
+ tenant_id: 'tenant1'
21
+ },
22
+ {
23
+ tenant_id: 'tenant2',
24
+ role_names: %w[role1 role2]
25
+ }
26
+ ]
27
+
28
+ params = {
29
+ loginId: 'name@mail.com',
30
+ email: 'name@mail.com',
31
+ phone: '+1-212-669-2542',
32
+ name: 'name',
33
+ givenName: 'name',
34
+ familyName: 'Ruby SDK',
35
+ userTenants: associated_tenants_to_hash_array(user_tenants_args),
36
+ test: false,
37
+ picture: 'https://www.example.com/picture.png',
38
+ customAttributes: { 'attr1' => 'value1', 'attr2' => 'value2' },
39
+ additionalIdentifiers: %w[id-1 id-2],
40
+ password: 's3cr3t',
41
+ ssoAppIds: %w[app1 app2],
42
+ invite: false
43
+ }
44
+
45
+ args = {
46
+ login_id: 'name@mail.com',
47
+ email: 'name@mail.com',
48
+ phone: '+1-212-669-2542',
49
+ name: 'name',
50
+ given_name: 'name',
51
+ family_name: 'Ruby SDK',
52
+ user_tenants: user_tenants_args,
53
+ picture: 'https://www.example.com/picture.png',
54
+ custom_attributes: { 'attr1' => 'value1', 'attr2' => 'value2' },
55
+ additional_identifiers: %w[id-1 id-2],
56
+ password: 's3cr3t',
57
+ sso_app_ids: %w[app1 app2]
58
+ }
59
+
17
60
  it 'is expected to create a user with user data' do
18
- user_tenants_args = [
19
- {
20
- tenant_id: 'tenant1'
21
- },
22
- {
23
- tenant_id: 'tenant2',
24
- role_names: %w[role1 role2]
25
- }
26
- ]
27
- expect(@instance).to receive(:post).with(
28
- USER_CREATE_PATH, {
29
- loginId: 'name@mail.com',
30
- email: 'name@mail.com',
31
- phone: '+1-212-669-2542',
32
- name: 'name',
33
- givenName: 'name',
34
- familyName: 'Ruby SDK',
35
- userTenants: associated_tenants_to_hash_array(user_tenants_args),
36
- test: false,
37
- picture: 'https://www.example.com/picture.png',
38
- customAttributes: { 'attr1' => 'value1', 'attr2' => 'value2' },
39
- additionalIdentifiers: %w[id-1 id-2],
40
- password: 's3cr3t',
41
- ssoAppIds: %w[app1 app2],
42
- invite: false
43
- }
44
- )
61
+ expect(@instance).to receive(:post).with(USER_CREATE_PATH, params)
45
62
 
46
63
  expect do
47
- @instance.create_user(
48
- login_id: 'name@mail.com',
49
- email: 'name@mail.com',
50
- phone: '+1-212-669-2542',
51
- name: 'name',
52
- given_name: 'name',
53
- family_name: 'Ruby SDK',
54
- user_tenants: user_tenants_args,
55
- picture: 'https://www.example.com/picture.png',
56
- custom_attributes: { 'attr1' => 'value1', 'attr2' => 'value2' },
57
- additional_identifiers: %w[id-1 id-2],
58
- password: 's3cr3t',
59
- sso_app_ids: %w[app1 app2]
60
- )
64
+ @instance.create_user(**args)
65
+ end.not_to raise_error
66
+ end
67
+
68
+ it 'is expected to create a test user with user data' do
69
+ params[:test] = true
70
+ expect(@instance).to receive(:post).with(TEST_USER_CREATE_PATH, params)
71
+
72
+ expect do
73
+ args[:test] = true
74
+ @instance.create_test_user(**args)
61
75
  end.not_to raise_error
62
76
  end
63
77
  end
@@ -108,14 +122,16 @@ describe Descope::Api::V1::Management::User do
108
122
  loginId: 'name@mail.com',
109
123
  email: 'name@mail.com',
110
124
  test: false,
111
- invite: true
125
+ invite: true,
126
+ templateId: "tid",
112
127
  }
113
128
  )
114
129
 
115
130
  expect do
116
131
  @instance.invite_user(
117
132
  login_id: 'name@mail.com',
118
- email: 'name@mail.com'
133
+ email: 'name@mail.com',
134
+ template_id: "tid",
119
135
  )
120
136
  end.not_to raise_error
121
137
  end
@@ -223,6 +239,8 @@ describe Descope::Api::V1::Management::User do
223
239
  it 'is expected to respond to a search_all method' do
224
240
  expect(@instance).to respond_to(:search_all_users)
225
241
 
242
+ tenant_role_ids = { 'tenant1' => ['roleA', 'roleB'] }
243
+ tenant_role_names = { 'tenant1' => ['roleName1', 'roleName2'] }
226
244
  expect(@instance).to receive(:post).with(
227
245
  USERS_SEARCH_PATH, {
228
246
  loginId: 'someone@example.com',
@@ -234,7 +252,13 @@ describe Descope::Api::V1::Management::User do
234
252
  ssoOnly: false,
235
253
  text: 'some text',
236
254
  testUsersOnly: false,
237
- withTestUser: false
255
+ withTestUser: false,
256
+ tenantRoleIds: {
257
+ 'tenant1' => { values: ['roleA', 'roleB'] }
258
+ },
259
+ tenantRoleNames: {
260
+ 'tenant1' => { values: ['roleName1', 'roleName2'] }
261
+ }
238
262
  }
239
263
  )
240
264
 
@@ -248,7 +272,9 @@ describe Descope::Api::V1::Management::User do
248
272
  page: 1,
249
273
  sso_app_ids: [],
250
274
  test_users_only: false,
251
- with_test_user: false
275
+ with_test_user: false,
276
+ tenant_role_ids: tenant_role_ids,
277
+ tenant_role_names: tenant_role_names
252
278
  )
253
279
  end.not_to raise_error
254
280
  end
@@ -704,4 +730,66 @@ describe Descope::Api::V1::Management::User do
704
730
  end.not_to raise_error
705
731
  end
706
732
  end
733
+
734
+ context '.patch_user' do
735
+ it 'is expected to respond to a patch user method' do
736
+ expect(@instance).to respond_to(:patch_user)
737
+ end
738
+
739
+ it 'is expected to respond to a user patch method' do
740
+ expect(@instance).to receive(:patch).with(
741
+ USER_PATCH_PATH, {
742
+ loginId: 'name@mail.com',
743
+ email: 'name@mail.com',
744
+ givenName: 'mister',
745
+ name: 'something else',
746
+ test: false,
747
+ invite: false
748
+ }
749
+ )
750
+
751
+ expect do
752
+ @instance.patch_user(
753
+ login_id: 'name@mail.com',
754
+ email: 'name@mail.com',
755
+ given_name: 'mister',
756
+ name: 'something else'
757
+ )
758
+ end.not_to raise_error
759
+ end
760
+ end
761
+
762
+ context '.search_all_test_users' do
763
+ it 'is expected to respond to a search_all_test_users method' do
764
+ expect(@instance).to respond_to(:search_all_test_users)
765
+
766
+ tenant_role_ids = { 'tenant1' => ['roleA', 'roleB'] }
767
+ tenant_role_names = { 'tenant1' => ['roleName1', 'roleName2'] }
768
+ expect(@instance).to receive(:post).with(
769
+ TEST_USERS_SEARCH_PATH, {
770
+ tenantIds: %w[t1 t2],
771
+ roleNames: %w[r1 r2],
772
+ limit: 0,
773
+ page: 0,
774
+ testUsersOnly: true,
775
+ withTestUser: true,
776
+ tenantRoleIds: {
777
+ 'tenant1' => { values: ['roleA', 'roleB'] }
778
+ },
779
+ tenantRoleNames: {
780
+ 'tenant1' => { values: ['roleName1', 'roleName2'] }
781
+ }
782
+ }
783
+ )
784
+
785
+ expect do
786
+ @instance.search_all_test_users(
787
+ tenant_ids: %w[t1 t2],
788
+ role_names: %w[r1 r2],
789
+ tenant_role_ids: tenant_role_ids,
790
+ tenant_role_names: tenant_role_names
791
+ )
792
+ end.not_to raise_error
793
+ end
794
+ end
707
795
  end
@@ -31,12 +31,32 @@ describe Descope::Api::V1::Session do
31
31
  end
32
32
 
33
33
  it 'is expected to post refresh session' do
34
- jwt_response = { 'fake': 'response' }
35
- allow(@instance).to receive(:generate_jwt_response).and_return(jwt_response)
36
-
37
- expect(@instance).to receive(:post).with(REFRESH_TOKEN_PATH, {}, {}, 'refresh_token')
38
- allow(@instance).to receive(:validate_token).with('refresh_token', nil).and_return({})
39
- expect { @instance.refresh_session(refresh_token: 'refresh_token') }.not_to raise_error
34
+ jwt_response = {
35
+ 'sessionJwt' => 'fake_session_jwt',
36
+ 'refreshJwt' => 'fake_refresh_jwt',
37
+ 'cookies' => {
38
+ 'refresh_token' => 'fake_refresh_cookie'
39
+ }
40
+ }
41
+ refresh_token = 'refresh_token'
42
+ audience = nil
43
+
44
+ allow(@instance).to receive(:validate_refresh_token_not_nil).with(refresh_token).and_return(true)
45
+ allow(@instance).to receive(:validate_token).with(refresh_token, audience).and_return(true)
46
+ allow(@instance).to receive(:post).with(REFRESH_TOKEN_PATH, {}, {}, refresh_token).and_return(jwt_response)
47
+ refresh_cookie = jwt_response['cookies'][REFRESH_SESSION_COOKIE_NAME] || jwt_response['refreshJwt']
48
+
49
+ allow(@instance).to receive(:generate_jwt_response).with(
50
+ response_body: jwt_response,
51
+ refresh_cookie:,
52
+ audience:
53
+ ).and_return(jwt_response)
54
+
55
+ expect { @instance.refresh_session(refresh_token:, audience:) }.not_to raise_error
56
+
57
+ # Optionally verify the response if needed
58
+ result = @instance.refresh_session(refresh_token:, audience:)
59
+ expect(result).to eq(jwt_response)
40
60
  end
41
61
  end
42
62
 
@@ -114,4 +134,97 @@ describe Descope::Api::V1::Session do
114
134
  expect { @instance.validate_and_refresh_session(session_token: 'session_token', refresh_token: 'refresh_token') }.to_not raise_error
115
135
  end
116
136
  end
137
+
138
+ context 'cookie domain fix for refresh_session' do
139
+ let(:refresh_token) { 'test_refresh_token' }
140
+ let(:session_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature' }
141
+ let(:refresh_jwt) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.refresh_sig' }
142
+
143
+ context 'when using cookie-only tokens with custom domain' do
144
+ let(:cookie_only_response) do
145
+ {
146
+ 'userId' => 'test123',
147
+ 'cookieExpiration' => 1640704758,
148
+ 'cookieDomain' => 'dev.lulukuku.com',
149
+ 'cookies' => {
150
+ 'DS' => session_jwt,
151
+ 'DSR' => refresh_jwt
152
+ }
153
+ }
154
+ end
155
+
156
+ it 'extracts tokens from cookies when not in response body' do
157
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
158
+ allow(@instance).to receive(:validate_token).and_return({})
159
+ allow(@instance).to receive(:post).and_return(cookie_only_response)
160
+ allow(@instance).to receive(:generate_jwt_response).and_return(cookie_only_response)
161
+
162
+ expect { @instance.refresh_session(refresh_token: refresh_token) }.not_to raise_error
163
+
164
+ result = @instance.refresh_session(refresh_token: refresh_token)
165
+ expect(result).to eq(cookie_only_response)
166
+ end
167
+
168
+ it 'passes correct refresh_cookie to generate_jwt_response' do
169
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
170
+ allow(@instance).to receive(:validate_token).and_return({})
171
+ allow(@instance).to receive(:post).and_return(cookie_only_response)
172
+
173
+ # Verify that refresh_cookie is extracted correctly from cookies
174
+ expected_refresh_cookie = refresh_jwt
175
+ expect(@instance).to receive(:generate_jwt_response).with(
176
+ response_body: cookie_only_response,
177
+ refresh_cookie: expected_refresh_cookie,
178
+ audience: nil
179
+ ).and_return(cookie_only_response)
180
+
181
+ @instance.refresh_session(refresh_token: refresh_token)
182
+ end
183
+ end
184
+
185
+ context 'when using mixed configuration (some tokens in body, some in cookies)' do
186
+ let(:mixed_response) do
187
+ {
188
+ 'sessionJwt' => session_jwt, # Session token in response body
189
+ 'userId' => 'test123',
190
+ 'cookies' => {
191
+ 'DSR' => refresh_jwt # Refresh token in cookies only
192
+ }
193
+ }
194
+ end
195
+
196
+ it 'handles mixed token locations correctly' do
197
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
198
+ allow(@instance).to receive(:validate_token).and_return({})
199
+ allow(@instance).to receive(:post).and_return(mixed_response)
200
+ allow(@instance).to receive(:generate_jwt_response).and_return(mixed_response)
201
+
202
+ expect { @instance.refresh_session(refresh_token: refresh_token) }.not_to raise_error
203
+ end
204
+ end
205
+
206
+ context 'backward compatibility with traditional response body tokens' do
207
+ let(:traditional_response) do
208
+ {
209
+ 'sessionJwt' => session_jwt,
210
+ 'refreshJwt' => refresh_jwt,
211
+ 'userId' => 'test123'
212
+ }
213
+ end
214
+
215
+ it 'continues to work with response body tokens' do
216
+ allow(@instance).to receive(:validate_refresh_token_not_nil).and_return(true)
217
+ allow(@instance).to receive(:validate_token).and_return({})
218
+ allow(@instance).to receive(:post).and_return(traditional_response)
219
+
220
+ expect(@instance).to receive(:generate_jwt_response).with(
221
+ response_body: traditional_response,
222
+ refresh_cookie: refresh_jwt, # Should use refreshJwt from response body
223
+ audience: nil
224
+ ).and_return(traditional_response)
225
+
226
+ @instance.refresh_session(refresh_token: refresh_token)
227
+ end
228
+ end
229
+ end
117
230
  end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Descope::Mixins::HTTP do
6
+ before(:all) do
7
+ dummy_instance = DummyClass.new
8
+ dummy_instance.extend(Descope::Mixins::HTTP)
9
+ @instance = dummy_instance
10
+ end
11
+
12
+ describe '#parse_cookie_value' do
13
+ it 'extracts cookie value from Set-Cookie header' do
14
+ cookie_header = 'DS=jwt_token_value; Path=/; Domain=example.com; HttpOnly; Secure'
15
+ result = @instance.parse_cookie_value(cookie_header, 'DS')
16
+ expect(result).to eq('jwt_token_value')
17
+ end
18
+
19
+ it 'extracts cookie value with complex JWT token' do
20
+ jwt_token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbS9QMmFiY2RlMTIzNDUifQ.signature'
21
+ cookie_header = "DSR=#{jwt_token}; Path=/; Domain=dev.example.com; HttpOnly; Secure; Max-Age=2592000"
22
+ result = @instance.parse_cookie_value(cookie_header, 'DSR')
23
+ expect(result).to eq(jwt_token)
24
+ end
25
+
26
+ it 'returns nil when cookie name is not found' do
27
+ cookie_header = 'OTHER=value; Path=/; Domain=example.com'
28
+ result = @instance.parse_cookie_value(cookie_header, 'DS')
29
+ expect(result).to be_nil
30
+ end
31
+
32
+ it 'handles cookie value with special characters' do
33
+ cookie_header = 'DS=token.with-special_chars123; Path=/; Domain=example.com'
34
+ result = @instance.parse_cookie_value(cookie_header, 'DS')
35
+ expect(result).to eq('token.with-special_chars123')
36
+ end
37
+
38
+ it 'handles cookie header with spaces around value' do
39
+ cookie_header = 'DS= spaced_token ; Path=/; Domain=example.com'
40
+ result = @instance.parse_cookie_value(cookie_header, 'DS')
41
+ expect(result).to eq('spaced_token')
42
+ end
43
+ end
44
+
45
+ describe '#safe_parse_json with cookie handling' do
46
+ let(:mock_body) do
47
+ {
48
+ 'userId' => 'test123',
49
+ 'cookieExpiration' => 1640704758,
50
+ 'cookieDomain' => 'dev.example.com'
51
+ }.to_json
52
+ end
53
+
54
+ context 'when RestClient cookies are available (same domain)' do
55
+ it 'uses RestClient cookies when available' do
56
+ mock_cookies = {
57
+ 'DS' => 'session_token_from_restclient',
58
+ 'DSR' => 'refresh_token_from_restclient'
59
+ }
60
+
61
+ result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: {})
62
+
63
+ expect(result['cookies']).to eq(mock_cookies)
64
+ expect(result['cookies']['DS']).to eq('session_token_from_restclient')
65
+ expect(result['cookies']['DSR']).to eq('refresh_token_from_restclient')
66
+ end
67
+
68
+ it 'handles only refresh token in RestClient cookies' do
69
+ mock_cookies = { 'DSR' => 'refresh_token_only' }
70
+
71
+ result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: {})
72
+
73
+ expect(result['cookies']).to eq(mock_cookies)
74
+ expect(result['cookies']['DSR']).to eq('refresh_token_only')
75
+ expect(result['cookies']['DS']).to be_nil
76
+ end
77
+ end
78
+
79
+ context 'when RestClient cookies are empty (custom domain)' do
80
+ let(:set_cookie_headers) do
81
+ [
82
+ 'DS=session_jwt_token; Path=/; Domain=dev.example.com; HttpOnly; Secure; SameSite=None',
83
+ 'DSR=refresh_jwt_token; Path=/; Domain=dev.example.com; HttpOnly; Secure; SameSite=None; Max-Age=2592000'
84
+ ]
85
+ end
86
+
87
+ it 'parses cookies from Set-Cookie headers when RestClient cookies are empty' do
88
+ mock_headers = { 'set-cookie' => set_cookie_headers }
89
+
90
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
91
+
92
+ expect(result['cookies']).to_not be_nil
93
+ expect(result['cookies']['DS']).to eq('session_jwt_token')
94
+ expect(result['cookies']['DSR']).to eq('refresh_jwt_token')
95
+ end
96
+
97
+ it 'parses cookies from Set-Cookie headers with symbol key (real RestClient behavior)' do
98
+ # RestClient returns headers with symbol keys like :set_cookie, not string keys
99
+ mock_headers = { set_cookie: set_cookie_headers }
100
+
101
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
102
+
103
+ expect(result['cookies']).to_not be_nil
104
+ expect(result['cookies']['DS']).to eq('session_jwt_token')
105
+ expect(result['cookies']['DSR']).to eq('refresh_jwt_token')
106
+ end
107
+
108
+ it 'parses cookies from Set-Cookie header when headers is a string' do
109
+ mock_headers = { 'Set-Cookie' => set_cookie_headers.first }
110
+
111
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
112
+
113
+ expect(result['cookies']).to_not be_nil
114
+ expect(result['cookies']['DS']).to eq('session_jwt_token')
115
+ end
116
+
117
+ it 'handles case-insensitive Set-Cookie header names' do
118
+ mock_headers = { 'Set-Cookie' => set_cookie_headers }
119
+
120
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
121
+
122
+ expect(result['cookies']['DS']).to eq('session_jwt_token')
123
+ expect(result['cookies']['DSR']).to eq('refresh_jwt_token')
124
+ end
125
+
126
+ it 'handles complex JWT tokens in Set-Cookie headers' do
127
+ jwt_session = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.session_sig'
128
+ jwt_refresh = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5kZXNjb3BlLmNvbSJ9.refresh_sig'
129
+
130
+ complex_headers = [
131
+ "DS=#{jwt_session}; Path=/; Domain=custom.example.com; HttpOnly; Secure; SameSite=None",
132
+ "DSR=#{jwt_refresh}; Path=/; Domain=custom.example.com; HttpOnly; Secure; SameSite=None; Max-Age=2592000"
133
+ ]
134
+ mock_headers = { set_cookie: complex_headers }
135
+
136
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
137
+
138
+ expect(result['cookies']['DS']).to eq(jwt_session)
139
+ expect(result['cookies']['DSR']).to eq(jwt_refresh)
140
+ end
141
+
142
+ it 'ignores non-Descope cookies in Set-Cookie headers' do
143
+ mixed_headers = [
144
+ 'DS=session_token; Path=/; Domain=dev.example.com; HttpOnly',
145
+ 'CLOUDFLARE_SESSION=cf_token; Path=/; Domain=.example.com; HttpOnly',
146
+ 'DSR=refresh_token; Path=/; Domain=dev.example.com; HttpOnly'
147
+ ]
148
+ mock_headers = { set_cookie: mixed_headers }
149
+
150
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
151
+
152
+ expect(result['cookies']['DS']).to eq('session_token')
153
+ expect(result['cookies']['DSR']).to eq('refresh_token')
154
+ expect(result['cookies']['CLOUDFLARE_SESSION']).to be_nil
155
+ end
156
+ end
157
+
158
+ context 'when no cookies are available anywhere' do
159
+ it 'does not add cookies key to response' do
160
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: {})
161
+
162
+ expect(result).not_to have_key('cookies')
163
+ end
164
+
165
+ it 'handles missing Set-Cookie headers gracefully' do
166
+ mock_headers = { 'content-type' => 'application/json' }
167
+
168
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
169
+
170
+ expect(result).not_to have_key('cookies')
171
+ expect(result['userId']).to eq('test123')
172
+ end
173
+
174
+ it 'handles nil headers gracefully' do
175
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: nil)
176
+
177
+ expect(result).not_to have_key('cookies')
178
+ expect(result['userId']).to eq('test123')
179
+ end
180
+ end
181
+
182
+ context 'edge cases' do
183
+ it 'handles malformed Set-Cookie headers' do
184
+ malformed_headers = [
185
+ 'MALFORMED_COOKIE_NO_EQUALS',
186
+ 'DS=; Path=/; Domain=example.com', # Empty value
187
+ '=value_without_name; Path=/', # No name
188
+ ]
189
+ mock_headers = { set_cookie: malformed_headers }
190
+
191
+ result = @instance.safe_parse_json(mock_body, cookies: {}, headers: mock_headers)
192
+
193
+ # Should handle gracefully and not crash
194
+ expect(result['userId']).to eq('test123')
195
+ end
196
+
197
+ it 'prefers RestClient cookies over Set-Cookie headers when both available' do
198
+ mock_cookies = { 'DS' => 'restclient_token' }
199
+ set_cookie_headers = ['DS=header_token; Path=/; Domain=example.com']
200
+ mock_headers = { set_cookie: set_cookie_headers }
201
+
202
+ result = @instance.safe_parse_json(mock_body, cookies: mock_cookies, headers: mock_headers)
203
+
204
+ # Should prefer RestClient cookies
205
+ expect(result['cookies']['DS']).to eq('restclient_token')
206
+ end
207
+ end
208
+ end
209
+
210
+ describe 'integration with request method' do
211
+ it 'passes headers parameter to safe_parse_json' do
212
+ # Mock RestClient response with custom domain cookies
213
+ mock_response = double('response')
214
+ allow(mock_response).to receive(:code).and_return(200)
215
+ allow(mock_response).to receive(:body).and_return('{"success": true}')
216
+ allow(mock_response).to receive(:cookies).and_return({})
217
+ allow(mock_response).to receive(:headers).and_return({
218
+ set_cookie: ['DS=test_token; Domain=custom.example.com']
219
+ })
220
+
221
+ allow(@instance).to receive(:call).and_return(mock_response)
222
+
223
+ result = @instance.request(:get, '/test', {}, {})
224
+
225
+ expect(result['cookies']).to_not be_nil
226
+ expect(result['cookies']['DS']).to eq('test_token')
227
+ end
228
+ end
229
+ end
@@ -5,7 +5,6 @@ module Configuration
5
5
  module_function
6
6
 
7
7
  def config
8
- raise 'DESCOPE_MANAGEMENT_KEY is not set' if ENV['DESCOPE_MANAGEMENT_KEY'].nil?
9
8
  raise 'DESCOPE_PROJECT_ID is not set' if ENV['DESCOPE_PROJECT_ID'].nil?
10
9
 
11
10
  {
@@ -29,4 +29,25 @@ module SpecUtils
29
29
  value.each { |v| deep_stringify_keys(v) if v.is_a? Hash } if value.is_a? Array
30
30
  end
31
31
  end
32
+
33
+ def build_prefix
34
+ # Use GITHUB_RUN_NUMBER as the primary identifier, fall back to a timestamp if not available
35
+ prefix = ENV['GITHUB_RUN_NUMBER'] || ENV['GITHUB_RUN_ID']
36
+ prefix ? "build#{prefix}-" : "local-#{Time.now.to_i}-"
37
+ end
38
+
39
+ def wait_for_condition(max_wait: 60, interval: 2, description: 'condition')
40
+ start = Time.now
41
+ loop do
42
+ result = yield
43
+ return result if result
44
+
45
+ elapsed = Time.now - start
46
+ if elapsed > max_wait
47
+ raise "Timeout after #{max_wait}s waiting for #{description}"
48
+ end
49
+
50
+ sleep interval
51
+ end
52
+ end
32
53
  end