Skip to content

Commit bcaaff6

Browse files
authored
Allow pluggable encryptors for session seal/unseal (#432)
1 parent 67e7c05 commit bcaaff6

File tree

9 files changed

+213
-45
lines changed

9 files changed

+213
-45
lines changed

lib/workos.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def self.key
5656
autoload :DirectorySync, 'workos/directory_sync'
5757
autoload :DirectoryUser, 'workos/directory_user'
5858
autoload :EmailVerification, 'workos/email_verification'
59+
autoload :Encryptors, 'workos/encryptors'
5960
autoload :Event, 'workos/event'
6061
autoload :Events, 'workos/events'
6162
autoload :Factor, 'workos/factor'

lib/workos/authentication_response.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ def initialize(authentication_response_json, session = nil)
3131
@oauth_tokens = json[:oauth_tokens] ? WorkOS::OAuthTokens.new(json[:oauth_tokens].to_json) : nil
3232
@sealed_session =
3333
if session && session[:seal_session]
34-
WorkOS::Session.seal_data({
35-
access_token: access_token,
36-
refresh_token: refresh_token,
37-
user: user.to_json,
38-
organization_id: organization_id,
39-
impersonator: impersonator.to_json,
40-
}, session[:cookie_password],)
34+
WorkOS::Session.seal_data(
35+
{
36+
access_token: access_token,
37+
refresh_token: refresh_token,
38+
user: user.to_json,
39+
organization_id: organization_id,
40+
impersonator: impersonator.to_json,
41+
},
42+
session[:cookie_password],
43+
encryptor: session[:encryptor],
44+
)
4145
end
4246
end
4347
# rubocop:enable Metrics/AbcSize

lib/workos/encryptors.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module WorkOS
4+
# Encryptors module provides pluggable encryption implementations for session data.
5+
# The default encryptor is AesGcm, which uses AES-256-GCM encryption.
6+
module Encryptors
7+
autoload :AesGcm, 'workos/encryptors/aes_gcm'
8+
end
9+
end

lib/workos/encryptors/aes_gcm.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
require 'encryptor'
4+
require 'securerandom'
5+
require 'json'
6+
require 'base64'
7+
8+
module WorkOS
9+
module Encryptors
10+
# Default encryptor using AES-256-GCM.
11+
# Implements the encryptor interface: #seal(data, key) and #unseal(sealed_data, key)
12+
class AesGcm
13+
# Encrypts and seals data using AES-256-GCM
14+
# @param data [Hash] The data to seal
15+
# @param key [String] The encryption key
16+
# @return [String] Base64-encoded sealed data
17+
def seal(data, key)
18+
iv = SecureRandom.random_bytes(12)
19+
20+
encrypted_data = Encryptor.encrypt(
21+
value: JSON.generate(data),
22+
key: key,
23+
iv: iv,
24+
algorithm: 'aes-256-gcm',
25+
)
26+
Base64.encode64(iv + encrypted_data)
27+
end
28+
29+
# Decrypts and unseals data using AES-256-GCM
30+
# @param sealed_data [String] The sealed data to unseal
31+
# @param key [String] The decryption key
32+
# @return [Hash] The unsealed data with symbolized keys
33+
def unseal(sealed_data, key)
34+
decoded_data = Base64.decode64(sealed_data)
35+
iv = decoded_data[0..11]
36+
encrypted_data = decoded_data[12..]
37+
38+
decrypted_data = Encryptor.decrypt(
39+
value: encrypted_data,
40+
key: key,
41+
iv: iv,
42+
algorithm: 'aes-256-gcm',
43+
)
44+
45+
JSON.parse(decrypted_data, symbolize_names: true)
46+
end
47+
end
48+
end
49+
end

lib/workos/refresh_authentication_response.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ def initialize(authentication_response_json, session = nil)
2222
end
2323
@sealed_session =
2424
if session && session[:seal_session]
25-
WorkOS::Session.seal_data({
26-
access_token: access_token,
27-
refresh_token: refresh_token,
28-
user: user.to_json,
29-
organization_id: organization_id,
30-
impersonator: impersonator.to_json,
31-
}, session[:cookie_password],)
25+
WorkOS::Session.seal_data(
26+
{
27+
access_token: access_token,
28+
refresh_token: refresh_token,
29+
user: user.to_json,
30+
organization_id: organization_id,
31+
impersonator: impersonator.to_json,
32+
},
33+
session[:cookie_password],
34+
encryptor: session[:encryptor],
35+
)
3236
end
3337
end
3438
# rubocop:enable Metrics/AbcSize

lib/workos/session.rb

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ module WorkOS
1212
# The Session class provides helper methods for working with WorkOS sessions
1313
# This class is not meant to be instantiated in a user space, and is instantiated internally but exposed.
1414
class Session
15-
attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id
15+
attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id, :encryptor
1616

17-
def initialize(user_management:, client_id:, session_data:, cookie_password:)
17+
def initialize(user_management:, client_id:, session_data:, cookie_password:, encryptor: nil)
1818
raise ArgumentError, 'cookiePassword is required' if cookie_password.nil? || cookie_password.empty?
1919

20+
@encryptor = encryptor || WorkOS::Encryptors::AesGcm.new
21+
validate_encryptor!(@encryptor)
22+
2023
@user_management = user_management
2124
@cookie_password = cookie_password
2225
@session_data = session_data
@@ -37,7 +40,7 @@ def authenticate(include_expired: false, &claim_extractor)
3740
return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' } if @session_data.nil?
3841

3942
begin
40-
session = Session.unseal_data(@session_data, @cookie_password)
43+
session = Session.unseal_data(@session_data, @cookie_password, encryptor: @encryptor)
4144
rescue StandardError
4245
return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' }
4346
end
@@ -92,7 +95,7 @@ def refresh(options = nil)
9295
cookie_password = options.nil? || options[:cookie_password].nil? ? @cookie_password : options[:cookie_password]
9396

9497
begin
95-
session = Session.unseal_data(@session_data, cookie_password)
98+
session = Session.unseal_data(@session_data, cookie_password, encryptor: @encryptor)
9699
rescue StandardError
97100
return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' }
98101
end
@@ -104,7 +107,7 @@ def refresh(options = nil)
104107
client_id: @client_id,
105108
refresh_token: session[:refresh_token],
106109
organization_id: options.nil? || options[:organization_id].nil? ? nil : options[:organization_id],
107-
session: { seal_session: true, cookie_password: cookie_password },
110+
session: { seal_session: true, cookie_password: cookie_password, encryptor: @encryptor },
108111
)
109112

