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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +36 -0
  3. data/AGENTS.md +5 -0
  4. data/Appraisals +17 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CLAUDE.md +5 -0
  7. data/README.md +767 -3
  8. data/Rakefile +9 -4
  9. data/app/controllers/api_keys/keys_controller.rb +43 -11
  10. data/app/controllers/api_keys/security_controller.rb +8 -0
  11. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  12. data/app/views/api_keys/keys/_form.html.erb +31 -2
  13. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  14. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  15. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  16. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  17. data/app/views/api_keys/keys/_keys_table.html.erb +2 -7
  18. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  19. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  20. data/app/views/api_keys/keys/_show_token.html.erb +5 -1
  21. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  22. data/app/views/api_keys/keys/index.html.erb +40 -8
  23. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  24. data/app/views/layouts/api_keys/application.html.erb +113 -7
  25. data/context7.json +4 -0
  26. data/gemfiles/rails_7.2.gemfile +21 -0
  27. data/gemfiles/rails_8.0.gemfile +21 -0
  28. data/gemfiles/rails_8.1.gemfile +21 -0
  29. data/lib/api_keys/configuration.rb +77 -0
  30. data/lib/api_keys/errors.rb +73 -0
  31. data/lib/api_keys/form_builder_extensions.rb +158 -0
  32. data/lib/api_keys/helpers/expiration_options.rb +131 -0
  33. data/lib/api_keys/helpers/token_session.rb +68 -0
  34. data/lib/api_keys/helpers/view_helpers.rb +216 -0
  35. data/lib/api_keys/models/api_key.rb +229 -17
  36. data/lib/api_keys/models/concerns/has_api_keys.rb +183 -3
  37. data/lib/api_keys/services/authenticator.rb +45 -2
  38. data/lib/api_keys/services/digestor.rb +6 -2
  39. data/lib/api_keys/tenant_resolution.rb +3 -1
  40. data/lib/api_keys/version.rb +1 -1
  41. data/lib/api_keys.rb +12 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  44. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +9 -0
  45. data/lib/generators/api_keys/templates/initializer.rb +242 -120
  46. 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
- [![Gem Version](https://badge.fury.io/rb/api_keys.svg)](https://badge.fury.io/rb/api_keys)
3
+ [![Gem Version](https://badge.fury.io/rb/api_keys.svg)](https://badge.fury.io/rb/api_keys) [![Build Status](https://github.com/rameerez/api_keys/workflows/Tests/badge.svg)](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 salted hash, 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.
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`, for fast token lookup and low-latency API authentication. Tokens are salted via their prefix, and only stored as secure digests.
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