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.
- checksums.yaml +4 -4
- data/.github/CODEOWNERS +1 -1
- data/.github/workflows/ci.yml +2 -4
- data/.github/workflows/lint-pr-title.yml +20 -0
- data/.github/workflows/release-please.yml +25 -0
- data/.github/workflows/release.yml +22 -25
- data/.gitignore +1 -0
- data/.release-please-manifest.json +3 -0
- data/.rubocop.yml +11 -8
- data/.rubocop_todo.yml +94 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +32 -18
- data/Rakefile +8 -0
- data/context7.json +4 -0
- data/lib/workos/authentication_response.rb +32 -4
- data/lib/workos/cache.rb +94 -0
- data/lib/workos/client.rb +9 -1
- data/lib/workos/directory_sync.rb +1 -1
- data/lib/workos/directory_user.rb +31 -3
- data/lib/workos/encryptors/aes_gcm.rb +49 -0
- data/lib/workos/encryptors.rb +9 -0
- data/lib/workos/errors.rb +4 -0
- data/lib/workos/feature_flag.rb +34 -0
- data/lib/workos/mfa.rb +0 -1
- data/lib/workos/oauth_tokens.rb +29 -0
- data/lib/workos/organization.rb +14 -1
- data/lib/workos/organization_membership.rb +5 -1
- data/lib/workos/organizations.rb +87 -3
- data/lib/workos/profile.rb +10 -2
- data/lib/workos/refresh_authentication_response.rb +29 -2
- data/lib/workos/role.rb +38 -0
- data/lib/workos/session.rb +187 -0
- data/lib/workos/sso.rb +3 -24
- data/lib/workos/types/intent.rb +3 -1
- data/lib/workos/types/provider.rb +1 -1
- data/lib/workos/types/widget_scope.rb +15 -0
- data/lib/workos/types.rb +1 -0
- data/lib/workos/user.rb +7 -1
- data/lib/workos/user_management/session.rb +57 -0
- data/lib/workos/user_management.rb +213 -45
- data/lib/workos/version.rb +1 -1
- data/lib/workos/widgets.rb +46 -0
- data/lib/workos.rb +8 -0
- data/release-please-config.json +12 -0
- data/spec/lib/workos/cache_spec.rb +94 -0
- data/spec/lib/workos/directory_user_spec.rb +13 -3
- data/spec/lib/workos/encryptors/aes_gcm_spec.rb +41 -0
- data/spec/lib/workos/organizations_spec.rb +258 -1
- data/spec/lib/workos/portal_spec.rb +30 -0
- data/spec/lib/workos/role_spec.rb +142 -0
- data/spec/lib/workos/session_spec.rb +475 -0
- data/spec/lib/workos/sso_spec.rb +106 -5
- data/spec/lib/workos/user_management_spec.rb +496 -1
- data/spec/lib/workos/widgets_spec.rb +73 -0
- data/spec/support/fixtures/vcr_cassettes/directory_sync/get_user.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/organization/create_with_external_id.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/organization/list_organization_feature_flags.yml +78 -0
- data/spec/support/fixtures/vcr_cassettes/organization/list_organization_roles.yml +82 -0
- data/spec/support/fixtures/vcr_cassettes/organization/update_with_external_id.yml +78 -0
- data/spec/support/fixtures/vcr_cassettes/organization/update_with_external_id_null.yml +78 -0
- data/spec/support/fixtures/vcr_cassettes/organization/update_with_stripe_customer_id.yml +78 -0
- data/spec/support/fixtures/vcr_cassettes/organization/update_without_name.yml +85 -0
- data/spec/support/fixtures/vcr_cassettes/portal/generate_link_certificate_renewal.yml +72 -0
- data/spec/support/fixtures/vcr_cassettes/portal/generate_link_domain_verification.yml +72 -0
- data/spec/support/fixtures/vcr_cassettes/sso/profile.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_code/valid_with_oauth_tokens.yml +82 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_password/unverified.yml +82 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml +79 -78
- data/spec/support/fixtures/vcr_cassettes/user_management/create_organization_membership/valid_multiple_roles.yml +76 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/create_user_with_external_id.yml +77 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/get_user.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/user_management/list_sessions/valid.yml +38 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/accepted.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/expired.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/invalid.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/revoked.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/resend_invitation/valid.yml +83 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/reset_password/valid.yml +1 -1
- data/spec/support/fixtures/vcr_cassettes/user_management/update_organization_membership/valid_multiple_roles.yml +76 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/update_user/email.yml +82 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/update_user/locale.yml +76 -0
- data/spec/support/fixtures/vcr_cassettes/user_management/update_user/valid.yml +2 -2
- data/spec/support/fixtures/vcr_cassettes/user_management/update_user_external_id_null.yml +77 -0
- data/spec/support/fixtures/vcr_cassettes/widgets/get_token.yml +82 -0
- data/spec/support/fixtures/vcr_cassettes/widgets/get_token_invalid_organization_id.yml +74 -0
- data/spec/support/fixtures/vcr_cassettes/widgets/get_token_invalid_user_id.yml +74 -0
- data/spec/support/profile.txt +1 -1
- data/workos.gemspec +7 -3
- 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
|
data/spec/lib/workos/sso_spec.rb
CHANGED
|
@@ -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
|
|
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::
|
|
408
|
-
'
|
|
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::
|
|
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
|
|