110113
@session_data = auth_response.sealed_session
@@ -137,43 +140,34 @@ def get_logout_url(return_to: nil)
137140
@user_management.get_logout_url(session_id: auth_response[:session_id], return_to: return_to)
138141
end
139142

140-
# Encrypts and seals data using AES-256-GCM
143+
# Encrypts and seals data using the provided encryptor (defaults to AES-256-GCM)
141144
# @param data [Hash] The data to seal
142145
# @param key [String] The key to use for encryption
146+
# @param encryptor [Object] Optional encryptor that responds to #seal(data, key)
143147
# @return [String] The sealed data
144-
def self.seal_data(data, key)
145-
iv = SecureRandom.random_bytes(12)
146-
147-
encrypted_data = Encryptor.encrypt(
148-
value: JSON.generate(data),
149-
key: key,
150-
iv: iv,
151-
algorithm: 'aes-256-gcm',
152-
)
153-
Base64.encode64(iv + encrypted_data) # Combine IV with encrypted data and encode as base64
148+
def self.seal_data(data, key, encryptor: nil)
149+
enc = encryptor || WorkOS::Encryptors::AesGcm.new
150+
enc.seal(data, key)
154151
end
155152

156-
# Decrypts and unseals data using AES-256-GCM
153+
# Decrypts and unseals data using the provided encryptor (defaults to AES-256-GCM)
157154
# @param sealed_data [String] The sealed data to unseal
158155
# @param key [String] The key to use for decryption
156+
# @param encryptor [Object] Optional encryptor that responds to #unseal(sealed_data, key)
159157
# @return [Hash] The unsealed data
160-
def self.unseal_data(sealed_data, key)
161-
decoded_data = Base64.decode64(sealed_data)
162-
iv = decoded_data[0..11] # Extract the IV (first 12 bytes)
163-
encrypted_data = decoded_data[12..-1] # Extract the encrypted data
164-
165-
decrypted_data = Encryptor.decrypt(
166-
value: encrypted_data,
167-
key: key,
168-
iv: iv,
169-
algorithm: 'aes-256-gcm',
170-
)
171-
172-
JSON.parse(decrypted_data, symbolize_names: true) # Parse the decrypted JSON string back to original data
158+
def self.unseal_data(sealed_data, key, encryptor: nil)
159+
enc = encryptor || WorkOS::Encryptors::AesGcm.new
160+
enc.unseal(sealed_data, key)
173161
end
174162

