From 778d5bca76d295356a0c4103e949d82f8028fc06 Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Fri, 23 Aug 2024 14:26:55 +0200 Subject: [PATCH 1/7] Session helpers --- .ruby-version | 2 +- Gemfile.lock | 2 +- lib/workos.rb | 1 + lib/workos/authentication_response.rb | 21 ++- lib/workos/refresh_authentication_response.rb | 25 ++- lib/workos/session.rb | 164 ++++++++++++++++++ lib/workos/user_management.rb | 36 +++- 7 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 lib/workos/session.rb diff --git a/.ruby-version b/.ruby-version index b5021469..a0891f56 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.3.4 diff --git a/Gemfile.lock b/Gemfile.lock index 5c1da633..161130e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,4 +68,4 @@ DEPENDENCIES workos! BUNDLED WITH - 2.2.33 + 2.5.17 diff --git a/lib/workos.rb b/lib/workos.rb index 03ecefc9..f492f4c2 100644 --- a/lib/workos.rb +++ b/lib/workos.rb @@ -71,6 +71,7 @@ def self.key autoload :Profile, 'workos/profile' autoload :ProfileAndToken, 'workos/profile_and_token' autoload :RefreshAuthenticationResponse, 'workos/refresh_authentication_response' + autoload :Session, 'workos/session' autoload :SSO, 'workos/sso' autoload :Types, 'workos/types' autoload :User, 'workos/user' diff --git a/lib/workos/authentication_response.rb b/lib/workos/authentication_response.rb index 3bba0040..feb5ca2a 100644 --- a/lib/workos/authentication_response.rb +++ b/lib/workos/authentication_response.rb @@ -6,10 +6,12 @@ module WorkOS class AuthenticationResponse include HashProvider - attr_accessor :user, :organization_id, :impersonator, :access_token, :refresh_token + attr_accessor :user, :organization_id, :impersonator, :access_token, :refresh_token, :authentication_method, :sealed_session - def initialize(authentication_response_json) + def initialize(authentication_response_json, session = nil) json = JSON.parse(authentication_response_json, symbolize_names: true) + @access_token = json[:access_token] + @refresh_token = json[:refresh_token] @user = WorkOS::User.new(json[:user].to_json) @organization_id = json[:organization_id] @impersonator = @@ -17,8 +19,17 @@ def initialize(authentication_response_json) Impersonator.new(email: impersonator_json[:email], reason: impersonator_json[:reason],) end - @access_token = json[:access_token] - @refresh_token = json[:refresh_token] + @authentication_method = json[:authentication_method] + @sealed_session = + if (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]) + end end def to_json(*) @@ -28,6 +39,8 @@ def to_json(*) impersonator: impersonator.to_json, access_token: access_token, refresh_token: refresh_token, + authentication_method: authentication_method, + sealed_session: sealed_session, } end end diff --git a/lib/workos/refresh_authentication_response.rb b/lib/workos/refresh_authentication_response.rb index 4885e404..0c20c61c 100644 --- a/lib/workos/refresh_authentication_response.rb +++ b/lib/workos/refresh_authentication_response.rb @@ -6,18 +6,39 @@ module WorkOS class RefreshAuthenticationResponse include HashProvider - attr_accessor :access_token, :refresh_token + attr_accessor :user, :organization_id, :impersonator, :access_token, :refresh_token, :sealed_session - def initialize(authentication_response_json) + def initialize(authentication_response_json, session = nil) json = JSON.parse(authentication_response_json, symbolize_names: true) @access_token = json[:access_token] @refresh_token = json[:refresh_token] + @user = WorkOS::User.new(json[:user].to_json) + @organization_id = json[:organization_id] + @impersonator = + if (impersonator_json = json[:impersonator]) + Impersonator.new(email: impersonator_json[:email], + reason: impersonator_json[:reason],) + end + @sealed_session = + if (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]) + end end def to_json(*) { + user: user.to_json, + organization_id: organization_id, + impersonator: impersonator.to_json, access_token: access_token, refresh_token: refresh_token, + sealed_session: sealed_session, } end end diff --git a/lib/workos/session.rb b/lib/workos/session.rb new file mode 100644 index 00000000..4d805a69 --- /dev/null +++ b/lib/workos/session.rb @@ -0,0 +1,164 @@ +require_relative './user_management' +require 'jwt' +require 'uri' +require 'net/http' +require 'encryptor' +require 'securerandom' +require 'json' +require 'uri' + +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 + + def initialize(user_management:, client_id:, session_data:, cookie_password:) + 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 + + @jwks = create_remote_jwk_set(URI(@user_management.get_jwks_url(client_id))) + @jwks_algorithms = @jwks.map { |key| key[:alg] }.compact.uniq + end + + # Authenticates the user based on the session data + # @return [Hash] A hash containing the authentication response and a reason if the authentication failed + def authenticate + return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' } if @session_data.nil? + + begin + session = Session::unseal_data(@session_data, @cookie_password) + rescue StandardError => e + return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } + end + + return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:access_token] + + return { authenticated: false, reason: 'INVALID_JWT' } unless self.is_valid_jwt(session[:access_token]) + + decoded = JWT.decode(session[:access_token], nil, true, algorithms: @jwks_algorithms, jwks: @jwks).first + + { + authenticated: true, + session_id: decoded['sid'], + organization_id: decoded['org_id'], + role: decoded['role'], + permissions: decoded['permissions'], + user: session[:user], + impersonator: session[:impersonator], + reason: nil + } + end + + # Refreshes the session data using the refresh token stored in the session data + # @param options [Hash] Options for refreshing the session + # @option options [String] :cookie_password The password to use for unsealing the session data + # @option options [String] :organization_id The organization ID to use for refreshing the session + # @return [Hash] A hash containing a new sealed session, the authentication response, and a reason if the refresh failed + def refresh(options = nil) + cookie_password = options.nil? || options[:cookie_password].nil? ? @cookie_password : options[:cookie_password] + + session = Session::unseal_data(@session_data, cookie_password) + + return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:refresh_token] && session[:user] + + 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_data = auth_response.sealed_session + @cookie_password = cookie_password + + { + authenticated: true, + sealed_session: auth_response.sealed_session, + session: auth_response, + reason: nil + } + rescue StandardError => e + { authenticated: false, reason: e.message } + end + end + + # Returns a URL to redirect the user to for logging out + # @return [String] The URL to redirect the user to for logging out + def get_logout_url + auth_response = authenticate + + raise "Failed to extract session ID for logout URL: #{auth_response[:reason]}" unless auth_response[:authenticated] + + @user_management.get_logout_url(session_id: auth_response[:session_id]) + end + + private + + # Creates a JWKS set from a remote JWKS URL + # @param uri [URI] The URI of the JWKS + # @return [JWT::JWK::Set] The JWKS set + def create_remote_jwk_set(uri) + # Fetch the JWKS from the remote URL + response = Net::HTTP.get(uri) + + jwks_hash = JSON.parse(response) + jwks = JWT::JWK::Set.new(jwks_hash) + + # filter jwks so it only returns the keys where 'use' is equal to 'sig' + jwks.keys.select! { |key| key[:use] == 'sig' } + + jwks + end + + # Validates a JWT token using the JWKS set + # @param token [String] The JWT token to validate + # @return [Boolean] True if the token is valid, false otherwise + def is_valid_jwt(token) + JWT.decode(token, nil, true, algorithms: @jwks_algorithms, jwks: @jwks) + true + rescue + false + end + + # Encrypts and seals data using AES-256-GCM + # @param data [Hash] The data to seal + # @param key [String] The key to use for encryption + # @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 + end + + # Decrypts and unseals data using AES-256-GCM + # @param sealed_data [String] The sealed data to unseal + # @param key [String] The key to use for decryption + # @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 + end + end +end \ No newline at end of file diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 361c239c..f9fd4de1 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -37,6 +37,22 @@ class << self PROVIDERS = WorkOS::UserManagement::Types::Provider::ALL AUTH_FACTOR_TYPES = WorkOS::UserManagement::Types::AuthFactorType::ALL + # Load a sealed session + # + # @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 + # + # @return WorkOS::Session + def load_sealed_session(client_id:, session_data:, cookie_password:) + WorkOS::Session.new( + user_management: self, + client_id: client_id, + session_data: session_data, + cookie_password: cookie_password + ) + end + # Generate an OAuth 2.0 authorization URL that automatically directs a user # to their Identity Provider. # @@ -289,14 +305,20 @@ def authenticate_with_password(email:, password:, client_id:, ip_address: nil, u # @param [String] client_id The WorkOS client ID for the environment # @param [String] ip_address The IP address of the request from the user who is attempting to authenticate. # @param [String] user_agent The user agent of the request from the user who is attempting to authenticate. + # @param [Hash] session An optional hash that determines whether the session should be sealed and the cookie password. # # @return WorkOS::AuthenticationResponse def authenticate_with_code( code:, client_id:, ip_address: nil, - user_agent: nil + user_agent: nil, + session: nil ) + if session[:seal_session] == true and session[:cookie_password].nil? + raise ArgumentError, 'cookie_password is required when sealing session' + end + response = execute_request( request: post_request( path: '/user_management/authenticate', @@ -311,7 +333,7 @@ def authenticate_with_code( ), ) - WorkOS::AuthenticationResponse.new(response.body) + WorkOS::AuthenticationResponse.new(response.body, session) end # Authenticate a user using a refresh token. @@ -321,6 +343,7 @@ def authenticate_with_code( # @param [String] organization_id The organization to issue the new access token for. (Optional) # @param [String] ip_address The IP address of the request from the user who is attempting to authenticate. # @param [String] user_agent The user agent of the request from the user who is attempting to authenticate. + # @param [Hash] session An optional hash that determines whether the session should be sealed and the cookie password. # # @return WorkOS::RefreshAuthenticationResponse def authenticate_with_refresh_token( @@ -328,8 +351,13 @@ def authenticate_with_refresh_token( client_id:, organization_id: nil, ip_address: nil, - user_agent: nil + user_agent: nil, + session: nil ) + if session[:seal_session] == true and session[:cookie_password].nil? + raise ArgumentError, 'cookie_password is required when sealing session' + end + response = execute_request( request: post_request( path: '/user_management/authenticate', @@ -345,7 +373,7 @@ def authenticate_with_refresh_token( ), ) - WorkOS::RefreshAuthenticationResponse.new(response.body) + WorkOS::RefreshAuthenticationResponse.new(response.body, session) end # Authenticate user by Magic Auth Code. From a066b1845907f2da3327c85089b0a1106cee3cfd Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 14:38:14 +0200 Subject: [PATCH 2/7] Add tests --- Gemfile | 2 + Gemfile.lock | 6 + lib/workos/authentication_response.rb | 2 +- lib/workos/refresh_authentication_response.rb | 2 +- lib/workos/session.rb | 7 +- lib/workos/user_management.rb | 4 +- spec/lib/workos/session_spec.rb | 144 ++++++++++++++++ spec/lib/workos/user_management_spec.rb | 1 + .../authenticate_with_refresh_token/valid.yml | 157 +++++++++--------- 9 files changed, 241 insertions(+), 84 deletions(-) create mode 100644 spec/lib/workos/session_spec.rb diff --git a/Gemfile b/Gemfile index 7f4f5e95..6075e3ea 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ source 'https://rubygems.org' gemspec +gem 'jwt' +gem 'encryptor' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 161130e2..326b64e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,12 +9,16 @@ GEM addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) + base64 (0.2.0) bigdecimal (3.1.7) crack (1.0.0) bigdecimal rexml diff-lcs (1.5.1) + encryptor (3.0.0) hashdiff (1.1.0) + jwt (2.8.2) + base64 parallel (1.24.0) parser (3.3.0.5) ast (~> 2.4.1) @@ -61,6 +65,8 @@ PLATFORMS DEPENDENCIES bundler (>= 2.0.1) + encryptor + jwt rspec (~> 3.9.0) rubocop (~> 0.77) vcr (~> 5.0.0) diff --git a/lib/workos/authentication_response.rb b/lib/workos/authentication_response.rb index feb5ca2a..eb19d9a6 100644 --- a/lib/workos/authentication_response.rb +++ b/lib/workos/authentication_response.rb @@ -21,7 +21,7 @@ def initialize(authentication_response_json, session = nil) end @authentication_method = json[:authentication_method] @sealed_session = - if (session[:seal_session]) + if session and session[:seal_session] WorkOS::Session.seal_data({ access_token: access_token, refresh_token: refresh_token, diff --git a/lib/workos/refresh_authentication_response.rb b/lib/workos/refresh_authentication_response.rb index 0c20c61c..e7f9d6c6 100644 --- a/lib/workos/refresh_authentication_response.rb +++ b/lib/workos/refresh_authentication_response.rb @@ -20,7 +20,7 @@ def initialize(authentication_response_json, session = nil) reason: impersonator_json[:reason],) end @sealed_session = - if (session[:seal_session]) + if session and session[:seal_session] WorkOS::Session.seal_data({ access_token: access_token, refresh_token: refresh_token, diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 4d805a69..210c0c30 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -1,4 +1,3 @@ -require_relative './user_management' require 'jwt' require 'uri' require 'net/http' @@ -62,7 +61,11 @@ def authenticate def refresh(options = nil) cookie_password = options.nil? || options[:cookie_password].nil? ? @cookie_password : options[:cookie_password] - session = Session::unseal_data(@session_data, cookie_password) + begin + session = Session::unseal_data(@session_data, cookie_password) + rescue StandardError => e + return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } + end return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:refresh_token] && session[:user] diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index f9fd4de1..0b73ea45 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -315,7 +315,7 @@ def authenticate_with_code( user_agent: nil, session: nil ) - if session[:seal_session] == true and session[:cookie_password].nil? + if session and session[:seal_session] == true and session[:cookie_password].nil? raise ArgumentError, 'cookie_password is required when sealing session' end @@ -354,7 +354,7 @@ def authenticate_with_refresh_token( user_agent: nil, session: nil ) - if session[:seal_session] == true and session[:cookie_password].nil? + if session and session[:seal_session] == true and session[:cookie_password].nil? raise ArgumentError, 'cookie_password is required when sealing session' end diff --git a/spec/lib/workos/session_spec.rb b/spec/lib/workos/session_spec.rb new file mode 100644 index 00000000..e313848d --- /dev/null +++ b/spec/lib/workos/session_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +describe WorkOS::Session do + + let(:user_management) { instance_double('UserManagement') } + let(:client_id) { 'test_client_id' } + let(:cookie_password) { 'test_very_long_cookie_password__' } + let(:session_data) { 'test_session_data' } + let(:jwks_url) { 'https://api.workos.com/sso/jwks/client_123' } + let(:jwks_hash) { '{"keys":[{"alg":"RS256","kty":"RSA","use":"sig","n":"test_n","e":"AQAB","kid":"sso_oidc_key_pair_123","x5c":["test"],"x5t#S256":"test"}]}' } + let(:jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), { kid: 'sso_oidc_key_pair_123', use: 'sig', alg: 'RS256' }) } + + before do + allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url) + allow(Net::HTTP).to receive(:get).and_return(jwks_hash) + end + + describe 'initialize' do + it 'raises an error if cookie_password is nil or empty' do + expect { + WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: nil) + }.to raise_error(ArgumentError, 'cookiePassword is required') + + expect { + WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: '') + }.to raise_error(ArgumentError, 'cookiePassword is required') + end + + it 'initializes with valid parameters' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + expect(session.user_management).to eq(user_management) + expect(session.client_id).to eq(client_id) + expect(session.session_data).to eq(session_data) + expect(session.cookie_password).to eq(cookie_password) + expect(session.jwks.map(&:export)).to eq(JSON.parse(jwks_hash, symbolize_names: true)[:keys]) + expect(session.jwks_algorithms).to eq(['RS256']) + end + end + + describe '.authenticate' do + let(:valid_access_token) do + payload = { sid: 'session_id', org_id: 'org_id', role: 'role', permissions: ['read'], exp: Time.now.to_i + 3600 } + headers = { kid: jwk[:kid] } + JWT.encode(payload, jwk.signing_key, jwk[:alg], headers) + end + let(:session_data) { WorkOS::Session.seal_data({ access_token: valid_access_token, user: 'user', impersonator: 'impersonator' }, cookie_password) } + + it 'returns NO_SESSION_COOKIE_PROVIDED if session_data is nil' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: nil, cookie_password: cookie_password) + result = session.authenticate + expect(result).to eq({ authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' }) + end + + it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: 'invalid_data', cookie_password: cookie_password) + result = session.authenticate + expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' }) + end + + it 'returns INVALID_JWT if access_token is invalid' do + invalid_session_data = WorkOS::Session.seal_data({ access_token: 'invalid_token' }, cookie_password) + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: invalid_session_data, cookie_password: cookie_password) + result = session.authenticate + expect(result).to eq({ authenticated: false, reason: 'INVALID_JWT' }) + end + + it 'authenticates successfully with valid session_data' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + allow(session).to receive(:is_valid_jwt).and_return(true) + allow(JWT).to receive(:decode).and_return([{ 'sid' => 'session_id', 'org_id' => 'org_id', 'role' => 'role', 'permissions' => ['read'] }]) + + result = session.authenticate + expect(result).to eq({ + authenticated: true, + session_id: 'session_id', + organization_id: 'org_id', + role: 'role', + permissions: ['read'], + user: 'user', + impersonator: 'impersonator', + reason: nil + }) + end + end + + describe '.refresh' do + let(:refresh_token) { 'test_refresh_token' } + let(:session_data) { WorkOS::Session.seal_data({ refresh_token: refresh_token, user: 'user' }, cookie_password) } + let(:auth_response) { double('AuthResponse', sealed_session: 'new_sealed_session') } + + before do + allow(user_management).to receive(:authenticate_with_refresh_token).and_return(auth_response) + end + + it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: 'invalid_data', cookie_password: cookie_password) + result = session.refresh + expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' }) + end + + it 'refreshes the session successfully with valid session_data' do + session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + result = session.refresh + expect(result).to eq({ + authenticated: true, + sealed_session: 'new_sealed_session', + session: auth_response, + reason: nil + }) + end + end + + describe '.get_logout_url' do + let(:session) { WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) } + + context 'when authentication is successful' do + before do + allow(session).to receive(:authenticate).and_return({ + authenticated: true, + session_id: 'session_id', + reason: nil + }) + allow(user_management).to receive(:get_logout_url).with(session_id: 'session_id').and_return('https://example.com/logout') + end + + it 'returns the logout URL' do + expect(session.get_logout_url).to eq('https://example.com/logout') + end + end + + context 'when authentication fails' do + before do + allow(session).to receive(:authenticate).and_return({ + authenticated: false, + reason: 'Invalid session' + }) + end + + it 'raises an error' do + expect { session.get_logout_url }.to raise_error(RuntimeError, 'Failed to extract session ID for logout URL: Invalid session') + end + end + end +end \ No newline at end of file diff --git a/spec/lib/workos/user_management_spec.rb b/spec/lib/workos/user_management_spec.rb index bea5b56a..a7723cbb 100644 --- a/spec/lib/workos/user_management_spec.rb +++ b/spec/lib/workos/user_management_spec.rb @@ -467,6 +467,7 @@ ) expect(authentication_response.access_token).to eq('<ACCESS_TOKEN>') expect(authentication_response.refresh_token).to eq('<REFRESH_TOKEN>') + expect(authentication_response.user.id).to eq('user_01H93WD0R0KWF8Q7BK02C0RPYJ') end end end diff --git a/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml b/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml index e8381d6a..dd1b3328 100644 --- a/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml +++ b/spec/support/fixtures/vcr_cassettes/user_management/authenticate_with_refresh_token/valid.yml @@ -1,81 +1,82 @@ --- http_interactions: -- request: - method: post - uri: https://api.workos.com/user_management/authenticate - body: - encoding: UTF-8 - string: '{"refresh_token":"some_refresh_token","client_id":"client_123","client_secret":"<API_KEY>","ip_address":"200.240.210.16","user_agent":"Mozilla/5.0 - (Macintosh; Intel Mac OS X 10_15_7) Chrome/108.0.0.0 Safari/537.36","grant_type":"refresh_token"}' - headers: - Content-Type: - - application/json - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - WorkOS; ruby/3.2.2; arm64-darwin22; v4.0.0 - response: - status: - code: 200 - message: OK - headers: - Date: - - Mon, 18 Mar 2024 19:00:53 GMT - Content-Type: - - application/json; charset=utf-8 - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Cf-Ray: - - 866777d63b4627e8-SLC - Cf-Cache-Status: - - DYNAMIC - Etag: - - W/"335-M3MDQYhs5724SayBHHCwnBDn3qA" - Strict-Transport-Security: - - max-age=15552000; includeSubDomains - Vary: - - Origin, Accept-Encoding - Via: - - 1.1 spaces-router (devel) - Access-Control-Allow-Credentials: - - 'true' - Content-Security-Policy: - - 'default-src ''self'';base-uri ''self'';block-all-mixed-content;font-src ''self'' - https: data:;frame-ancestors ''self'';img-src ''self'' data:;object-src ''none'';script-src - ''self'';script-src-attr ''none'';style-src ''self'' https: ''unsafe-inline'';upgrade-insecure-requests' - Expect-Ct: - - max-age=0 - Referrer-Policy: - - no-referrer - X-Content-Type-Options: - - nosniff - X-Dns-Prefetch-Control: - - 'off' - X-Download-Options: - - noopen - X-Frame-Options: - - SAMEORIGIN - X-Permitted-Cross-Domain-Policies: - - none - X-Request-Id: - - 995ed1ed-e892-4049-86c9-0e07baa6cc4b - X-Xss-Protection: - - '0' - Set-Cookie: - - __cf_bm=2NHqv1cd1BisOc8KKcQ0oNzFxZZT4OHQd6c2QDuGnUU-1710788453-1.0.1.1-4BxBRzVrhL7rCH895PcfORXr_6Rnj3Oh5w1YG4xi7X1st62LMzb5dHZO7u7P.V1P8nBDAAt3Wbz7xsDTWrfWJg; - path=/; expires=Mon, 18-Mar-24 19:30:53 GMT; domain=.workos.com; HttpOnly; - Secure; SameSite=None - - __cfruid=06035c17e9b60a1d7a42a5b568146a0bb71a06dc-1710788453; path=/; domain=.workos.com; - HttpOnly; Secure; SameSite=None - Server: - - cloudflare - body: - encoding: UTF-8 - string: '{"access_token":"<ACCESS_TOKEN>","refresh_token":"<REFRESH_TOKEN>"}' - http_version: - recorded_at: Mon, 18 Mar 2024 19:00:53 GMT + - request: + method: post + uri: https://api.workos.com/user_management/authenticate + body: + encoding: UTF-8 + string: + '{"refresh_token":"some_refresh_token","client_id":"client_123","client_secret":"<API_KEY>","ip_address":"200.240.210.16","user_agent":"Mozilla/5.0 + (Macintosh; Intel Mac OS X 10_15_7) Chrome/108.0.0.0 Safari/537.36","grant_type":"refresh_token"}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - '*/*' + User-Agent: + - WorkOS; ruby/3.2.2; arm64-darwin22; v4.0.0 + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 18 Mar 2024 19:00:53 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - 866777d63b4627e8-SLC + Cf-Cache-Status: + - DYNAMIC + Etag: + - W/"335-M3MDQYhs5724SayBHHCwnBDn3qA" + Strict-Transport-Security: + - max-age=15552000; includeSubDomains + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 spaces-router (devel) + Access-Control-Allow-Credentials: + - 'true' + Content-Security-Policy: + - "default-src 'self';base-uri 'self';block-all-mixed-content;font-src 'self' + https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src + 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + Expect-Ct: + - max-age=0 + Referrer-Policy: + - no-referrer + X-Content-Type-Options: + - nosniff + X-Dns-Prefetch-Control: + - 'off' + X-Download-Options: + - noopen + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 995ed1ed-e892-4049-86c9-0e07baa6cc4b + X-Xss-Protection: + - '0' + Set-Cookie: + - __cf_bm=2NHqv1cd1BisOc8KKcQ0oNzFxZZT4OHQd6c2QDuGnUU-1710788453-1.0.1.1-4BxBRzVrhL7rCH895PcfORXr_6Rnj3Oh5w1YG4xi7X1st62LMzb5dHZO7u7P.V1P8nBDAAt3Wbz7xsDTWrfWJg; + path=/; expires=Mon, 18-Mar-24 19:30:53 GMT; domain=.workos.com; HttpOnly; + Secure; SameSite=None + - __cfruid=06035c17e9b60a1d7a42a5b568146a0bb71a06dc-1710788453; path=/; domain=.workos.com; + HttpOnly; Secure; SameSite=None + Server: + - cloudflare + body: + encoding: UTF-8 + string: '{"user":{"object":"user","id":"user_01H93WD0R0KWF8Q7BK02C0RPYJ","email":"test@workos.app","email_verified":true,"first_name":"Lucille","last_name":"Bluth","created_at":"2023-08-30T18:48:26.517Z","updated_at":"2023-08-30T18:58:00.821Z","user_type":"unmanaged","email_verified_at":"2023-08-30T18:58:00.915Z","google_oauth_profile_id":null,"microsoft_oauth_profile_id":null},"access_token":"<ACCESS_TOKEN>","refresh_token":"<REFRESH_TOKEN>"}' + http_version: + recorded_at: Mon, 18 Mar 2024 19:00:53 GMT recorded_with: VCR 5.0.0 From 6c2de0c6404645b7165b3c56c269985a0b649689 Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 15:05:07 +0200 Subject: [PATCH 3/7] Rubocop --- Gemfile | 2 +- lib/workos/authentication_response.rb | 24 ++- lib/workos/refresh_authentication_response.rb | 16 +- lib/workos/session.rb | 100 ++++++----- lib/workos/user_management.rb | 12 +- spec/lib/workos/session_spec.rb | 156 +++++++++++++----- 6 files changed, 204 insertions(+), 106 deletions(-) diff --git a/Gemfile b/Gemfile index 6075e3ea..cb47b448 100644 --- a/Gemfile +++ b/Gemfile @@ -3,5 +3,5 @@ source 'https://rubygems.org' gemspec +gem 'encryptor' gem 'jwt' -gem 'encryptor' \ No newline at end of file diff --git a/lib/workos/authentication_response.rb b/lib/workos/authentication_response.rb index eb19d9a6..84642790 100644 --- a/lib/workos/authentication_response.rb +++ b/lib/workos/authentication_response.rb @@ -6,8 +6,15 @@ module WorkOS class AuthenticationResponse include HashProvider - attr_accessor :user, :organization_id, :impersonator, :access_token, :refresh_token, :authentication_method, :sealed_session + attr_accessor :user, + :organization_id, + :impersonator, + :access_token, + :refresh_token, + :authentication_method, + :sealed_session + # rubocop:disable Metrics/AbcSize def initialize(authentication_response_json, session = nil) json = JSON.parse(authentication_response_json, symbolize_names: true) @access_token = json[:access_token] @@ -21,16 +28,17 @@ def initialize(authentication_response_json, session = nil) end @authentication_method = json[:authentication_method] @sealed_session = - if session and session[:seal_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]) + access_token: access_token, + refresh_token: refresh_token, + user: user.to_json, + organization_id: organization_id, + impersonator: impersonator.to_json, + }, session[:cookie_password],) end end + # rubocop:enable Metrics/AbcSize def to_json(*) { diff --git a/lib/workos/refresh_authentication_response.rb b/lib/workos/refresh_authentication_response.rb index e7f9d6c6..76d7ce73 100644 --- a/lib/workos/refresh_authentication_response.rb +++ b/lib/workos/refresh_authentication_response.rb @@ -8,6 +8,7 @@ class RefreshAuthenticationResponse attr_accessor :user, :organization_id, :impersonator, :access_token, :refresh_token, :sealed_session + # rubocop:disable Metrics/AbcSize def initialize(authentication_response_json, session = nil) json = JSON.parse(authentication_response_json, symbolize_names: true) @access_token = json[:access_token] @@ -20,16 +21,17 @@ def initialize(authentication_response_json, session = nil) reason: impersonator_json[:reason],) end @sealed_session = - if session and session[:seal_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]) + access_token: access_token, + refresh_token: refresh_token, + user: user.to_json, + organization_id: organization_id, + impersonator: impersonator.to_json, + }, session[:cookie_password],) end end + # rubocop:enable Metrics/AbcSize def to_json(*) { diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 210c0c30..7a23b03b 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jwt' require 'uri' require 'net/http' @@ -9,6 +11,7 @@ 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. + # rubocop:disable Metrics/ClassLength class Session attr_accessor :jwks, :jwks_algorithms, :user_management, :cookie_password, :session_data, :client_id @@ -30,14 +33,13 @@ def authenticate return { authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' } if @session_data.nil? begin - session = Session::unseal_data(@session_data, @cookie_password) - rescue StandardError => e + session = Session.unseal_data(@session_data, @cookie_password) + rescue StandardError return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } end return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } unless session[:access_token] - - return { authenticated: false, reason: 'INVALID_JWT' } unless self.is_valid_jwt(session[:access_token]) + return { authenticated: false, reason: 'INVALID_JWT' } unless is_valid_jwt(session[:access_token]) decoded = JWT.decode(session[:access_token], nil, true, algorithms: @jwks_algorithms, jwks: @jwks).first @@ -49,7 +51,7 @@ def authenticate permissions: decoded['permissions'], user: session[:user], impersonator: session[:impersonator], - reason: nil + reason: nil, } end @@ -57,13 +59,17 @@ def authenticate # @param options [Hash] Options for refreshing the session # @option options [String] :cookie_password The password to use for unsealing the session data # @option options [String] :organization_id The organization ID to use for refreshing the session - # @return [Hash] A hash containing a new sealed session, the authentication response, and a reason if the refresh failed + # @return [Hash] A hash containing a new sealed session, the authentication response, + # and a reason if the refresh failed + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity 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) - rescue StandardError => e + session = Session.unseal_data(@session_data, cookie_password) + rescue StandardError return { authenticated: false, reason: 'INVALID_SESSION_COOKIE' } end @@ -74,7 +80,7 @@ def refresh(options = nil) 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: { seal_session: true, cookie_password: cookie_password }, ) @session_data = auth_response.sealed_session @@ -84,50 +90,29 @@ def refresh(options = nil) authenticated: true, sealed_session: auth_response.sealed_session, session: auth_response, - reason: nil + reason: nil, } rescue StandardError => e { authenticated: false, reason: e.message } end end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity # Returns a URL to redirect the user to for logging out # @return [String] The URL to redirect the user to for logging out + # rubocop:disable Naming/AccessorMethodName def get_logout_url auth_response = authenticate - raise "Failed to extract session ID for logout URL: #{auth_response[:reason]}" unless auth_response[:authenticated] + unless auth_response[:authenticated] + raise "Failed to extract session ID for logout URL: #{auth_response[:reason]}" + end @user_management.get_logout_url(session_id: auth_response[:session_id]) end - - private - - # Creates a JWKS set from a remote JWKS URL - # @param uri [URI] The URI of the JWKS - # @return [JWT::JWK::Set] The JWKS set - def create_remote_jwk_set(uri) - # Fetch the JWKS from the remote URL - response = Net::HTTP.get(uri) - - jwks_hash = JSON.parse(response) - jwks = JWT::JWK::Set.new(jwks_hash) - - # filter jwks so it only returns the keys where 'use' is equal to 'sig' - jwks.keys.select! { |key| key[:use] == 'sig' } - - jwks - end - - # Validates a JWT token using the JWKS set - # @param token [String] The JWT token to validate - # @return [Boolean] True if the token is valid, false otherwise - def is_valid_jwt(token) - JWT.decode(token, nil, true, algorithms: @jwks_algorithms, jwks: @jwks) - true - rescue - false - end + # rubocop:enable Naming/AccessorMethodName # Encrypts and seals data using AES-256-GCM # @param data [Hash] The data to seal @@ -140,7 +125,7 @@ def self.seal_data(data, key) value: JSON.generate(data), key: key, iv: iv, - algorithm: 'aes-256-gcm' + algorithm: 'aes-256-gcm', ) Base64.encode64(iv + encrypted_data) # Combine IV with encrypted data and encode as base64 end @@ -158,10 +143,41 @@ def self.unseal_data(sealed_data, key) value: encrypted_data, key: key, iv: iv, - algorithm: 'aes-256-gcm' + algorithm: 'aes-256-gcm', ) JSON.parse(decrypted_data, symbolize_names: true) # Parse the decrypted JSON string back to original data end + + private + + # Creates a JWKS set from a remote JWKS URL + # @param uri [URI] The URI of the JWKS + # @return [JWT::JWK::Set] The JWKS set + def create_remote_jwk_set(uri) + # Fetch the JWKS from the remote URL + response = Net::HTTP.get(uri) + + jwks_hash = JSON.parse(response) + jwks = JWT::JWK::Set.new(jwks_hash) + + # filter jwks so it only returns the keys where 'use' is equal to 'sig' + jwks.keys.select! { |key| key[:use] == 'sig' } + + jwks + end + + # Validates a JWT token using the JWKS set + # @param token [String] The JWT token to validate + # @return [Boolean] True if the token is valid, false otherwise + # rubocop:disable Naming/PredicateName + def is_valid_jwt(token) + JWT.decode(token, nil, true, algorithms: @jwks_algorithms, jwks: @jwks) + true + rescue StandardError + false + end + # rubocop:enable Naming/PredicateName end -end \ No newline at end of file + # rubocop:enable Metrics/ClassLength +end diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 0b73ea45..ae6fcaca 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -49,7 +49,7 @@ def load_sealed_session(client_id:, session_data:, cookie_password:) user_management: self, client_id: client_id, session_data: session_data, - cookie_password: cookie_password + cookie_password: cookie_password, ) end @@ -305,7 +305,8 @@ def authenticate_with_password(email:, password:, client_id:, ip_address: nil, u # @param [String] client_id The WorkOS client ID for the environment # @param [String] ip_address The IP address of the request from the user who is attempting to authenticate. # @param [String] user_agent The user agent of the request from the user who is attempting to authenticate. - # @param [Hash] session An optional hash that determines whether the session should be sealed and the cookie password. + # @param [Hash] session An optional hash that determines whether the session should be sealed and + # the optional cookie password. # # @return WorkOS::AuthenticationResponse def authenticate_with_code( @@ -315,7 +316,7 @@ def authenticate_with_code( user_agent: nil, session: nil ) - if session and session[:seal_session] == true and session[:cookie_password].nil? + if session && (session[:seal_session] == true) && session[:cookie_password].nil? raise ArgumentError, 'cookie_password is required when sealing session' end @@ -343,7 +344,8 @@ def authenticate_with_code( # @param [String] organization_id The organization to issue the new access token for. (Optional) # @param [String] ip_address The IP address of the request from the user who is attempting to authenticate. # @param [String] user_agent The user agent of the request from the user who is attempting to authenticate. - # @param [Hash] session An optional hash that determines whether the session should be sealed and the cookie password. + # @param [Hash] session An optional hash that determines whether the session should be sealed and + # the optional cookie password. # # @return WorkOS::RefreshAuthenticationResponse def authenticate_with_refresh_token( @@ -354,7 +356,7 @@ def authenticate_with_refresh_token( user_agent: nil, session: nil ) - if session and session[:seal_session] == true and session[:cookie_password].nil? + if session && (session[:seal_session] == true) && session[:cookie_password].nil? raise ArgumentError, 'cookie_password is required when sealing session' end diff --git a/spec/lib/workos/session_spec.rb b/spec/lib/workos/session_spec.rb index e313848d..1c0035a3 100644 --- a/spec/lib/workos/session_spec.rb +++ b/spec/lib/workos/session_spec.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true describe WorkOS::Session do - let(:user_management) { instance_double('UserManagement') } let(:client_id) { 'test_client_id' } let(:cookie_password) { 'test_very_long_cookie_password__' } let(:session_data) { 'test_session_data' } let(:jwks_url) { 'https://api.workos.com/sso/jwks/client_123' } - let(:jwks_hash) { '{"keys":[{"alg":"RS256","kty":"RSA","use":"sig","n":"test_n","e":"AQAB","kid":"sso_oidc_key_pair_123","x5c":["test"],"x5t#S256":"test"}]}' } + let(:jwks_hash) { '{"keys":[{"alg":"RS256","kty":"RSA","use":"sig","n":"test_n","e":"AQAB","kid":"sso_oidc_key_pair_123","x5c":["test"],"x5t#S256":"test"}]}' } # rubocop:disable all let(:jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), { kid: 'sso_oidc_key_pair_123', use: 'sig', alg: 'RS256' }) } before do @@ -17,17 +16,32 @@ describe 'initialize' do it 'raises an error if cookie_password is nil or empty' do - expect { - WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: nil) - }.to raise_error(ArgumentError, 'cookiePassword is required') - - expect { - WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: '') - }.to raise_error(ArgumentError, 'cookiePassword is required') + expect do + WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: nil, + ) + end.to raise_error(ArgumentError, 'cookiePassword is required') + + expect do + WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: '', + ) + end.to raise_error(ArgumentError, 'cookiePassword is required') end it 'initializes with valid parameters' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: cookie_password, + ) expect(session.user_management).to eq(user_management) expect(session.client_id).to eq(client_id) expect(session.session_data).to eq(session_data) @@ -39,47 +53,84 @@ describe '.authenticate' do let(:valid_access_token) do - payload = { sid: 'session_id', org_id: 'org_id', role: 'role', permissions: ['read'], exp: Time.now.to_i + 3600 } + payload = { + sid: 'session_id', + org_id: 'org_id', + role: 'role', + permissions: ['read'], + exp: Time.now.to_i + 3600, + } headers = { kid: jwk[:kid] } JWT.encode(payload, jwk.signing_key, jwk[:alg], headers) end - let(:session_data) { WorkOS::Session.seal_data({ access_token: valid_access_token, user: 'user', impersonator: 'impersonator' }, cookie_password) } + let(:session_data) do + WorkOS::Session.seal_data({ + access_token: valid_access_token, + user: 'user', + impersonator: 'impersonator', + }, cookie_password,) +end it 'returns NO_SESSION_COOKIE_PROVIDED if session_data is nil' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: nil, cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: nil, + cookie_password: cookie_password, + ) result = session.authenticate expect(result).to eq({ authenticated: false, reason: 'NO_SESSION_COOKIE_PROVIDED' }) end it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: 'invalid_data', cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: 'invalid_data', + cookie_password: cookie_password, + ) result = session.authenticate expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' }) end it 'returns INVALID_JWT if access_token is invalid' do invalid_session_data = WorkOS::Session.seal_data({ access_token: 'invalid_token' }, cookie_password) - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: invalid_session_data, cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: invalid_session_data, + cookie_password: cookie_password, + ) result = session.authenticate expect(result).to eq({ authenticated: false, reason: 'INVALID_JWT' }) end it 'authenticates successfully with valid session_data' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: cookie_password, + ) allow(session).to receive(:is_valid_jwt).and_return(true) - allow(JWT).to receive(:decode).and_return([{ 'sid' => 'session_id', 'org_id' => 'org_id', 'role' => 'role', 'permissions' => ['read'] }]) + allow(JWT).to receive(:decode).and_return([{ + 'sid' => 'session_id', + 'org_id' => 'org_id', + 'role' => 'role', + 'permissions' => ['read'], + }]) result = session.authenticate expect(result).to eq({ - authenticated: true, - session_id: 'session_id', - organization_id: 'org_id', - role: 'role', - permissions: ['read'], - user: 'user', - impersonator: 'impersonator', - reason: nil - }) + authenticated: true, + session_id: 'session_id', + organization_id: 'org_id', + role: 'role', + permissions: ['read'], + user: 'user', + impersonator: 'impersonator', + reason: nil, + }) end end @@ -93,33 +144,50 @@ end it 'returns INVALID_SESSION_COOKIE if session_data is invalid' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: 'invalid_data', cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: 'invalid_data', + cookie_password: cookie_password, + ) result = session.refresh expect(result).to eq({ authenticated: false, reason: 'INVALID_SESSION_COOKIE' }) end it 'refreshes the session successfully with valid session_data' do - session = WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) + session = WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: cookie_password, + ) result = session.refresh expect(result).to eq({ - authenticated: true, - sealed_session: 'new_sealed_session', - session: auth_response, - reason: nil - }) + authenticated: true, + sealed_session: 'new_sealed_session', + session: auth_response, + reason: nil, + }) end end describe '.get_logout_url' do - let(:session) { WorkOS::Session.new(user_management: user_management, client_id: client_id, session_data: session_data, cookie_password: cookie_password) } + let(:session) do + WorkOS::Session.new( + user_management: user_management, + client_id: client_id, + session_data: session_data, + cookie_password: cookie_password, + ) + end context 'when authentication is successful' do before do allow(session).to receive(:authenticate).and_return({ - authenticated: true, - session_id: 'session_id', - reason: nil - }) + authenticated: true, + session_id: 'session_id', + reason: nil, + }) allow(user_management).to receive(:get_logout_url).with(session_id: 'session_id').and_return('https://example.com/logout') end @@ -131,14 +199,16 @@ context 'when authentication fails' do before do allow(session).to receive(:authenticate).and_return({ - authenticated: false, - reason: 'Invalid session' - }) + authenticated: false, + reason: 'Invalid session', + }) end it 'raises an error' do - expect { session.get_logout_url }.to raise_error(RuntimeError, 'Failed to extract session ID for logout URL: Invalid session') + expect { session.get_logout_url }.to raise_error( + RuntimeError, 'Failed to extract session ID for logout URL: Invalid session', + ) end end end -end \ No newline at end of file +end From c3b7a76d5801bf52a47c5d9e6a71a624f0a42aa4 Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 15:18:33 +0200 Subject: [PATCH 4/7] Put ruby back where it was --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index a0891f56..b5021469 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.0.2 From 2c87085cf826aef798519a5d071471916cfd90d6 Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 15:20:10 +0200 Subject: [PATCH 5/7] And the gemfile --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 326b64e5..304014cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,4 +74,4 @@ DEPENDENCIES workos! BUNDLED WITH - 2.5.17 + 2.2.33 From b84ccd9e106006d8c142a113e8d2ccfdc641cc0e Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 16:35:54 +0200 Subject: [PATCH 6/7] move gems to gemspec --- Gemfile | 2 -- Gemfile.lock | 4 ++-- workos.gemspec | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index cb47b448..7f4f5e95 100644 --- a/Gemfile +++ b/Gemfile @@ -3,5 +3,3 @@ source 'https://rubygems.org' gemspec -gem 'encryptor' -gem 'jwt' diff --git a/Gemfile.lock b/Gemfile.lock index 304014cb..559de6fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,8 @@ PATH remote: . specs: workos (5.6.0) + encryptor (~> 3.0) + jwt (~> 2.8) GEM remote: https://rubygems.org/ @@ -65,8 +67,6 @@ PLATFORMS DEPENDENCIES bundler (>= 2.0.1) - encryptor - jwt rspec (~> 3.9.0) rubocop (~> 0.77) vcr (~> 5.0.0) diff --git a/workos.gemspec b/workos.gemspec index 524c5a28..37636dae 100644 --- a/workos.gemspec +++ b/workos.gemspec @@ -21,6 +21,9 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] + spec.add_dependency 'jwt', '~> 2.8' + spec.add_dependency 'encryptor', '~> 3.0' + spec.add_development_dependency 'bundler', '>= 2.0.1' spec.add_development_dependency 'rspec', '~> 3.9.0' spec.add_development_dependency 'rubocop', '~> 0.77' From a77a08fb31f4d03f447fde25de6edec422b1a65b Mon Sep 17 00:00:00 2001 From: Paul Asjes <p.asjes@gmail.com> Date: Tue, 27 Aug 2024 16:46:04 +0200 Subject: [PATCH 7/7] rubocop again --- workos.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workos.gemspec b/workos.gemspec index 37636dae..98d092b9 100644 --- a/workos.gemspec +++ b/workos.gemspec @@ -21,8 +21,8 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.add_dependency 'jwt', '~> 2.8' spec.add_dependency 'encryptor', '~> 3.0' + spec.add_dependency 'jwt', '~> 2.8' spec.add_development_dependency 'bundler', '>= 2.0.1' spec.add_development_dependency 'rspec', '~> 3.9.0'