api_keys 0.2.1 → 0.4.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ module Helpers
5
+ # View helpers for displaying API key information.
6
+ #
7
+ # These helpers provide formatted data without HTML opinions,
8
+ # allowing integrators to build their own UI while using
9
+ # consistent data formatting.
10
+ #
11
+ # @example Include in your ApplicationHelper
12
+ # module ApplicationHelper
13
+ # include ApiKeys::Helpers::ViewHelpers
14
+ # end
15
+ #
16
+ # @example Or include in a specific controller
17
+ # class Settings::ApiKeysController < ApplicationController
18
+ # helper ApiKeys::Helpers::ViewHelpers
19
+ # end
20
+ #
21
+ module ViewHelpers
22
+ # Returns the status of an API key as a symbol.
23
+ #
24
+ # @param api_key [ApiKeys::ApiKey] The API key
25
+ # @return [Symbol] :active, :expired, or :revoked
26
+ #
27
+ # @example
28
+ # api_key_status(@key) # => :active
29
+ #
30
+ def api_key_status(api_key)
31
+ return :revoked if api_key.revoked?
32
+ return :expired if api_key.expired?
33
+
34
+ :active
35
+ end
36
+
37
+ # Returns a human-readable status label for an API key.
38
+ #
39
+ # @param api_key [ApiKeys::ApiKey] The API key
40
+ # @return [String] "Active", "Expired", or "Revoked"
41
+ #
42
+ # @example
43
+ # api_key_status_label(@key) # => "Active"
44
+ #
45
+ def api_key_status_label(api_key)
46
+ case api_key_status(api_key)
47
+ when :active then "Active"
48
+ when :expired then "Expired"
49
+ when :revoked then "Revoked"
50
+ end
51
+ end
52
+
53
+ # Returns the environment label for an API key.
54
+ #
55
+ # @param api_key [ApiKeys::ApiKey] The API key
56
+ # @return [String] "Test", "Live", or "Default" (if no environment set)
57
+ #
58
+ # @example
59
+ # api_key_environment_label(@key) # => "Live"
60
+ #
61
+ def api_key_environment_label(api_key)
62
+ return "Default" if api_key.environment.blank?
63
+
64
+ api_key.environment.to_s.capitalize
65
+ end
66
+
67
+ # Returns the key type label for an API key.
68
+ #
69
+ # @param api_key [ApiKeys::ApiKey] The API key
70
+ # @return [String] "Publishable", "Secret", or the key_type capitalized
71
+ #
72
+ # @example
73
+ # api_key_type_label(@key) # => "Secret"
74
+ #
75
+ def api_key_type_label(api_key)
76
+ return "Secret" if api_key.key_type.blank?
77
+
78
+ case api_key.key_type.to_s
79
+ when "publishable" then "Publishable"
80
+ when "secret" then "Secret"
81
+ else api_key.key_type.to_s.capitalize
82
+ end
83
+ end
84
+
85
+ # Returns whether the key is a publishable (public) key type.
86
+ # Useful for conditional rendering (e.g., showing/hiding copy button).
87
+ #
88
+ # @param api_key [ApiKeys::ApiKey] The API key
89
+ # @return [Boolean] true if the key is publishable
90
+ #
91
+ # @example
92
+ # <% if api_key_publishable?(@key) %>
93
+ # <%= button_tag "Copy", data: { token: @key.viewable_token } %>
94
+ # <% end %>
95
+ #
96
+ def api_key_publishable?(api_key)
97
+ api_key.key_type.to_s == "publishable"
98
+ end
99
+
100
+ # Returns whether the key is a secret key type.
101
+ #
102
+ # @param api_key [ApiKeys::ApiKey] The API key
103
+ # @return [Boolean] true if the key is secret (or legacy with no type)
104
+ #
105
+ def api_key_secret?(api_key)
106
+ api_key.key_type.blank? || api_key.key_type.to_s == "secret"
107
+ end
108
+
109
+ # Detects the environment from a token string by parsing its prefix.
110
+ # Useful for displaying environment info when you only have the token.
111
+ #
112
+ # @param token [String] The full token string (e.g., "sk_test_abc123...")
113
+ # @return [Symbol, nil] The detected environment (:test, :live, etc.) or nil
114
+ #
115
+ # @example
116
+ # api_key_environment_from_token("sk_test_abc123") # => :test
117
+ # api_key_environment_from_token("pk_live_xyz789") # => :live
118
+ # api_key_environment_from_token("ak_abc123") # => nil
119
+ #
120
+ def api_key_environment_from_token(token)
121
+ return nil if token.blank?
122
+ return nil if token.length > 500 # Reasonable max length for tokens
123
+
124
+ config = ApiKeys.configuration
125
+ return nil unless config.environments.present?
126
+
127
+ # Check each configured environment's prefix segment
128
+ config.environments.each do |env_name, env_config|
129
+ segment = env_config[:prefix_segment]
130
+ next if segment.blank?
131
+
132
+ # Match pattern like _test_ or _live_ in the token
133
+ if token.include?("_#{segment}_")
134
+ return env_name
135
+ end
136
+ end
137
+
138
+ nil
139
+ end
140
+
141
+ # Returns a human-readable environment label from a token string.
142
+ # Convenience wrapper around api_key_environment_from_token.
143
+ #
144
+ # @param token [String] The full token string
145
+ # @return [String] "Test mode", "Live mode", or "Default" if unknown
146
+ #
147
+ # @example
148
+ # api_key_environment_label_from_token("sk_test_abc") # => "Test mode"
149
+ # api_key_environment_label_from_token("sk_live_xyz") # => "Live mode"
150
+ #
151
+ def api_key_environment_label_from_token(token)
152
+ env = api_key_environment_from_token(token)
153
+ return "Default" if env.nil?
154
+
155
+ "#{env.to_s.capitalize} mode"
156
+ end
157
+
158
+ # Returns a hash of status information for an API key.
159
+ # Useful for building custom status badges.
160
+ #
161
+ # @param api_key [ApiKeys::ApiKey] The API key
162
+ # @return [Hash] Hash with :status, :label, and :color keys
163
+ #
164
+ # @example
165
+ # info = api_key_status_info(@key)
166
+ # # => { status: :active, label: "Active", color: :green }
167
+ #
168
+ def api_key_status_info(api_key)
169
+ status = api_key_status(api_key)
170
+ {
171
+ status: status,
172
+ label: api_key_status_label(api_key),
173
+ color: status_color(status)
174
+ }
175
+ end
176
+
177
+ # Returns a hash of type information for an API key.
178
+ # Useful for building custom type badges.
179
+ #
180
+ # @param api_key [ApiKeys::ApiKey] The API key
181
+ # @return [Hash] Hash with :type, :label, and :color keys
182
+ #
183
+ # @example
184
+ # info = api_key_type_info(@key)
185
+ # # => { type: :publishable, label: "Publishable", color: :green }
186
+ #
187
+ def api_key_type_info(api_key)
188
+ type = case api_key.key_type.to_s
189
+ when "", "secret" then :secret
190
+ when "publishable" then :publishable
191
+ else api_key.key_type.to_s
192
+ end
193
+ {
194
+ type: type,
195
+ label: api_key_type_label(api_key),
196
+ color: type_color(type)
197
+ }
198
+ end
199
+
200
+ private
201
+
202
+ def status_color(status)
203
+ case status
204
+ when :active then :green
205
+ when :expired then :red
206
+ when :revoked then :gray
207
+ else :gray
208
+ end
209
+ end
210
+
211
+ def type_color(type)
212
+ case type
213
+ when :publishable then :green
214
+ when :secret then :amber
215
+ else :gray
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -10,8 +10,8 @@ module ApiKeys
10
10
  class CallbacksJob < ActiveJob::Base
