api_keys 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +147 -38
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +92 -10
  6. data/app/controllers/api_keys/keys_controller.rb +40 -18
  7. data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
  8. data/app/views/api_keys/keys/_form.html.erb +3 -3
  9. data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
  10. data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
  11. data/app/views/api_keys/keys/_key_row.html.erb +1 -1
  12. data/app/views/api_keys/keys/_key_status.html.erb +3 -3
  13. data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
  14. data/app/views/api_keys/keys/_show_token.html.erb +5 -46
  15. data/app/views/api_keys/keys/_token_display.html.erb +3 -3
  16. data/app/views/api_keys/keys/index.html.erb +2 -2
  17. data/app/views/api_keys/keys/show.html.erb +2 -2
  18. data/app/views/api_keys/security/best_practices.html.erb +7 -7
  19. data/app/views/layouts/api_keys/application.html.erb +159 -12
  20. data/lib/api_keys/authentication.rb +39 -11
  21. data/lib/api_keys/configuration.rb +412 -24
  22. data/lib/api_keys/engine.rb +5 -20
  23. data/lib/api_keys/form_builder_extensions.rb +12 -2
  24. data/lib/api_keys/helpers/expiration_options.rb +11 -3
  25. data/lib/api_keys/helpers/token_session.rb +143 -8
  26. data/lib/api_keys/helpers/view_helpers.rb +5 -1
  27. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  28. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  29. data/lib/api_keys/models/api_key.rb +244 -25
  30. data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
  31. data/lib/api_keys/services/authenticator.rb +263 -118
  32. data/lib/api_keys/services/digestor.rb +76 -13
  33. data/lib/api_keys/services/token_generator.rb +41 -1
  34. data/lib/api_keys/tenant_resolution.rb +2 -4
  35. data/lib/api_keys/version.rb +1 -1
  36. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  37. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +24 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -14
  39. data/lib/generators/api_keys/templates/initializer.rb +54 -17
  40. metadata +16 -16
  41. data/.simplecov +0 -36
  42. data/AGENTS.md +0 -5
  43. data/Appraisals +0 -17
  44. data/CLAUDE.md +0 -5
  45. data/Rakefile +0 -37
  46. data/context7.json +0 -4
  47. data/gemfiles/rails_7.2.gemfile +0 -21
  48. data/gemfiles/rails_8.0.gemfile +0 -21
  49. data/gemfiles/rails_8.1.gemfile +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2eb256f13ca90621678457aba6cfb4892da3c0497f6932b797f1648a672c009
4
- data.tar.gz: c74450d08743ee817e41590e200fe244621c742b2f5687e257499b31867f4e6e
3
+ metadata.gz: 51db6ca1f89acc5b0638e33db41b88694b8be6275c3528a72012b3e2609d8736
4
+ data.tar.gz: 40c384e1af9d2befca96c4c5a3395daff9059ee19334ee01f997c59396367bdd
5
5
  SHA512:
