keycloak_ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/request_performer.rb
4
+
5
+ module KeycloakRuby
6
+ # Responsible for performing HTTP requests with HTTParty
7
+ # and validating the response. This class helps to reduce
8
+ # FeatureEnvy and keep the Client code cleaner.
9
+ # :reek:FeatureEnvy
10
+ class RequestPerformer
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ # Executes an HTTP request and verifies the response code.
16
+ #
17
+ # @param request_params [KeycloakRuby::RequestParams] - an object containing
18
+ # :http_method, :url, :headers, :body, :success_codes, :error_class, :error_message
19
+ #
20
+ # @return [HTTParty::Response] The HTTParty response object on success.
21
+ # @raise [request_params.error_class] If the response code is not in success_codes
22
+ # or HTTParty raises an error.
23
+ def call(request_params)
24
+ # To reduce FeatureEnvy, extract local variables
25
+ http_method = request_params.http_method
26
+ url = request_params.url
27
+ headers = request_params.headers
28
+ body = request_params.body
29
+
30
+ response = HTTParty.send(http_method, url, headers: headers, body: body)
31
+ verify_response!(response, request_params)
32
+ response
33
+ rescue HTTParty::Error => e
34
+ KeycloakRuby.logger.error("#{request_params.error_message} (HTTParty error): #{e.message}")
35
+ raise request_params.error_class, e.message
36
+ end
37
+
38
+ private
39
+
40
+ # Safe validation: returns true/false
41
+ def verify_response(response, request_params)
42
+ code = response.code
43
+ success_codes = request_params.success_codes
44
+
45
+ case success_codes
46
+ when Range
47
+ success_codes.cover?(code)
48
+ when Array
49
+ success_codes.include?(code)
50
+ else
51
+ code == success_codes
52
+ end
53
+ end
54
+
55
+ # Bang version that raises an error on invalid response
56
+ def verify_response!(response, request_params)
57
+ return if verify_response(response, request_params)
58
+
59
+ code = response.code
60
+ error_message = request_params.error_message
61
+ KeycloakRuby.logger.error("#{error_message}: #{code} => #{response.body}")
62
+ raise request_params.error_class, "#{error_message}: #{code} => #{response.body}"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeycloakRuby
4
+ # Validates Keycloak API responses with both safe and strict modes
5
+ #
6
+ # Provides two validation approaches:
7
+ # 1. Safe validation (#validate) - returns boolean
8
+ # 2. Strict validation (#validate!) - raises detailed exceptions
9
+ #
10
+ # @example Safe validation
11
+ # validator = ResponseValidator.new(response)
12
+ # if validator.validate
13
+ # # proceed with valid response
14
+ # else
15
+ # # handle invalid response
16
+ # end
17
+ #
18
+ # @example Strict validation
19
+ # begin
20
+ # data = ResponseValidator.new(response).validate!
21
+ # # use validated data
22
+ # rescue KeycloakRuby::Errors::TokenRefreshFailed => e
23
+ # # handle error
24
+ # end
25
+ class ResponseValidator
26
+ # Initialize with the HTTP response
27
+ # @param response [HTTP::Response] The raw HTTP response from Keycloak
28
+ def initialize(response)
29
+ @response = response
30
+ @data = parse_response_body
31
+ end
32
+
33
+ # Safe validation - returns boolean instead of raising exceptions
34
+ # @return [Boolean] true if response is valid, false otherwise
35
+ def validate
36
+ return false unless valid_http_status?
37
+ return false if invalid_grant?
38
+ return false if error_present?
39
+
40
+ access_token_present?
41
+ end
42
+
43
+ # Strict validation - raises exceptions for invalid responses
44
+ # @return [Hash] Parsed response data if valid
45
+ # @raise [KeycloakRuby::Errors::TokenRefreshFailed] if validation fails
46
+ def validate!
47
+ validate or raise_validation_error
48
+ @data
49
+ end
50
+
51
+ private
52
+
53
+ # Parses JSON response body, returns empty hash on failure
54
+ # @return [Hash]
55
+ def parse_response_body
56
+ JSON.parse(@response.body)
57
+ rescue JSON::ParserError
58
+ {}
59
+ end
60
+
61
+ # Checks if HTTP status indicates success
62
+ # @return [Boolean]
63
+ def valid_http_status?
64
+ @response.success?
65
+ end
66
+
67
+ # Checks for OAuth2 "invalid_grant" error
68
+ # @return [Boolean]
69
+ def invalid_grant?
70
+ @data["error"] == "invalid_grant"
71
+ end
72
+
73
+ # Checks for any error in response
74
+ # @return [Boolean]
75
+ def error_present?
76
+ @data.key?("error")
77
+ end
78
+
79
+ # Verifies access token presence
80
+ # @return [Boolean]
81
+ def access_token_present?
82
+ @data["access_token"].present?
83
+ end
84
+
85
+ # Raises appropriate validation error based on failure reason
86
+ # @raise [KeycloakRuby::Errors::TokenRefreshFailed]
87
+ def raise_validation_error # rubocop:disable Metrics/MethodLength
88
+ if !valid_http_status?
89
+ raise Errors::TokenRefreshFailed,
90
+ "Keycloak API request failed with status #{@response.code}: #{extract_error_message}"
91
+ elsif invalid_grant?
92
+ raise Errors::TokenRefreshFailed,
93
+ "Invalid grant: #{@data["error_description"] || "Refresh token invalid or expired"}"
94
+ elsif error_present?
95
+ raise Errors::TokenRefreshFailed,
96
+ "Keycloak error: #{@data["error"]} - #{@data["error_description"]}"
97
+ else
98
+ raise Errors::TokenRefreshFailed,
99
+ "Invalid response: access token missing from response"
100
+ end
101
+ end
102
+
103
+ # Extracts error message from response
104
+ # @return [String]
105
+ def extract_error_message
106
+ if @data["error_description"]
107
+ @data["error_description"]
108
+ elsif @response.body.length < 100 # Prevent huge error messages
109
+ @response.body
110
+ else
111
+ "See response body for details"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # keycloak_ruby/testing/keycloak_helpers.rb
4
+ # :reek:UtilityFunction :reek:ControlParameter :reek:ManualDispatch :reek:BooleanParameter :reek:LongParameterList
5
+ module KeycloakRuby
6
+ module Testing
7
+ # Helper module for tests with Keycloak
8
+ module KeycloakHelpers
9
+ # Combines both sign-in approaches with automatic detection of test type
10
+ def sign_in(user, test_type: auto_detect_test_type)
11
+ case test_type
12
+ when :request
13
+ mock_token_service(user)
14
+ when :feature, :system
15
+ mock_keycloak_login(user, use_capybara: true)
16
+ else # :controller, :view, etc.
17
+ mock_keycloak_login(user, use_capybara: false)
18
+ end
19
+ end
20
+
21
+ # Мокирует авторизацию в request-тестах, подставляя указанного пользователя в current_user.
22
+ # Нужно, так как в request-тестах нет прямого доступа к сессиям и внешним сервисам авторизации.
23
+ def mock_token_service(user)
24
+ token_service_double = instance_double(KeycloakRuby::TokenService, find_user: user)
25
+ allow(KeycloakRuby::TokenService).to receive(:new).and_return(token_service_double)
26
+ end
27
+
28
+ def create_keycloak_user(username:, email:, password:, temporary:)
29
+ user_data = KeycloakRuby::User.create(username:, email:, password:, temporary:)
30
+ KeycloakHelpers.track_keycloak_user(user_data["id"])
31
+ user_data
32
+ end
33
+
34
+ # Delete all users from Keycloak
35
+ def self.delete_all_keycloak_users
36
+ users = KeycloakRuby::User.find("") # Empty search string to find all users
37
+ users.each do |user|
38
+ KeycloakRuby::User.delete_by_id(user["id"])
39
+ end
40
+ end
41
+
42
+ def self.track_keycloak_user(user_id)
43
+ @keycloak_users ||= []
44
+ @keycloak_users << user_id
45
+ end
46
+
47
+ def self.cleanup_keycloak_users
48
+ return unless @keycloak_users
49
+
50
+ @keycloak_users.each do |user_id|
51
+ KeycloakRuby::User.delete(user_id)
52
+ end
53
+ @keycloak_users.clear
54
+ end
55
+
56
+ private
57
+
58
+ def mock_keycloak_login(user, use_capybara: true)
59
+ OmniAuth.config.test_mode = true
60
+ token_data = generate_fake_tokens(user)
61
+ OmniAuth.config.mock_auth[:keycloak] = token_data
62
+
63
+ if use_capybara
64
+ capybara_login
65
+ else
66
+ store_session(token_data.credentials)
67
+ end
68
+ end
69
+
70
+ def capybara_login
71
+ visit "/login"
72
+ click_on I18n.t("user.login") if page.has_button? I18n.t("user.login")
73
+ end
74
+
75
+ def generate_fake_tokens(user)
76
+ email = user.email
77
+ token_payload = { "email" => email, "exp" => 2.hours.from_now.to_i }
78
+
79
+ OmniAuth::AuthHash.new(provider: "keycloak", uid: "uid-#{email}", info: { email: email },
80
+ credentials: OmniAuth::AuthHash.new(
81
+ token: JWT.encode(token_payload, nil, "none"),
82
+ refresh_token: "fake-refresh-#{email}",
83
+ id_token: "fake-id-#{email}",
84
+ expires_at: 2.hours.from_now.to_i
85
+ ))
86
+ end
87
+
88
+ def store_session(credentials)
89
+ session[:access_token] = credentials[:token]
90
+ session[:refresh_token] = credentials[:refresh_token]
91
+ session[:id_token] = credentials[:id_token]
92
+ end
93
+
94
+ def rspec_auto_detect_test_type
95
+ if RSpec.current_example.metadata[:type] == :request
96
+ :request
97
+ elsif defined?(Capybara::DSL) && Capybara.current_driver != :rack_test
98
+ :feature
99
+ else
100
+ :controller
101
+ end
102
+ end
103
+
104
+ def auto_detect_test_type
105
+ if defined?(RSpec) && RSpec.current_example
106
+ rspec_auto_detect_test_type
107
+ else
108
+ :feature
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ # Automatically include in common test frameworks
115
+ if defined?(RSpec)
116
+ RSpec.configure do |config|
117
+ config.include KeycloakRuby::Testing::KeycloakHelpers
118
+ end
119
+ elsif defined?(Minitest)
120
+ module Minitest
121
+ class Test
122
+ include KeycloakRuby::Testing::KeycloakHelpers
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/testing.rb
4
+ require_relative "testing/keycloak_helpers"
5
+
6
+ module KeycloakRuby
7
+ # Include test methods
8
+ module Testing
9
+ def self.included(base)
10
+ base.include KeycloakHelpers
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/token_refresher.rb`
4
+ module KeycloakRuby
5
+ # Handles OAuth2 refresh token flow with Keycloak
6
+ #
7
+ # Responsible for:
8
+ # - Executing refresh token requests
9
+ # - Validating responses
10
+ # - Managing refresh failures
11
+ #
12
+ # @example Basic usage
13
+ # refresher = TokenRefresher.new(session, config)
14
+ # new_tokens = refresher.call
15
+ #
16
+ # @example With error handling
17
+ # begin
18
+ # refresher.call
19
+ # rescue KeycloakRuby::Errors::TokenRefreshFailed => e
20
+ # # Handle token refresh failure (e.g., redirect to login)
21
+ # end
22
+ class TokenRefresher
23
+ def initialize(session, config)
24
+ @session = session
25
+ @config = config
26
+ end
27
+
28
+ # Main entry point - refreshes the token
29
+ # @return [Hash] New token data if successful
30
+ # @raise [KeycloakRuby::Errors::TokenRefreshFailed] if refresh fails
31
+ def call
32
+ refresh_token_flow
33
+ end
34
+
35
+ private
36
+
37
+ def refresh_token_flow
38
+ log_refresh_attempt
39
+ response = request_refresh
40
+ validate_response(response)
41
+ rescue HTTParty::Error => e
42
+ handle_http_error(e)
43
+ end
44
+
45
+ def request_refresh
46
+ HTTParty.post(
47
+ @config.token_url,
48
+ body: refresh_params,
49
+ headers: headers,
50
+ timeout: 30 # Add timeout for safety
51
+ )
52
+ end
53
+
54
+ def validate_response(response)
55
+ validator = ResponseValidator.new(response)
56
+
57
+ if validator.validate
58
+ log_successful_refresh
59
+ validator.validate! # Returns the validated token data
60
+ else
61
+ handle_failed_validation(response)
62
+ end
63
+ end
64
+
65
+ def handle_http_error(exception)
66
+ error_message = "Token refresh HTTP error: #{exception.message}"
67
+ KeycloakRuby.logger.error(error_message)
68
+ raise Errors::TokenRefreshFailed, error_message
69
+ end
70
+
71
+ # :reek:FeatureEnvy
72
+ def handle_failed_validation(response)
73
+ error_message = "Token refresh failed. Status: #{response.code}, Body: #{response.body.truncate(200)}"
74
+ KeycloakRuby.logger.error(error_message)
75
+ raise Errors::TokenRefreshFailed, error_message
76
+ end
77
+
78
+ def refresh_params
79
+ {
80
+ grant_type: "refresh_token",
81
+ client_id: @config.oauth_client_id,
82
+ client_secret: @config.oauth_client_secret,
83
+ refresh_token: @session[:refresh_token]
84
+ }
85
+ end
86
+
87
+ def headers
88
+ {
89
+ "Content-Type" => "application/x-www-form-urlencoded",
90
+ "Accept" => "application/json",
91
+ "User-Agent" => "KeycloakRuby/#{KeycloakRuby::Version::VERSION}"
92
+ }
93
+ end
94
+
95
+ def log_refresh_attempt
96
+ KeycloakRuby.logger.info("Attempting token refresh for client: #{@config.oauth_client_id}")
97
+ end
98
+
99
+ def log_successful_refresh
100
+ KeycloakRuby.logger.info("Successfully refreshed tokens for client: #{@config.oauth_client_id}")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/token_service.rb
4
+ module KeycloakRuby
5
+ # Service for check and refresh jwt tokens
6
+ class TokenService
7
+ def initialize(session, config = KeycloakRuby::Config.new)
8
+ @session = session
9
+ @config = config
10
+ @refresh_mutex = Mutex.new # Mutex ensures only one refresh happens at a time
11
+ end
12
+
13
+ # Finds user by token claims
14
+ # @return [User, nil]
15
+ def find_user
16
+ claims = current_token or return
17
+ user = ::User.find_by(email: claims["email"])
18
+ clear_tokens unless user
19
+ user
20
+ end
21
+
22
+ # Store token
23
+ def store_tokens(data)
24
+ @session[:access_token] = extract_access_token(data)
25
+ @session[:refresh_token] = data["refresh_token"] if data["refresh_token"]
26
+ @session[:id_token] = data["id_token"] if data["id_token"]
27
+ end
28
+
29
+ def clear_tokens
30
+ %i[access_token refresh_token id_token].each { |token| @session.delete(token) }
31
+ end
32
+
33
+ private
34
+
35
+ # It's necessary, because omniauth return request.env["omniauth.auth"] as 'token', not 'access_token'
36
+ def extract_access_token(data)
37
+ data["token"] || data["access_token"]
38
+ end
39
+
40
+ # Gets current token or attempts refresh if expired
41
+ # @return [Hash, nil] Decoded token claims
42
+ def current_token
43
+ token = @session[:access_token] or return
44
+ decode_token(token)
45
+ rescue Errors::TokenExpired # Normal refresh
46
+ refresh_current_token
47
+ rescue Errors::TokenInvalid => e # Wrong token refresh
48
+ KeycloakRuby.logger.error("JWT Error: #{e.message}")
49
+ clear_tokens
50
+ nil
51
+ end
52
+
53
+ # Decodes JWT token
54
+ # @raise [Errors::TokenExpired, Errors::TokenInvalid]
55
+ def decode_token(token)
56
+ options = jwt_decode_options
57
+
58
+ if Rails.env.test?
59
+ JWT.decode(token, nil, false).first # без проверки подписи в тестах
60
+ else
61
+ JWT.decode(token, nil, true, options).first
62
+ end
63
+ rescue JWT::ExpiredSignature => e
64
+ raise Errors::TokenExpired, e.message
65
+ rescue JWT::DecodeError => e
66
+ raise Errors::TokenInvalid, e.message
67
+ end
68
+
69
+ def fetch_jwks
70
+ realm_url = @config.realm_url
71
+ @fetch_jwks ||= Rails.cache.fetch("keycloak_jwks", expires_in: 1.hour) do
72
+ uri = URI("#{realm_url}/protocol/openid-connect/certs")
73
+ JSON.parse(Net::HTTP.get(uri))
74
+ end
75
+ end
76
+
77
+ # Attempts to refresh the current token
78
+ def refresh_current_token
79
+ @refresh_mutex.synchronize do
80
+ new_tokens = TokenRefresher.new(@session, @config).call
81
+ store_tokens(new_tokens)
82
+ decode_token(new_tokens["access_token"])
83
+ end
84
+ rescue Errors::TokenRefreshFailed => e
85
+ KeycloakRuby.logger.error("Refresh failed: #{e.message}")
86
+ clear_tokens
87
+ nil
88
+ end
89
+
90
+ # JWT decoding options with JWKS
91
+ def jwt_decode_options
92
+ {
93
+ algorithms: ["RS256"],
94
+ verify_iss: true,
95
+ iss: issuer_url,
96
+ # verify_aud: true, # Вроде рекомендуют, но у нас не работает
97
+ aud: @config.oauth_client_id,
98
+ verify_expiration: true,
99
+ jwks: fetch_jwks
100
+ }
101
+ end
102
+
103
+ # Constructs issuer URL from configuration
104
+ def issuer_url
105
+ @issuer_url ||= @config.realm_url
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/user.rb
4
+ module KeycloakRuby
5
+ # User-related operations for interacting with Keycloak.
6
+ # This class provides a simple interface for creating, deleting, and finding users in Keycloak.
7
+ class User
8
+ class << self
9
+ # Creates a new user in Keycloak.
10
+ #
11
+ # @param user_attrs [Hash] A hash of user attributes (e.g., :username, :email, :password, :temporary).
12
+ # @return [Hash] The created user's data.
13
+ # @raise [KeycloakRuby::Error] If the user creation fails.
14
+ def create(user_attrs = {})
15
+ client.create_user(user_attrs)
16
+ end
17
+
18
+ # Deletes users from Keycloak based on a search string.
19
+ #
20
+ # @param search_string [String] A string to search for users (e.g., username, email, etc.).
21
+ # @return [void]
22
+ # @raise [KeycloakRuby::Error] If any user deletion fails.
23
+ def delete(search_string)
24
+ client.delete_users(search_string)
25
+ end
26
+
27
+ # Deletes a user from Keycloak by ID.
28
+ #
29
+ # @param user_id [String] The ID of the user to delete.
30
+ # @return [void]
31
+ # @raise [KeycloakRuby::Error] If the deletion fails.
32
+ def delete_by_id(user_id)
33
+ client.delete_user_by_id(user_id)
34
+ end
35
+
36
+ # Finds users in Keycloak based on a search string.
37
+ #
38
+ # @param search_string [String] A string to search for users (e.g., username, email, etc.).
39
+ # @return [Array<Hash>] An array of user objects (hashes) matching the search criteria.
40
+ # @raise [KeycloakRuby::Error] If the search fails.
41
+ def find(search_string)
42
+ client.find_users(search_string)
43
+ end
44
+
45
+ private
46
+
47
+ # Provides a singleton instance of the KeycloakRuby::Client.
48
+ #
49
+ # @return [KeycloakRuby::Client] The client instance used for making API requests.
50
+ def client
51
+ @client ||= KeycloakRuby::Client.new
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/keycloak_ruby/version.rb
4
+ # Module for interacting with Keycloak
5
+ module KeycloakRuby
6
+ # Version module following Semantic Versioning 2.0 guidelines
7
+ # Provides detailed version information and helper methods
8
+ #
9
+ # @example Getting version information
10
+ # KeycloakRuby::Version::VERSION # => "0.1.0"
11
+ # KeycloakRuby::Version.to_a # => [0, 1, 0]
12
+ # KeycloakRuby::Version.to_h # => { major: 0, minor: 1, patch: 0, pre: nil }
13
+ # KeycloakRuby.version # => "0.1.0"
14
+ #
15
+ # @example Checking version
16
+ # KeycloakRuby::Version >= '0.1.0' # => true
17
+ # Module for work with Version
18
+ module Version
19
+ # Major version number (incompatible API changes)
20
+ MAJOR = 0
21
+ # Minor version number (backwards-compatible functionality)
22
+ MINOR = 1
23
+ # Patch version number (backwards-compatible bug fixes)
24
+ PATCH = 0
25
+ # Pre-release version (nil for stable releases)
26
+ PRE = nil
27
+
28
+ # Full version string
29
+ VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join(".").freeze
30
+
31
+ # Returns version components as an array
32
+ # @return [Array<Integer, Integer, Integer, String|nil>]
33
+ def self.to_a
34
+ [MAJOR, MINOR, PATCH, PRE]
35
+ end
36
+
37
+ # Returns version components as a hash
38
+ # @return [Hash<Symbol, Integer|String|nil>]
39
+ def self.to_h
40
+ { major: MAJOR, minor: MINOR, patch: PATCH, pre: PRE }
41
+ end
42
+
43
+ # Compares version with another version string
44
+ # @param version_string [String] version to compare with (e.g., "1.2.3")
45
+ # @return [Boolean]
46
+ def self.>=(version_string)
47
+ Gem::Version.new(VERSION) >= Gem::Version.new(version_string)
48
+ end
49
+
50
+ # Returns the full version string
51
+ # @return [String]
52
+ def self.to_s
53
+ VERSION
54
+ end
55
+ end
56
+
57
+ # Returns the current gem version
58
+ # @return [String]
59
+ def self.version
60
+ Version::VERSION
61
+ end
62
+ end