diff --git a/lib/workos.rb b/lib/workos.rb index 12a5617c..07595bec 100644 --- a/lib/workos.rb +++ b/lib/workos.rb @@ -60,6 +60,7 @@ def self.key autoload :Events, 'workos/events' autoload :Factor, 'workos/factor' autoload :FeatureFlag, 'workos/feature_flag' + autoload :IronSealUnseal, 'workos/iron_seal_unseal' autoload :Impersonator, 'workos/impersonator' autoload :Invitation, 'workos/invitation' autoload :MagicAuth, 'workos/magic_auth' diff --git a/lib/workos/authentication_response.rb b/lib/workos/authentication_response.rb index 9d675881..dd6fe50a 100644 --- a/lib/workos/authentication_response.rb +++ b/lib/workos/authentication_response.rb @@ -31,13 +31,18 @@ def initialize(authentication_response_json, session = nil) @oauth_tokens = json[:oauth_tokens] ? WorkOS::OAuthTokens.new(json[:oauth_tokens].to_json) : nil @sealed_session = if session && session[:seal_session] - WorkOS::Session.seal_data({ - access_token: access_token, - refresh_token: refresh_token, - user: user.to_json, - organization_id: organization_id, - impersonator: impersonator.to_json, - }, session[:cookie_password],) + algorithm = session[:seal_algorithm] || :aes_gcm + WorkOS::Session.seal_data( + { + access_token: access_token, + refresh_token: refresh_token, + user: user.to_json, + organization_id: organization_id, + impersonator: impersonator.to_json, + }, + session[:cookie_password], + algorithm: algorithm, + ) end end # rubocop:enable Metrics/AbcSize diff --git a/lib/workos/iron_seal_unseal.rb b/lib/workos/iron_seal_unseal.rb new file mode 100644 index 00000000..913ec3ac --- /dev/null +++ b/lib/workos/iron_seal_unseal.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module WorkOS + # Seals and unseals session data using the Iron (Fe26.2) format compatible with iron-webcrypto. + # Use as an optional algorithm for WorkOS::Session.seal_data / unseal_data via algorithm: :iron. + module IronSealUnseal + MAC_PREFIX = 'Fe26.2' + DELIMITER = '*' + VERSION_DELIMITER = '~' + TIMESTAMP_SKEW_SEC = 60 + DEFAULT_TTL_SEC = 120 + + class UnsealError < StandardError; end + + class << self + # Seal data in Fe26.2 format (compatible with iron-webcrypto). + # @param data [Hash] The data to seal (will be JSON-encoded) + # @param password [String] Password (must be at least 32 characters) + # @param ttl_sec [Integer] Time-to-live in seconds (default: 120) + # @return [String] The sealed string + def seal(data, password, ttl_sec: DEFAULT_TTL_SEC) + raise ArgumentError, 'password must be at least 32 characters' if password.to_s.length < 32 + + expiration_ms = ((Time.now + ttl_sec).to_f * 1000).to_i + prefix = MAC_PREFIX + password_id = '' + encryption_salt = SecureRandom.base64(16).tr('+/', '-_').delete('=') + hmac_salt = SecureRandom.base64(16).tr('+/', '-_').delete('=') + iv = SecureRandom.random_bytes(16) + encryption_key = derive_key(password, encryption_salt, 32) + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.encrypt + cipher.key = encryption_key + cipher.iv = iv + payload = data.is_a?(String) ? data : data.to_json + encrypted = cipher.update(payload) + cipher.final + encryption_iv_b64 = Base64.urlsafe_encode64(iv).delete('=') + encrypted_b64 = Base64.urlsafe_encode64(encrypted).delete('=') + expiration = expiration_ms.to_s + mac_base_string = [prefix, password_id, encryption_salt, encryption_iv_b64, encrypted_b64, expiration].join(DELIMITER) + integrity_key = derive_key(password, hmac_salt, 32) + hmac = OpenSSL::HMAC.digest('SHA256', integrity_key, mac_base_string) + hmac_b64 = Base64.urlsafe_encode64(hmac).delete('=') + [prefix, password_id, encryption_salt, encryption_iv_b64, encrypted_b64, expiration, hmac_salt, hmac_b64].join(DELIMITER) + end + + # Unseal data sealed in Fe26.2 format. + # @param sealed [String] Full cookie value (may include "~2" suffix from iron-session) + # @param password [String] Password (or Hash of password_id => password) + # @param skip_expiration [Boolean] If true, ignore TTL (default: false) + # @return [Hash] Decoded session with symbolized keys + # @raise [UnsealError] on invalid/expired seal or wrong password + def unseal(sealed, password, skip_expiration: false) + raise ArgumentError, 'password must be at least 32 characters' if password.is_a?(String) && password.to_s.length < 32 + + inner_seal = sealed.to_s.split(VERSION_DELIMITER).first + parts = inner_seal.split(DELIMITER) + raise UnsealError, 'Incorrect number of sealed components (expected 8)' unless parts.length == 8 + + prefix, password_id, encryption_salt, encryption_iv_b64, encrypted_b64, expiration, hmac_salt, hmac_b64 = parts + raise UnsealError, "Wrong mac prefix (expected #{MAC_PREFIX})" unless prefix == MAC_PREFIX + + unless skip_expiration + if expiration.to_s.match?(/\A\d+\z/) + exp_ms = expiration.to_i + now_ms = (Time.now.to_f * 1000).to_i + raise UnsealError, 'Expired seal' if exp_ms <= now_ms - (TIMESTAMP_SKEW_SEC * 1000) + end + end + + pass = resolve_password(password, password_id) + mac_base_string = [prefix, password_id, encryption_salt, encryption_iv_b64, encrypted_b64, expiration].join(DELIMITER) + + integrity_key = derive_key(pass, hmac_salt, 32) + expected_hmac = OpenSSL::HMAC.digest('SHA256', integrity_key, mac_base_string) + expected_hmac_b64 = base64url_encode(expected_hmac) + raise UnsealError, 'Bad hmac value' unless secure_compare(hmac_b64, expected_hmac_b64) + + encryption_key = derive_key(pass, encryption_salt, 32) + iv = base64url_decode(encryption_iv_b64) + encrypted = base64url_decode(encrypted_b64) + + decrypted = aes256_cbc_decrypt(encryption_key, iv, encrypted) + JSON.parse(decrypted, symbolize_names: true) + end + + private + + def resolve_password(password, password_id) + return password if password.is_a?(String) + raise UnsealError, "Cannot find password: #{password_id}" unless password[password_id] + + password[password_id] + end + + def derive_key(password, salt, key_length) + OpenSSL::KDF.pbkdf2_hmac( + password, + salt: salt, + iterations: 1, + length: key_length, + hash: OpenSSL::Digest.new('SHA1'), + ) + end + + def aes256_cbc_decrypt(key, iv, ciphertext) + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.decrypt + cipher.key = key + cipher.iv = iv + cipher.update(ciphertext) + cipher.final + end + + def base64url_decode(str) + padding = (4 - str.length % 4) % 4 + Base64.urlsafe_decode64(str + ('=' * padding)) + end + + def base64url_encode(bytes) + Base64.urlsafe_encode64(bytes).delete('=') + end + + def secure_compare(a, b) + return false unless a.bytesize == b.bytesize + + l = a.unpack('C*') + r = b.unpack('C*') + result = 0 + l.zip(r) { |x, y| result |= x ^ y } + result.zero? + end + end + end +end diff --git a/lib/workos/refresh_authentication_response.rb b/lib/workos/refresh_authentication_response.rb index 76d7ce73..cbd01779 100644 --- a/lib/workos/refresh_authentication_response.rb +++ b/lib/workos/refresh_authentication_response.rb @@ -22,13 +22,18 @@ def initialize(authentication_response_json, session = nil) end @sealed_session = if session && session[:seal_session] - WorkOS::Session.seal_data({ - access_token: access_token, - refresh_token: refresh_token, - user: user.to_json, - organization_id: organization_id, - impersonator: impersonator.to_json, - }, session[:cookie_password],) + algorithm = session[:seal_algorithm] || :aes_gcm + WorkOS::Session.seal_data( + { + access_token: access_token, + refresh_token: refresh_token, + user: user.to_json, + organization_id: organization_id, + impersonator: impersonator.to_json, + }, + session[:cookie_password], + algorithm: algorithm, + ) end end # rubocop:enable Metrics/AbcSize diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 2beb0d52..b8f94920 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -12,15 +12,16 @@ module WorkOS # The Session class provides helper methods for working with WorkOS sessions # This class is not meant to be instantiated in a user space, and is instantiated internally but exposed. class Session - attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id + attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id, :seal_algorithm - def initialize(user_management:, client_id:, session_data:, cookie_password:) + def initialize(user_management:, client_id:, session_data:, cookie_password:, seal_algorithm: :aes_gcm) raise ArgumentError, 'cookiePassword is required' if cookie_password.nil? || cookie_password.empty? @user_management = user_management @cookie_password = cookie_password @session_data = session_data @client_id = client_id + @seal_algorithm = seal_algorithm @jwks = Cache.fetch("jwks_#{client_id}", expires_in: 5 * 60) do create_remote_jwk_set(URI(@user_management.get_jwks_url(client_id))) @@ -36,7 +37,7 @@ def authenticate(include_expired: false) return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' } if @session_data.nil? begin - session = Session.unseal_data(@session_data, @cookie_password) + session = Session.unseal_data(@session_data, @cookie_password, algorithm: @seal_algorithm) rescue StandardError return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } end @@ -89,19 +90,22 @@ def refresh(options = nil) cookie_password = options.nil? || options[:cookie_password].nil? ? @cookie_password : options[:cookie_password] begin - session = Session.unseal_data(@session_data, cookie_password) + session = Session.unseal_data(@session_data, cookie_password, algorithm: @seal_algorithm) rescue StandardError return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } end return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:refresh_token] && session[:user] + session_opts = { seal_session: true, cookie_password: cookie_password } + session_opts[:seal_algorithm] = @seal_algorithm if @seal_algorithm && @seal_algorithm != :aes_gcm + begin auth_response = @user_management.authenticate_with_refresh_token( client_id: @client_id, refresh_token: session[:refresh_token], organization_id: options.nil? || options[:organization_id].nil? ? nil : options[:organization_id], - session: { seal_session: true, cookie_password: cookie_password }, + session: session_opts, ) @session_data = auth_response.sealed_session @@ -134,39 +138,51 @@ def get_logout_url(return_to: nil) @user_management.get_logout_url(session_id: auth_response[:session_id], return_to: return_to) end - # Encrypts and seals data using AES-256-GCM + # Encrypts and seals data. # @param data [Hash] The data to seal # @param key [String] The key to use for encryption + # @param algorithm [Symbol] :aes_gcm (default) or :iron for Iron Fe26.2 format (compatible with iron-webcrypto) # @return [String] The sealed data - def self.seal_data(data, key) - iv = SecureRandom.random_bytes(12) - - encrypted_data = Encryptor.encrypt( - value: JSON.generate(data), - key: key, - iv: iv, - algorithm: 'aes-256-gcm', - ) - Base64.encode64(iv + encrypted_data) # Combine IV with encrypted data and encode as base64 + def self.seal_data(data, key, algorithm: :aes_gcm) + case algorithm + when :iron + WorkOS::IronSealUnseal.seal(data, key) + else + iv = SecureRandom.random_bytes(12) + + encrypted_data = Encryptor.encrypt( + value: JSON.generate(data), + key: key, + iv: iv, + algorithm: 'aes-256-gcm', + ) + Base64.encode64(iv + encrypted_data) # Combine IV with encrypted data and encode as base64 + end end - # Decrypts and unseals data using AES-256-GCM + # Decrypts and unseals data. # @param sealed_data [String] The sealed data to unseal # @param key [String] The key to use for decryption + # @param algorithm [Symbol] :aes_gcm (default) or :iron for Iron Fe26.2 format # @return [Hash] The unsealed data - def self.unseal_data(sealed_data, key) - decoded_data = Base64.decode64(sealed_data) - iv = decoded_data[0..11] # Extract the IV (first 12 bytes) - encrypted_data = decoded_data[12..-1] # Extract the encrypted data - - decrypted_data = Encryptor.decrypt( - value: encrypted_data, - key: key, - iv: iv, - algorithm: 'aes-256-gcm', - ) - - JSON.parse(decrypted_data, symbolize_names: true) # Parse the decrypted JSON string back to original data + def self.unseal_data(sealed_data, key, algorithm: :aes_gcm) + case algorithm + when :iron + WorkOS::IronSealUnseal.unseal(sealed_data, key, skip_expiration: false) + else + decoded_data = Base64.decode64(sealed_data) + iv = decoded_data[0..11] # Extract the IV (first 12 bytes) + encrypted_data = decoded_data[12..-1] # Extract the encrypted data + + decrypted_data = Encryptor.decrypt( + value: encrypted_data, + key: key, + iv: iv, + algorithm: 'aes-256-gcm', + ) + + JSON.parse(decrypted_data, symbolize_names: true) # Parse the decrypted JSON string back to original data + end end private diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 238c3cd6..bb4ec40e 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -42,14 +42,16 @@ class << self # @param [String] client_id The WorkOS client ID for the environment # @param [String] session_data The sealed session data # @param [String] cookie_password The password used to seal the session + # @param [Symbol] seal_algorithm Optional. :aes_gcm (default) or :iron for Iron Fe26.2 format # # @return WorkOS::Session - def load_sealed_session(client_id:, session_data:, cookie_password:) + def load_sealed_session(client_id:, session_data:, cookie_password:, seal_algorithm: :aes_gcm) WorkOS::Session.new( user_management: self, client_id: client_id, session_data: session_data, cookie_password: cookie_password, + seal_algorithm: seal_algorithm, ) end diff --git a/spec/lib/workos/iron_seal_unseal_spec.rb b/spec/lib/workos/iron_seal_unseal_spec.rb new file mode 100644 index 00000000..f794215b --- /dev/null +++ b/spec/lib/workos/iron_seal_unseal_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +RSpec.describe WorkOS::IronSealUnseal do + let(:password) { 'a' * 32 } + let(:payload) { { access_token: 'tok', user: { id: 'user_01' } } } + + describe '.seal' do + it 'produces a string with Fe26.2 prefix' do + sealed = described_class.seal(payload, password) + expect(sealed).to start_with(described_class::MAC_PREFIX) + end + + it 'raises ArgumentError when password is shorter than 32 characters' do + expect { described_class.seal(payload, 'short') }.to raise_error( + ArgumentError, /password must be at least 32 characters/ + ) + end + end + + describe '.unseal' do + context 'with a valid seal (round-trip)' do + it 'returns the decoded session hash with symbolized keys' do + sealed = described_class.seal(payload, password) + result = described_class.unseal(sealed, password) + expect(result).to eq(payload) + end + + it 'accepts sealed string with version suffix (~2)' do + sealed = described_class.seal(payload, password) + result = described_class.unseal("#{sealed}#{described_class::VERSION_DELIMITER}2", password) + expect(result).to eq(payload) + end + end + + context 'when password is too short' do + it 'raises ArgumentError' do + sealed = described_class.seal(payload, password) + expect { described_class.unseal(sealed, 'short') }.to raise_error( + ArgumentError, /password must be at least 32/ + ) + end + end + + context 'when sealed has wrong number of parts' do + it 'raises UnsealError' do + bad_seal = 'Fe26.2*a*b*c' + expect { described_class.unseal(bad_seal, password) }.to raise_error( + described_class::UnsealError, /Incorrect number of sealed components/ + ) + end + end + + context 'when prefix is not Fe26.2' do + it 'raises UnsealError' do + sealed = described_class.seal(payload, password) + bad_seal = sealed.sub('Fe26.2', 'Fe26.1') + expect { described_class.unseal(bad_seal, password) }.to raise_error( + described_class::UnsealError, /Wrong mac prefix/ + ) + end + end + + context 'when seal is expired' do + it 'raises UnsealError with skip_expiration: false' do + sealed = described_class.seal(payload, password, ttl_sec: -300) + expect { described_class.unseal(sealed, password, skip_expiration: false) }.to raise_error( + described_class::UnsealError, /Expired seal/ + ) + end + + it 'returns session with skip_expiration: true' do + sealed = described_class.seal(payload, password, ttl_sec: -300) + result = described_class.unseal(sealed, password, skip_expiration: true) + expect(result).to eq(payload) + end + end + + context 'when HMAC is invalid (wrong password or tampered)' do + it 'raises UnsealError for wrong password' do + sealed = described_class.seal(payload, password) + wrong_password = 'b' * 32 + expect { described_class.unseal(sealed, wrong_password) }.to raise_error( + described_class::UnsealError, /Bad hmac value/ + ) + end + end + end +end diff --git a/spec/lib/workos/session_spec.rb b/spec/lib/workos/session_spec.rb index d88037ec..c10c9f2a 100644 --- a/spec/lib/workos/session_spec.rb +++ b/spec/lib/workos/session_spec.rb @@ -198,6 +198,29 @@ }) end + it 'authenticates when session is sealed with algorithm: :iron' do + iron_session_data = WorkOS::Session.seal_data( + { + access_token: valid_access_token, + user: 'user', + impersonator: 'impersonator', + }, + cookie_password, + algorithm: :iron, + ) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: iron_session_data, + cookie_password: cookie_password, + seal_algorithm: :iron, + ) + allow_any_instance_of(JWT::Decode).to receive(:verify_signature).and_return(true) + result = session.authenticate + expect(result[:authenticated]).to eq(true) + expect(result[:session_id]).to eq('session_id') + end + it 'authenticates successfully with valid session_data' do session = WorkOS::Session.new( user_management: user_management, @@ -299,6 +322,24 @@ end end + describe '.seal_data and .unseal_data' do + it 'round-trips with default algorithm (aes_gcm)' do + data = { access_token: 'tok', user: 'u1' } + sealed = WorkOS::Session.seal_data(data, cookie_password) + expect(sealed).not_to eq(data) + unsealed = WorkOS::Session.unseal_data(sealed, cookie_password) + expect(unsealed).to eq(data) + end + + it 'round-trips with algorithm: :iron' do + data = { access_token: 'tok', user: 'u1' } + sealed = WorkOS::Session.seal_data(data, cookie_password, algorithm: :iron) + expect(sealed).to start_with(WorkOS::IronSealUnseal::MAC_PREFIX) + unsealed = WorkOS::Session.unseal_data(sealed, cookie_password, algorithm: :iron) + expect(unsealed).to eq(data) + end + end + describe '.refresh' do let(:user_management) { instance_double('UserManagement') } let(:refresh_token) { 'test_refresh_token' }