clavis 0.7.1

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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.actrc +4 -0
  3. data/.cursor/rules/ruby-gem.mdc +49 -0
  4. data/.gemignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +88 -0
  7. data/.vscode/settings.json +22 -0
  8. data/CHANGELOG.md +127 -0
  9. data/CODE_OF_CONDUCT.md +3 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +838 -0
  12. data/Rakefile +341 -0
  13. data/UPGRADE.md +57 -0
  14. data/app/assets/stylesheets/clavis.css +133 -0
  15. data/app/controllers/clavis/auth_controller.rb +133 -0
  16. data/config/database.yml +16 -0
  17. data/config/routes.rb +49 -0
  18. data/docs/SECURITY.md +340 -0
  19. data/docs/TESTING.md +78 -0
  20. data/docs/integration.md +272 -0
  21. data/error_handling.md +355 -0
  22. data/file_structure.md +221 -0
  23. data/gemfiles/rails_80.gemfile +17 -0
  24. data/gemfiles/rails_80.gemfile.lock +286 -0
  25. data/implementation_plan.md +523 -0
  26. data/lib/clavis/configuration.rb +196 -0
  27. data/lib/clavis/controllers/concerns/authentication.rb +232 -0
  28. data/lib/clavis/controllers/concerns/session_management.rb +117 -0
  29. data/lib/clavis/engine.rb +191 -0
  30. data/lib/clavis/errors.rb +205 -0
  31. data/lib/clavis/logging.rb +116 -0
  32. data/lib/clavis/models/concerns/oauth_authenticatable.rb +169 -0
  33. data/lib/clavis/oauth_identity.rb +174 -0
  34. data/lib/clavis/providers/apple.rb +135 -0
  35. data/lib/clavis/providers/base.rb +432 -0
  36. data/lib/clavis/providers/custom_provider_example.rb +57 -0
  37. data/lib/clavis/providers/facebook.rb +84 -0
  38. data/lib/clavis/providers/generic.rb +63 -0
  39. data/lib/clavis/providers/github.rb +87 -0
  40. data/lib/clavis/providers/google.rb +98 -0
  41. data/lib/clavis/providers/microsoft.rb +57 -0
  42. data/lib/clavis/security/csrf_protection.rb +79 -0
  43. data/lib/clavis/security/https_enforcer.rb +90 -0
  44. data/lib/clavis/security/input_validator.rb +192 -0
  45. data/lib/clavis/security/parameter_filter.rb +64 -0
  46. data/lib/clavis/security/rate_limiter.rb +109 -0
  47. data/lib/clavis/security/redirect_uri_validator.rb +124 -0
  48. data/lib/clavis/security/session_manager.rb +220 -0
  49. data/lib/clavis/security/token_storage.rb +114 -0
  50. data/lib/clavis/user_info_normalizer.rb +74 -0
  51. data/lib/clavis/utils/nonce_store.rb +14 -0
  52. data/lib/clavis/utils/secure_token.rb +17 -0
  53. data/lib/clavis/utils/state_store.rb +18 -0
  54. data/lib/clavis/version.rb +6 -0
  55. data/lib/clavis/view_helpers.rb +260 -0
  56. data/lib/clavis.rb +132 -0
  57. data/lib/generators/clavis/controller/controller_generator.rb +48 -0
  58. data/lib/generators/clavis/controller/templates/controller.rb.tt +137 -0
  59. data/lib/generators/clavis/controller/templates/views/login.html.erb.tt +145 -0
  60. data/lib/generators/clavis/install_generator.rb +182 -0
  61. data/lib/generators/clavis/templates/add_oauth_to_users.rb +28 -0
  62. data/lib/generators/clavis/templates/clavis.css +133 -0
  63. data/lib/generators/clavis/templates/initializer.rb +47 -0
  64. data/lib/generators/clavis/templates/initializer.rb.tt +76 -0
  65. data/lib/generators/clavis/templates/migration.rb +18 -0
  66. data/lib/generators/clavis/templates/migration.rb.tt +16 -0
  67. data/lib/generators/clavis/user_method/user_method_generator.rb +219 -0
  68. data/lib/tasks/provider_verification.rake +77 -0
  69. data/llms.md +487 -0
  70. data/log/development.log +20 -0
  71. data/log/test.log +0 -0
  72. data/sig/clavis.rbs +4 -0
  73. data/testing_plan.md +710 -0
  74. metadata +258 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clavis
