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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- 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
|
-
#
|
|
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 =>
|
|
32
|
-
log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type}
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
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 =>
|
|
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}
|
|
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 =>
|
|
51
|
-
log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}
|
|
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
|