Skip to content

Commit 1d8b4aa

Browse files
authored
fix: decode legacy v6 sealed sessions on unseal (#479)
1 parent 6728358 commit 1d8b4aa

3 files changed

Lines changed: 124 additions & 3 deletions

File tree

lib/workos/encryptors/aes_gcm.rb

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ def seal(data, key)
2727

2828
def unseal(sealed, key)
2929
raw = Base64.decode64(sealed.to_s)
30+
decode_v7(raw, key)
31+
rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error
32+
begin
33+
decode_old(raw, key)
34+
rescue ArgumentError, OpenSSL::Cipher::CipherError
35+
raise original_error
36+
end
37+
end
38+
39+
private
40+
41+
def decode_v7(raw, key)
3042
raise ArgumentError, "Sealed payload too short" if raw.bytesize < 1 + 12 + 16
3143
version = raw.byteslice(0, 1).bytes.first
3244
raise ArgumentError, "Unknown seal version: #{version}" unless version == SEAL_VERSION
@@ -37,7 +49,29 @@ def unseal(sealed, key)
3749
cipher.key = derive_key(key)
3850
cipher.iv = iv
3951
cipher.auth_tag = tag
40-
decoded = cipher.update(ciphertext) + cipher.final
52+
53+
parse_decoded(cipher.update(ciphertext) + cipher.final)
54+
end
55+
56+
def decode_old(raw, key)
57+
# v6 sealed sessions were Base64(iv + ciphertext + auth_tag) using the
58+
# `encryptor` gem without the v7 version byte or key derivation.
59+
raise ArgumentError, "Legacy sealed payload too short" if raw.bytesize < 12 + 16
60+
61+
iv = raw.byteslice(0, 12)
62+
encrypted = raw.byteslice(12, raw.bytesize - 12)
63+
ciphertext = encrypted.byteslice(0, encrypted.bytesize - 16)
64+
tag = encrypted.byteslice(encrypted.bytesize - 16, 16)
65+
66+
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
67+
cipher.key = key.to_s
68+
cipher.iv = iv
69+
cipher.auth_tag = tag
70+
71+
parse_decoded(cipher.update(ciphertext) + cipher.final)
72+
end
73+
74+
def parse_decoded(decoded)
4175
decoded.force_encoding(Encoding::UTF_8)
4276
begin
4377
JSON.parse(decoded)
@@ -46,8 +80,6 @@ def unseal(sealed, key)
4680
end
4781
end
4882

49-
private
50-
5183
def derive_key(passphrase)
5284
Digest::SHA256.digest(passphrase.to_s)
5385
end

test/workos/test_encryptors_aes_gcm.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# @oagen-ignore-file
44
require "test_helper"
55
require "base64"
6+
require "json"
7+
require "openssl"
8+
require "securerandom"
69

710
class EncryptorsAesGcmTest < Minitest::Test
811
PASSWORD = "test-cookie-password-at-least-32"
@@ -51,4 +54,22 @@ def test_each_seal_produces_unique_output
5154
sealed2 = @enc.seal(data, PASSWORD)
5255
refute_equal sealed1, sealed2
5356
end
57+
58+
def test_unseal_reads_legacy_v6_payload
59+
data = {"access_token" => "tok_abc", "refresh_token" => "ref_xyz"}
60+
sealed = legacy_v6_seal(data, PASSWORD)
61+
assert_equal data, @enc.unseal(sealed, PASSWORD)
62+
end
63+
64+
private
65+
66+
def legacy_v6_seal(data, key)
67+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
68+
iv = SecureRandom.random_bytes(12)
69+
cipher.key = key
70+
cipher.iv = iv
71+
ciphertext = cipher.update(JSON.generate(data)) + cipher.final
72+
73+
Base64.encode64(iv + ciphertext + cipher.auth_tag)
74+
end
5475
end

test/workos/test_session.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require "openssl"
77
require "jwt"
88
require "base64"
9+
require "securerandom"
910

1011
class SessionTest < Minitest::Test
1112
PASSWORD = "very-long-cookie-password-secret"
@@ -84,6 +85,22 @@ def test_authenticate_returns_success_with_decoded_claims
8485
assert_equal "u_1", result.user["id"]
8586
end
8687

88+
def test_authenticate_reads_legacy_v6_sealed_session
89+
rsa, pub = signing_key_pair
90+
access_token = make_jwt({"sid" => "session_v6", "org_id" => "org_legacy", "exp" => Time.now.to_i + 60}, rsa)
91+
sealed = legacy_v6_seal({"access_token" => access_token, "user" => {"id" => "u_legacy"}}, PASSWORD)
92+
93+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
94+
.to_return(status: 200, body: jwks_payload(pub).to_json)
95+
96+
result = @sm.authenticate(seal_data: sealed, cookie_password: PASSWORD)
97+
assert_kind_of WorkOS::SessionManager::AuthSuccess, result
98+
assert result.authenticated
99+
assert_equal "session_v6", result.session_id
100+
assert_equal "org_legacy", result.organization_id
101+
assert_equal "u_legacy", result.user["id"]
102+
end
103+
87104
def test_authenticate_merges_custom_claims_from_block
88105
rsa, pub = signing_key_pair
89106
access_token = make_jwt(
@@ -244,6 +261,45 @@ def test_refresh_seals_session_client_side_and_returns_refresh_success
244261
assert_equal "rt_new", unsealed["refresh_token"]
245262
end
246263

264+
def test_refresh_reads_legacy_v6_sealed_session
265+
rsa, pub = signing_key_pair
266+
old_access = make_jwt({"sid" => "session_old_v6", "exp" => Time.now.to_i - 60}, rsa)
267+
sealed = legacy_v6_seal(
268+
{"access_token" => old_access, "refresh_token" => "rt_old_v6", "user" => {"id" => "u_v6"}},
269+
PASSWORD
270+
)
271+
272+
new_access = make_jwt({"sid" => "session_new_v6", "org_id" => "org_v6", "role" => "member", "exp" => Time.now.to_i + 300}, rsa)
273+
api_response = {
274+
"access_token" => new_access,
275+
"refresh_token" => "rt_new_v6",
276+
"user" => {"id" => "u_v6", "email" => "legacy@example.com"},
277+
"impersonator" => nil
278+
}
279+
280+
stub_request(:post, "https://api.workos.com/user_management/authenticate")
281+
.with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "rt_old_v6"))
282+
.to_return(status: 200, body: api_response.to_json)
283+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
284+
.to_return(status: 200, body: jwks_payload(pub).to_json)
285+
286+
session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
287+
result = session.refresh
288+
289+
assert_kind_of WorkOS::SessionManager::RefreshSuccess, result
290+
assert result.authenticated
291+
assert_equal "session_new_v6", result.session_id
292+
assert_equal "org_v6", result.organization_id
293+
assert_equal "member", result.role
294+
assert_equal "u_v6", result.user["id"]
295+
296+
refute_empty result.sealed_session
297+
unsealed = @sm.unseal_data(result.sealed_session, PASSWORD)
298+
assert_equal new_access, unsealed["access_token"]
299+
assert_equal "rt_new_v6", unsealed["refresh_token"]
300+
assert_equal "u_v6", unsealed["user"]["id"]
301+
end
302+
247303
def test_refresh_updates_internal_seal_data_for_subsequent_authenticate
248304
rsa, pub = signing_key_pair
249305
old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa)
@@ -383,4 +439,16 @@ def custom.unseal(sealed, _key)
383439
assert_kind_of WorkOS::SessionManager::AuthSuccess, result
384440
assert_equal "s_custom", result.session_id
385441
end
442+
443+
private
444+
445+
def legacy_v6_seal(data, key)
446+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
447+
iv = SecureRandom.random_bytes(12)
448+
cipher.key = key
449+
cipher.iv = iv
450+
ciphertext = cipher.update(JSON.generate(data)) + cipher.final
451+
452+
Base64.encode64(iv + ciphertext + cipher.auth_tag)
453+
end
386454
end

0 commit comments

Comments
 (0)