Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/workos.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 12 additions & 7 deletions lib/workos/authentication_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions lib/workos/iron_seal_unseal.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +96 to +104
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PBKDF2 with only 1 iteration is cryptographically weak

Suggested change
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 derive_key(password, salt, key_length)
OpenSSL::KDF.pbkdf2_hmac(
password,
salt: salt,
iterations: 100000,
length: key_length,
hash: OpenSSL::Digest.new('SHA256'),
)
end
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/workos/iron_seal_unseal.rb
Line: 96:104

Comment:
PBKDF2 with only 1 iteration is cryptographically weak

```suggestion
      def derive_key(password, salt, key_length)
        OpenSSL::KDF.pbkdf2_hmac(
          password,
          salt: salt,
          iterations: 100000,
          length: key_length,
          hash: OpenSSL::Digest.new('SHA256'),
        )
      end
```

How can I resolve this? If you propose a fix, please make it concise.


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
19 changes: 12 additions & 7 deletions lib/workos/refresh_authentication_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 46 additions & 30 deletions lib/workos/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion lib/workos/user_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading