api_keys 0.2.1 → 0.3.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/.simplecov +36 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +9 -0
- data/CLAUDE.md +5 -0
- data/README.md +767 -3
- data/Rakefile +9 -4
- data/app/controllers/api_keys/keys_controller.rb +43 -11
- 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 +31 -2
- 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 +2 -7
- 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 +5 -1
- 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/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +113 -7
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +21 -0
- data/gemfiles/rails_8.0.gemfile +21 -0
- data/gemfiles/rails_8.1.gemfile +21 -0
- data/lib/api_keys/configuration.rb +77 -0
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +158 -0
- data/lib/api_keys/helpers/expiration_options.rb +131 -0
- data/lib/api_keys/helpers/token_session.rb +68 -0
- data/lib/api_keys/helpers/view_helpers.rb +216 -0
- data/lib/api_keys/models/api_key.rb +229 -17
- data/lib/api_keys/models/concerns/has_api_keys.rb +183 -3
- data/lib/api_keys/services/authenticator.rb +45 -2
- data/lib/api_keys/services/digestor.rb +6 -2
- data/lib/api_keys/tenant_resolution.rb +3 -1
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -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 +9 -0
- data/lib/generators/api_keys/templates/initializer.rb +242 -120
- metadata +24 -58
data/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# 🔑 `api_keys` – Secure API keys for your Rails app
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/api_keys)
|
|
3
|
+
[](https://badge.fury.io/rb/api_keys) [](https://github.com/rameerez/api_keys/actions)
|
|
4
|
+
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=api_keys)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=api_keys)!
|
|
4
7
|
|
|
5
8
|
`api_keys` makes it simple to add secure, production-ready API key authentication to any Rails app. Generate keys, restrict scopes, auto-expire tokens, revoke tokens, gate endpoints. It also provides a self-serve dashboard for your users to self-issue and manage their API keys themselves. All tokens are hashed securely by default, and never stored in plaintext.
|
|
6
9
|
|
|
@@ -139,6 +142,522 @@ To make the experience between your app and the `api_keys` dashboard more seamle
|
|
|
139
142
|
|
|
140
143
|
You can check out the dashboard on the [live demo website](https://apikeys.rameerez.com).
|
|
141
144
|
|
|
145
|
+
### Customizing the Dashboard
|
|
146
|
+
|
|
147
|
+
The gem provides two levels of customization for the mounted dashboard:
|
|
148
|
+
|
|
149
|
+
#### Level 1: Use the stock dashboard (default)
|
|
150
|
+
Works out of the box with good defaults. No configuration needed.
|
|
151
|
+
|
|
152
|
+
#### Level 2: Override CSS variables
|
|
153
|
+
Tweak colors and spacing by overriding CSS variables in your application's stylesheet:
|
|
154
|
+
|
|
155
|
+
```css
|
|
156
|
+
:root {
|
|
157
|
+
--api-keys-primary-color: #your-brand-color;
|
|
158
|
+
--api-keys-danger-color: #dc3545;
|
|
159
|
+
--api-keys-success-color: #28a745;
|
|
160
|
+
--api-keys-badge-secret-bg: #e7f1ff;
|
|
161
|
+
--api-keys-badge-publishable-bg: #fef3cd;
|
|
162
|
+
/* See layout file for all available variables */
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Building Custom Integrations
|
|
167
|
+
|
|
168
|
+
If you need complete control over the UI (e.g., to match your design system with Tailwind, Bootstrap, etc.), you can build your own views and controllers while using the gem's model layer and helpers.
|
|
169
|
+
|
|
170
|
+
The gem provides a comprehensive set of helpers specifically designed for custom integrations. These patterns are battle-tested from real production integrations.
|
|
171
|
+
|
|
172
|
+
##### What You'll Need
|
|
173
|
+
|
|
174
|
+
A complete custom integration typically requires:
|
|
175
|
+
|
|
176
|
+
| Component | Purpose |
|
|
177
|
+
|-----------|---------|
|
|
178
|
+
| Initializer | Configure the gem + opt into form helpers |
|
|
179
|
+
| Routes | RESTful resources (~6 lines) |
|
|
180
|
+
| Controller | Handle CRUD operations (~90 lines) |
|
|
181
|
+
| Views | index, new, edit, success pages |
|
|
182
|
+
| Helper include | One line in ApplicationHelper |
|
|
183
|
+
|
|
184
|
+
##### Quick Setup
|
|
185
|
+
|
|
186
|
+
**1. Initializer** (`config/initializers/api_keys.rb`):
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Include form builder extensions for cleaner forms
|
|
190
|
+
Rails.application.config.to_prepare do
|
|
191
|
+
ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
ApiKeys.configure do |config|
|
|
195
|
+
config.current_owner_method = :current_organization
|
|
196
|
+
config.authenticate_owner_method = :authenticate_organization!
|
|
197
|
+
# ... other config
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**2. Routes** (`config/routes.rb`):
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
namespace :settings do
|
|
205
|
+
resources :api_keys, only: [:index, :new, :create, :edit, :update] do
|
|
206
|
+
post :revoke, on: :member
|
|
207
|
+
get :success, on: :collection
|
|
208
|
+
post :create_publishable, on: :collection # If using key types
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**3. Helper** (`app/helpers/application_helper.rb`):
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
module ApplicationHelper
|
|
217
|
+
include ApiKeys::ViewHelpers
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**4. Controller** - See the complete example below.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
### Complete Controller Example
|
|
226
|
+
|
|
227
|
+
Here's a production-ready controller (~90 lines) that handles all API key operations:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# app/controllers/settings/api_keys_controller.rb
|
|
231
|
+
module Settings
|
|
232
|
+
class ApiKeysController < ApplicationController
|
|
233
|
+
before_action :set_api_key, only: [:edit, :update, :revoke]
|
|
234
|
+
before_action :set_available_scopes, only: [:new, :create, :edit, :update]
|
|
235
|
+
|
|
236
|
+
def index
|
|
237
|
+
@publishable_key = current_organization.api_keys.publishable.active.first
|
|
238
|
+
@secret_keys = current_organization.api_keys.secret.active.order(created_at: :desc)
|
|
239
|
+
@inactive_keys = current_organization.api_keys.secret.inactive.order(created_at: :desc)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def new
|
|
243
|
+
@api_key = current_organization.api_keys.build(key_type: :secret)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def create
|
|
247
|
+
@api_key = current_organization.create_api_key!(
|
|
248
|
+
name: api_key_params[:name],
|
|
249
|
+
key_type: :secret,
|
|
250
|
+
scopes: api_key_params[:scopes],
|
|
251
|
+
expires_at_preset: params.dig(:api_key, :expires_at_preset)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
255
|
+
redirect_to success_settings_api_keys_path
|
|
256
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
257
|
+
@api_key = e.record
|
|
258
|
+
flash.now[:alert] = "Failed to create API key."
|
|
259
|
+
render :new, status: :unprocessable_entity
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def success
|
|
263
|
+
@token = ApiKeys::TokenSession.retrieve_once(session)
|
|
264
|
+
redirect_to settings_api_keys_path, alert: "Token can only be shown once." and return if @token.blank?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def edit
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def update
|
|
271
|
+
if @api_key.update(api_key_params)
|
|
272
|
+
redirect_to settings_api_keys_path, notice: "API key updated."
|
|
273
|
+
else
|
|
274
|
+
flash.now[:alert] = "Failed to update API key."
|
|
275
|
+
render :edit, status: :unprocessable_entity
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def create_publishable
|
|
280
|
+
unless current_organization.can_create_api_key?(key_type: :publishable)
|
|
281
|
+
redirect_to settings_api_keys_path, alert: "You already have a publishable key."
|
|
282
|
+
return
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
current_organization.create_api_key!(name: "SDK Key", key_type: :publishable)
|
|
286
|
+
redirect_to settings_api_keys_path, notice: "Publishable key created!"
|
|
287
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
288
|
+
redirect_to settings_api_keys_path, alert: "Failed to create key."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def revoke
|
|
292
|
+
if @api_key.revocable?
|
|
293
|
+
@api_key.revoke!
|
|
294
|
+
redirect_to settings_api_keys_path, notice: "API key revoked."
|
|
295
|
+
else
|
|
296
|
+
redirect_to settings_api_keys_path, alert: "This key cannot be revoked."
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def set_api_key
|
|
303
|
+
@api_key = current_organization.api_keys.find(params[:id])
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def set_available_scopes
|
|
307
|
+
@available_scopes = current_organization.available_api_key_scopes
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def api_key_params
|
|
311
|
+
params.require(:api_key).permit(:name, scopes: [])
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
### Model Scopes
|
|
320
|
+
|
|
321
|
+
Filter keys by type and status:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
# By key type (when using key_types feature)
|
|
325
|
+
@org.api_keys.publishable # Only publishable keys
|
|
326
|
+
@org.api_keys.secret # Secret keys (and legacy keys without type)
|
|
327
|
+
|
|
328
|
+
# By status
|
|
329
|
+
@org.api_keys.active # Not revoked and not expired
|
|
330
|
+
@org.api_keys.inactive # Revoked or expired
|
|
331
|
+
@org.api_keys.expired # Past expiration date
|
|
332
|
+
@org.api_keys.revoked # Manually revoked
|
|
333
|
+
|
|
334
|
+
# Chain them
|
|
335
|
+
@org.api_keys.publishable.active
|
|
336
|
+
@org.api_keys.secret.inactive.order(created_at: :desc)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
### Owner Instance Methods
|
|
342
|
+
|
|
343
|
+
Methods available on any model with `has_api_keys`:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
# Get available scopes for forms
|
|
347
|
+
@available_scopes = current_org.available_api_key_scopes
|
|
348
|
+
# Returns owner-specific scopes, or falls back to global config
|
|
349
|
+
|
|
350
|
+
# Check if owner can create a key (respects limits)
|
|
351
|
+
current_org.can_create_api_key?(key_type: :publishable)
|
|
352
|
+
# => false if limit reached
|
|
353
|
+
|
|
354
|
+
# Create a key with all options
|
|
355
|
+
@api_key = current_org.create_api_key!(
|
|
356
|
+
name: "My Key",
|
|
357
|
+
key_type: :secret, # or :publishable
|
|
358
|
+
scopes: ["read", "write"], # Blank values auto-removed
|
|
359
|
+
expires_at: 30.days.from_now, # Explicit date
|
|
360
|
+
expires_at_preset: "30_days", # OR use preset (takes precedence)
|
|
361
|
+
environment: :live, # Defaults to current_environment
|
|
362
|
+
metadata: { team: "backend" } # Optional JSON metadata
|
|
363
|
+
)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
### API Key Instance Methods
|
|
369
|
+
|
|
370
|
+
Methods available on `ApiKeys::ApiKey` instances:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
# Token (only available immediately after creation)
|
|
374
|
+
@api_key.token # => "sk_live_abc123..." (plaintext, once only)
|
|
375
|
+
|
|
376
|
+
# Display
|
|
377
|
+
@api_key.masked_token # => "sk_live_••••abc1" (safe for UI)
|
|
378
|
+
@api_key.viewable_token # => full token if public key type, nil otherwise
|
|
379
|
+
|
|
380
|
+
# Status checks
|
|
381
|
+
@api_key.active? # => true if not revoked and not expired
|
|
382
|
+
@api_key.expired? # => true if past expires_at
|
|
383
|
+
@api_key.revoked? # => true if manually revoked
|
|
384
|
+
@api_key.revocable? # => false for non-revocable key types
|
|
385
|
+
|
|
386
|
+
# Type checks (when using key_types)
|
|
387
|
+
@api_key.public_key_type? # => true if token can be viewed again
|
|
388
|
+
@api_key.key_type # => "publishable", "secret", or nil
|
|
389
|
+
@api_key.environment # => "test", "live", or nil
|
|
390
|
+
|
|
391
|
+
# Actions
|
|
392
|
+
@api_key.revoke! # Revoke the key (raises if not revocable)
|
|
393
|
+
|
|
394
|
+
# Scopes
|
|
395
|
+
@api_key.scopes # => ["read", "write"]
|
|
396
|
+
@api_key.allows_scope?("read") # => true
|
|
397
|
+
|
|
398
|
+
# Metadata
|
|
399
|
+
@api_key.name # => "Production Server"
|
|
400
|
+
@api_key.created_at
|
|
401
|
+
@api_key.expires_at
|
|
402
|
+
@api_key.last_used_at
|
|
403
|
+
@api_key.requests_count # If tracking enabled
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
### Token Session Helper
|
|
409
|
+
|
|
410
|
+
Manages the "show token once" pattern for secret keys:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
# Store token after creation
|
|
414
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
415
|
+
|
|
416
|
+
# Retrieve and clear (returns nil on subsequent calls)
|
|
417
|
+
@token = ApiKeys::TokenSession.retrieve_once(session)
|
|
418
|
+
|
|
419
|
+
# With custom session key (if managing multiple token types)
|
|
420
|
+
ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
|
|
421
|
+
@token = ApiKeys::TokenSession.retrieve_once(session, key: :my_custom_key)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
### Expiration Options Helper
|
|
427
|
+
|
|
428
|
+
For building expiration dropdowns:
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
# Get options for select
|
|
432
|
+
ApiKeys::ExpirationOptions.for_select
|
|
433
|
+
# => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ["30 days", "30_days"], ...]
|
|
434
|
+
|
|
435
|
+
# Get default value
|
|
436
|
+
ApiKeys::ExpirationOptions.default_value
|
|
437
|
+
# => "no_expiration"
|
|
438
|
+
|
|
439
|
+
# Parse a preset to a date
|
|
440
|
+
ApiKeys::ExpirationOptions.parse("30_days")
|
|
441
|
+
# => 30.days.from_now
|
|
442
|
+
|
|
443
|
+
ApiKeys::ExpirationOptions.parse("no_expiration")
|
|
444
|
+
# => nil
|
|
445
|
+
|
|
446
|
+
# Exclude "no expiration" option
|
|
447
|
+
ApiKeys::ExpirationOptions.for_select(include_no_expiration: false)
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
### Form Builder Extensions (Opt-in)
|
|
453
|
+
|
|
454
|
+
Add to your initializer to enable:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
Rails.application.config.to_prepare do
|
|
458
|
+
ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
|
|
459
|
+
end
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
#### `api_key_expiration_select`
|
|
463
|
+
|
|
464
|
+
Renders a select dropdown with all expiration presets:
|
|
465
|
+
|
|
466
|
+
```erb
|
|
467
|
+
<%# Basic usage %>
|
|
468
|
+
<%= form.api_key_expiration_select %>
|
|
469
|
+
|
|
470
|
+
<%# With CSS classes (Tailwind example) %>
|
|
471
|
+
<%= form.api_key_expiration_select(class: "w-full px-4 py-3 border rounded-lg") %>
|
|
472
|
+
|
|
473
|
+
<%# With custom default selection %>
|
|
474
|
+
<%= form.api_key_expiration_select(selected: "30_days") %>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
#### `api_key_scopes_checkboxes`
|
|
478
|
+
|
|
479
|
+
Renders scope checkboxes with a block for custom markup:
|
|
480
|
+
|
|
481
|
+
```erb
|
|
482
|
+
<%# With block - you control the HTML, gem handles the logic %>
|
|
483
|
+
<%= form.api_key_scopes_checkboxes(@available_scopes) do |scope, checked| %>
|
|
484
|
+
<label class="flex items-center gap-2">
|
|
485
|
+
<%= check_box_tag "api_key[scopes][]", scope, checked, class: "rounded" %>
|
|
486
|
+
<code><%= scope %></code>
|
|
487
|
+
</label>
|
|
488
|
+
<% end %>
|
|
489
|
+
|
|
490
|
+
<%# For new records, all scopes are checked by default %>
|
|
491
|
+
<%# For existing records, only the key's current scopes are checked %>
|
|
492
|
+
|
|
493
|
+
<%# Override checked state %>
|
|
494
|
+
<%= form.api_key_scopes_checkboxes(@scopes, checked: :none) do |scope, checked| %>
|
|
495
|
+
...
|
|
496
|
+
<% end %>
|
|
497
|
+
|
|
498
|
+
<%# checked options: :all, :none, or an array of specific scopes %>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
#### `api_key_token_data`
|
|
502
|
+
|
|
503
|
+
Returns structured data for building token display UIs:
|
|
504
|
+
|
|
505
|
+
```erb
|
|
506
|
+
<% data = form.api_key_token_data %>
|
|
507
|
+
<code><%= data[:masked] %></code>
|
|
508
|
+
|
|
509
|
+
<% if data[:viewable] %>
|
|
510
|
+
<button data-token="<%= data[:full] %>">Copy</button>
|
|
511
|
+
<% end %>
|
|
512
|
+
|
|
513
|
+
<%# Returns: { masked:, full:, viewable:, type:, environment: } %>
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
### View Helpers
|
|
519
|
+
|
|
520
|
+
Include in your ApplicationHelper:
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
module ApplicationHelper
|
|
524
|
+
include ApiKeys::ViewHelpers
|
|
525
|
+
end
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
#### Status Helpers
|
|
529
|
+
|
|
530
|
+
```erb
|
|
531
|
+
<%# Get status as symbol %>
|
|
532
|
+
<%= api_key_status(@key) %>
|
|
533
|
+
<%# => :active, :expired, or :revoked %>
|
|
534
|
+
|
|
535
|
+
<%# Get human-readable label %>
|
|
536
|
+
<%= api_key_status_label(@key) %>
|
|
537
|
+
<%# => "Active", "Expired", or "Revoked" %>
|
|
538
|
+
|
|
539
|
+
<%# Get full status info for styling %>
|
|
540
|
+
<% info = api_key_status_info(@key) %>
|
|
541
|
+
<span class="<%= info[:color] == :green ? 'bg-green-100' : 'bg-red-100' %>">
|
|
542
|
+
<%= info[:label] %>
|
|
543
|
+
</span>
|
|
544
|
+
<%# Returns: { status: :active, label: "Active", color: :green } %>
|
|
545
|
+
<%# Colors: :green (active), :red (revoked), :gray (expired) %>
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
#### Type & Environment Helpers
|
|
549
|
+
|
|
550
|
+
```erb
|
|
551
|
+
<%# Key type label %>
|
|
552
|
+
<%= api_key_type_label(@key) %>
|
|
553
|
+
<%# => "Publishable", "Secret", or nil %>
|
|
554
|
+
|
|
555
|
+
<%# Environment label %>
|
|
556
|
+
<%= api_key_environment_label(@key) %>
|
|
557
|
+
<%# => "Test", "Live", or "Default" %>
|
|
558
|
+
|
|
559
|
+
<%# Type checks %>
|
|
560
|
+
<%= api_key_publishable?(@key) %> <%# => true/false %>
|
|
561
|
+
<%= api_key_secret?(@key) %> <%# => true/false %>
|
|
562
|
+
|
|
563
|
+
<%# Get environment from a token string (useful on success page) %>
|
|
564
|
+
<%= api_key_environment_from_token(@token) %>
|
|
565
|
+
<%# => :test, :live, or nil %>
|
|
566
|
+
|
|
567
|
+
<%= api_key_environment_label_from_token(@token) %>
|
|
568
|
+
<%# => "Test mode", "Live mode", or "Default" %>
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
### View Examples
|
|
574
|
+
|
|
575
|
+
#### Index Page (Key List)
|
|
576
|
+
|
|
577
|
+
```erb
|
|
578
|
+
<%# Publishable key section %>
|
|
579
|
+
<% if @publishable_key %>
|
|
580
|
+
<code><%= @publishable_key.viewable_token || @publishable_key.masked_token %></code>
|
|
581
|
+
<span><%= api_key_environment_label(@publishable_key) %> mode</span>
|
|
582
|
+
<% else %>
|
|
583
|
+
<%= button_to create_publishable_settings_api_keys_path, method: :post do %>
|
|
584
|
+
Create Publishable Key
|
|
585
|
+
<% end %>
|
|
586
|
+
<% end %>
|
|
587
|
+
|
|
588
|
+
<%# Secret keys table %>
|
|
589
|
+
<% @secret_keys.each do |key| %>
|
|
590
|
+
<tr>
|
|
591
|
+
<td><%= key.name || "Unnamed key" %></td>
|
|
592
|
+
<td><code><%= key.masked_token %></code></td>
|
|
593
|
+
<td><%= api_key_status_label(key) %></td>
|
|
594
|
+
<td>
|
|
595
|
+
<%= link_to "Edit", edit_settings_api_key_path(key) %>
|
|
596
|
+
<%= button_to "Revoke", revoke_settings_api_key_path(key), method: :post %>
|
|
597
|
+
</td>
|
|
598
|
+
</tr>
|
|
599
|
+
<% end %>
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
#### New/Edit Form
|
|
603
|
+
|
|
604
|
+
```erb
|
|
605
|
+
<%= form_with(model: @api_key, url: settings_api_keys_path) do |form| %>
|
|
606
|
+
<%# Name %>
|
|
607
|
+
<%= form.text_field :name, placeholder: "e.g., Production Server" %>
|
|
608
|
+
|
|
609
|
+
<%# Expiration (new keys only) %>
|
|
610
|
+
<%= form.api_key_expiration_select(class: "form-select") %>
|
|
611
|
+
|
|
612
|
+
<%# Scopes %>
|
|
613
|
+
<%= form.api_key_scopes_checkboxes(@available_scopes) do |scope, checked| %>
|
|
614
|
+
<label>
|
|
615
|
+
<%= check_box_tag "api_key[scopes][]", scope, checked %>
|
|
616
|
+
<%= scope %>
|
|
617
|
+
</label>
|
|
618
|
+
<% end %>
|
|
619
|
+
|
|
620
|
+
<%= form.submit %>
|
|
621
|
+
<% end %>
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
#### Success Page (Show Token Once)
|
|
625
|
+
|
|
626
|
+
```erb
|
|
627
|
+
<% if @token.present? %>
|
|
628
|
+
<input type="text" value="<%= @token %>" readonly>
|
|
629
|
+
<button data-copy="<%= @token %>">Copy</button>
|
|
630
|
+
<span><%= api_key_environment_label_from_token(@token) %></span>
|
|
631
|
+
|
|
632
|
+
<p>This key will only be shown once. Copy it now!</p>
|
|
633
|
+
<% else %>
|
|
634
|
+
<p>Token already shown. Create a new key if needed.</p>
|
|
635
|
+
<%= link_to "Create New Key", new_settings_api_key_path %>
|
|
636
|
+
<% end %>
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
### Best Practices
|
|
642
|
+
|
|
643
|
+
Based on real production integrations:
|
|
644
|
+
|
|
645
|
+
1. **Use RESTful routes** - `resources :api_keys` with member/collection actions, not custom route definitions.
|
|
646
|
+
|
|
647
|
+
2. **Separate form-only params** - Access `expires_at_preset` via `params.dig(:api_key, :expires_at_preset)` rather than including it in strong params (it's not a model attribute).
|
|
648
|
+
|
|
649
|
+
3. **Use `before_action` for shared setup** - Extract `@available_scopes` to a before_action rather than setting it in multiple actions.
|
|
650
|
+
|
|
651
|
+
4. **Let validations handle errors** - Rescue `ActiveRecord::RecordInvalid` and re-render the form rather than pre-checking everything.
|
|
652
|
+
|
|
653
|
+
5. **Use the gem's helpers consistently** - Use `api_key_status_label(key)` everywhere rather than hardcoding "Active" in some places.
|
|
654
|
+
|
|
655
|
+
6. **Check limits before showing UI** - Use `can_create_api_key?(key_type:)` to conditionally show/hide create buttons.
|
|
656
|
+
|
|
657
|
+
7. **Keep controllers thin** - The gem handles token generation, hashing, scope filtering, and validation. Your controller just orchestrates.
|
|
658
|
+
|
|
659
|
+
See the "How it works" section below for additional model methods.
|
|
660
|
+
|
|
142
661
|
|
|
143
662
|
## How it works
|
|
144
663
|
|
|
@@ -160,7 +679,7 @@ plaintext_token = @api_key.token
|
|
|
160
679
|
|
|
161
680
|
For security reasons, the **gem does not store the generated key** in the database.
|
|
162
681
|
|
|
163
|
-
We only store a
|
|
682
|
+
We only store a secure hash (SHA256 by default), so the API key / API token itself is only available in plaintext immediately after creation, as `@api_key.token` – the `.token` method won't work any other time.
|
|
164
683
|
|
|
165
684
|
With this token, your users can make calls to your endpoints by attaching it as an `"Authorization: Bearer ak_123abc..."` in their HTTP calls headers, like this:
|
|
166
685
|
|
|
@@ -336,6 +855,45 @@ The gem installation creates an initializer at `config/initializers/api_keys.rb`
|
|
|
336
855
|
|
|
337
856
|
The default initializer is self-explanatory and self-documented, please consider spending a bit of time reading through it if you want to fine-tune the gem.
|
|
338
857
|
|
|
858
|
+
### Token prefixes
|
|
859
|
+
|
|
860
|
+
API keys are generated with a prefix followed by random characters:
|
|
861
|
+
|
|
862
|
+
```
|
|
863
|
+
ak_7Hq2mJ6vK9pRs3xYz9...
|
|
864
|
+
^^ ^^^^^^^^^^^
|
|
865
|
+
prefix random part
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
The prefix makes it easy to identify API keys at a glance (in logs, code reviews, etc.) and helps services like GitHub detect leaked credentials.
|
|
869
|
+
|
|
870
|
+
**Simple mode (default):** All keys use the same prefix from `token_prefix`:
|
|
871
|
+
```ruby
|
|
872
|
+
config.token_prefix = -> { "myapp_" } # → myapp_abc123...
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Key types mode:** When you configure `key_types`, different key types get different prefixes based on their type and environment (Stripe-style):
|
|
876
|
+
```ruby
|
|
877
|
+
# With key_types configured, prefixes come from the type configuration:
|
|
878
|
+
# publishable + test → pk_test_abc123...
|
|
879
|
+
# secret + live → sk_live_xyz789...
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
#### Prefix precedence
|
|
883
|
+
|
|
884
|
+
When using key types, here's how the prefix is determined:
|
|
885
|
+
|
|
886
|
+
| `key_types` Config | `key_type` Param | Resulting Prefix |
|
|
887
|
+
|-------------------|------------------|------------------|
|
|
888
|
+
| `{}` (disabled) | any | Uses `token_prefix` → `"ak_..."` |
|
|
889
|
+
| Configured | `nil` | Uses `token_prefix` → `"ak_..."` |
|
|
890
|
+
| Configured | `:publishable` | Uses type config → `"pk_test_..."` |
|
|
891
|
+
| Configured | `:secret` | Uses type config → `"sk_live_..."` |
|
|
892
|
+
|
|
893
|
+
In short: `token_prefix` is only used when key types are not configured OR when creating a key without specifying a `key_type`. When you specify both `key_types` config and a `key_type` parameter, the prefix comes entirely from the key type configuration.
|
|
894
|
+
|
|
895
|
+
See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section below for full details.
|
|
896
|
+
|
|
339
897
|
Some highlights:
|
|
340
898
|
|
|
341
899
|
### Accept API keys via query params instead of Authentication HTTP headers
|
|
@@ -354,7 +912,7 @@ config.query_param = "api_key"
|
|
|
354
912
|
|
|
355
913
|
### Changing the hashing function to `bcrypt` for maximum security
|
|
356
914
|
|
|
357
|
-
By default, the `api_keys` gem hashes tokens using `sha256`,
|
|
915
|
+
By default, the `api_keys` gem hashes tokens using `sha256`, which is the industry standard for API keys (used by Stripe, GitHub, AWS). SHA256 is secure for high-entropy tokens because the 192 bits of randomness make brute-force attacks computationally infeasible. We use SHA256 and not other hasing algorithms for fast token lookup and low-latency API authentication.
|
|
358
916
|
|
|
359
917
|
If you need slower, password-grade hashing (e.g., for extremely sensitive tokens), you can switch to bcrypt:
|
|
360
918
|
|
|
@@ -414,6 +972,212 @@ There's also a `track_requests_count` config option that you can turn on so the
|
|
|
414
972
|
|
|
415
973
|
But again, this is turned off by default for performance purposes, and depends on having a working, well-configured Active Job backend.
|
|
416
974
|
|
|
975
|
+
## Key Types: Stripe-style Publishable & Secret Keys
|
|
976
|
+
|
|
977
|
+
For applications that distribute software with embedded API keys (desktop apps, mobile apps, CLI tools), you may want to differentiate between key types with different permission levels. The `api_keys` gem supports Stripe-style publishable/secret key types with optional test/live environment isolation.
|
|
978
|
+
|
|
979
|
+
### Why Key Types?
|
|
980
|
+
|
|
981
|
+
When you distribute software with an embedded API key, that key can potentially be extracted by malicious users. Key types solve this by letting you create:
|
|
982
|
+
|
|
983
|
+
- **Publishable keys** (`pk_test_...`, `pk_live_...`): Safe to embed in distributed apps. Limited permissions (e.g., can only validate licenses, not issue new ones). Cannot be revoked (to prevent accidentally breaking all deployed apps).
|
|
984
|
+
|
|
985
|
+
- **Secret keys** (`sk_test_...`, `sk_live_...`): Full access, meant for server-side use only. Can be revoked anytime.
|
|
986
|
+
|
|
987
|
+
### Configuration
|
|
988
|
+
|
|
989
|
+
Enable key types in your initializer:
|
|
990
|
+
|
|
991
|
+
```ruby
|
|
992
|
+
# config/initializers/api_keys.rb
|
|
993
|
+
ApiKeys.configure do |config|
|
|
994
|
+
config.key_types = {
|
|
995
|
+
publishable: {
|
|
996
|
+
prefix: "pk", # Token prefix → pk_test_, pk_live_
|
|
997
|
+
permissions: %w[read validate], # Scope ceiling (max permissions allowed)
|
|
998
|
+
revocable: false, # Cannot be revoked or deleted
|
|
999
|
+
limit: 1 # Max 1 per owner per environment
|
|
1000
|
+
},
|
|
1001
|
+
secret: {
|
|
1002
|
+
prefix: "sk",
|
|
1003
|
+
permissions: :all # No scope restrictions
|
|
1004
|
+
# revocable defaults to true, limit defaults to nil (unlimited)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
config.environments = {
|
|
1009
|
+
test: { prefix_segment: "test" }, # → pk_test_, sk_test_
|
|
1010
|
+
live: { prefix_segment: "live" } # → pk_live_, sk_live_
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
# Detect current environment automatically
|
|
1014
|
+
config.current_environment = -> { Rails.env.production? ? :live : :test }
|
|
1015
|
+
|
|
1016
|
+
# Enable strict environment isolation (test keys fail in prod, live keys fail in dev)
|
|
1017
|
+
config.strict_environment_isolation = true
|
|
1018
|
+
end
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Creating Typed Keys
|
|
1022
|
+
|
|
1023
|
+
```ruby
|
|
1024
|
+
# Create a publishable key (limited permissions, cannot be revoked)
|
|
1025
|
+
pk = user.create_api_key!(
|
|
1026
|
+
name: "Production App",
|
|
1027
|
+
key_type: :publishable,
|
|
1028
|
+
environment: :live # Optional, defaults to current_environment
|
|
1029
|
+
)
|
|
1030
|
+
pk.token # => "pk_live_abc123..."
|
|
1031
|
+
|
|
1032
|
+
# Create a secret key (full access)
|
|
1033
|
+
sk = user.create_api_key!(
|
|
1034
|
+
name: "Admin Dashboard",
|
|
1035
|
+
key_type: :secret
|
|
1036
|
+
)
|
|
1037
|
+
sk.token # => "sk_test_xyz789..."
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
### Scope Ceiling
|
|
1041
|
+
|
|
1042
|
+
When a key type has limited `permissions`, any scopes you pass are filtered:
|
|
1043
|
+
|
|
1044
|
+
```ruby
|
|
1045
|
+
# Publishable keys can only have read/validate permissions
|
|
1046
|
+
pk = user.create_api_key!(
|
|
1047
|
+
key_type: :publishable,
|
|
1048
|
+
scopes: %w[read validate issue_license admin] # Tries to request all
|
|
1049
|
+
)
|
|
1050
|
+
pk.scopes # => ["read", "validate"] # Only allowed scopes kept
|
|
1051
|
+
|
|
1052
|
+
# Secret keys with permissions: :all keep everything
|
|
1053
|
+
sk = user.create_api_key!(
|
|
1054
|
+
key_type: :secret,
|
|
1055
|
+
scopes: %w[read validate issue_license admin]
|
|
1056
|
+
)
|
|
1057
|
+
sk.scopes # => ["read", "validate", "issue_license", "admin"]
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
### Non-Revocable Keys
|
|
1061
|
+
|
|
1062
|
+
Keys with `revocable: false` protect against accidental deletion:
|
|
1063
|
+
|
|
1064
|
+
```ruby
|
|
1065
|
+
pk = user.create_api_key!(key_type: :publishable)
|
|
1066
|
+
|
|
1067
|
+
pk.revocable? # => false
|
|
1068
|
+
pk.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1069
|
+
pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
The dashboard UI automatically hides the revoke button for non-revocable keys.
|
|
1073
|
+
|
|
1074
|
+
### Public Keys (Viewable Tokens)
|
|
1075
|
+
|
|
1076
|
+
#### The Problem: Non-Revocable Key Lockout
|
|
1077
|
+
|
|
1078
|
+
Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove.
|
|
1079
|
+
|
|
1080
|
+
This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys.
|
|
1081
|
+
|
|
1082
|
+
#### The Solution: Storing Public Keys
|
|
1083
|
+
|
|
1084
|
+
For publishable keys—which are *designed* to be embedded in client-side code and distributed apps—there's no security benefit to hiding the token. These keys are meant to be public! Stripe, for example, lets you view your publishable key anytime in the dashboard.
|
|
1085
|
+
|
|
1086
|
+
The `public: true` option stores the plaintext token in metadata so users can view it again:
|
|
1087
|
+
|
|
1088
|
+
```ruby
|
|
1089
|
+
config.key_types = {
|
|
1090
|
+
publishable: {
|
|
1091
|
+
prefix: "pk",
|
|
1092
|
+
permissions: %w[read validate],
|
|
1093
|
+
revocable: false,
|
|
1094
|
+
public: true, # Store token for later viewing
|
|
1095
|
+
limit: 1
|
|
1096
|
+
},
|
|
1097
|
+
secret: {
|
|
1098
|
+
prefix: "sk",
|
|
1099
|
+
permissions: :all
|
|
1100
|
+
# public: false (default) - NEVER store secret keys!
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
#### Security: Why This is Safe
|
|
1106
|
+
|
|
1107
|
+
> [!IMPORTANT]
|
|
1108
|
+
> The `public` option only works when BOTH conditions are met:
|
|
1109
|
+
> - `public: true` is set in the key type configuration
|
|
1110
|
+
> - `revocable: false` is set (non-revocable keys only)
|
|
1111
|
+
|
|
1112
|
+
This double-check is a deliberate safety measure:
|
|
1113
|
+
|
|
1114
|
+
1. **Secret keys are NEVER stored** — Even if you accidentally set `public: true` on a secret key type, the gem checks for `revocable: false` as well. Secret keys are revocable by default, so they're protected.
|
|
1115
|
+
|
|
1116
|
+
2. **Revocable keys are NEVER stored** — If a key can be revoked, users can always delete it and create a new one. There's no lockout risk, so no need to store the token.
|
|
1117
|
+
|
|
1118
|
+
3. **Only truly public keys are stored** — Publishable keys with limited permissions, designed for client-side embedding, are the only keys that get stored. These tokens provide no security benefit when hidden—they're meant to be distributed.
|
|
1119
|
+
|
|
1120
|
+
> [!WARNING]
|
|
1121
|
+
> ⚠️ **Never set `public: true` on secret keys or any key type with sensitive permissions.** The gem prevents this by requiring `revocable: false`, but you should also never configure it that way.
|
|
1122
|
+
|
|
1123
|
+
When a key is public, the dashboard shows a "Show" button to reveal the full token:
|
|
1124
|
+
|
|
1125
|
+
```ruby
|
|
1126
|
+
pk = user.create_api_key!(key_type: :publishable)
|
|
1127
|
+
pk.public_key_type? # => true
|
|
1128
|
+
pk.viewable_token # => "pk_test_abc123..." (the full token)
|
|
1129
|
+
|
|
1130
|
+
sk = user.create_api_key!(key_type: :secret)
|
|
1131
|
+
sk.public_key_type? # => false
|
|
1132
|
+
sk.viewable_token # => nil (not stored)
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
### Environment Isolation
|
|
1136
|
+
|
|
1137
|
+
With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:
|
|
1138
|
+
|
|
1139
|
+
```ruby
|
|
1140
|
+
# In production (current_environment returns :live)
|
|
1141
|
+
# A test key will fail authentication with error_code: :environment_mismatch
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
This prevents accidentally using test keys in production (or vice versa).
|
|
1145
|
+
|
|
1146
|
+
### Key Limits
|
|
1147
|
+
|
|
1148
|
+
The `limit` option restricts how many keys of a type can exist per owner per environment:
|
|
1149
|
+
|
|
1150
|
+
```ruby
|
|
1151
|
+
# With limit: 1 for publishable keys
|
|
1152
|
+
user.create_api_key!(key_type: :publishable, environment: :test) # Works
|
|
1153
|
+
user.create_api_key!(key_type: :publishable, environment: :test) # Raises validation error
|
|
1154
|
+
|
|
1155
|
+
# But can have one per environment
|
|
1156
|
+
user.create_api_key!(key_type: :publishable, environment: :live) # Works
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
### Sandbox/Live Naming
|
|
1160
|
+
|
|
1161
|
+
You can use any environment names. For Stripe-style sandbox:
|
|
1162
|
+
|
|
1163
|
+
```ruby
|
|
1164
|
+
config.environments = {
|
|
1165
|
+
sandbox: { prefix_segment: "test" }, # → pk_test_
|
|
1166
|
+
live: { prefix_segment: "live" } # → pk_live_
|
|
1167
|
+
}
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
### Upgrading Existing Installations
|
|
1171
|
+
|
|
1172
|
+
If you're adding key types to an existing installation, run the migration generator:
|
|
1173
|
+
|
|
1174
|
+
```bash
|
|
1175
|
+
rails g api_keys:add_key_types
|
|
1176
|
+
rails db:migrate
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
Existing keys without `key_type`/`environment` continue to work normally (backwards compatible).
|
|
1180
|
+
|
|
417
1181
|
## Enterprise-ready by design
|
|
418
1182
|
The `api_keys` gem ships with:
|
|
419
1183
|
|