6
- metadata.gz: 2807a51c53e139efbb8d464067b9640e682ef72d7c70c95e8ba1c3d16f97e6818d4e89251e1db40a4be516c9e1294057c1e2903e2e7d55669576044d0e2952a5
7
- data.tar.gz: 5927cd629287b119d7224306c4e28f35d3dd38241951735ceffeaaada381317e48a0168464e4f101828ef9ba3a7634bc13b9b9146e7725a6bf50f982aaeaa1b4
6
+ metadata.gz: 8ba7f9f769475fbf703446fa91ba19500393391eb1f1fa9b45eb5772fb46e044f8af492058d306c64bcf657c0c3654846d1d188916e4edaf7ee9e9a68571deed
7
+ data.tar.gz: f6ede6c7e458b1cdbac9cd580e46536ba735891e283c4a4e2eececbc4c194b91e36ca5756a43cf8b3ef80c9d5066fa50d35cdc59efa246543bbc5e85b3ebd352
data/CHANGELOG.md CHANGED
@@ -1,3 +1,71 @@
1
+ ## [0.4.1] - 2026-08-24
2
+
3
+ ### Fixed
4
+
5
+ - Stop the dashboard's Content Security Policy from blocking the host application's own stylesheets, scripts, and self-hosted webfonts. The 0.4.0 policy declared `default-src 'none'` with `script-src`/`style-src` reduced to the per-request nonce, but Rails only stamps that nonce onto `stylesheet_link_tag`/`javascript_include_tag` when the host application sets `content_security_policy_nonce_auto` (off by default, and not something an engine can enable for its host). Any application rendering its normal layout on engine pages got an unstyled, inert dashboard with no server-side error. The default policy now trusts same-origin scripts, styles, and fonts, adds `font-src`, and allows `https:` images, while keeping the nonce, `default-src 'self'`, `base-uri 'none'`, `object-src 'none'`, `frame-ancestors 'none'`, `frame-src 'none'`, `form-action 'self'`, and `connect-src 'self'`.
6
+ - Stop generated install and authentication-index migrations from carrying an unreachable `migration_version` instance method; the generator already renders the Active Record version into each migration superclass.
7
+
8
+ ### Added
9
+
10
+ - `config.dashboard_content_security_policy` selects the policy the mounted dashboard declares: `:default` (new default, hardened but compatible with a normal host layout), `:strict` (the 0.4.0 nonce-only policy, unchanged), or `false`/`nil` to declare nothing and leave the host application's policy alone. The setting is resolved per request and validated on assignment.
11
+
12
+ ### Upgrade notes
13
+
14
+ - Applications that relied on the 0.4.0 nonce-only dashboard policy should set `config.dashboard_content_security_policy = :strict` to keep it. That policy requires a layout that serves nothing un-nonced on engine pages; the gem's built-in layout satisfies it, and host layouts additionally need `config.content_security_policy_nonce_auto = true`.
15
+
16
+ ### Maintenance
17
+
18
+ - Move SimpleCov startup/reporting into the test helper and replace deprecated filtering/tracking APIs so the enforced line and branch coverage gates remain compatible with future SimpleCov releases.
19
+
20
+ ## [0.4.0] - 2026-08-09
21
+
22
+ This security-focused release hardens authentication, authorization, credential handling, the self-serve dashboard, background jobs, configuration, dependencies, CI, and the release supply chain. Upgrading is strongly recommended. Existing installations must apply the authentication lookup migration before deploying this version.
23
+
24
+ ### Security
25
+
26
+ - Make token caching a non-authoritative ID lookup hint: every hit reloads current database state and cryptographically re-verifies the presented token, so revocation, expiration, scope changes, and cache poisoning fail closed.
27
+ - Replace token-derived SHA1 cache identifiers with SHA256 and prevent plaintext tokens, digests, stored public tokens, request objects, and authentication result objects from leaking through gem logs, inspection, serialization, errors, or async callback arguments.
28
+ - Bound token sizes, bcrypt input length, bcrypt cost, and bcrypt candidate work; add an indexed prefix/last-four/algorithm lookup for existing installations.
29
+ - Bound historical-prefix discovery and fall back to an indexed last-four lookup so cache poisoning or unusually many retired prefixes cannot create unbounded request work or strand valid bcrypt keys.
30
+ - Serialize all quota checks and inserts by locking the owner row, including direct Active Record creation paths.
31
+ - Enforce immutable authentication identity fields, supported digest formats, bounded scopes/metadata/identifiers/session payloads, runtime permission ceilings, configured key types, and fail-closed environment isolation.
32
+ - Require every newly created key to have a configured type once key-types mode is enabled; existing untyped legacy keys remain compatible.
33
+ - Require explicit finite permissions and `revocable: false` for intentionally stored public tokens.
34
+ - Fail closed when dashboard owner authentication is missing or ineffective; bind one-time session tokens to their created key; reject malformed create/update payloads; scope every dashboard lookup to the current owner; and add no-store, anti-framing, referrer, MIME-sniffing, and permissions headers.
35
+ - Apply an enforcing nonce-based dashboard Content Security Policy and remove inline handlers, un-nonced scripts, third-party assets, and inline style attributes from credential-bearing views.
36
+ - Enforce HTTPS authentication by default in production, disable query-string credentials in the demo, and remove third-party code from pages that handle tokens.
37
+ - Pin GitHub Actions to immutable commits and add dependency auditing, Brakeman, CodeQL, dependency review, and Dependabot configuration.
38
+ - Remove the unused Claude Code workflow and its third-party CI/OIDC surface.
39
+ - Encrypt one-time secret-token handoffs inside the Rails session with an application-derived AES-256-GCM key, bind them to the created key, expire them after ten minutes, and delete them on first retrieval. Legacy in-flight plaintext handoffs remain readable for upgrade continuity but are never newly written.
40
+
41
+ ### Reliability
42
+
43
+ - Execute authentication callbacks exactly once with serializable, credential-free context hashes.
44
+ - Resolve job queues dynamically, prevent out-of-order/future jobs from corrupting usage timestamps, and keep request counters atomic.
45
+ - Validate security-sensitive global and per-owner configuration early and store permission policy as defensive frozen copies.
46
+ - Ensure each appraisal excludes the next Rails line's prereleases; Rails 7.2 uses its upstream-compatible Minitest 5 while Rails 8/default suites remain on Minitest 6.
47
+ - Return `403 Forbidden` when a valid key lacks a required scope while retaining `401 Unauthorized` for missing or invalid credentials.
48
+ - Honor the public `parent_controller` configuration (with a backward-compatible internal Engine fallback), cascade owner deletion across non-revocable keys, and debounce `last_used_at` jobs for one minute by default when exact request counting is disabled.
49
+ - Remove redundant single-column polymorphic-owner indexes from new-install migrations and exclude development-only metadata from built gems.
50
+ - Resolve every finding recorded in [issue #12](https://github.com/rameerez/api_keys/issues/12) through a code fix, test, explicit bounded design, or superseding hardening control.
51
+
52
+ ### Upgrade notes
53
+
54
+ - Existing installations should run `rails generate api_keys:add_authentication_index` and `rails db:migrate` before deploying this version.
55
+ - Invalid expiration presets now raise `ArgumentError` instead of silently creating a key without expiration.
56
+ - Authentication identity fields (`token_digest`, algorithm, prefix, last four, owner, key type, and environment) can no longer be changed through normal Active Record updates after creation.
57
+ - Typed keys with blank or retired environments now fail closed. Repair any such legacy rows before deployment; untyped legacy keys are unaffected.
58
+ - When `key_types` is configured, new keys must pass `key_type:` or use a configured `default_key_type`; this does not invalidate existing untyped keys.
59
+ - Authentication callbacks now execute asynchronously exactly once with small, credential-free context hashes instead of request, result, or model objects. Update callback consumers and ensure the application has a durable Active Job backend where delivery matters.
60
+ - Production authentication now requires HTTPS by default and fails closed when a request appears insecure. Verify TLS termination and trusted proxy forwarding before deployment.
61
+ - Scope and permission policy is enforced at creation, update, and authentication time. Malformed scopes and scopes above a configured ceiling are rejected; blank scopes deny access whenever a scope policy is enabled.
62
+ - Reassess every permission ceiling configured with `public: true`. The gem validates that public tokens are non-revocable and finitely scoped, but only the host application can determine whether each named permission is safe for an untrusted client.
63
+ - The actively security-tested matrix is Ruby 3.3, 3.4, and 4.0 with Rails 7.2, 8.0, and 8.1. The gemspec continues to permit Ruby 3.1+ for compatibility, but older runtimes are outside the documented security-support matrix.
64
+ - Missing-scope responses now use HTTP 403 instead of 401. Clients that branch on the previous status should update.
65
+ - New one-time token handoffs are encrypted and expire after ten minutes. Custom integrations must use `ApiKeys::TokenSession` rather than reading its internal session payload directly.
66
+ - `last_used_at` updates are debounced for one minute by default when `track_requests_count` is false. Set `stats_update_interval = 0` for per-request timestamps; enabling exact request counting necessarily enqueues a stats job for every successful authentication.
67
+ - Deleting an owner now removes all associated API-key rows, including non-revocable types. Direct user-level `revoke!`, `destroy`, and `destroy!` protections remain unchanged.
68
+
1
69
  ## [0.3.0] - 2026-02-09
2
70
 
3
71
  - Add Stripe-style key types and environments (publishable/secret keys with test/live isolation)
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > [!TIP]
6
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
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, 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.
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.
9
9
 
10
10
  [ 🟢 [Live interactive demo website](https://apikeys.rameerez.com) ]
11
11
 
@@ -30,6 +30,17 @@ rails db:migrate
30
30
 
31
31
  And you're done!
32
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
+
33
44
  ## Quick Start
34
45
 
35
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:
@@ -101,6 +112,16 @@ class ApplicationController < ActionController::Base
101
112
  end
102
113
  ```
103
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
+
104
125
  #### Common scenarios
105
126
 
106
127
  **Organization with user membership:**
@@ -163,6 +184,8 @@ Tweak colors and spacing by overriding CSS variables in your application's style
163
184
  }
164
185
  ```
165
186
 
187
+ Engine pages ship an enforcing Content Security Policy. The default allows same-origin scripts, styles, and fonts, so your own stylesheet loads normally — see [Dashboard Content Security Policy](#dashboard-content-security-policy) to tighten or disable it.
188
+
166
189
  #### Building Custom Integrations
167
190
 
168
191
  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.
@@ -260,7 +283,7 @@ module Settings
260
283
  end
261
284
 
262
285
  def success
263
- @token = ApiKeys::TokenSession.retrieve_once(session)
286
+ @token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
264
287
  redirect_to settings_api_keys_path, alert: "Token can only be shown once." and return if @token.blank?
265
288
  end
266
289
 
@@ -407,20 +430,26 @@ Methods available on `ApiKeys::ApiKey` instances:
407
430
 
408
431
  ### Token Session Helper
409
432
 
410
- Manages the "show token once" pattern for secret keys:
433
+ 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.
411
434
 
412
435
  ```ruby
413
436
  # Store token after creation
414
437
  ApiKeys::TokenSession.store(session, @api_key)
415
438
 
416
- # Retrieve and clear (returns nil on subsequent calls)
417
- @token = ApiKeys::TokenSession.retrieve_once(session)
439
+ # Retrieve and clear, bound to the expected key (nil on mismatch or reuse)
440
+ @token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
418
441
 
419
442
  # With custom session key (if managing multiple token types)
420
443
  ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
421
- @token = ApiKeys::TokenSession.retrieve_once(session, key: :my_custom_key)
444
+ @token = ApiKeys::TokenSession.retrieve_once(
445
+ session,
446
+ key: :my_custom_key,
447
+ api_key: @api_key
448
+ )
422
449
  ```
423
450
 
451
+ 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.
452
+
424
453
  ---
425
454
 
426
455
  ### Expiration Options Helper
@@ -668,7 +697,7 @@ If you want to write your own front-end instead of using the provided dashboard,
668
697
  ```ruby
669
698
  @api_key = @user.create_api_key!(
670
699
  name: "my-key",
671
- scopes: "['read', 'write']",
700
+ scopes: %w[read write],
672
701
  expires_at: 42.days.from_now
673
702
  )
674
703
 
@@ -677,14 +706,14 @@ plaintext_token = @api_key.token
677
706
  # => ak_123abc...
678
707
  ```
679
708
 
680
- For security reasons, the **gem does not store the generated key** in the database.
709
+ For security reasons, the gem does not store generated **secret** keys in the database.
681
710
 
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.
711
+ 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.
683
712
 
684
713
  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:
685
714
 
686
715
  ```bash
687
- curl -X GET -H "Authorization: Bearer ak_123abc..." "http://example.com/api/endpoint"
716
+ curl -X GET -H "Authorization: Bearer YOUR_API_KEY" "https://example.com/api/endpoint" # gitleaks:allow
688
717
  ```
689
718
 
690
719
  ### Listing all keys for users
@@ -814,6 +843,8 @@ You can require a specific scope for any endpoint like:
814
843
  authenticate_api_key!(scope: "write")
815
844
  ```
816
845
 
846
+ 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"`.
847
+
817
848
  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:
818
849
 
819
850
  ```ruby
@@ -904,7 +935,7 @@ By default, the `api_key` gem expects API keys to come *exclusively* as HTTP Aut
904
935
  https://example.com/api/endpoint?api_key=ak_123abc...
905
936
  ```
906
937
 
907
- This is not recommended security-wise because you'll be leaking API tokens everywhere in your logs, but if you want to enable this, just set the query param name you're expecting the API key token to be in:
938
+ 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:
908
939
 
909
940
  ```ruby
910
941
  config.query_param = "api_key"
@@ -912,7 +943,7 @@ config.query_param = "api_key"
912
943
 
913
944
  ### Changing the hashing function to `bcrypt` for maximum security
914
945
 
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.
946
+ 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.
916
947
 
917
948
  If you need slower, password-grade hashing (e.g., for extremely sensitive tokens), you can switch to bcrypt:
918
949
 
@@ -920,47 +951,98 @@ If you need slower, password-grade hashing (e.g., for extremely sensitive tokens
920
951
  config.hash_strategy = :bcrypt
921
952
  ```
922
953
 
923
- Note: bcrypt is ~50–100x slower than SHA256. For most API use cases, sha256 is more than sufficient.
954
+ 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.
924
955
 
925
- `sha256` has O(1) lookup, `bcrypt` doesn't. This means that if you switch to `bcrypt`, you may observe ~100ms lags on every API call, for every token auth that's not cached.
956
+ `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.
926
957
 
927
- For 99% of APIs, `sha256` is more than secure enough and far better for performance.
958
+ Use SHA256 for ordinary high-entropy API keys unless your threat model specifically calls for a deliberately expensive verifier.
928
959
 
929
960
  ### Increase cache TTL
930
961
 
931
- We cache token lookups to improve performance, especially for repeated requests. This keeps `bcrypt` and `sha256` strategies fast under load.
962
+ 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.
932
963
 
933
- By default, we use a 5-second TTL, which offers a strong balance: most requests benefit from caching, while revoked keys stop working almost immediately.
964
+ 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.
934
965
 
935
- If security is your top priority (e.g. rapid revocation after suspected key compromise), you can disable caching entirely:
966
+ You can disable the lookup hint if you prefer not to use Rails.cache:
936
967
 
937
968
  ```ruby
938
969
  config.cache_ttl = 0.seconds # disables caching
939
970
  ```
940
971
 
941
- If performance matters more than real-time revocation, increase the TTL to reduce DB hits:
972
+ Increase the TTL to reduce repeated lookup work without making cached state authoritative:
942
973
 
943
974
  ```ruby
944
- config.cache_ttl = 2.minutes # boosts performance at cost of slower revocation
975
+ config.cache_ttl = 2.minutes
945
976
  ```
946
977
 
947
- ⚠️ Security note: Revoked keys may remain valid for up to cache_ttl. For strict real-time revocation, set cache_ttl = 0.
978
+ ### Dashboard Content Security Policy
979
+
980
+ The mounted dashboard renders credential material, so it declares its own enforcing `Content-Security-Policy` on engine pages even when your application has none. You choose how strict it is:
981
+
982
+ ```ruby
983
+ config.dashboard_content_security_policy = :default # :default (default), :strict, or false/nil
984
+ ```
985
+
986
+ | Value | Behavior |
987
+ |-------|----------|
988
+ | `:default` | Hardened, but compatible with a normal host layout. |
989
+ | `:strict` | Nonce-only: nothing loads unless it carries the engine's per-request nonce. |
990
+ | `false` / `nil` | The gem declares nothing; your application's policy applies unchanged. |
991
+
992
+ **`:default`** emits (nonce regenerated per request):
993
+
994
+ ```
995
+ base-uri 'none'; object-src 'none'; frame-ancestors 'none'; frame-src 'none';
996
+ form-action 'self'; connect-src 'self'; default-src 'self';
997
+ script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…';
998
+ font-src 'self' data:; img-src 'self' https: data:
999
+ ```
1000
+
1001
+ Framing, plugins, `<base>` hijacking, and cross-origin form posts stay blocked, and the dashboard's own inline `<style>`/`<script>` blocks stay nonce-gated — but your layout's stylesheets, scripts, and self-hosted webfonts still load.
1002
+
1003
+ **`:strict`** restores the nonce-only policy: `default-src 'none'` with no source expression for scripts or styles, so the per-request nonce is the only thing that can run.
1004
+
1005
+ ```ruby
1006
+ config.dashboard_content_security_policy = :strict
1007
+ ```
1008
+
1009
+ Only use `:strict` if the layout rendered on engine pages serves nothing un-nonced. Rails does **not** put the nonce on `stylesheet_link_tag` / `javascript_include_tag` unless the host application opts in:
1010
+
1011
+ ```ruby
1012
+ # config/application.rb — required for :strict with a normal host layout
1013
+ config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
1014
+ config.content_security_policy_nonce_auto = true
1015
+ ```
1016
+
1017
+ Without that, `:strict` blocks your layout's own asset tags and the dashboard renders unstyled and inert. The gem's built-in layout is fully self-contained and works under `:strict` as-is.
1018
+
1019
+ **`false` / `nil`** leaves your application's policy completely alone. If your policy is nonce-based, allow the dashboard's nonced inline `<style>` and `<script>` yourself.
1020
+
1021
+ Any directive the gem does not set is inherited from your application's policy.
948
1022
 
949
1023
 
950
1024
  ## Callbacks: analytics, logging, usage monitoring & auditing
951
1025
 
952
- The gem offers two callbacks that get executed every single time an API key is checked and authenticated (through `authenticate_api_key!` in controllers, for example)
1026
+ 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.
953
1027
 
954
1028
  You can define logic for them:
955
1029
  ```ruby
956
- config.before_authentication = ->(request) { Rails.logger.info "Authenticating request: #{request.uuid}" }
1030
+ config.before_authentication = ->(context) do
1031
+ Rails.logger.info "Authenticating request: #{context[:request_uuid]}"
1032
+ end
957
1033
 
958
- config.after_authentication = ->(result) { MyAnalytics.track_auth(result) }
1034
+ config.after_authentication = ->(context) do
1035
+ MyAnalytics.track_auth(
1036
+ success: context[:success],
1037
+ error_code: context[:error_code],
1038
+ api_key_id: context[:api_key_id]
1039
+ )
1040
+ end
959
1041
  ```
960
1042
 
961
1043
  This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem.
962
1044
 
963
- Since these callbacks get called every single time an endpoint request is made, we can't just execute the code synchronously, blocking the thread and making the endpoint lag. Instead, we enqueue an async job that process the callback code, however long it is. You can configure which queue these jobs get enqueued to.
1045
+ 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.
964
1046
 
965
1047
  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.
966
1048
 
@@ -972,6 +1054,15 @@ There's also a `track_requests_count` config option that you can turn on so the
972
1054
 
973
1055
  But again, this is turned off by default for performance purposes, and depends on having a working, well-configured Active Job backend.
974
1056
 
1057
+ 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:
1058
+
1059
+ ```ruby
1060
+ config.stats_update_interval = 5.minutes # coarser, lower write volume
1061
+ config.stats_update_interval = 0 # record every successful request
1062
+ ```
1063
+
1064
+ 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.
1065
+
975
1066
  ## Key Types: Stripe-style Publishable & Secret Keys
976
1067
 
977
1068
  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.
@@ -980,9 +1071,9 @@ For applications that distribute software with embedded API keys (desktop apps,
980
1071
 
981
1072
  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
1073
 
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).
1074
+ - **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.
984
1075
 
985
- - **Secret keys** (`sk_test_...`, `sk_live_...`): Full access, meant for server-side use only. Can be revoked anytime.
1076
+ - **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime.
986
1077
 
987
1078
  ### Configuration
988
1079
 
@@ -1015,6 +1106,10 @@ ApiKeys.configure do |config|
1015
1106
 
1016
1107
  # Enable strict environment isolation (test keys fail in prod, live keys fail in dev)
1017
1108
  config.strict_environment_isolation = true
1109
+
1110
+ # Optional: use this type when create_api_key! omits key_type.
1111
+ # Without a default, every new key must specify key_type explicitly.
1112
+ config.default_key_type = :secret
1018
1113
  end
1019
1114
  ```
1020
1115
 
@@ -1029,7 +1124,7 @@ pk = user.create_api_key!(
1029
1124
  )
1030
1125
  pk.token # => "pk_live_abc123..."
1031
1126
 
1032
- # Create a secret key (full access)
1127
+ # Create a secret key (access is controlled by its scopes/type ceiling)
1033
1128
  sk = user.create_api_key!(
1034
1129
  name: "Admin Dashboard",
1035
1130
  key_type: :secret
@@ -1070,6 +1165,7 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
1070
1165
  ```
1071
1166
 
1072
1167
  The dashboard UI automatically hides the revoke button for non-revocable keys.
1168
+ 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.
1073
1169
 
1074
1170
  ### Public Keys (Viewable Tokens)
1075
1171
 
@@ -1081,7 +1177,7 @@ This is especially problematic when combined with `limit: 1`, which restricts us
1081
1177
 
1082
1178
  #### The Solution: Storing Public Keys
1083
1179
 
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.
1180
+ 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.
1085
1181
 
1086
1182
  The `public: true` option stores the plaintext token in metadata so users can view it again:
1087
1183
 
@@ -1102,23 +1198,24 @@ config.key_types = {
1102
1198
  }
1103
1199
  ```
1104
1200
 
1105
- #### Security: Why This is Safe
1201
+ #### Security constraints
1106
1202
 
1107
1203
  > [!IMPORTANT]
1108
- > The `public` option only works when BOTH conditions are met:
1204
+ > The `public` option only works when all of these conditions are met:
1109
1205
  > - `public: true` is set in the key type configuration
1110
1206
  > - `revocable: false` is set (non-revocable keys only)
1207
+ > - `permissions` is a finite, non-empty array (never `:all`)
1111
1208
 
1112
- This double-check is a deliberate safety measure:
1209
+ These checks are deliberate safety measures:
1113
1210
 
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.
1211
+ 1. **Configuration is validated early** — Public types must explicitly be non-revocable and have a finite, non-empty permission ceiling.
1115
1212
 
1116
1213
  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
1214
 
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.
1215
+ 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.
1119
1216
 
1120
1217
  > [!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.
1218
+ > ⚠️ **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.
1122
1219
 
1123
1220
  When a key is public, the dashboard shows a "Show" button to reveal the full token:
1124
1221
 
@@ -1143,6 +1240,10 @@ With `strict_environment_isolation = true`, keys can only authenticate in their
1143
1240
 
1144
1241
  This prevents accidentally using test keys in production (or vice versa).
1145
1242
 
1243
+ 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.
1244
+
1245
+ 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.
1246
+
1146
1247
  ### Key Limits
1147
1248
 
1148
1249
  The `limit` option restricts how many keys of a type can exist per owner per environment:
@@ -1181,7 +1282,7 @@ Existing keys without `key_type`/`environment` continue to work normally (backwa
1181
1282
  ## Enterprise-ready by design
1182
1283
  The `api_keys` gem ships with:
1183
1284
 
1184
- - Flexible storage
1285
+ - Active Record storage across supported Rails databases
1185
1286
  - Async hooks
1186
1287
  - ActiveJob support
1187
1288
  - Polymorphic ownership (User, Org, etc.)
@@ -1203,11 +1304,17 @@ There's a demo Rails app showcasing the features in the `api_keys` gem under `te
1203
1304
 
1204
1305
  ## Testing
1205
1306
 
1206
- Run the test suite with `bundle exec rake test`
1307
+ Run the default test suite with `bundle exec rake test`. Run all supported Rails appraisals with:
1308
+
1309
+ ```bash
1310
+ bundle exec appraisal rails-7.2 rake test
1311
+ bundle exec appraisal rails-8.0 rake test
1312
+ bundle exec appraisal rails-8.1 rake test
1313
+ ```
1207
1314
 
1208
1315
  ## Development
1209
1316
 
1210
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
1317
+ 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.
1211
1318
 
1212
1319
  To install this gem onto your local machine, run `bundle exec rake install`.
1213
1320
 
@@ -1215,6 +1322,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
1215
1322
 
1216
1323
  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.
1217
1324
 
1325
+ Please report vulnerabilities privately as described in [SECURITY.md](SECURITY.md), not in a public issue.
1326
+
1218
1327
  ## License
1219
1328
 
1220
1329
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/SECURITY.md ADDED
@@ -0,0 +1,33 @@
1
+ # Security Policy
2
+
3
+ `api_keys` handles authentication credentials. Please report suspected vulnerabilities privately and avoid including real API keys, production data, or customer information in any report.
4
+
5
+ ## Supported versions
6
+
7
+ Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.3, 3.4, and 4.0 with patched Rails 7.2, 8.0, and 8.1 releases. Older Ruby and Rails versions may remain installable for compatibility, but runtimes that no longer receive upstream security fixes are not security-supported.
8
+
9
+ ## Reporting a vulnerability
10
+
11
+ Use GitHub's **Report a vulnerability** button on the [`api_keys` security advisories page](https://github.com/rameerez/api_keys/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `api_keys security report`.
12
+
13
+ Include:
14
+
15
+ - the affected version and environment;
16
+ - a minimal reproduction or proof of concept;
17
+ - the impact you believe is possible; and
18
+ - any suggested mitigation or patch.
19
+
20
+ Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you. If the issue affects downstream applications, we will prioritize a patched release and clear upgrade guidance.
21
+
22
+ ## Operational security
23
+
24
+ Applications remain responsible for:
25
+
26
+ - trusted TLS/proxy configuration, endpoint authorization, and request rate limiting;
27
+ - encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff;
28
+ - database, cache, queue, log, backup, and observability access controls;
29
+ - filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
30
+ - classifying every permission on a `public: true` key type as safe for an untrusted public client and maintaining a disable/replacement procedure for non-revocable identifiers; and
31
+ - prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
32
+
33
+ The gem bounds authentication work, but applications should still rate-limit credential endpoints. Do not treat a key prefix, last four characters, cache entry, or public key as proof of authorization.