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
@@ -4,23 +4,159 @@
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
- <%# Note: No CSS included here to keep it CSS-framework-agnostic %>
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 %>">
13
+ /*
14
+ * API Keys CSS Variables
15
+ * Override these in your host application to customize the dashboard appearance.
16
+ * Example in your app's CSS:
17
+ * :root {
18
+ * --api-keys-primary-color: #your-brand-color;
19
+ * --api-keys-danger-color: #your-danger-color;
20
+ * }
21
+ */
22
+ :root {
23
+ /* Colors */
24
+ --api-keys-primary-color: #007bff;
25
+ --api-keys-danger-color: #c23539;
26
+ --api-keys-success-color: #28a745;
27
+ --api-keys-warning-color: #ffc107;
28
+ --api-keys-muted-color: #6c757d;
29
+
30
+ /* Badge colors */
31
+ --api-keys-badge-secret-bg: #e7f1ff;
32
+ --api-keys-badge-secret-color: #004085;
33
+ --api-keys-badge-publishable-bg: #fef3cd;
34
+ --api-keys-badge-publishable-color: #856404;
35
+ --api-keys-badge-live-bg: #d4edda;
36
+ --api-keys-badge-live-color: #155724;
37
+ --api-keys-badge-test-bg: #f8d7da;
38
+ --api-keys-badge-test-color: #721c24;
39
+
40
+ /* Status colors */
41
+ --api-keys-status-active-color: green;
42
+ --api-keys-status-revoked-color: orange;
43
+ --api-keys-status-expired-color: red;
44
+
45
+ /* Spacing */
46
+ --api-keys-section-padding: 1.5em;
47
+ --api-keys-section-margin: 2em;
48
+ --api-keys-border-radius: 8px;
49
+
50
+ /* Typography */
51
+ --api-keys-font-family: inherit;
52
+ --api-keys-code-font-size: 0.8em;
53
+ }
54
+
15
55
  body {
16
- /* grid-template-columns: 1fr min(100rem, 90%) 1fr !important; */
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);
17
143
  }
18
144
 
19
145
  @media (prefers-color-scheme: dark) {
146
+ :root {
147
+ --api-keys-badge-secret-bg: #1a365d;
148
+ --api-keys-badge-secret-color: #90cdf4;
149
+ --api-keys-badge-publishable-bg: #744210;
150
+ --api-keys-badge-publishable-color: #faf089;
151
+ --api-keys-badge-live-bg: #22543d;
152
+ --api-keys-badge-live-color: #9ae6b4;
153
+ --api-keys-badge-test-bg: #742a2a;
154
+ --api-keys-badge-test-color: #feb2b2;
155
+ }
156
+
20
157
  body {
21
- /* Define dark mode variables directly */
22
- --bg-color:rgb(14, 14, 14);
23
- --bg-secondary-color:rgb(34, 34, 34);
158
+ --bg-color: rgb(14, 14, 14);
159
+ --bg-secondary-color: rgb(34, 34, 34);
24
160
  --font-color: #f5f5f5;
25
161
  --color-grey: #ccc;
26
162
  --color-darkGrey: #777;
@@ -29,7 +165,7 @@
29
165
 
30
166
  code, pre {
31
167
  color: var(--font-color);
32
- font-size: 0.8em;
168
+ font-size: var(--api-keys-code-font-size);
33
169
  }
34
170
 
35
171
  .api-keys-align-center {
@@ -52,7 +188,51 @@
52
188
  .api-keys-action-buttons button {
53
189
  background: none;
54
190
  padding: 0;
55
- color: #c23539
191
+ color: var(--api-keys-danger-color);
192
+ }
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);
56
236
  }
57
237
 
58
238
  .api-keys-button-text {
@@ -62,7 +242,7 @@
62
242
  #api-keys-flash-container {
63
243
  margin-top: 1em;
64
244
  }
65
-
245
+
66
246
  .api-keys-flash-message {
67
247
  background-color: rgba(255, 255, 255, 0.15);
68
248
  padding: 0.8em 0;
@@ -77,6 +257,38 @@
77
257
  margin-top: 2.4em;
78
258
  }
79
259
 
260
+ /* API Keys sections styling */
261
+ .api-keys-section {
262
+ margin-top: var(--api-keys-section-margin);
263
+ padding: var(--api-keys-section-padding);
264
+ border: 1px solid var(--color-grey, #ccc);
265
+ border-radius: var(--api-keys-border-radius);
266
+ background-color: var(--bg-secondary-color, #f9f9f9);
267
+ }
268
+
269
+ .api-keys-section h2 {
270
+ margin-top: 0;
271
+ margin-bottom: 0.5em;
272
+ font-size: 1.25em;
273
+ }
274
+
275
+ .api-keys-section-description {
276
+ margin-bottom: 1em;
277
+ color: var(--color-darkGrey, #666);
278
+ font-size: 0.9em;
279
+ }
280
+
281
+ .api-keys-info-text {
282
+ margin-bottom: 1em;
283
+ }
284
+
285
+ @media (prefers-color-scheme: dark) {
286
+ .api-keys-section {
287
+ background-color: var(--bg-secondary-color, rgb(34, 34, 34));
288
+ border-color: var(--color-darkGrey, #555);
289
+ }
290
+ }
291
+
80
292
  </style>
81
293
 
82
294
  <nav class="nav container">
@@ -84,7 +296,7 @@
84
296
  <%# Use configured return URL and text %>
85
297
  <%= link_to ApiKeys.configuration.return_text, ApiKeys.configuration.return_url %>
86
298
  <%# Link to keys index using engine's path helper %>
87
- <%= link_to "My API Keys", api_keys.keys_path, class: "active" %>
299
+ <%= link_to "My API Keys", keys_path, class: "active" %>
88
300
  </div>
89
301
  <div class="nav-center">
90
302
  </div>
@@ -109,7 +321,48 @@
109
321
  <%# Footer content %>
110
322
  </footer>
111
323
 
112
- <%# Add any necessary JS includes if needed later %>
324
+ <script nonce="<%= content_security_policy_nonce %>">
325
+ // Minimal JS for API Keys dashboard - event delegation for show/copy tokens
326
+ document.addEventListener('click', function(e) {
327
+ // Show token (one-way: masked -> full, no hiding back)
328
+ if (e.target.matches('.btn-show-token')) {
329
+ var cell = e.target.closest('td');
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;
334
+ }
335
+
336
+ // Copy token to clipboard
337
+ if (e.target.matches('.btn-copy-token')) {
338
+ var token = e.target.getAttribute('data-token');
339
+ navigator.clipboard.writeText(token).then(function() {
340
+ var originalText = e.target.textContent;
341
+ e.target.textContent = 'Copied!';
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);
362
+ });
363
+ }
364
+ });
365
+ </script>
113
366
 
114
367
  </body>
115
- </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) # Assumes a User class exists
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] Authenticator result: #{result.inspect}"
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. Required: #{scope}, Key scopes: #{@current_api_key.scopes}"
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
- render_unauthorized(error_code: :missing_scope, message: "API key does not have the required scope(s): #{scope}", required_scope: scope)
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 true unless current_api_key # Should not happen if authenticate_api_key! ran
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 => e
128
- log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}: #{e.message}"
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 CallbacksJob for type: #{callback_type} with context: #{context.inspect}"
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 => e
154
- log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}: #{e.message}"
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