api_keys 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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +393 -0
  5. data/Rakefile +32 -0
  6. data/api_keys_dashboard.webp +0 -0
  7. data/api_keys_permissions.webp +0 -0
  8. data/api_keys_token.webp +0 -0
  9. data/app/controllers/api_keys/application_controller.rb +62 -0
  10. data/app/controllers/api_keys/keys_controller.rb +129 -0
  11. data/app/controllers/api_keys/security_controller.rb +16 -0
  12. data/app/views/api_keys/keys/_form.html.erb +106 -0
  13. data/app/views/api_keys/keys/_key_row.html.erb +72 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +52 -0
  15. data/app/views/api_keys/keys/_show_token.html.erb +88 -0
  16. data/app/views/api_keys/keys/edit.html.erb +5 -0
  17. data/app/views/api_keys/keys/index.html.erb +26 -0
  18. data/app/views/api_keys/keys/new.html.erb +5 -0
  19. data/app/views/api_keys/keys/show.html.erb +12 -0
  20. data/app/views/api_keys/security/best_practices.html.erb +70 -0
  21. data/app/views/layouts/api_keys/application.html.erb +115 -0
  22. data/config/routes.rb +18 -0
  23. data/lib/api_keys/authentication.rb +160 -0
  24. data/lib/api_keys/configuration.rb +125 -0
  25. data/lib/api_keys/controller.rb +47 -0
  26. data/lib/api_keys/engine.rb +76 -0
  27. data/lib/api_keys/jobs/callbacks_job.rb +69 -0
  28. data/lib/api_keys/jobs/update_stats_job.rb +58 -0
  29. data/lib/api_keys/logging.rb +42 -0
  30. data/lib/api_keys/models/api_key.rb +209 -0
  31. data/lib/api_keys/models/concerns/has_api_keys.rb +144 -0
  32. data/lib/api_keys/services/authenticator.rb +255 -0
  33. data/lib/api_keys/services/digestor.rb +68 -0
  34. data/lib/api_keys/services/token_generator.rb +32 -0
  35. data/lib/api_keys/tenant_resolution.rb +40 -0
  36. data/lib/api_keys/version.rb +5 -0
  37. data/lib/api_keys.rb +49 -0
  38. data/lib/generators/api_keys/install_generator.rb +70 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +100 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +160 -0
  41. metadata +184 -0
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "../services/token_generator"
5
+ require_relative "../services/digestor"
6
+
7
+ module ApiKeys
8
+ # The core ActiveRecord model representing an API key.
9
+ class ApiKey < ActiveRecord::Base
10
+ self.table_name = "api_keys"
11
+
12
+ # == Concerns ==
13
+ # TODO: Potentially extract token generation/hashing logic into concerns
14
+
15
+ # == Associations ==
16
+ belongs_to :owner, polymorphic: true, optional: true
17
+
18
+ # == Attributes & Serialization ==
19
+ # Expose the plaintext token only immediately after creation
20
+ attr_reader :token
21
+
22
+ # JSON attributes (:scopes, :metadata) are defined in the engine initializer
23
+ # using ActiveSupport.on_load(:active_record) to ensure DB connection is ready.
24
+
25
+ # == Validations ==
26
+ validates :token_digest, presence: true, uniqueness: { case_sensitive: true }
27
+ validates :prefix, presence: true
28
+ validates :digest_algorithm, presence: true
29
+ validates :last4, presence: true, length: { is: 4 }
30
+ # validates :scopes, presence: true # Default handled by attribute def
31
+ # validates :metadata, presence: true # Default handled by attribute def
32
+ validates :name,
33
+ length: { maximum: 60 },
34
+ # Allow letters, numbers, underscores, hyphens, and spaces. No leading/trailing spaces.
35
+ format: { with: /\A[a-zA-Z0-9_ -]+\z/, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
36
+ allow_blank: true # Apply length and format only if name is present
37
+
38
+ validates :name, presence: true, if: :name_required? # Only require presence conditionally
39
+ validate :within_quota, on: :create, if: -> { owner.present? && (owner_configured? || ApiKeys.configuration.default_max_keys_per_owner.present?) }
40
+
41
+ # TODO: Add validation for expires_at > Time.current if present
42
+ validate :expiration_date_cannot_be_in_the_past, if: :expires_at?
43
+
44
+ # TODO: Add validation for scope string format
45
+ # TODO: Add validation for prefix format (e.g., must end with _)
46
+
47
+ # == Callbacks ==
48
+ before_validation :set_defaults, on: :create
49
+ # Generate digest BEFORE validation runs
50
+ before_validation :generate_token_and_digest, on: :create
51
+
52
+ # == Scopes ==
53
+ scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
54
+ scope :revoked, -> { where.not(revoked_at: nil) }
55
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
56
+ scope :inactive, -> { revoked.or(expired) }
57
+ scope :for_prefix, ->(prefix) { where(prefix: prefix) }
58
+ scope :for_owner, ->(owner) { where(owner: owner) }
59
+ # TODO: Add more scopes as needed (e.g., for_owner)
60
+
61
+ # == Instance Methods ==
62
+
63
+ def revoke!
64
+ update!(revoked_at: Time.current)
65
+ end
66
+
67
+ def revoked?
68
+ revoked_at.present?
69
+ end
70
+
71
+ def expired?
72
+ expires_at? && expires_at <= Time.current
73
+ end
74
+
75
+ def active?
76
+ !revoked? && !expired?
77
+ end
78
+
79
+ # Basic scope check. Assumes scopes are stored as an array of strings.
80
+ # Returns true if the key has no specific scopes (allowing all) or includes the required scope.
81
+ def allows_scope?(required_scope)
82
+ # Type casting for scopes/metadata happens via the attribute definition in the engine.
83
+ # Ensure the attribute is loaded/defined before using it.
84
+ # Check if the attribute method exists before calling .blank? or .include?
85
+ return true unless respond_to?(:scopes) # Guard clause if loaded before attribute definition
86
+ scopes.blank? || scopes.include?(required_scope.to_s)
87
+ end
88
+
89
+ # Provides a masked version of the token for display (e.g., ak_live_••••rj4p)
90
+ # Requires the plaintext token to be available (only right after creation).
91
+ def masked_token
92
+ # return "[Token not available]" unless token # No longer needed
93
+ # Show prefix, 4 bullets, last 4 chars of the random part
94
+ # random_part = token.delete_prefix(prefix) # No longer needed
95
+ # "#{prefix}••••#{random_part.last(4)}" # No longer needed
96
+
97
+ # Use the stored prefix and last4 attributes
98
+ return "[Invalid Key Data]" unless prefix.present? && last4.present?
99
+ "#{prefix}••••#{last4}"
100
+ end
101
+
102
+ # == Class Methods ==
103
+ # Most creation logic is handled by standard ActiveRecord methods + callbacks
104
+
105
+ private
106
+
107
+ # Set defaults for attributes not handled by the `attribute` API in the engine.
108
+ def set_defaults
109
+ # NOTE: Defaults for scopes/metadata handled by `attribute` definitions in engine initializer.
110
+
111
+ # Determine the prefix: owner-specific setting > global config
112
+ # Note: `owner` might not be set yet if called outside normal AR flow.
113
+ owner_prefix_config = nil
114
+ if owner.present? && owner.class.respond_to?(:api_keys_settings)
115
+ owner_prefix_config = owner.class.api_keys_settings[:token_prefix]
116
+ end
117
+
118
+ # Use owner setting if present, otherwise fall back to global config
119
+ prefix_config = owner_prefix_config || ApiKeys.configuration.token_prefix
120
+
121
+ # Evaluate the prefix config (it might be a Proc)
122
+ # Ensure `self.prefix` is only set if it's not already present.
123
+ self.prefix ||= prefix_config.is_a?(Proc) ? prefix_config.call : prefix_config
124
+
125
+ # Removed default scopes logic here. It's correctly handled in the
126
+ # HasApiKeys#create_api_key! helper method, which is the intended
127
+ # way to create keys with proper default scope application.
128
+ end
129
+
130
+ # Generates the secure token, hashes it, and sets relevant attributes.
131
+ # Called before validation on create.
132
+ def generate_token_and_digest
133
+ # Generate token only if digest isn't already set (allows creating records with pre-hashed keys if needed)
134
+ return if token_digest.present?
135
+
136
+ # Ensure prefix default is set if needed (e.g., if validation skipped or called directly)
137
+ set_defaults unless self.prefix.present?
138
+
139
+ # Use the configured generator
140
+ generated_token = ApiKeys::Services::TokenGenerator.call(prefix: self.prefix)
141
+ @token = generated_token # Store plaintext temporarily in instance var for display
142
+
143
+ # Safety check: Ensure generated token starts with the expected prefix
144
+ unless @token.start_with?(self.prefix)
145
+ raise ApiKeys::Error, "Generated token '#{@token}' does not match expected prefix '#{self.prefix}'. Check TokenGenerator."
146
+ end
147
+
148
+ # Use the configured digestor
149
+ digest_result = ApiKeys::Services::Digestor.digest(token: @token)
150
+
151
+ self.token_digest = digest_result[:digest]
152
+ self.digest_algorithm = digest_result[:algorithm]
153
+
154
+ # Extract and store the last 4 chars of the random part
155
+ random_part = @token.delete_prefix(self.prefix)
156
+ self.last4 = random_part.last(4) # Store last4
157
+
158
+ # Set default expiration if configured globally and not set individually
159
+ # Needs to happen here since it relies on Time.current
160
+ if ApiKeys.configuration.expire_after.present? && self.expires_at.nil?
161
+ self.expires_at = ApiKeys.configuration.expire_after.from_now
162
+ end
163
+ end
164
+
165
+ # == Validation Helpers ==
166
+
167
+ def owner_present_and_configured?
168
+ owner.present? && owner_configured?
169
+ end
170
+
171
+ def owner_configured?
172
+ owner.class.respond_to?(:api_keys_settings)
173
+ end
174
+
175
+ def name_required?
176
+ if owner_configured?
177
+ owner.class.api_keys_settings[:require_name]
178
+ else
179
+ ApiKeys.configuration.require_key_name
180
+ end
181
+ end
182
+
183
+ def within_quota
184
+ # Determine the applicable limit: owner-specific setting first, then global config.
185
+ limit = if owner_configured?
186
+ owner.class.api_keys_settings[:max_keys]
187
+ else
188
+ ApiKeys.configuration.default_max_keys_per_owner
189
+ end
190
+
191
+ # Only validate if a limit is actually set (either per-owner or globally).
192
+ return unless limit.present?
193
+
194
+ # Count only *active* keys for the quota check.
195
+ # Ensure `owner` association is loaded if needed, or use SQL count.
196
+ # Note: Ensure the owner association is set before this validation runs.
197
+ current_active_keys = owner.api_keys.active.count
198
+
199
+ if current_active_keys >= limit
200
+ errors.add(:base, "exceeds maximum allowed API keys (#{limit}) for this owner")
201
+ end
202
+ end
203
+
204
+ def expiration_date_cannot_be_in_the_past
205
+ errors.add(:expires_at, "can't be in the past") if expires_at.present? && expires_at < Time.current
206
+ end
207
+
208
+ end
209
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ApiKeys
6
+ module Models
7
+ module Concerns
8
+ # Concern to add API key capabilities to an owner model (e.g., User, Organization).
9
+ # This module provides the `has_api_keys` class method when extended onto ActiveRecord::Base.
10
+ module HasApiKeys
11
+ extend ActiveSupport::Concern
12
+
13
+ # Module containing class methods to be extended onto ActiveRecord::Base
14
+ module ClassMethods
15
+ # Defines the association and allows configuration for the specific owner model.
16
+ #
17
+ # Example:
18
+ # class User < ApplicationRecord
19
+ # # Using keyword arguments:
20
+ # has_api_keys max_keys: 5, require_name: true
21
+ #
22
+ # # Or using a block:
23
+ # has_api_keys do
24
+ # max_keys 10
25
+ # require_name false
26
+ # default_scopes %w[read write]
27
+ # end
28
+ # end
29
+ def has_api_keys(**options, &block)
30
+ # Include the concern's instance methods into the calling class (e.g., User)
31
+ # Ensures any instance-level helpers in HasApiKeys are available on the owner.
32
+ include ApiKeys::Models::Concerns::HasApiKeys unless included_modules.include?(ApiKeys::Models::Concerns::HasApiKeys)
33
+
34
+ # Define the core association on the specific class calling this method
35
+ has_many :api_keys,
36
+ class_name: "ApiKeys::ApiKey",
37
+ as: :owner,
38
+ dependent: :destroy # Consider :nullify based on requirements
39
+
40
+ # Define class_attribute for settings if not already defined.
41
+ # This ensures inheritance works correctly (subclasses get their own copy).
42
+ unless respond_to?(:api_keys_settings)
43
+ class_attribute :api_keys_settings, instance_writer: false, default: {}
44
+ end
45
+
46
+ # Initialize settings for this specific class, merging defaults and options
47
+ current_settings = {
48
+ # Default to global config values first
49
+ max_keys: ApiKeys.configuration&.default_max_keys_per_owner,
50
+ require_name: ApiKeys.configuration&.require_key_name,
51
+ default_scopes: ApiKeys.configuration&.default_scopes || []
52
+ }.merge(options) # Merge keyword arguments first
53
+
54
+ # Apply DSL block if provided, allowing overrides
55
+ if block_given?
56
+ dsl = DslProvider.new(current_settings)
57
+ dsl.instance_eval(&block)
58
+ end
59
+
60
+ # Assign the final settings hash to the class attribute for this class
61
+ self.api_keys_settings = current_settings
62
+
63
+ # TODO: Add validation hook to check key limit on create?
64
+ # validates_with ApiKeys::Validators::MaxKeysValidator, on: :create, if: -> { api_keys_settings[:max_keys].present? }
65
+ end
66
+ end
67
+
68
+ # DSL provider class to handle the block configuration
69
+ class DslProvider # Keep nested or move to a separate file if it grows
70
+ def initialize(settings)
71
+ @settings = settings # Operates directly on the hash passed in
72
+ end
73
+
74
+ def max_keys(value)
75
+ @settings[:max_keys] = value
76
+ end
77
+
78
+ def require_name(value)
79
+ @settings[:require_name] = value
80
+ end
81
+
82
+ def default_scopes(value)
83
+ @settings[:default_scopes] = Array(value)
84
+ end
85
+
86
+ # Placeholder for future scope definitions
87
+ # def define_scope(name, description:)
88
+ # # In v1, this might just store metadata for documentation/future use
89
+ # # Could store in a separate class attribute or within settings hash.
90
+ # @settings[:defined_scopes] ||= {}
91
+ # @settings[:defined_scopes][name.to_s] = description
92
+ # end
93
+ end
94
+
95
+ # --- Instance Methods ---
96
+ # Methods included in the owner model (e.g., User).
97
+
98
+ # Creates a new API key for this owner instance and returns the ApiKey instance.
99
+ # Raises ActiveRecord::RecordInvalid if creation fails.
100
+ #
101
+ # @param name [String] The name for the new API key (required).
102
+ # @param scopes [Array<String>, nil] Scopes for the key. Defaults to owner/global settings.
103
+ # @param expires_at [Time, nil] Optional expiration timestamp.
104
+ # @param metadata [Hash, nil] Optional metadata hash.
105
+ # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
106
+ # is available via the `#token` attribute on this instance
107
+ # *only until it's reloaded*.
108
+ def create_api_key!(name: nil, scopes: nil, expires_at: nil, metadata: nil)
109
+ # Fetch default scopes from this owner class's settings, falling back to global config.
110
+ owner_settings = self.class.api_keys_settings
111
+ default_scopes = owner_settings&.[](:default_scopes) || ApiKeys.configuration.default_scopes || []
112
+
113
+ # Use provided scopes if given, otherwise use the calculated defaults.
114
+ key_scopes = scopes.nil? ? default_scopes : Array(scopes)
115
+
116
+ # Create the key using the association, letting AR handle owner_id/type.
117
+ api_key = self.api_keys.create!(
118
+ name: name,
119
+ scopes: key_scopes,
120
+ expires_at: expires_at,
121
+ metadata: metadata || {} # Ensure metadata is at least an empty hash
122
+ # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
123
+ )
124
+
125
+ # Return the ApiKey instance itself.
126
+ # The plaintext token is available via `api_key.token` immediately after this.
127
+ api_key
128
+ end
129
+
130
+ # Example: Check if the owner has reached their API key limit.
131
+ # def reached_api_key_limit?
132
+ # limit = self.class.api_keys_settings[:max_keys]
133
+ # # Ensure api_keys association is loaded or query count
134
+ # limit && api_keys.count >= limit # Or use a counter cache
135
+ # end
136
+
137
+ # Example: Get the specific settings for this owner instance's class.
138
+ # def api_keys_config
139
+ # self.class.api_keys_settings
140
+ # end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/cache"
4
+ require "active_support/core_ext/object/blank"
5
+ require "digest"
6
+ require_relative "../models/api_key"
7
+ require_relative "../services/digestor"
8
+ require_relative "../logging"
9
+
10
+ module ApiKeys
11
+ module Services
12
+ # Authenticates an incoming request by extracting and verifying an API key.
13
+ class Authenticator
14
+ extend ApiKeys::Logging
15
+
16
+ # Result object for authentication attempts.
17
+ Result = Struct.new(:success?, :api_key, :error_code, :message, keyword_init: true) do
18
+ def self.success(api_key)
19
+ new(success?: true, api_key: api_key)
20
+ end
21
+
22
+ def self.failure(error_code:, message:)
23
+ new(success?: false, error_code: error_code, message: message)
24
+ end
25
+ end
26
+
27
+ # Authenticates the request.
28
+ #
29
+ # @param request [ActionDispatch::Request] The incoming request object.
30
+ # @return [ApiKeys::Services::Authenticator::Result] The result of the authentication attempt.
31
+ def self.call(request)
32
+ log_debug "[ApiKeys Auth] Authenticator.call started for request: #{request.uuid}"
33
+ config = ApiKeys.configuration
34
+ config.before_authentication&.call(request)
35
+
36
+ # === HTTPS Check (Production Only) ===
37
+ if defined?(Rails.env) && Rails.env.production? && config.https_only_production
38
+ if request.protocol == "http://"
39
+ warning_message = "[ApiKeys Security] API key authentication attempted over insecure HTTP connection in production."
40
+ log_warn warning_message
41
+ if config.https_strict_mode
42
+ log_warn "[ApiKeys Security] Strict mode enabled: Aborting authentication."
43
+ result = Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.")
44
+ config.after_authentication&.call(result)
45
+ return result # Halt execution due to strict mode
46
+ end
47
+ end
48
+ end
49
+ # === End HTTPS Check ===
50
+
51
+ token = extract_token(request, config)
52
+
53
+ unless token
54
+ log_debug "[ApiKeys Auth] Token extraction failed."
55
+ result = Result.failure(error_code: :missing_token, message: "API token is missing")
56
+ config.after_authentication&.call(result)
57
+ return result
58
+ end
59
+
60
+ log_debug "[ApiKeys Auth] Token extracted successfully. Verifying..."
61
+ # Pass the original token AND config to find_and_verify_key
62
+ api_key = find_and_verify_key(token, config)
63
+
64
+ result = if api_key&.active?
65
+ log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
66
+ # TODO: Optionally update last_used_at and requests_count
67
+ Result.success(api_key)
68
+ elsif api_key&.revoked?
69
+ log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
70
+ Result.failure(error_code: :revoked_key, message: "API key has been revoked")
71
+ elsif api_key&.expired?
72
+ log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
73
+ Result.failure(error_code: :expired_key, message: "API key has expired")
74
+ else # Not found, mismatch, or inactive
75
+ log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
76
+ Result.failure(error_code: :invalid_token, message: "API token is invalid")
77
+ end
78
+
79
+ log_debug "[ApiKeys Auth] Authenticator.call finished. Result: #{result.inspect}"
80
+ config.after_authentication&.call(result)
81
+ result
82
+ end
83
+
84
+ private
85
+
86
+ # Extracts the token string from the request headers or query parameters.
87
+ def self.extract_token(request, config)
88
+ # Check header first (preferred)
89
+ if config.header.present?
90
+ header_value = request.headers[config.header]
91
+ log_debug "[ApiKeys Auth] Checking header '#{config.header}': '#{header_value}'"
92
+ if header_value
93
+ # Handle "Bearer <token>" scheme
94
+ match = header_value.match(/^Bearer\s+(.*)$/i)
95
+ if match
96
+ log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
97
+ return match[1]
98
+ end
99
+ # Fallback: return the raw header value if no Bearer scheme
100
+ log_debug "[ApiKeys Auth] No Bearer scheme, using raw header value as token."
101
+ return header_value
102
+ end
103
+ end
104
+
105
+ # Check query parameter as fallback (if configured)
106
+ if config.query_param.present?
107
+ param_value = request.query_parameters[config.query_param]
108
+ log_debug "[ApiKeys Auth] Checking query param '#{config.query_param}': '#{param_value}'"
109
+ if param_value.present?
110
+ log_debug "[ApiKeys Auth] Extracted token from query parameter."
111
+ return param_value
112
+ end
113
+ end
114
+
115
+ log_debug "[ApiKeys Auth] No token found in headers or query parameters."
116
+ nil # No token found
117
+ end
118
+
119
+ # Finds the ApiKey record corresponding to the token and verifies it securely.
120
+ # Uses caching if enabled.
121
+ # @param token [String] The plaintext token from the request.
122
+ # @param config [ApiKeys::Configuration] The current configuration.
123
+ # @return [ApiKeys::ApiKey, nil] The verified ApiKey instance or nil.
124
+ def self.find_and_verify_key(token, config)
125
+ cache_key = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash
126
+ cache_ttl = config.cache_ttl.to_i
127
+ log_debug "[ApiKeys Auth] Verifying token. Cache key: #{cache_key}, TTL: #{cache_ttl}"
128
+
129
+ if cache_ttl > 0
130
+ cached_result = rails_cache&.read(cache_key)
131
+ log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}"
132
+ # Return cached result ONLY if it's a valid ApiKey instance (a true cache hit)
133
+ if cached_result.is_a?(ApiKeys::ApiKey)
134
+ log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}"
135
+ return cached_result
136
+ elsif cached_result.nil?
137
+ log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup."
138
+ # Continue execution if it's a cache miss (nil)
139
+ else
140
+ # Handle unexpected cache values (e.g., old symbol :not_found)
141
+ log_warn "[ApiKeys Auth] Invalid cache value found: #{cached_result.inspect}. Proceeding to DB lookup."
142
+ end
143
+ end
144
+
145
+ # --- Cache miss or TTL=0: Perform DB lookup & verification ---
146
+ log_debug "[ApiKeys Auth] Performing DB lookup and verification."
147
+
148
+ # 1. Determine the expected hashing strategy (assuming single strategy for now)
149
+ strategy = config.hash_strategy.to_sym
150
+ log_debug "[ApiKeys Auth] Using strategy: #{strategy}"
151
+
152
+ # 2. Find and verify the key based on the strategy.
153
+ verified_key = nil
154
+ if strategy == :bcrypt
155
+ # Optimization: Check against the *configured* prefix first.
156
+ configured_prefix = config.token_prefix.call
157
+ matched_prefix = nil
158
+
159
+ if token.start_with?(configured_prefix)
160
+ log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}"
161
+ matched_prefix = configured_prefix
162
+ else
163
+ # Fallback: If no match, check against all known prefixes (cached).
164
+ log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes."
165
+ known_prefixes = fetch_known_prefixes(config)
166
+ # Sort by length descending to find the longest match first
167
+ matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) }
168
+ log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}"
169
+ end
170
+
171
+ possible_keys_scope = if matched_prefix
172
+ ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt')
173
+ else
174
+ # This path is now less likely but covers cases where token matches no known prefix.
175
+ log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup."
176
+ ApiKeys::ApiKey.none # Return an empty relation
177
+ end
178
+
179
+ log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql)
180
+ possible_keys = possible_keys_scope.to_a
181
+ log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt."
182
+
183
+ # Securely compare the provided token against the digests of potential keys
184
+ verified_key = possible_keys.find do |key|
185
+ match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt)
186
+ log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}"
187
+ match_result
188
+ end
189
+
190
+ elsif strategy == :sha256
191
+ # For sha256, we hash the incoming token and look for an exact match
192
+ # Note: Prefix lookup isn't useful here as the full hash is needed for the query.
193
+ token_digest = Digest::SHA256.hexdigest(token)
194
+ log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}"
195
+
196
+ # Find the key directly by the calculated digest and algorithm
197
+ verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256')
198
+
199
+ if verified_key
200
+ log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}"
201
+ else
202
+ log_debug "[ApiKeys Auth] No key found matching the SHA256 digest."
203
+ end
204
+
205
+ else
206
+ # Log unsupported strategy
207
+ log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}"
208
+ end
209
+
210
+ log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}"
211
+ # --- End DB Lookup ---
212
+
213
+ # Cache the result (either the found ApiKey instance or nil for a miss)
214
+ if cache_ttl > 0 && rails_cache
215
+ log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}"
216
+ rails_cache.write(cache_key, verified_key, expires_in: cache_ttl)
217
+ end
218
+
219
+ verified_key
220
+ end
221
+
222
+ # Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
223
+ def self.fetch_known_prefixes(config)
224
+ cache_key = "api_keys:known_prefixes"
225
+ cache_ttl = config.cache_ttl.to_i # Use the same TTL as key lookup for consistency
226
+
227
+ if cache_ttl > 0
228
+ cached_prefixes = rails_cache&.read(cache_key)
229
+ return cached_prefixes if cached_prefixes.is_a?(Array)
230
+ log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB."
231
+ end
232
+
233
+ # Fetch distinct, non-null prefixes from the database
234
+ prefixes = ApiKeys::ApiKey.distinct.pluck(:prefix).compact
235
+
236
+ if cache_ttl > 0 && rails_cache
237
+ log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}"
238
+ rails_cache.write(cache_key, prefixes, expires_in: cache_ttl)
239
+ end
240
+
241
+ prefixes
242
+ end
243
+
244
+ # Helper for accessing Rails cache safely
245
+ def self.rails_cache
246
+ defined?(Rails) ? Rails.cache : nil
247
+ end
248
+
249
+ # NOTE: Removing the incorrect private `find_key_by_token` method.
250
+ # def find_key_by_token(token)
251
+ # ...
252
+ # end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bcrypt"
4
+ require "digest"
5
+
6
+ module ApiKeys
7
+ module Services
8
+ # Handles hashing (digesting) and verifying tokens based on configured strategy.
9
+ class Digestor
10
+ # Creates a digest of the given token using the configured strategy.
11
+ #
12
+ # @param token [String] The plaintext token.
13
+ # @param strategy [Symbol] The hashing strategy (:bcrypt or :sha256).
14
+ # @return [Hash] A hash containing the digest and the algorithm used.
15
+ # e.g., { digest: "...", algorithm: "bcrypt" }
16
+ def self.digest(token:, strategy: ApiKeys.configuration.hash_strategy)
17
+ case strategy
18
+ when :bcrypt
19
+ # BCrypt handles salt generation internally
20
+ digest = BCrypt::Password.create(token, cost: BCrypt::Engine.cost)
21
+ { digest: digest.to_s, algorithm: "bcrypt" }
22
+ when :sha256
23
+ # Note: Simple SHA256 without salt/pepper. Consider enhancing if needed.
24
+ # BCrypt is generally preferred for password/token hashing.
25
+ digest = Digest::SHA256.hexdigest(token)
26
+ { digest: digest, algorithm: "sha256" }
27
+ else
28
+ raise ArgumentError, "Unsupported hash strategy: #{strategy}. Use :bcrypt or :sha256."
29
+ end
30
+ end
31
+
32
+ # Securely compares a plaintext token against a stored digest.
33
+ # Uses the configured secure comparison proc and hash strategy.
34
+ #
35
+ # @param token [String] The plaintext token provided by the user/client.
36
+ # @param stored_digest [String] The hashed digest stored in the database.
37
+ # @param strategy [Symbol] The hashing strategy used to create the stored_digest.
38
+ # @param comparison_proc [Proc] The secure comparison function.
39
+ # @return [Boolean] True if the token matches the digest, false otherwise.
40
+ def self.match?(token:, stored_digest:, strategy: ApiKeys.configuration.hash_strategy, comparison_proc: ApiKeys.configuration.secure_compare_proc)
41
+ return false if token.blank? || stored_digest.blank?
42
+
43
+ case strategy
44
+ when :bcrypt
45
+ begin
46
+ bcrypt_object = BCrypt::Password.new(stored_digest)
47
+ # BCrypt's `==` operator is designed for secure comparison
48
+ bcrypt_object == token
49
+ rescue BCrypt::Errors::InvalidHash
50
+ # If the stored digest isn't a valid BCrypt hash, comparison fails
51
+ false
52
+ end
53
+ when :sha256
54
+ # Directly compare the SHA256 hash of the input token with the stored digest
55
+ comparison_proc.call(stored_digest, Digest::SHA256.hexdigest(token))
56
+ else
57
+ # Strategy mismatch or unsupported strategy should fail comparison safely
58
+ Rails.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check." if defined?(Rails.logger)
59
+ false
60
+ end
61
+ rescue ArgumentError => e
62
+ # Catch potential errors from Digest or comparison proc
63
+ Rails.logger.error "[ApiKeys] Digestor comparison error: #{e.message}" if defined?(Rails.logger)
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end