11
11
  include ApiKeys::Logging
12
12
 
13
- # Use the queue name specified in the configuration (evaluated at load time)
14
- queue_as ApiKeys.configuration.callbacks_job_queue
13
+ # Resolve the configured queue for each job so initializer changes apply.
14
+ queue_as { ApiKeys.configuration.callbacks_job_queue }
15
15
 
16
16
  # Executes the appropriate callback based on the type.
17
17
  #
@@ -28,10 +28,8 @@ module ApiKeys
28
28
  else
29
29
  log_warn "[ApiKeys::Jobs::CallbacksJob] Unknown callback type: #{callback_type}"
30
30
  end
31
- rescue StandardError => e
32
- log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} with context #{context.inspect}: #{e.class}: #{e.message}
33
- #{e.backtrace.join("
34
- ")}"
31
+ rescue StandardError => error
32
+ log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} (#{error.class})."
35
33
  # Avoid retrying callback errors by default, as the original request succeeded.
36
34
  # Depending on callback importance, users might configure retries separately.
37
35
  end
@@ -51,17 +49,12 @@ module ApiKeys
51
49
  arity = callback_proc.arity
52
50
  log_debug "[ApiKeys::Jobs::CallbacksJob] Executing callback with arity #{arity}"
53
51
 
54
- begin
55
- if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
56
- callback_proc.call(context)
57
- elsif arity == 0 # Handle procs accepting no args
58
- callback_proc.call
59
- else
60
- log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
61
- end
62
- rescue StandardError => e
63
- # Log the specific error from the user's callback code
64
- raise # Re-raise to be caught by the main perform rescue block for logging
52
+ if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
53
+ callback_proc.call(context)
54
+ elsif arity == 0 # Handle procs accepting no args
55
+ callback_proc.call
56
+ else
57
+ log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
65
58
  end
66
59
  end
67
60
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_job"
4
+ require "active_support/core_ext/numeric/time"
4
5
  require_relative "../models/api_key"
5
6
  require_relative "../logging"
6
7
 
@@ -11,8 +12,10 @@ module ApiKeys
11
12
  class UpdateStatsJob < ActiveJob::Base
12
13
  include ApiKeys::Logging # Include logging helpers
13
14
 
14
- # Use the queue name specified in the configuration (evaluated at load time)
15
- queue_as ApiKeys.configuration.stats_job_queue
15
+ MAX_FUTURE_CLOCK_SKEW = 5.minutes
16
+
17
+ # Resolve the configured queue for each job so initializer changes apply.
18
+ queue_as { ApiKeys.configuration.stats_job_queue }
16
19
 
17
20
  # Perform the database updates for the given ApiKey.
18
21
  #
@@ -26,11 +29,25 @@ module ApiKeys
26
29
  return
27
30
  end
28
31
 
29
- log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id} at #{timestamp}"
32
+ unless timestamp.respond_to?(:to_time)
33
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Invalid timestamp for ApiKey ID: #{api_key_id}. Skipping stats update."
34
+ return
35
+ end
36
+ timestamp = timestamp.to_time
37
+
38
+ if timestamp > Time.current + MAX_FUTURE_CLOCK_SKEW
39
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Rejected a timestamp too far in the future."
40
+ return
41
+ end
42
+
43
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id}"
30
44
 
