api_keys 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +147 -38
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +92 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +412 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- 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 +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +24 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -14
- data/lib/generators/api_keys/templates/initializer.rb +54 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>🔑 API Keys</title>
|
|
7
|
-
<%#
|
|
8
|
-
<link rel="stylesheet" href="https://unpkg.com/chota@latest">
|
|
9
|
-
<%# Host application is expected to provide styling %>
|
|
7
|
+
<%# Self-contained styles: do not load mutable third-party assets on secret-bearing pages. %>
|
|
10
8
|
<%= csrf_meta_tags %>
|
|
11
9
|
<%= csp_meta_tag %>
|
|
12
10
|
</head>
|
|
13
11
|
<body>
|
|
14
|
-
<style>
|
|
12
|
+
<style nonce="<%= content_security_policy_nonce %>">
|
|
15
13
|
/*
|
|
16
14
|
* API Keys CSS Variables
|
|
17
15
|
* Override these in your host application to customize the dashboard appearance.
|
|
@@ -56,6 +54,92 @@
|
|
|
56
54
|
|
|
57
55
|
body {
|
|
58
56
|
font-family: var(--api-keys-font-family);
|
|
57
|
+
margin: 0;
|
|
58
|
+
color: var(--font-color, #222);
|
|
59
|
+
background: var(--bg-color, #fff);
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.container {
|
|
64
|
+
width: min(94%, 1080px);
|
|
65
|
+
margin-inline: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.nav, .row {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 1rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.nav {
|
|
75
|
+
justify-content: space-between;
|
|
76
|
+
min-height: 4rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.nav-left, .nav-right {
|
|
80
|
+
display: flex;
|
|
81
|
+
gap: 1rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.col { flex: 1; }
|
|
85
|
+
.col-5 { width: min(100%, 42rem); }
|
|
86
|
+
.col-7 { flex: 0 0 58%; }
|
|
87
|
+
.col-8 { width: min(100%, 54rem); }
|
|
88
|
+
.is-right { justify-content: flex-end; }
|
|
89
|
+
.is-center { justify-content: center; }
|
|
90
|
+
.text-center { text-align: center; }
|
|
91
|
+
|
|
92
|
+
input, select, button, .button {
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
font: inherit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
input[type="text"], select {
|
|
98
|
+
width: 100%;
|
|
99
|
+
padding: 0.65rem;
|
|
100
|
+
border: 1px solid var(--color-grey, #bbb);
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
background: var(--bg-color, #fff);
|
|
103
|
+
color: inherit;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
button, .button {
|
|
107
|
+
display: inline-block;
|
|
108
|
+
padding: 0.55rem 0.85rem;
|
|
109
|
+
border: 1px solid currentColor;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
text-decoration: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.primary {
|
|
116
|
+
color: #fff;
|
|
117
|
+
background: var(--api-keys-primary-color);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.card {
|
|
121
|
+
padding: 1rem;
|
|
122
|
+
border: 1px solid var(--color-grey, #ccc);
|
|
123
|
+
border-radius: var(--api-keys-border-radius);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
table {
|
|
127
|
+
width: 100%;
|
|
128
|
+
border-collapse: collapse;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
th, td {
|
|
132
|
+
padding: 0.65rem;
|
|
133
|
+
border-bottom: 1px solid var(--color-grey, #ddd);
|
|
134
|
+
text-align: left;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.api-keys-table-wrapper { overflow-x: auto; }
|
|
138
|
+
.tag, .api-keys-badge {
|
|
139
|
+
display: inline-block;
|
|
140
|
+
padding: 0.15rem 0.4rem;
|
|
141
|
+
border-radius: 999px;
|
|
142
|
+
background: var(--bg-secondary-color, #eee);
|
|
59
143
|
}
|
|
60
144
|
|
|
61
145
|
@media (prefers-color-scheme: dark) {
|
|
@@ -107,6 +191,50 @@
|
|
|
107
191
|
color: var(--api-keys-danger-color);
|
|
108
192
|
}
|
|
109
193
|
|
|
194
|
+
[hidden] { display: none !important; }
|
|
195
|
+
.api-keys-form-errors { color: var(--api-keys-danger-color); }
|
|
196
|
+
.api-keys-form-help { display: block; color: var(--color-darkGrey, #666); }
|
|
197
|
+
.api-keys-empty-state { text-align: center; padding: 2em; }
|
|
198
|
+
.api-keys-expired-text { color: var(--api-keys-status-expired-color); }
|
|
199
|
+
.api-keys-token-panel { padding: 1em; margin: 1em 0; border-radius: 4px; }
|
|
200
|
+
.api-keys-token-value { overflow-wrap: anywhere; }
|
|
201
|
+
.api-keys-token-scopes, .api-keys-show-footer { margin-top: 2em; }
|
|
202
|
+
.api-keys-token-break { overflow-wrap: anywhere; }
|
|
203
|
+
.btn-show-token, .btn-copy-token {
|
|
204
|
+
margin-left: 0.5rem;
|
|
205
|
+
padding: 0.15rem 0.4rem;
|
|
206
|
+
font-size: 0.75em;
|
|
207
|
+
}
|
|
208
|
+
.api-keys-action-disabled {
|
|
209
|
+
color: var(--api-keys-muted-color);
|
|
210
|
+
cursor: help;
|
|
211
|
+
}
|
|
212
|
+
.api-keys-status-active { color: var(--api-keys-status-active-color); }
|
|
213
|
+
.api-keys-status-revoked { color: var(--api-keys-status-revoked-color); }
|
|
214
|
+
.api-keys-status-expired { color: var(--api-keys-status-expired-color); }
|
|
215
|
+
.api-keys-badge-type, .api-keys-badge-env {
|
|
216
|
+
margin-left: 0.25rem;
|
|
217
|
+
padding: 0.15rem 0.4rem;
|
|
218
|
+
border-radius: 3px;
|
|
219
|
+
font-size: 0.75em;
|
|
220
|
+
}
|
|
221
|
+
.api-keys-badge-publishable {
|
|
222
|
+
color: var(--api-keys-badge-publishable-color);
|
|
223
|
+
background-color: var(--api-keys-badge-publishable-bg);
|
|
224
|
+
}
|
|
225
|
+
.api-keys-badge-secret {
|
|
226
|
+
color: var(--api-keys-badge-secret-color);
|
|
227
|
+
background-color: var(--api-keys-badge-secret-bg);
|
|
228
|
+
}
|
|
229
|
+
.api-keys-badge-live {
|
|
230
|
+
color: var(--api-keys-badge-live-color);
|
|
231
|
+
background-color: var(--api-keys-badge-live-bg);
|
|
232
|
+
}
|
|
233
|
+
.api-keys-badge-test {
|
|
234
|
+
color: var(--api-keys-badge-test-color);
|
|
235
|
+
background-color: var(--api-keys-badge-test-bg);
|
|
236
|
+
}
|
|
237
|
+
|
|
110
238
|
.api-keys-button-text {
|
|
111
239
|
padding-left: 0.2em;
|
|
112
240
|
}
|
|
@@ -114,7 +242,7 @@
|
|
|
114
242
|
#api-keys-flash-container {
|
|
115
243
|
margin-top: 1em;
|
|
116
244
|
}
|
|
117
|
-
|
|
245
|
+
|
|
118
246
|
.api-keys-flash-message {
|
|
119
247
|
background-color: rgba(255, 255, 255, 0.15);
|
|
120
248
|
padding: 0.8em 0;
|
|
@@ -168,7 +296,7 @@
|
|
|
168
296
|
<%# Use configured return URL and text %>
|
|
169
297
|
<%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url %>
|
|
170
298
|
<%# Link to keys index using engine's path helper %>
|
|
171
|
-
<%= link_to "My API Keys",
|
|
299
|
+
<%= link_to "My API Keys", keys_path, class: "active" %>
|
|
172
300
|
</div>
|
|
173
301
|
<div class="nav-center">
|
|
174
302
|
</div>
|
|
@@ -193,16 +321,16 @@
|
|
|
193
321
|
<%# Footer content %>
|
|
194
322
|
</footer>
|
|
195
323
|
|
|
196
|
-
<script>
|
|
324
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
197
325
|
// Minimal JS for API Keys dashboard - event delegation for show/copy tokens
|
|
198
326
|
document.addEventListener('click', function(e) {
|
|
199
327
|
// Show token (one-way: masked -> full, no hiding back)
|
|
200
328
|
if (e.target.matches('.btn-show-token')) {
|
|
201
329
|
var cell = e.target.closest('td');
|
|
202
|
-
cell.querySelector('.token-masked').
|
|
203
|
-
cell.querySelector('.token-full').
|
|
204
|
-
e.target.
|
|
205
|
-
cell.querySelector('.btn-copy-token').
|
|
330
|
+
cell.querySelector('.token-masked').hidden = true;
|
|
331
|
+
cell.querySelector('.token-full').hidden = false;
|
|
332
|
+
e.target.hidden = true;
|
|
333
|
+
cell.querySelector('.btn-copy-token').hidden = false;
|
|
206
334
|
}
|
|
207
335
|
|
|
208
336
|
// Copy token to clipboard
|
|
@@ -212,10 +340,29 @@
|
|
|
212
340
|
var originalText = e.target.textContent;
|
|
213
341
|
e.target.textContent = 'Copied!';
|
|
214
342
|
setTimeout(function() { e.target.textContent = originalText; }, 2000);
|
|
343
|
+
}).catch(function() {
|
|
344
|
+
e.target.textContent = 'Copy failed';
|
|
345
|
+
setTimeout(function() { e.target.textContent = 'Copy'; }, 2000);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Copy a newly issued secret without duplicating it into an HTML attribute.
|
|
350
|
+
if (e.target.closest('.btn-copy-new-token')) {
|
|
351
|
+
var button = e.target.closest('.btn-copy-new-token');
|
|
352
|
+
var tokenElement = document.getElementById('api-key-token');
|
|
353
|
+
var buttonText = button.querySelector('.api-keys-button-text');
|
|
354
|
+
if (!navigator.clipboard || !tokenElement || !buttonText) return;
|
|
355
|
+
|
|
356
|
+
navigator.clipboard.writeText(tokenElement.textContent || '').then(function() {
|
|
357
|
+
buttonText.textContent = 'Copied!';
|
|
358
|
+
setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
|
|
359
|
+
}).catch(function() {
|
|
360
|
+
buttonText.textContent = 'Copy failed';
|
|
361
|
+
setTimeout(function() { buttonText.textContent = 'Copy'; }, 2000);
|
|
215
362
|
});
|
|
216
363
|
}
|
|
217
364
|
});
|
|
218
365
|
</script>
|
|
219
366
|
|
|
220
367
|
</body>
|
|
221
|
-
</html>
|
|
368
|
+
</html>
|
|
@@ -32,17 +32,21 @@ module ApiKeys
|
|
|
32
32
|
current_api_key&.owner
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
alias_method :current_api_key_owner, :current_api_owner
|
|
36
|
+
|
|
35
37
|
# Convenience helper: returns the owner if it's a User instance.
|
|
36
38
|
# @return [User, nil]
|
|
37
39
|
def current_api_user
|
|
38
40
|
owner = current_api_owner
|
|
39
|
-
owner if owner.is_a?(::User)
|
|
41
|
+
owner if defined?(::User) && owner.is_a?(::User)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
private
|
|
43
45
|
|
|
44
46
|
# The core authentication method.
|
|
45
47
|
def authenticate_api_key!(scope: nil)
|
|
48
|
+
@current_api_key = nil
|
|
49
|
+
remove_instance_variable(:@current_api_tenant) if instance_variable_defined?(:@current_api_tenant)
|
|
46
50
|
log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
|
|
47
51
|
|
|
48
52
|
# Enqueue before_authentication callback asynchronously
|
|
@@ -50,13 +54,12 @@ module ApiKeys
|
|
|
50
54
|
|
|
51
55
|
# Perform synchronous authentication
|
|
52
56
|
result = Services::Authenticator.call(request)
|
|
53
|
-
log_debug "[ApiKeys Auth]
|
|
57
|
+
log_debug "[ApiKeys Auth] Authentication result: success=#{result.success?}, error_code=#{result.error_code || 'none'}"
|
|
54
58
|
|
|
55
59
|
# Prepare context for after_authentication callback
|
|
56
60
|
after_auth_context = {
|
|
57
61
|
success: result.success?,
|
|
58
62
|
error_code: result.error_code,
|
|
59
|
-
message: result.message,
|
|
60
63
|
api_key_id: result.api_key&.id # Pass ID only, not the full object
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -65,10 +68,18 @@ module ApiKeys
|
|
|
65
68
|
log_debug "[ApiKeys Auth] Authentication successful. Key ID: #{@current_api_key.id}"
|
|
66
69
|
|
|
67
70
|
if scope && !check_api_key_scopes(scope)
|
|
68
|
-
log_debug "[ApiKeys Auth] Scope check failed
|
|
71
|
+
log_debug "[ApiKeys Auth] Scope check failed for key ID #{@current_api_key.id}."
|
|
69
72
|
# Add required scope info to context before rendering/enqueueing
|
|
70
73
|
after_auth_context[:required_scope_check] = { required: scope, passed: false }
|
|
71
|
-
|
|
74
|
+
after_auth_context[:success] = false
|
|
75
|
+
after_auth_context[:error_code] = :missing_scope
|
|
76
|
+
@current_api_key = nil
|
|
77
|
+
render_unauthorized(
|
|
78
|
+
error_code: :missing_scope,
|
|
79
|
+
message: "API key does not have the required scope(s): #{scope}",
|
|
80
|
+
status: :forbidden,
|
|
81
|
+
required_scope: scope
|
|
82
|
+
)
|
|
72
83
|
else
|
|
73
84
|
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
|
74
85
|
# Authentication and scope check successful, enqueue stats update
|
|
@@ -90,7 +101,7 @@ module ApiKeys
|
|
|
90
101
|
# @param required_scopes [String, Array<String>] The required scope(s).
|
|
91
102
|
# @return [Boolean] True if the key has all required scopes, false otherwise.
|
|
92
103
|
def check_api_key_scopes(required_scopes)
|
|
93
|
-
return
|
|
104
|
+
return false unless current_api_key
|
|
94
105
|
return true if required_scopes.blank?
|
|
95
106
|
|
|
96
107
|
Array(required_scopes).all? do |req_scope|
|
|
@@ -112,6 +123,11 @@ module ApiKeys
|
|
|
112
123
|
return unless ApiKeys.configuration.enable_async_operations
|
|
113
124
|
return unless current_api_key
|
|
114
125
|
|
|
126
|
+
if stats_update_debounced?
|
|
127
|
+
log_debug "[ApiKeys Auth] Skipping a recently recorded last-used update for ApiKey ID: #{current_api_key.id}"
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
115
131
|
# Check ActiveJob configuration and warn if using suboptimal adapters
|
|
116
132
|
adapter = ActiveJob::Base.queue_adapter
|
|
117
133
|
if adapter.is_a?(ActiveJob::QueueAdapters::InlineAdapter)
|
|
@@ -124,11 +140,23 @@ module ApiKeys
|
|
|
124
140
|
timestamp = Time.current # Capture time once for the job
|
|
125
141
|
log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
|
|
126
142
|
ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
|
|
127
|
-
rescue StandardError =>
|
|
128
|
-
log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}
|
|
143
|
+
rescue StandardError => error
|
|
144
|
+
log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id} (#{error.class})."
|
|
129
145
|
end
|
|
130
146
|
end
|
|
131
147
|
|
|
148
|
+
def stats_update_debounced?
|
|
149
|
+
config = ApiKeys.configuration
|
|
150
|
+
return false if config.track_requests_count
|
|
151
|
+
|
|
152
|
+
interval = config.stats_update_interval
|
|
153
|
+
seconds = interval.to_f if interval.respond_to?(:to_f)
|
|
154
|
+
return false unless seconds&.finite? && seconds.positive?
|
|
155
|
+
return false unless current_api_key.last_used_at
|
|
156
|
+
|
|
157
|
+
current_api_key.last_used_at >= Time.current - seconds
|
|
158
|
+
end
|
|
159
|
+
|
|
132
160
|
# Helper to safely enqueue callback jobs.
|
|
133
161
|
def enqueue_callback(callback_type, context)
|
|
134
162
|
# Return early if async operations are globally disabled
|
|
@@ -148,10 +176,10 @@ module ApiKeys
|
|
|
148
176
|
|
|
149
177
|
# Proceed with enqueueing if it's a configured callback
|
|
150
178
|
begin
|
|
151
|
-
log_debug "[ApiKeys Auth] Enqueuing
|
|
179
|
+
log_debug "[ApiKeys Auth] Enqueuing callback job for type: #{callback_type}"
|
|
152
180
|
ApiKeys::Jobs::CallbacksJob.perform_later(callback_type, context)
|
|
153
|
-
rescue StandardError =>
|
|
154
|
-
log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}
|
|
181
|
+
rescue StandardError => error
|
|
182
|
+
log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type} (#{error.class})."
|
|
155
183
|
# Don't fail the request if callback enqueueing fails
|
|
156
184
|
end
|
|
157
185
|
end
|