api_keys 0.2.1 β 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- data/Rakefile +0 -32
data/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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
4
|
|
|
5
|
-
|
|
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)!
|
|
7
|
+
|
|
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, and gate endpoints. It also provides a self-serve dashboard for users to issue and manage their own API keys. Secret tokens are hashed and shown only once. Plaintext is stored only for a key type that you explicitly mark as public, non-revocable, and limited to a finite permission set.
|
|
6
9
|
|
|
7
10
|
[ π’ [Live interactive demo website](https://apikeys.rameerez.com) ]
|
|
8
11
|
|
|
@@ -27,6 +30,17 @@ rails db:migrate
|
|
|
27
30
|
|
|
28
31
|
And you're done!
|
|
29
32
|
|
|
33
|
+
### Upgrading an existing installation
|
|
34
|
+
|
|
35
|
+
Install the bounded bcrypt authentication lookup index before deploying the current hardening changes:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
rails generate api_keys:add_authentication_index
|
|
39
|
+
rails db:migrate
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The generated migration is idempotent and uses a concurrent PostgreSQL index where supported.
|
|
43
|
+
|
|
30
44
|
## Quick Start
|
|
31
45
|
|
|
32
46
|
Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have:
|
|
@@ -98,6 +112,16 @@ class ApplicationController < ActionController::Base
|
|
|
98
112
|
end
|
|
99
113
|
```
|
|
100
114
|
|
|
115
|
+
If the mounted dashboard should inherit from a different controller, configure it in the same initializer before the engine controllers load:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
ApiKeys.configure do |config|
|
|
119
|
+
config.parent_controller = "Admin::ApplicationController"
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The setting accepts a controller class or a valid constant-name string and defaults to `::ApplicationController`.
|
|
124
|
+
|
|
101
125
|
#### Common scenarios
|
|
102
126
|
|
|
103
127
|
**Organization with user membership:**
|
|
@@ -139,6 +163,528 @@ To make the experience between your app and the `api_keys` dashboard more seamle
|
|
|
139
163
|
|
|
140
164
|
You can check out the dashboard on the [live demo website](https://apikeys.rameerez.com).
|
|
141
165
|
|
|
166
|
+
### Customizing the Dashboard
|
|
167
|
+
|
|
168
|
+
The gem provides two levels of customization for the mounted dashboard:
|
|
169
|
+
|
|
170
|
+
#### Level 1: Use the stock dashboard (default)
|
|
171
|
+
Works out of the box with good defaults. No configuration needed.
|
|
172
|
+
|
|
173
|
+
#### Level 2: Override CSS variables
|
|
174
|
+
Tweak colors and spacing by overriding CSS variables in your application's stylesheet:
|
|
175
|
+
|
|
176
|
+
```css
|
|
177
|
+
:root {
|
|
178
|
+
--api-keys-primary-color: #your-brand-color;
|
|
179
|
+
--api-keys-danger-color: #dc3545;
|
|
180
|
+
--api-keys-success-color: #28a745;
|
|
181
|
+
--api-keys-badge-secret-bg: #e7f1ff;
|
|
182
|
+
--api-keys-badge-publishable-bg: #fef3cd;
|
|
183
|
+
/* See layout file for all available variables */
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### Building Custom Integrations
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
The gem provides a comprehensive set of helpers specifically designed for custom integrations. These patterns are battle-tested from real production integrations.
|
|
192
|
+
|
|
193
|
+
##### What You'll Need
|
|
194
|
+
|
|
195
|
+
A complete custom integration typically requires:
|
|
196
|
+
|
|
197
|
+
| Component | Purpose |
|
|
198
|
+
|-----------|---------|
|
|
199
|
+
| Initializer | Configure the gem + opt into form helpers |
|
|
200
|
+
| Routes | RESTful resources (~6 lines) |
|
|
201
|
+
| Controller | Handle CRUD operations (~90 lines) |
|
|
202
|
+
| Views | index, new, edit, success pages |
|
|
203
|
+
| Helper include | One line in ApplicationHelper |
|
|
204
|
+
|
|
205
|
+
##### Quick Setup
|
|
206
|
+
|
|
207
|
+
**1. Initializer** (`config/initializers/api_keys.rb`):
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# Include form builder extensions for cleaner forms
|
|
211
|
+
Rails.application.config.to_prepare do
|
|
212
|
+
ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
ApiKeys.configure do |config|
|
|
216
|
+
config.current_owner_method = :current_organization
|
|
217
|
+
config.authenticate_owner_method = :authenticate_organization!
|
|
218
|
+
# ... other config
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**2. Routes** (`config/routes.rb`):
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
namespace :settings do
|
|
226
|
+
resources :api_keys, only: [:index, :new, :create, :edit, :update] do
|
|
227
|
+
post :revoke, on: :member
|
|
228
|
+
get :success, on: :collection
|
|
229
|
+
post :create_publishable, on: :collection # If using key types
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**3. Helper** (`app/helpers/application_helper.rb`):
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
module ApplicationHelper
|
|
238
|
+
include ApiKeys::ViewHelpers
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**4. Controller** - See the complete example below.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### Complete Controller Example
|
|
247
|
+
|
|
248
|
+
Here's a production-ready controller (~90 lines) that handles all API key operations:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
# app/controllers/settings/api_keys_controller.rb
|
|
252
|
+
module Settings
|
|
253
|
+
class ApiKeysController < ApplicationController
|
|
254
|
+
before_action :set_api_key, only: [:edit, :update, :revoke]
|
|
255
|
+
before_action :set_available_scopes, only: [:new, :create, :edit, :update]
|
|
256
|
+
|
|
257
|
+
def index
|
|
258
|
+
@publishable_key = current_organization.api_keys.publishable.active.first
|
|
259
|
+
@secret_keys = current_organization.api_keys.secret.active.order(created_at: :desc)
|
|
260
|
+
@inactive_keys = current_organization.api_keys.secret.inactive.order(created_at: :desc)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def new
|
|
264
|
+
@api_key = current_organization.api_keys.build(key_type: :secret)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def create
|
|
268
|
+
@api_key = current_organization.create_api_key!(
|
|
269
|
+
name: api_key_params[:name],
|
|
270
|
+
key_type: :secret,
|
|
271
|
+
scopes: api_key_params[:scopes],
|
|
272
|
+
expires_at_preset: params.dig(:api_key, :expires_at_preset)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
276
|
+
redirect_to success_settings_api_keys_path
|
|
277
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
278
|
+
@api_key = e.record
|
|
279
|
+
flash.now[:alert] = "Failed to create API key."
|
|
280
|
+
render :new, status: :unprocessable_entity
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def success
|
|
284
|
+
@token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
|
|
285
|
+
redirect_to settings_api_keys_path, alert: "Token can only be shown once." and return if @token.blank?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def edit
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def update
|
|
292
|
+
if @api_key.update(api_key_params)
|
|
293
|
+
redirect_to settings_api_keys_path, notice: "API key updated."
|
|
294
|
+
else
|
|
295
|
+
flash.now[:alert] = "Failed to update API key."
|
|
296
|
+
render :edit, status: :unprocessable_entity
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def create_publishable
|
|
301
|
+
unless current_organization.can_create_api_key?(key_type: :publishable)
|
|
302
|
+
redirect_to settings_api_keys_path, alert: "You already have a publishable key."
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
current_organization.create_api_key!(name: "SDK Key", key_type: :publishable)
|
|
307
|
+
redirect_to settings_api_keys_path, notice: "Publishable key created!"
|
|
308
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
309
|
+
redirect_to settings_api_keys_path, alert: "Failed to create key."
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def revoke
|
|
313
|
+
if @api_key.revocable?
|
|
314
|
+
@api_key.revoke!
|
|
315
|
+
redirect_to settings_api_keys_path, notice: "API key revoked."
|
|
316
|
+
else
|
|
317
|
+
redirect_to settings_api_keys_path, alert: "This key cannot be revoked."
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private
|
|
322
|
+
|
|
323
|
+
def set_api_key
|
|
324
|
+
@api_key = current_organization.api_keys.find(params[:id])
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def set_available_scopes
|
|
328
|
+
@available_scopes = current_organization.available_api_key_scopes
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def api_key_params
|
|
332
|
+
params.require(:api_key).permit(:name, scopes: [])
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### Model Scopes
|
|
341
|
+
|
|
342
|
+
Filter keys by type and status:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# By key type (when using key_types feature)
|
|
346
|
+
@org.api_keys.publishable # Only publishable keys
|
|
347
|
+
@org.api_keys.secret # Secret keys (and legacy keys without type)
|
|
348
|
+
|
|
349
|
+
# By status
|
|
350
|
+
@org.api_keys.active # Not revoked and not expired
|
|
351
|
+
@org.api_keys.inactive # Revoked or expired
|
|
352
|
+
@org.api_keys.expired # Past expiration date
|
|
353
|
+
@org.api_keys.revoked # Manually revoked
|
|
354
|
+
|
|
355
|
+
# Chain them
|
|
356
|
+
@org.api_keys.publishable.active
|
|
357
|
+
@org.api_keys.secret.inactive.order(created_at: :desc)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### Owner Instance Methods
|
|
363
|
+
|
|
364
|
+
Methods available on any model with `has_api_keys`:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
# Get available scopes for forms
|
|
368
|
+
@available_scopes = current_org.available_api_key_scopes
|
|
369
|
+
# Returns owner-specific scopes, or falls back to global config
|
|
370
|
+
|
|
371
|
+
# Check if owner can create a key (respects limits)
|
|
372
|
+
current_org.can_create_api_key?(key_type: :publishable)
|
|
373
|
+
# => false if limit reached
|
|
374
|
+
|
|
375
|
+
# Create a key with all options
|
|
376
|
+
@api_key = current_org.create_api_key!(
|
|
377
|
+
name: "My Key",
|
|
378
|
+
key_type: :secret, # or :publishable
|
|
379
|
+
scopes: ["read", "write"], # Blank values auto-removed
|
|
380
|
+
expires_at: 30.days.from_now, # Explicit date
|
|
381
|
+
expires_at_preset: "30_days", # OR use preset (takes precedence)
|
|
382
|
+
environment: :live, # Defaults to current_environment
|
|
383
|
+
metadata: { team: "backend" } # Optional JSON metadata
|
|
384
|
+
)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
### API Key Instance Methods
|
|
390
|
+
|
|
391
|
+
Methods available on `ApiKeys::ApiKey` instances:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
# Token (only available immediately after creation)
|
|
395
|
+
@api_key.token # => "sk_live_abc123..." (plaintext, once only)
|
|
396
|
+
|
|
397
|
+
# Display
|
|
398
|
+
@api_key.masked_token # => "sk_live_β’β’β’β’abc1" (safe for UI)
|
|
399
|
+
@api_key.viewable_token # => full token if public key type, nil otherwise
|
|
400
|
+
|
|
401
|
+
# Status checks
|
|
402
|
+
@api_key.active? # => true if not revoked and not expired
|
|
403
|
+
@api_key.expired? # => true if past expires_at
|
|
404
|
+
@api_key.revoked? # => true if manually revoked
|
|
405
|
+
@api_key.revocable? # => false for non-revocable key types
|
|
406
|
+
|
|
407
|
+
# Type checks (when using key_types)
|
|
408
|
+
@api_key.public_key_type? # => true if token can be viewed again
|
|
409
|
+
@api_key.key_type # => "publishable", "secret", or nil
|
|
410
|
+
@api_key.environment # => "test", "live", or nil
|
|
411
|
+
|
|
412
|
+
# Actions
|
|
413
|
+
@api_key.revoke! # Revoke the key (raises if not revocable)
|
|
414
|
+
|
|
415
|
+
# Scopes
|
|
416
|
+
@api_key.scopes # => ["read", "write"]
|
|
417
|
+
@api_key.allows_scope?("read") # => true
|
|
418
|
+
|
|
419
|
+
# Metadata
|
|
420
|
+
@api_key.name # => "Production Server"
|
|
421
|
+
@api_key.created_at
|
|
422
|
+
@api_key.expires_at
|
|
423
|
+
@api_key.last_used_at
|
|
424
|
+
@api_key.requests_count # If tracking enabled
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
### Token Session Helper
|
|
430
|
+
|
|
431
|
+
Manages the "show token once" pattern for secret keys. The helper encrypts each handoff with an application-derived AES-256-GCM key, binds it to the created key, expires it after ten minutes, and deletes it on first retrieval. The session contains only ciphertext and the non-secret key IDβnot the plaintext tokenβeven when the host uses Rails' encrypted cookie session store.
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
# Store token after creation
|
|
435
|
+
ApiKeys::TokenSession.store(session, @api_key)
|
|
436
|
+
|
|
437
|
+
# Retrieve and clear, bound to the expected key (nil on mismatch or reuse)
|
|
438
|
+
@token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
|
|
439
|
+
|
|
440
|
+
# With custom session key (if managing multiple token types)
|
|
441
|
+
ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
|
|
442
|
+
@token = ApiKeys::TokenSession.retrieve_once(
|
|
443
|
+
session,
|
|
444
|
+
key: :my_custom_key,
|
|
445
|
+
api_key: @api_key
|
|
446
|
+
)
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Treat the session payload as private implementation detail; use `store`, `available?`, and `retrieve_once` rather than reading it directly. A failed, expired, tampered, mismatched, or reused handoff returns `nil` and fails closed.
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
### Expiration Options Helper
|
|
454
|
+
|
|
455
|
+
For building expiration dropdowns:
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
# Get options for select
|
|
459
|
+
ApiKeys::ExpirationOptions.for_select
|
|
460
|
+
# => [["No Expiration", "no_expiration"], ["7 days", "7_days"], ["30 days", "30_days"], ...]
|
|
461
|
+
|
|
462
|
+
# Get default value
|
|
463
|
+
ApiKeys::ExpirationOptions.default_value
|
|
464
|
+
# => "no_expiration"
|
|
465
|
+
|
|
466
|
+
# Parse a preset to a date
|
|
467
|
+
ApiKeys::ExpirationOptions.parse("30_days")
|
|
468
|
+
# => 30.days.from_now
|
|
469
|
+
|
|
470
|
+
ApiKeys::ExpirationOptions.parse("no_expiration")
|
|
471
|
+
# => nil
|
|
472
|
+
|
|
473
|
+
# Exclude "no expiration" option
|
|
474
|
+
ApiKeys::ExpirationOptions.for_select(include_no_expiration: false)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
### Form Builder Extensions (Opt-in)
|
|
480
|
+
|
|
481
|
+
Add to your initializer to enable:
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
Rails.application.config.to_prepare do
|
|
485
|
+
ActionView::Helpers::FormBuilder.include(ApiKeys::FormBuilderExtensions)
|
|
486
|
+
end
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
#### `api_key_expiration_select`
|
|
490
|
+
|
|
491
|
+
Renders a select dropdown with all expiration presets:
|
|
492
|
+
|
|
493
|
+
```erb
|
|
494
|
+
<%# Basic usage %>
|
|
495
|
+
<%= form.api_key_expiration_select %>
|
|
496
|
+
|
|
497
|
+
<%# With CSS classes (Tailwind example) %>
|
|
498
|
+
<%= form.api_key_expiration_select(class: "w-full px-4 py-3 border rounded-lg") %>
|
|
499
|
+
|
|
500
|
+
<%# With custom default selection %>
|
|
501
|
+
<%= form.api_key_expiration_select(selected: "30_days") %>
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
#### `api_key_scopes_checkboxes`
|
|
505
|
+
|
|
506
|
+
Renders scope checkboxes with a block for custom markup:
|
|
507
|
+
|
|
508
|
+
```erb
|
|
509
|
+
<%# With block - you control the HTML, gem handles the logic %>
|
|
510
|
+
<%= form.api_key_scopes_checkboxes(@available_scopes) do |scope, checked| %>
|
|
511
|
+
<label class="flex items-center gap-2">
|
|
512
|
+
<%= check_box_tag "api_key[scopes][]", scope, checked, class: "rounded" %>
|
|
513
|
+
<code><%= scope %></code>
|
|
514
|
+
</label>
|
|
515
|
+
<% end %>
|
|
516
|
+
|
|
517
|
+
<%# For new records, all scopes are checked by default %>
|
|
518
|
+
<%# For existing records, only the key's current scopes are checked %>
|
|
519
|
+
|
|
520
|
+
<%# Override checked state %>
|
|
521
|
+
<%= form.api_key_scopes_checkboxes(@scopes, checked: :none) do |scope, checked| %>
|
|
522
|
+
...
|
|
523
|
+
<% end %>
|
|
524
|
+
|
|
525
|
+
<%# checked options: :all, :none, or an array of specific scopes %>
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
#### `api_key_token_data`
|
|
529
|
+
|
|
530
|
+
Returns structured data for building token display UIs:
|
|
531
|
+
|
|
532
|
+
```erb
|
|
533
|
+
<% data = form.api_key_token_data %>
|
|
534
|
+
<code><%= data[:masked] %></code>
|
|
535
|
+
|
|
536
|
+
<% if data[:viewable] %>
|
|
537
|
+
<button data-token="<%= data[:full] %>">Copy</button>
|
|
538
|
+
<% end %>
|
|
539
|
+
|
|
540
|
+
<%# Returns: { masked:, full:, viewable:, type:, environment: } %>
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### View Helpers
|
|
546
|
+
|
|
547
|
+
Include in your ApplicationHelper:
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
module ApplicationHelper
|
|
551
|
+
include ApiKeys::ViewHelpers
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
#### Status Helpers
|
|
556
|
+
|
|
557
|
+
```erb
|
|
558
|
+
<%# Get status as symbol %>
|
|
559
|
+
<%= api_key_status(@key) %>
|
|
560
|
+
<%# => :active, :expired, or :revoked %>
|
|
561
|
+
|
|
562
|
+
<%# Get human-readable label %>
|
|
563
|
+
<%= api_key_status_label(@key) %>
|
|
564
|
+
<%# => "Active", "Expired", or "Revoked" %>
|
|
565
|
+
|
|
566
|
+
<%# Get full status info for styling %>
|
|
567
|
+
<% info = api_key_status_info(@key) %>
|
|
568
|
+
<span class="<%= info[:color] == :green ? 'bg-green-100' : 'bg-red-100' %>">
|
|
569
|
+
<%= info[:label] %>
|
|
570
|
+
</span>
|
|
571
|
+
<%# Returns: { status: :active, label: "Active", color: :green } %>
|
|
572
|
+
<%# Colors: :green (active), :red (revoked), :gray (expired) %>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
#### Type & Environment Helpers
|
|
576
|
+
|
|
577
|
+
```erb
|
|
578
|
+
<%# Key type label %>
|
|
579
|
+
<%= api_key_type_label(@key) %>
|
|
580
|
+
<%# => "Publishable", "Secret", or nil %>
|
|
581
|
+
|
|
582
|
+
<%# Environment label %>
|
|
583
|
+
<%= api_key_environment_label(@key) %>
|
|
584
|
+
<%# => "Test", "Live", or "Default" %>
|
|
585
|
+
|
|
586
|
+
<%# Type checks %>
|
|
587
|
+
<%= api_key_publishable?(@key) %> <%# => true/false %>
|
|
588
|
+
<%= api_key_secret?(@key) %> <%# => true/false %>
|
|
589
|
+
|
|
590
|
+
<%# Get environment from a token string (useful on success page) %>
|
|
591
|
+
<%= api_key_environment_from_token(@token) %>
|
|
592
|
+
<%# => :test, :live, or nil %>
|
|
593
|
+
|
|
594
|
+
<%= api_key_environment_label_from_token(@token) %>
|
|
595
|
+
<%# => "Test mode", "Live mode", or "Default" %>
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
### View Examples
|
|
601
|
+
|
|
602
|
+
#### Index Page (Key List)
|
|
603
|
+
|
|
604
|
+
```erb
|
|
605
|
+
<%# Publishable key section %>
|
|
606
|
+
<% if @publishable_key %>
|
|
607
|
+
<code><%= @publishable_key.viewable_token || @publishable_key.masked_token %></code>
|
|
608
|
+
<span><%= api_key_environment_label(@publishable_key) %> mode</span>
|
|
609
|
+
<% else %>
|
|
610
|
+
<%= button_to create_publishable_settings_api_keys_path, method: :post do %>
|
|
611
|
+
Create Publishable Key
|
|
612
|
+
<% end %>
|
|
613
|
+
<% end %>
|
|
614
|
+
|
|
615
|
+
<%# Secret keys table %>
|
|
616
|
+
<% @secret_keys.each do |key| %>
|
|
617
|
+
<tr>
|
|
618
|
+
<td><%= key.name || "Unnamed key" %></td>
|
|
619
|
+
<td><code><%= key.masked_token %></code></td>
|
|
620
|
+
<td><%= api_key_status_label(key) %></td>
|
|
621
|
+
<td>
|
|
622
|
+
<%= link_to "Edit", edit_settings_api_key_path(key) %>
|
|
623
|
+
<%= button_to "Revoke", revoke_settings_api_key_path(key), method: :post %>
|
|
624
|
+
</td>
|
|
625
|
+
</tr>
|
|
626
|
+
<% end %>
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
#### New/Edit Form
|
|
630
|
+
|
|
631
|
+
```erb
|
|
632
|
+
<%= form_with(model: @api_key, url: settings_api_keys_path) do |form| %>
|
|
633
|
+
<%# Name %>
|
|
634
|
+
<%= form.text_field :name, placeholder: "e.g., Production Server" %>
|
|
635
|
+
|
|
636
|
+
<%# Expiration (new keys only) %>
|
|
637
|
+
<%= form.api_key_expiration_select(class: "form-select") %>
|
|
638
|
+
|
|
639
|
+
<%# Scopes %>
|
|
640
|
+
<%= form.api_key_scopes_checkboxes(@available_scopes) do |scope, checked| %>
|
|
641
|
+
<label>
|
|
642
|
+
<%= check_box_tag "api_key[scopes][]", scope, checked %>
|
|
643
|
+
<%= scope %>
|
|
644
|
+
</label>
|
|
645
|
+
<% end %>
|
|
646
|
+
|
|
647
|
+
<%= form.submit %>
|
|
648
|
+
<% end %>
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
#### Success Page (Show Token Once)
|
|
652
|
+
|
|
653
|
+
```erb
|
|
654
|
+
<% if @token.present? %>
|
|
655
|
+
<input type="text" value="<%= @token %>" readonly>
|
|
656
|
+
<button data-copy="<%= @token %>">Copy</button>
|
|
657
|
+
<span><%= api_key_environment_label_from_token(@token) %></span>
|
|
658
|
+
|
|
659
|
+
<p>This key will only be shown once. Copy it now!</p>
|
|
660
|
+
<% else %>
|
|
661
|
+
<p>Token already shown. Create a new key if needed.</p>
|
|
662
|
+
<%= link_to "Create New Key", new_settings_api_key_path %>
|
|
663
|
+
<% end %>
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
### Best Practices
|
|
669
|
+
|
|
670
|
+
Based on real production integrations:
|
|
671
|
+
|
|
672
|
+
1. **Use RESTful routes** - `resources :api_keys` with member/collection actions, not custom route definitions.
|
|
673
|
+
|
|
674
|
+
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).
|
|
675
|
+
|
|
676
|
+
3. **Use `before_action` for shared setup** - Extract `@available_scopes` to a before_action rather than setting it in multiple actions.
|
|
677
|
+
|
|
678
|
+
4. **Let validations handle errors** - Rescue `ActiveRecord::RecordInvalid` and re-render the form rather than pre-checking everything.
|
|
679
|
+
|
|
680
|
+
5. **Use the gem's helpers consistently** - Use `api_key_status_label(key)` everywhere rather than hardcoding "Active" in some places.
|
|
681
|
+
|
|
682
|
+
6. **Check limits before showing UI** - Use `can_create_api_key?(key_type:)` to conditionally show/hide create buttons.
|
|
683
|
+
|
|
684
|
+
7. **Keep controllers thin** - The gem handles token generation, hashing, scope filtering, and validation. Your controller just orchestrates.
|
|
685
|
+
|
|
686
|
+
See the "How it works" section below for additional model methods.
|
|
687
|
+
|
|
142
688
|
|
|
143
689
|
## How it works
|
|
144
690
|
|
|
@@ -149,7 +695,7 @@ If you want to write your own front-end instead of using the provided dashboard,
|
|
|
149
695
|
```ruby
|
|
150
696
|
@api_key = @user.create_api_key!(
|
|
151
697
|
name: "my-key",
|
|
152
|
-
scopes:
|
|
698
|
+
scopes: %w[read write],
|
|
153
699
|
expires_at: 42.days.from_now
|
|
154
700
|
)
|
|
155
701
|
|
|
@@ -158,14 +704,14 @@ plaintext_token = @api_key.token
|
|
|
158
704
|
# => ak_123abc...
|
|
159
705
|
```
|
|
160
706
|
|
|
161
|
-
For security reasons, the
|
|
707
|
+
For security reasons, the gem does not store generated **secret** keys in the database.
|
|
162
708
|
|
|
163
|
-
|
|
709
|
+
Only a secure digest is stored (SHA256 by default), so a secret token is available as `@api_key.token` only on the newly created in-memory object. Reloading clears it. The explicit public-key mode described below is the sole plaintext-storage exception.
|
|
164
710
|
|
|
165
711
|
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
712
|
|
|
167
713
|
```bash
|
|
168
|
-
curl -X GET -H "Authorization: Bearer
|
|
714
|
+
curl -X GET -H "Authorization: Bearer YOUR_API_KEY" "https://example.com/api/endpoint" # gitleaks:allow
|
|
169
715
|
```
|
|
170
716
|
|
|
171
717
|
### Listing all keys for users
|
|
@@ -295,6 +841,8 @@ You can require a specific scope for any endpoint like:
|
|
|
295
841
|
authenticate_api_key!(scope: "write")
|
|
296
842
|
```
|
|
297
843
|
|
|
844
|
+
A missing or invalid credential returns HTTP 401. A valid key that lacks the requested scope is authenticated but unauthorized, so it returns HTTP 403 with `error: "missing_scope"`.
|
|
845
|
+
|
|
298
846
|
It may be cleaner if you pass it as a Proc to `before_action` β and it may result in better-organized code if you do it endpoint-per-endpoint, immediately before each method definition, like this:
|
|
299
847
|
|
|
300
848
|
```ruby
|
|
@@ -336,6 +884,45 @@ The gem installation creates an initializer at `config/initializers/api_keys.rb`
|
|
|
336
884
|
|
|
337
885
|
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
886
|
|
|
887
|
+
### Token prefixes
|
|
888
|
+
|
|
889
|
+
API keys are generated with a prefix followed by random characters:
|
|
890
|
+
|
|
891
|
+
```
|
|
892
|
+
ak_7Hq2mJ6vK9pRs3xYz9...
|
|
893
|
+
^^ ^^^^^^^^^^^
|
|
894
|
+
prefix random part
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
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.
|
|
898
|
+
|
|
899
|
+
**Simple mode (default):** All keys use the same prefix from `token_prefix`:
|
|
900
|
+
```ruby
|
|
901
|
+
config.token_prefix = -> { "myapp_" } # β myapp_abc123...
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**Key types mode:** When you configure `key_types`, different key types get different prefixes based on their type and environment (Stripe-style):
|
|
905
|
+
```ruby
|
|
906
|
+
# With key_types configured, prefixes come from the type configuration:
|
|
907
|
+
# publishable + test β pk_test_abc123...
|
|
908
|
+
# secret + live β sk_live_xyz789...
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
#### Prefix precedence
|
|
912
|
+
|
|
913
|
+
When using key types, here's how the prefix is determined:
|
|
914
|
+
|
|
915
|
+
| `key_types` Config | `key_type` Param | Resulting Prefix |
|
|
916
|
+
|-------------------|------------------|------------------|
|
|
917
|
+
| `{}` (disabled) | any | Uses `token_prefix` β `"ak_..."` |
|
|
918
|
+
| Configured | `nil` | Uses `token_prefix` β `"ak_..."` |
|
|
919
|
+
| Configured | `:publishable` | Uses type config β `"pk_test_..."` |
|
|
920
|
+
| Configured | `:secret` | Uses type config β `"sk_live_..."` |
|
|
921
|
+
|
|
922
|
+
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.
|
|
923
|
+
|
|
924
|
+
See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section below for full details.
|
|
925
|
+
|
|
339
926
|
Some highlights:
|
|
340
927
|
|
|
341
928
|
### Accept API keys via query params instead of Authentication HTTP headers
|
|
@@ -346,7 +933,7 @@ By default, the `api_key` gem expects API keys to come *exclusively* as HTTP Aut
|
|
|
346
933
|
https://example.com/api/endpoint?api_key=ak_123abc...
|
|
347
934
|
```
|
|
348
935
|
|
|
349
|
-
|
|
936
|
+
Do not enable this in production. URLs routinely reach logs, browser history, analytics, proxies, caches, and referrer data. If a constrained development/test integration requires it, set the expected parameter name explicitly:
|
|
350
937
|
|
|
351
938
|
```ruby
|
|
352
939
|
config.query_param = "api_key"
|
|
@@ -354,7 +941,7 @@ config.query_param = "api_key"
|
|
|
354
941
|
|
|
355
942
|
### Changing the hashing function to `bcrypt` for maximum security
|
|
356
943
|
|
|
357
|
-
By default,
|
|
944
|
+
By default, `api_keys` hashes tokens using SHA256. A fast digest is appropriate here because tokens are generated from 192 bits of randomness by default (and never less than 128 bits), rather than chosen by a human. It also permits indexed, low-latency authentication.
|
|
358
945
|
|
|
359
946
|
If you need slower, password-grade hashing (e.g., for extremely sensitive tokens), you can switch to bcrypt:
|
|
360
947
|
|
|
@@ -362,47 +949,53 @@ If you need slower, password-grade hashing (e.g., for extremely sensitive tokens
|
|
|
362
949
|
config.hash_strategy = :bcrypt
|
|
363
950
|
```
|
|
364
951
|
|
|
365
|
-
Note: bcrypt is
|
|
952
|
+
Note: bcrypt is substantially slower than SHA256. For most API use cases, SHA256 is appropriate because generated tokens have at least 128 bits of cryptographic randomness.
|
|
366
953
|
|
|
367
|
-
`sha256` has
|
|
954
|
+
`sha256` has a direct digest lookup; bcrypt requires a bounded candidate lookup and an expensive comparison. Add the authentication index shown in the upgrade section before enabling bcrypt. The gem rejects total bcrypt token values over 72 bytes to prevent bcrypt's historical input truncation behavior, so keep the configured prefix plus encoded random portion within that bound.
|
|
368
955
|
|
|
369
|
-
|
|
956
|
+
Use SHA256 for ordinary high-entropy API keys unless your threat model specifically calls for a deliberately expensive verifier.
|
|
370
957
|
|
|
371
958
|
### Increase cache TTL
|
|
372
959
|
|
|
373
|
-
We cache
|
|
960
|
+
We cache only the database ID lookup hint. Every cache hit reloads the current row and cryptographically re-verifies the presented token; cached records never authorize a request by themselves.
|
|
374
961
|
|
|
375
|
-
By default,
|
|
962
|
+
By default, the hint uses a 5-second TTL. Revocation, expiration, scope changes, environment changes, and other stored authorization state are checked from the database on every request and take effect immediately for new authentication attempts.
|
|
376
963
|
|
|
377
|
-
|
|
964
|
+
You can disable the lookup hint if you prefer not to use Rails.cache:
|
|
378
965
|
|
|
379
966
|
```ruby
|
|
380
967
|
config.cache_ttl = 0.seconds # disables caching
|
|
381
968
|
```
|
|
382
969
|
|
|
383
|
-
|
|
970
|
+
Increase the TTL to reduce repeated lookup work without making cached state authoritative:
|
|
384
971
|
|
|
385
972
|
```ruby
|
|
386
|
-
config.cache_ttl = 2.minutes
|
|
973
|
+
config.cache_ttl = 2.minutes
|
|
387
974
|
```
|
|
388
975
|
|
|
389
|
-
β οΈ Security note: Revoked keys may remain valid for up to cache_ttl. For strict real-time revocation, set cache_ttl = 0.
|
|
390
|
-
|
|
391
976
|
|
|
392
977
|
## Callbacks: analytics, logging, usage monitoring & auditing
|
|
393
978
|
|
|
394
|
-
The
|
|
979
|
+
The controller concern can enqueue two callbacks for each authentication attempt. To keep secrets out of job payloads, callbacks receive small serializable context hashesβnot request, result, or model objects.
|
|
395
980
|
|
|
396
981
|
You can define logic for them:
|
|
397
982
|
```ruby
|
|
398
|
-
config.before_authentication = ->(
|
|
983
|
+
config.before_authentication = ->(context) do
|
|
984
|
+
Rails.logger.info "Authenticating request: #{context[:request_uuid]}"
|
|
985
|
+
end
|
|
399
986
|
|
|
400
|
-
config.after_authentication = ->(
|
|
987
|
+
config.after_authentication = ->(context) do
|
|
988
|
+
MyAnalytics.track_auth(
|
|
989
|
+
success: context[:success],
|
|
990
|
+
error_code: context[:error_code],
|
|
991
|
+
api_key_id: context[:api_key_id]
|
|
992
|
+
)
|
|
993
|
+
end
|
|
401
994
|
```
|
|
402
995
|
|
|
403
996
|
This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem.
|
|
404
997
|
|
|
405
|
-
|
|
998
|
+
The `before_authentication` context contains `request_uuid`. The `after_authentication` context contains `success`, `error_code`, `api_key_id`, and, when scopes were requested, `required_scope_check`. Jobs are asynchronous, so βbeforeβ means it is enqueued before verification; queue execution order is not guaranteed. Configure a persistent Active Job backend and the callback queue appropriate for your application.
|
|
406
999
|
|
|
407
1000
|
The downside of this, of course, is that callbacks will only work if you have a valid, well-configured Active Job backend for your Rails app, like Sidekiq or [`solid_queue`](https://github.com/rails/solid_queue/), which comes by default in Rails 8. If Active Job is not well configured, well, your callbacks just won't get executed.
|
|
408
1001
|
|
|
@@ -414,10 +1007,235 @@ There's also a `track_requests_count` config option that you can turn on so the
|
|
|
414
1007
|
|
|
415
1008
|
But again, this is turned off by default for performance purposes, and depends on having a working, well-configured Active Job backend.
|
|
416
1009
|
|
|
1010
|
+
When exact request counting is off, `last_used_at` updates are debounced for one minute by default to avoid one queue insert and database write for every high-volume API call:
|
|
1011
|
+
|
|
1012
|
+
```ruby
|
|
1013
|
+
config.stats_update_interval = 5.minutes # coarser, lower write volume
|
|
1014
|
+
config.stats_update_interval = 0 # record every successful request
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
Enabling `track_requests_count` bypasses this debounce because every successful request must be counted. Set `enable_async_operations = false` if the application handles statistics and callbacks elsewhere and wants the gem to enqueue no background jobs.
|
|
1018
|
+
|
|
1019
|
+
## Key Types: Stripe-style Publishable & Secret Keys
|
|
1020
|
+
|
|
1021
|
+
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.
|
|
1022
|
+
|
|
1023
|
+
### Why Key Types?
|
|
1024
|
+
|
|
1025
|
+
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:
|
|
1026
|
+
|
|
1027
|
+
- **Publishable keys** (`pk_test_...`, `pk_live_...`): Intentionally exposed identifiers. Embed them only when every configured permission is safe for an untrusted public client; assume anyone can extract and abuse them. They cannot be revoked individually.
|
|
1028
|
+
|
|
1029
|
+
- **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime.
|
|
1030
|
+
|
|
1031
|
+
### Configuration
|
|
1032
|
+
|
|
1033
|
+
Enable key types in your initializer:
|
|
1034
|
+
|
|
1035
|
+
```ruby
|
|
1036
|
+
# config/initializers/api_keys.rb
|
|
1037
|
+
ApiKeys.configure do |config|
|
|
1038
|
+
config.key_types = {
|
|
1039
|
+
publishable: {
|
|
1040
|
+
prefix: "pk", # Token prefix β pk_test_, pk_live_
|
|
1041
|
+
permissions: %w[read validate], # Scope ceiling (max permissions allowed)
|
|
1042
|
+
revocable: false, # Cannot be revoked or deleted
|
|
1043
|
+
limit: 1 # Max 1 per owner per environment
|
|
1044
|
+
},
|
|
1045
|
+
secret: {
|
|
1046
|
+
prefix: "sk",
|
|
1047
|
+
permissions: :all # No scope restrictions
|
|
1048
|
+
# revocable defaults to true, limit defaults to nil (unlimited)
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
config.environments = {
|
|
1053
|
+
test: { prefix_segment: "test" }, # β pk_test_, sk_test_
|
|
1054
|
+
live: { prefix_segment: "live" } # β pk_live_, sk_live_
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
# Detect current environment automatically
|
|
1058
|
+
config.current_environment = -> { Rails.env.production? ? :live : :test }
|
|
1059
|
+
|
|
1060
|
+
# Enable strict environment isolation (test keys fail in prod, live keys fail in dev)
|
|
1061
|
+
config.strict_environment_isolation = true
|
|
1062
|
+
|
|
1063
|
+
# Optional: use this type when create_api_key! omits key_type.
|
|
1064
|
+
# Without a default, every new key must specify key_type explicitly.
|
|
1065
|
+
config.default_key_type = :secret
|
|
1066
|
+
end
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
### Creating Typed Keys
|
|
1070
|
+
|
|
1071
|
+
```ruby
|
|
1072
|
+
# Create a publishable key (limited permissions, cannot be revoked)
|
|
1073
|
+
pk = user.create_api_key!(
|
|
1074
|
+
name: "Production App",
|
|
1075
|
+
key_type: :publishable,
|
|
1076
|
+
environment: :live # Optional, defaults to current_environment
|
|
1077
|
+
)
|
|
1078
|
+
pk.token # => "pk_live_abc123..."
|
|
1079
|
+
|
|
1080
|
+
# Create a secret key (access is controlled by its scopes/type ceiling)
|
|
1081
|
+
sk = user.create_api_key!(
|
|
1082
|
+
name: "Admin Dashboard",
|
|
1083
|
+
key_type: :secret
|
|
1084
|
+
)
|
|
1085
|
+
sk.token # => "sk_test_xyz789..."
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
### Scope Ceiling
|
|
1089
|
+
|
|
1090
|
+
When a key type has limited `permissions`, any scopes you pass are filtered:
|
|
1091
|
+
|
|
1092
|
+
```ruby
|
|
1093
|
+
# Publishable keys can only have read/validate permissions
|
|
1094
|
+
pk = user.create_api_key!(
|
|
1095
|
+
key_type: :publishable,
|
|
1096
|
+
scopes: %w[read validate issue_license admin] # Tries to request all
|
|
1097
|
+
)
|
|
1098
|
+
pk.scopes # => ["read", "validate"] # Only allowed scopes kept
|
|
1099
|
+
|
|
1100
|
+
# Secret keys with permissions: :all keep everything
|
|
1101
|
+
sk = user.create_api_key!(
|
|
1102
|
+
key_type: :secret,
|
|
1103
|
+
scopes: %w[read validate issue_license admin]
|
|
1104
|
+
)
|
|
1105
|
+
sk.scopes # => ["read", "validate", "issue_license", "admin"]
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
### Non-Revocable Keys
|
|
1109
|
+
|
|
1110
|
+
Keys with `revocable: false` protect against accidental deletion:
|
|
1111
|
+
|
|
1112
|
+
```ruby
|
|
1113
|
+
pk = user.create_api_key!(key_type: :publishable)
|
|
1114
|
+
|
|
1115
|
+
pk.revocable? # => false
|
|
1116
|
+
pk.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1117
|
+
pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
The dashboard UI automatically hides the revoke button for non-revocable keys.
|
|
1121
|
+
Deleting the owning record still cascades deletion to all of its API keys, including non-revocable types, so account deletion and privacy-erasure flows cannot be blocked. The non-revocable guard applies to direct key-level user actions, not owner lifecycle cleanup.
|
|
1122
|
+
|
|
1123
|
+
### Public Keys (Viewable Tokens)
|
|
1124
|
+
|
|
1125
|
+
#### The Problem: Non-Revocable Key Lockout
|
|
1126
|
+
|
|
1127
|
+
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.
|
|
1128
|
+
|
|
1129
|
+
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.
|
|
1130
|
+
|
|
1131
|
+
#### The Solution: Storing Public Keys
|
|
1132
|
+
|
|
1133
|
+
For publishable keys that grant *only operations safe for an unauthenticated public client*, hiding the token provides no secrecy benefit: distributed clients necessarily expose it. Never use this design for a permission that protects confidential data or sensitive actions.
|
|
1134
|
+
|
|
1135
|
+
The `public: true` option stores the plaintext token in metadata so users can view it again:
|
|
1136
|
+
|
|
1137
|
+
```ruby
|
|
1138
|
+
config.key_types = {
|
|
1139
|
+
publishable: {
|
|
1140
|
+
prefix: "pk",
|
|
1141
|
+
permissions: %w[read validate],
|
|
1142
|
+
revocable: false,
|
|
1143
|
+
public: true, # Store token for later viewing
|
|
1144
|
+
limit: 1
|
|
1145
|
+
},
|
|
1146
|
+
secret: {
|
|
1147
|
+
prefix: "sk",
|
|
1148
|
+
permissions: :all
|
|
1149
|
+
# public: false (default) - NEVER store secret keys!
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
#### Security constraints
|
|
1155
|
+
|
|
1156
|
+
> [!IMPORTANT]
|
|
1157
|
+
> The `public` option only works when all of these conditions are met:
|
|
1158
|
+
> - `public: true` is set in the key type configuration
|
|
1159
|
+
> - `revocable: false` is set (non-revocable keys only)
|
|
1160
|
+
> - `permissions` is a finite, non-empty array (never `:all`)
|
|
1161
|
+
|
|
1162
|
+
These checks are deliberate safety measures:
|
|
1163
|
+
|
|
1164
|
+
1. **Configuration is validated early** β Public types must explicitly be non-revocable and have a finite, non-empty permission ceiling.
|
|
1165
|
+
|
|
1166
|
+
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.
|
|
1167
|
+
|
|
1168
|
+
3. **Your application defines what is public** β The gem cannot infer the business impact of a permission name. Only mark a type public when every permission in its ceiling is safe for an unauthenticated client to possess.
|
|
1169
|
+
|
|
1170
|
+
> [!WARNING]
|
|
1171
|
+
> β οΈ **Never set `public: true` on secret keys or any key type with sensitive permissions.** Validation prevents `:all` and missing permission ceilings, but your application remains responsible for classifying each named permission correctly.
|
|
1172
|
+
|
|
1173
|
+
When a key is public, the dashboard shows a "Show" button to reveal the full token:
|
|
1174
|
+
|
|
1175
|
+
```ruby
|
|
1176
|
+
pk = user.create_api_key!(key_type: :publishable)
|
|
1177
|
+
pk.public_key_type? # => true
|
|
1178
|
+
pk.viewable_token # => "pk_test_abc123..." (the full token)
|
|
1179
|
+
|
|
1180
|
+
sk = user.create_api_key!(key_type: :secret)
|
|
1181
|
+
sk.public_key_type? # => false
|
|
1182
|
+
sk.viewable_token # => nil (not stored)
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
### Environment Isolation
|
|
1186
|
+
|
|
1187
|
+
With `strict_environment_isolation = true`, keys can only authenticate in their matching environment:
|
|
1188
|
+
|
|
1189
|
+
```ruby
|
|
1190
|
+
# In production (current_environment returns :live)
|
|
1191
|
+
# A test key will fail authentication with error_code: :environment_mismatch
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
This prevents accidentally using test keys in production (or vice versa).
|
|
1195
|
+
|
|
1196
|
+
Typed keys always require a non-blank stored environment. When `environments` is configured, authentication also rejects typed keys whose stored environment is no longer configured, even if strict isolation is disabled. Untyped legacy keys remain compatible.
|
|
1197
|
+
|
|
1198
|
+
Once `key_types` is configured, all newly created keys must supply `key_type:` or use `default_key_type`; the gem will not silently create a new untyped key outside the configured permission and environment policy. Existing untyped keys created before enabling the feature continue to authenticate under the documented legacy rules.
|
|
1199
|
+
|
|
1200
|
+
### Key Limits
|
|
1201
|
+
|
|
1202
|
+
The `limit` option restricts how many keys of a type can exist per owner per environment:
|
|
1203
|
+
|
|
1204
|
+
```ruby
|
|
1205
|
+
# With limit: 1 for publishable keys
|
|
1206
|
+
user.create_api_key!(key_type: :publishable, environment: :test) # Works
|
|
1207
|
+
user.create_api_key!(key_type: :publishable, environment: :test) # Raises validation error
|
|
1208
|
+
|
|
1209
|
+
# But can have one per environment
|
|
1210
|
+
user.create_api_key!(key_type: :publishable, environment: :live) # Works
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
### Sandbox/Live Naming
|
|
1214
|
+
|
|
1215
|
+
You can use any environment names. For Stripe-style sandbox:
|
|
1216
|
+
|
|
1217
|
+
```ruby
|
|
1218
|
+
config.environments = {
|
|
1219
|
+
sandbox: { prefix_segment: "test" }, # β pk_test_
|
|
1220
|
+
live: { prefix_segment: "live" } # β pk_live_
|
|
1221
|
+
}
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
### Upgrading Existing Installations
|
|
1225
|
+
|
|
1226
|
+
If you're adding key types to an existing installation, run the migration generator:
|
|
1227
|
+
|
|
1228
|
+
```bash
|
|
1229
|
+
rails g api_keys:add_key_types
|
|
1230
|
+
rails db:migrate
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
Existing keys without `key_type`/`environment` continue to work normally (backwards compatible).
|
|
1234
|
+
|
|
417
1235
|
## Enterprise-ready by design
|
|
418
1236
|
The `api_keys` gem ships with:
|
|
419
1237
|
|
|
420
|
-
-
|
|
1238
|
+
- Active Record storage across supported Rails databases
|
|
421
1239
|
- Async hooks
|
|
422
1240
|
- ActiveJob support
|
|
423
1241
|
- Polymorphic ownership (User, Org, etc.)
|
|
@@ -439,11 +1257,17 @@ There's a demo Rails app showcasing the features in the `api_keys` gem under `te
|
|
|
439
1257
|
|
|
440
1258
|
## Testing
|
|
441
1259
|
|
|
442
|
-
Run the test suite with `bundle exec rake test
|
|
1260
|
+
Run the default test suite with `bundle exec rake test`. Run all supported Rails appraisals with:
|
|
1261
|
+
|
|
1262
|
+
```bash
|
|
1263
|
+
bundle exec appraisal rails-7.2 rake test
|
|
1264
|
+
bundle exec appraisal rails-8.0 rake test
|
|
1265
|
+
bundle exec appraisal rails-8.1 rake test
|
|
1266
|
+
```
|
|
443
1267
|
|
|
444
1268
|
## Development
|
|
445
1269
|
|
|
446
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then
|
|
1270
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run `bundle exec rake test`. You can also run `bin/console` for an interactive prompt.
|
|
447
1271
|
|
|
448
1272
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
449
1273
|
|
|
@@ -451,6 +1275,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
|
|
|
451
1275
|
|
|
452
1276
|
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/api_keys. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
|
|
453
1277
|
|
|
1278
|
+
Please report vulnerabilities privately as described in [SECURITY.md](SECURITY.md), not in a public issue.
|
|
1279
|
+
|
|
454
1280
|
## License
|
|
455
1281
|
|
|
456
1282
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|