175163
private
176164

165+
def validate_encryptor!(enc)
166+
return if enc.respond_to?(:seal) && enc.respond_to?(:unseal)
167+
168+
raise ArgumentError, 'encryptor must respond to #seal(data, key) and #unseal(sealed_data, key)'
169+
end
170+
177171
# Creates a JWKS set from a remote JWKS URL
178172
# @param uri [URI] The URI of the JWKS
179173
# @return [JWT::JWK::Set] The JWKS set

lib/workos/user_management.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,16 @@ class << self
4242
# @param [String] client_id The WorkOS client ID for the environment
4343
# @param [String] session_data The sealed session data
4444
# @param [String] cookie_password The password used to seal the session
45+
# @param [Object] encryptor Optional custom encryptor that responds to #seal and #unseal
4546
#
4647
# @return WorkOS::Session
47-
def load_sealed_session(client_id:, session_data:, cookie_password:)
48+
def load_sealed_session(client_id:, session_data:, cookie_password:, encryptor: nil)
4849
WorkOS::Session.new(
4950
user_management: self,
5051
client_id: client_id,
5152
session_data: session_data,
5253
cookie_password: cookie_password,
54+
encryptor: encryptor,
5355
)
5456
end
5557

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe WorkOS::Encryptors::AesGcm do
4+
subject(:encryptor) { described_class.new }
5+
6+
let(:key) { 'a' * 32 }
7+
let(:data) { { access_token: 'tok_123', user: { id: 'user_01' } } }
8+
9+
describe '#seal' do
10+
it 'returns a base64-encoded string' do
11+
sealed = encryptor.seal(data, key)
12+
expect(sealed).to be_a(String)
13+
expect { Base64.decode64(sealed) }.not_to raise_error
14+
end
15+
16+
it 'produces different output each time (random IV)' do
17+
sealed1 = encryptor.seal(data, key)
18+
sealed2 = encryptor.seal(data, key)
19+
expect(sealed1).not_to eq(sealed2)
20+
end
21+
end
22+
23+
describe '#unseal' do
24+
it 'round-trips data correctly' do
25+
sealed = encryptor.seal(data, key)
26+
unsealed = encryptor.unseal(sealed, key)
27+
expect(unsealed).to eq(data)
28+
end
29+
30+
it 'returns hash with symbolized keys' do
31+
sealed = encryptor.seal({ 'string_key' => 'value' }, key)
32+
unsealed = encryptor.unseal(sealed, key)
33+
expect(unsealed.keys.first).to be_a(Symbol)
34+
end
35+
36+
it 'raises error with wrong key' do
37+
sealed = encryptor.seal(data, key)
38+
expect { encryptor.unseal(sealed, 'b' * 32) }.to raise_error(OpenSSL::Cipher::CipherError)
39+
end
40+
end
41+
end

spec/lib/workos/session_spec.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,68 @@
408408
end
409409
end
410410
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
411475
end

0 commit comments

Comments
 (0)