4
+ module Security
5
+ module ParameterFilter
6
+ SENSITIVE_PARAMETERS = [
7
+ :code, # Authorization code
8
+ :token, # Access token
9
+ :access_token, # Access token
10
+ :refresh_token, # Refresh token
11
+ :id_token, # ID token
12
+ :client_secret, # Client secret
13
+ :state, # CSRF state token
14
+ :nonce, # OIDC nonce
15
+ :password, # Just in case
16
+ :secret # Generic secret
17
+ ].freeze
18
+
19
+ class << self
20
+ # Filters sensitive parameters from a hash
21
+ # @param params [Hash] The parameters to filter
22
+ # @return [Hash] The filtered parameters
23
+ def filter_parameters(params)
24
+ return params unless Clavis.configuration.parameter_filter_enabled
25
+ return params if params.nil? || !params.is_a?(Hash)
26
+
27
+ filtered_params = params.dup
28
+ SENSITIVE_PARAMETERS.each do |param|
29
+ filtered_params[param] = "[FILTERED]" if filtered_params.key?(param)
30
+
31
+ # Also check for string keys
32
+ string_param = param.to_s
33
+ filtered_params[string_param] = "[FILTERED]" if filtered_params.key?(string_param)
34
+ end
35
+
36
+ filtered_params
37
+ end
38
+
39
+ # Alias for filter_parameters for backward compatibility
40
+ alias filter_params filter_parameters
41
+
42
+ # Installs the parameter filter in Rails if available
43
+ # This should be called during initialization
44
+ def install_rails_filter
45
+ return unless defined?(Rails) && Rails.application&.config.respond_to?(:filter_parameters)
46
+
47
+ Rails.application.config.filter_parameters.concat(SENSITIVE_PARAMETERS)
48
+ Clavis.logger.info("Installed Clavis parameter filters in Rails")
49
+ end
50
+
51
+ # Logs parameters safely by filtering sensitive values
52
+ # @param params [Hash] The parameters to log
53
+ # @param level [Symbol] The log level (:info, :debug, etc.)
54
+ # @param message [String] The message to log
55
+ def log_parameters(params, level: :debug, message: "Parameters")
56
+ return unless Clavis.logger
57
+
58
+ filtered = filter_parameters(params)
59
+ Clavis.logger.send(level, "#{message}: #{filtered.inspect}")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clavis
4
+ module Security
5
+ # RateLimiter provides integration with Rack::Attack for protecting Clavis endpoints
6
+ # against DDoS and brute force attacks.
7
+ module RateLimiter
8
+ class << self
9
+ # Configures Rack::Attack with default throttling rules for Clavis
10
+ # @param app [Rails::Application] The Rails application
11
+ def configure(_app)
12
+ return unless defined?(Rack::Attack)
13
+
14
+ # Skip if rate limiting is disabled
15
+ return unless Clavis.configuration.rate_limiting_enabled
16
+
17
+ Clavis.logger.info("Configuring Rack::Attack for Clavis endpoints")
18
+
19
+ configure_throttles
20
+ configure_blocklist
21
+ configure_response
22
+ end
23
+
24
+ # Configures throttling rules for Clavis endpoints
25
+ def configure_throttles
26
+ return unless defined?(Rack::Attack)
27
+
28
+ # Throttle login attempts for a given email parameter
29
+ Rack::Attack.throttle("clavis/auth/callback/email", limit: 5, period: 20.seconds) do |req|
30
+ if req.path =~ %r{/auth/\w+/callback} && req.params["email"].present?
31
+ # Throttle by normalized email
32
+ req.params["email"].to_s.downcase.gsub(/\s+/, "")
33
+ end
34
+ end
35
+
36
+ # Throttle OAuth callback attempts by IP address
37
+ Rack::Attack.throttle("clavis/auth/callback/ip", limit: 15, period: 60.seconds) do |req|
38
+ req.ip if req.path =~ %r{/auth/\w+/callback}
39
+ end
40
+
41
+ # Throttle OAuth authorize endpoints
42
+ Rack::Attack.throttle("clavis/auth/authorize/ip", limit: 20, period: 60.seconds) do |req|
43
+ req.ip if req.path =~ %r{/auth/\w+} && !req.path.include?("/callback")
44
+ end
45
+
46
+ # Allow custom throttles to be defined via configuration
47
+ Clavis.configuration.custom_throttles.each do |name, config|
48
+ Rack::Attack.throttle("clavis/#{name}", limit: config[:limit], period: config[:period].seconds) do |req|
49
+ instance_exec(req, &config[:block]) if config[:block].respond_to?(:call)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Configures blocklist rules
55
+ def configure_blocklist
56
+ return unless defined?(Rack::Attack)
57
+
58
+ # Block failed login attempts
59
+ Rack::Attack.blocklist("clavis/fail2ban") do |req|
60
+ Rack::Attack::Fail2Ban.filter("clavis-pentesters-#{req.ip}", maxretry: 5, findtime: 10.minutes,
61
+ bantime: 30.minutes) do
62
+ req.path =~ %r{/auth/\w+/callback} && req.env["rack.attack.match_data"]
63
+ end
64
+ end
65
+ end
66
+
67
+ # Configures Rack::Attack response
68
+ def configure_response
69
+ return unless defined?(Rack::Attack)
70
+
71
+ Rack::Attack.throttled_responder = lambda do |_req|
72
+ [
73
+ 429, # status
74
+ { "Content-Type" => "application/json" }, # headers
75
+ [{ error: "Rate limit exceeded. Please retry later." }.to_json] # body
76
+ ]
77
+ end
78
+ end
79
+
80
+ # Installs Rack::Attack in the Rails application
81
+ # @param app [Rails::Application] The Rails application
82
+ def install(app)
83
+ return unless defined?(Rack::Attack)
84
+
85
+ # Skip if rate limiting is disabled
86
+ return unless Clavis.configuration.rate_limiting_enabled
87
+
88
+ # Add Rack::Attack as middleware if not already included
89
+ # Check if Rack::Attack is already in the middleware stack
90
+ rack_attack_included = false
91
+
92
+ if app.middleware.respond_to?(:each)
93
+ app.middleware.each do |middleware|
94
+ if middleware.klass == Rack::Attack
95
+ rack_attack_included = true
96
+ break
97
+ end
98
+ end
99
+ end
100
+
101
+ # Add Rack::Attack if not already included
102
+ app.middleware.use(Rack::Attack) unless rack_attack_included
103
+
104
+ configure(app)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Clavis
6
+ module Security
7
+ module RedirectUriValidator
8
+ class << self
9
+ # Validates a URI against the configured allowed hosts
10
+ # @param uri [String] The URI to validate
11
+ # @return [Boolean] Whether the URI is valid
12
+ def valid_uri?(uri)
13
+ return false if uri.nil? || uri.empty?
14
+
15
+ begin
16
+ parsed_uri = URI.parse(uri)
17
+
18
+ # Check if localhost is allowed in development
19
+ return allow_localhost_in_development? if localhost?(parsed_uri.host)
20
+
21
+ # Check against allowed hosts
22
+ return true if host_allowed?(parsed_uri.host)
23
+
24
+ false
25
+ rescue URI::InvalidURIError
26
+ false
27
+ end
28
+ end
29
+
30
+ # Validates a URI for a specific provider
31
+ # @param provider_name [Symbol] The provider name
32
+ # @param uri [String] The URI to validate
33
+ # @return [Boolean] Whether the URI is valid for the provider
34
+ def valid_provider_uri?(provider_name, uri)
35
+ return false if uri.nil? || uri.empty?
36
+
37
+ begin
38
+ # First check if the URI is valid against allowed hosts
39
+ return false unless valid_uri?(uri)
40
+
41
+ # Get the configured redirect URI for the provider
42
+ provider_config = Clavis.configuration.provider_config(provider_name)
43
+ configured_uri = provider_config[:redirect_uri]
44
+
45
+ return true if configured_uri.nil? # No specific URI configured
46
+
47
+ parsed_uri = URI.parse(uri)
48
+ parsed_configured_uri = URI.parse(configured_uri)
49
+
50
+ # Check if exact matching is required
51
+ return uri == configured_uri if Clavis.configuration.exact_redirect_uri_matching
52
+
53
+ # Check if the host and path match
54
+ parsed_uri.host == parsed_configured_uri.host &&
55
+ parsed_uri.path == parsed_configured_uri.path
56
+ rescue URI::InvalidURIError, Clavis::ProviderNotConfigured
57
+ false
58
+ end
59
+ end
60
+
61
+ # Validates a URI and raises an exception if invalid
62
+ # @param uri [String] The URI to validate
63
+ # @return [Boolean] Whether the URI is valid
64
+ # @raise [Clavis::InvalidRedirectUri] If the URI is invalid and raise_on_invalid_redirect is true
65
+ def validate_uri!(uri)
66
+ is_valid = valid_uri?(uri)
67
+
68
+ raise Clavis::InvalidRedirectUri, uri if !is_valid && Clavis.configuration.raise_on_invalid_redirect
69
+
70
+ is_valid
71
+ end
72
+
73
+ # Validates a URI for a specific provider and raises an exception if invalid
74
+ # @param provider_name [Symbol] The provider name
75
+ # @param uri [String] The URI to validate
76
+ # @return [Boolean] Whether the URI is valid for the provider
77
+ # @raise [Clavis::InvalidRedirectUri] If the URI is invalid and raise_on_invalid_redirect is true
78
+ def validate_provider_uri!(provider_name, uri)
79
+ is_valid = valid_provider_uri?(provider_name, uri)
80
+
81
+ raise Clavis::InvalidRedirectUri, uri if !is_valid && Clavis.configuration.raise_on_invalid_redirect
82
+
83
+ is_valid
84
+ end
85
+
86
+ private
87
+
88
+ # Checks if a host is localhost
89
+ # @param host [String] The host to check
90
+ # @return [Boolean] Whether the host is localhost
91
+ def localhost?(host)
92
+ ["localhost", "127.0.0.1"].include?(host)
93
+ end
94
+
95
+ # Checks if localhost is allowed in the current environment
96
+ # @return [Boolean] Whether localhost is allowed
97
+ def allow_localhost_in_development?
98
+ return false unless Clavis.configuration.allow_localhost_in_development
99
+
100
+ # If Rails is not defined, allow localhost in development mode
101
+ return true unless defined?(Rails)
102
+
103
+ # Allow localhost in development or test environments
104
+ Rails.env.development? || Rails.env.test?
105
+ end
106
+
107
+ # Checks if a host is in the allowed hosts list
108
+ # @param host [String] The host to check
109
+ # @return [Boolean] Whether the host is allowed
110
+ def host_allowed?(host)
111
+ return false if host.nil?
112
+
113
+ allowed_hosts = Clavis.configuration.allowed_redirect_hosts
114
+ return false if allowed_hosts.empty?
115
+
116
+ # Check if the host matches any allowed host or is a subdomain of an allowed host
117
+ allowed_hosts.any? do |allowed_host|
118
+ host == allowed_host || host.end_with?(".#{allowed_host}")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Clavis
6
+ module Security
7
+ module SessionManager
8
+ class << self
9
+ # Store a value in the session with a namespaced key
10
+ # @param session [Hash] The session hash
11
+ # @param key [Symbol] The key to store the value under
12
+ # @param value [Object] The value to store
13
+ def store(session, key, value)
14
+ session[namespaced_key(key)] = value
15
+ end
16
+
17
+ # Retrieve a value from the session
18
+ # @param session [Hash] The session hash
19
+ # @param key [Symbol] The key to retrieve the value from
20
+ # @return [Object, nil] The value or nil if not found
21
+ def retrieve(session, key)
22
+ session[namespaced_key(key)]
23
+ end
24
+
25
+ # Delete a value from the session
26
+ # @param session [Hash] The session hash
27
+ # @param key [Symbol] The key to delete
28
+ # @return [Object, nil] The deleted value or nil if not found
29
+ def delete(session, key)
30
+ session.delete(namespaced_key(key))
31
+ end
32
+
33
+ # Generate a secure random state parameter and store it in the session
34
+ # @param session [Hash] The session hash
35
+ # @return [String] The generated state
36
+ def generate_and_store_state(session)
37
+ state = SecureRandom.hex(32)
38
+ store(session, :oauth_state, state)
39
+ state
40
+ end
41
+
42
+ # Check if a state parameter is valid
43
+ # @param session [Hash] The session hash
44
+ # @param state [String] The state parameter to validate
45
+ # @param clear_after_validation [Boolean] Whether to clear the state after validation
46
+ # @return [Boolean] Whether the state is valid
47
+ def valid_state?(session, state, clear_after_validation: false)
48
+ stored_state = retrieve(session, :oauth_state)
49
+
50
+ # Clear the state if requested
51
+ delete(session, :oauth_state) if clear_after_validation
52
+
53
+ # Validate the state
54
+ return false if stored_state.nil? || state.nil?
55
+
56
+ # Validate the state format if input validation is enabled
57
+ return false if Clavis.configuration.validate_inputs && !Clavis::Security::InputValidator.valid_state?(state)
58
+
59
+ stored_state == state
60
+ end
61
+
62
+ # Generate a secure random nonce and store it in the session
63
+ # @param session [Hash] The session hash
64
+ # @return [String] The generated nonce
65
+ def generate_and_store_nonce(session)
66
+ nonce = SecureRandom.hex(32)
67
+ store(session, :oauth_nonce, nonce)
68
+ nonce
69
+ end
70
+
71
+ # Check if a nonce is valid
72
+ # @param session [Hash] The session hash
73
+ # @param nonce [String] The nonce to validate
74
+ # @param clear_after_validation [Boolean] Whether to clear the nonce after validation
75
+ # @return [Boolean] Whether the nonce is valid
76
+ def valid_nonce?(session, nonce, clear_after_validation: false)
77
+ stored_nonce = retrieve(session, :oauth_nonce)
78
+
79
+ # Clear the nonce if requested
80
+ delete(session, :oauth_nonce) if clear_after_validation
81
+
82
+ # Validate the nonce
83
+ return false if stored_nonce.nil? || nonce.nil?
84
+
85
+ # Validate the nonce format if input validation is enabled
86
+ return false if Clavis.configuration.validate_inputs && !Clavis::Security::InputValidator.valid_state?(nonce)
87
+
88
+ stored_nonce == nonce
89
+ end
90
+
91
+ # Store a redirect URI in the session
92
+ # @param session [Hash] The session hash
93
+ # @param redirect_uri [String] The redirect URI to store
94
+ def store_redirect_uri(session, redirect_uri)
95
+ # Validate the redirect URI before storing
96
+ return unless redirect_uri && !redirect_uri.empty?
97
+
98
+ # Sanitize the redirect URI if input sanitization is enabled
99
+ redirect_uri = Clavis::Security::InputValidator.sanitize(redirect_uri) if Clavis.configuration.sanitize_inputs
100
+
101
+ Clavis::Security::RedirectUriValidator.validate_uri!(redirect_uri)
102
+ store(session, :oauth_redirect_uri, redirect_uri)
103
+ end
104
+
105
+ # Retrieve a redirect URI from the session
106
+ # @param session [Hash] The session hash
107
+ # @return [String, nil] The redirect URI or nil if not found
108
+ def retrieve_redirect_uri(session)
109
+ retrieve(session, :oauth_redirect_uri)
110
+ end
111
+
112
+ # Validate and retrieve a redirect URI from the session
113
+ # @param session [Hash] The session hash
114
+ # @param default [String] The default redirect URI to use if none is stored
115
+ # @return [String] The validated redirect URI or the default
116
+ def validate_and_retrieve_redirect_uri(session, default: "/")
117
+ redirect_uri = retrieve_redirect_uri(session)
118
+ delete(session, :oauth_redirect_uri)
119
+
120
+ if redirect_uri && !redirect_uri.empty?
121
+ # Sanitize the redirect URI if input sanitization is enabled
122
+ if Clavis.configuration.sanitize_inputs
123
+ redirect_uri = Clavis::Security::InputValidator.sanitize(redirect_uri)
124
+ end
125
+
126
+ # Validate the redirect URI
127
+ Clavis::Security::RedirectUriValidator.validate_uri!(redirect_uri)
128
+ redirect_uri
129
+ else
130
+ default
131
+ end
132
+ end
133
+
134
+ # Rotate the session ID after authentication
135
+ # @param session [Hash] The session hash
136
+ # @param new_session_id [String] The new session ID
137
+ # @param preserve_keys [Array<Symbol>] Keys to preserve during rotation
138
+ def rotate_session_id(session, new_session_id, preserve_keys: [])
139
+ # Skip rotation if disabled in configuration
140
+ return unless Clavis.configuration.rotate_session_after_login
141
+
142
+ # Store values to preserve
143
+ preserved_values = {}
144
+ preserve_keys.each do |key|
145
+ preserved_values[key] = session[key] if session.key?(key)
146
+ end
147
+
148
+ # Clear the session
149
+ session.clear
150
+
151
+ # Set the new session ID
152
+ session[:id] = new_session_id
153
+
154
+ # Restore preserved values
155
+ preserved_values.each do |key, value|
156
+ session[key] = value
157
+ end
158
+ end
159
+
160
+ # Rotate session to improve security after login
161
+ # @param request [ActionDispatch::Request] The current request
162
+ def rotate_session(request)
163
+ return unless Clavis.configuration.rotate_session_after_login
164
+ return unless request.respond_to?(:session)
165
+
166
+ if defined?(Rails) && Rails.version.to_f >= 6.0
167
+ # For Rails 6.0+, use the built-in reset_session functionality
168
+ # but preserve all session data
169
+ old_session_data = {}
170
+
171
+ # Copy all session data
172
+ request.session.each do |key, value|
173
+ old_session_data[key] = value
174
+ end
175
+
176
+ # Reset the session
177
+ request.env["rack.session.options"][:renew] = true
178
+ request.reset_session
179
+
180
+ # Restore session data
181
+ old_session_data.each do |key, value|
182
+ request.session[key] = value
183
+ end
184
+ else
185
+ # For older Rails versions or non-Rails apps, use our custom implementation
186
+ keys_to_preserve = request.session.respond_to?(:keys) ? request.session.keys.map(&:to_sym) : []
187
+ new_session_id = SecureRandom.hex(32)
188
+ rotate_session_id(request.session, new_session_id, preserve_keys: keys_to_preserve)
189
+ end
190
+ end
191
+
192
+ # Store authentication information in the session
193
+ # @param session [Hash] The session hash
194
+ # @param auth_hash [Hash] The authentication hash
195
+ def store_auth_info(session, auth_hash)
196
+ return unless auth_hash
197
+
198
+ # Store minimal information in the session
199
+ store(session, :provider, auth_hash[:provider])
200
+ store(session, :uid, auth_hash[:uid])
201
+
202
+ # Store email if available
203
+ store(session, :email, auth_hash.dig(:info, :email)) if auth_hash.dig(:info, :email)
204
+
205
+ # Store name if available
206
+ store(session, :name, auth_hash.dig(:info, :name)) if auth_hash.dig(:info, :name)
207
+ end
208
+
209
+ private
210
+
211
+ # Create a namespaced key for session storage
212
+ # @param key [Symbol] The key to namespace
213
+ # @return [Symbol] The namespaced key
214
+ def namespaced_key(key)
215
+ :"#{Clavis.configuration.session_key_prefix}_#{key}"
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module Clavis
8
+ module Security
9
+ module TokenStorage
10
+ class << self
11
+ # Encrypts a token if encryption is enabled in configuration
12
+ # @param token [String, Hash] The token to encrypt
13
+ # @return [String, Hash] The encrypted token or the original token if encryption is disabled
14
+ def encrypt(token)
15
+ return token unless Clavis.configuration.encrypt_tokens
16
+ return token if token.nil?
17
+
18
+ key = Clavis.configuration.effective_encryption_key
19
+ return token if key.nil?
20
+
21
+ # Convert hash to JSON string if token is a hash
22
+ token_str = token.is_a?(Hash) ? JSON.generate(token) : token.to_s
23
+
24
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
25
+ cipher.encrypt
26
+ cipher.key = normalize_key(key)
27
+ iv = cipher.random_iv
28
+
29
+ encrypted = cipher.update(token_str) + cipher.final
30
+ Base64.strict_encode64("#{Base64.strict_encode64(iv)}--#{Base64.strict_encode64(encrypted)}")
31
+ end
32
+
33
+ # Decrypts a token if encryption is enabled in configuration
34
+ # @param encrypted_token [String] The encrypted token to decrypt
35
+ # @return [String, Hash] The decrypted token or the original token if encryption is disabled
36
+ def decrypt(encrypted_token)
37
+ return encrypted_token unless Clavis.configuration.encrypt_tokens
38
+ return encrypted_token if encrypted_token.nil?
39
+
40
+ key = Clavis.configuration.effective_encryption_key
41
+ return encrypted_token if key.nil?
42
+
43
+ begin
44
+ decoded = Base64.strict_decode64(encrypted_token)
45
+ iv_b64, data_b64 = decoded.split("--", 2)
46
+
47
+ iv = Base64.strict_decode64(iv_b64)
48
+ data = Base64.strict_decode64(data_b64)
49
+
50
+ decipher = OpenSSL::Cipher.new("AES-256-CBC")
51
+ decipher.decrypt
52
+ decipher.key = normalize_key(key)
53
+ decipher.iv = iv
54
+
55
+ decrypted = decipher.update(data) + decipher.final
56
+
57
+ # Try to parse as JSON in case it's a hash
58
+ begin
59
+ JSON.parse(decrypted, symbolize_names: true)
60
+ rescue JSON::ParserError
61
+ decrypted
62
+ end
63
+ rescue StandardError => e
64
+ Clavis.logger.error("Failed to decrypt token: #{e.message}")
65
+ encrypted_token
66
+ end
67
+ end
68
+
69
+ # Returns a serializer for use with ActiveRecord::Encryption
70
+ # This allows tokens to be automatically encrypted when stored in the database
71
+ # @return [Object] A serializer object with serialize and deserialize methods
72
+ def active_record_serializer
73
+ Serializer.new
74
+ end
75
+
76
+ private
77
+
78
+ # Ensures the encryption key is the correct length for AES-256
79
+ def normalize_key(key)
80
+ if key.bytesize < 32
81
+ # Pad the key if it's too short
82
+ key.ljust(32, "0")
83
+ elsif key.bytesize > 32
84
+ # Use a digest if the key is too long
85
+ Digest::SHA256.digest(key)
86
+ else
87
+ key
88
+ end
89
+ end
90
+ end
91
+
92
+ # Serializer class for ActiveRecord::Encryption
93
+ class Serializer
94
+ # Serialize and encrypt a token
95
+ # @param token [String, Hash] The token to serialize
96
+ # @return [String] The serialized and encrypted token
97
+ def dump(token)
98
+ TokenStorage.encrypt(token)
99
+ end
100
+
101
+ # Deserialize and decrypt a token
102
+ # @param encrypted_token [String] The encrypted token to deserialize
103
+ # @return [String, Hash] The deserialized and decrypted token
104
+ def load(encrypted_token)
105
+ TokenStorage.decrypt(encrypted_token)
106
+ end
107
+
108
+ # Alias methods for compatibility with different ActiveRecord versions
109
+ alias serialize dump
110
+ alias deserialize load
111
+ end
112
+ end
113
+ end
114
+ end