31
- # Use provided timestamp for consistency
32
- # Use update_column to skip validations/callbacks for performance
33
- api_key.update_column(:last_used_at, timestamp)
45
+ # Jobs may execute out of order. Update only when this event is newer so
46
+ # last_used_at can never regress to an earlier request timestamp.
47
+ ApiKey
48
+ .where(id: api_key.id)
49
+ .where("last_used_at IS NULL OR last_used_at < ?", timestamp)
50
+ .update_all(last_used_at: timestamp)
34
51
 
35
52
  # Conditionally increment requests_count if configured
36
53
  if ApiKeys.configuration.track_requests_count
@@ -41,16 +58,14 @@ module ApiKeys
41
58
 
42
59
  log_debug "[ApiKeys::Jobs::UpdateStatsJob] Finished updating stats for ApiKey ID: #{api_key_id}"
43
60
 
44
- rescue ActiveRecord::ActiveRecordError => e
61
+ rescue ActiveRecord::ActiveRecordError => error
45
62
  # Log error but don't automatically retry unless configured to do so.
46
63
  # Frequent stats updates might tolerate occasional failures better than endless retries.
47
- log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id}. Error: #{e.message}"
64
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id} (#{error.class})."
48
65
  # Depending on ActiveJob adapter, specific retry logic might be needed here
49
66
  # or configured globally. For now, just log.
50
- rescue StandardError => e
51
- log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}. Error: #{e.class}: #{e.message}
52
- #{e.backtrace.join("
53
- ")}"
67
+ rescue StandardError => error
68
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id} (#{error.class})."
54
69
  # Consider re-raising or using a dead-letter queue strategy depending on job system
55
70
  end
56
71
  end