api_keys 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +101 -39
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +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 +374 -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 +32 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
  39. data/lib/generators/api_keys/templates/initializer.rb +36 -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: 3183ee0be98a005fd2fc318937f9f8aeb6d8a6c0528a0b75d7234e55cde80480
4
+ data.tar.gz: 67080c1cddf08ee647657c8845a9332c61279249d8c9765ea0ad6fda4e4961a3
5
5
  SHA512:
6
- metadata.gz: 2807a51c53e139efbb8d464067b9640e682ef72d7c70c95e8ba1c3d16f97e6818d4e89251e1db40a4be516c9e1294057c1e2903e2e7d55669576044d0e2952a5
7
- data.tar.gz: 5927cd629287b119d7224306c4e28f35d3dd38241951735ceffeaaada381317e48a0168464e4f101828ef9ba3a7634bc13b9b9146e7725a6bf50f982aaeaa1b4
6
+ metadata.gz: ff1adbe9b462443d7f278013030780bfc99ec2ef5acb6a814966a3530950147069bf33cc79d3d35ae8f6af8f76bf43b3a9a42d9692e22f9757df3c172b186535
7
+ data.tar.gz: 6f3f9febcd26069d345739d6268c8bcd4acce174ccdd2c24f915c8cfabf5404c2aa708e3538219ad8be30c8392d633101a6f1e52d7b099e123bf01474511859e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,54 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.4.0] - 2026-08-09
4
+
5
+ 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.
6
+
7
+ ### Security
8
+
9
+ - 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.
10
+ - 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.
11
+ - Bound token sizes, bcrypt input length, bcrypt cost, and bcrypt candidate work; add an indexed prefix/last-four/algorithm lookup for existing installations.
12
+ - 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.
13
+ - Serialize all quota checks and inserts by locking the owner row, including direct Active Record creation paths.
14
+ - 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.
15
+ - Require every newly created key to have a configured type once key-types mode is enabled; existing untyped legacy keys remain compatible.
16
+ - Require explicit finite permissions and `revocable: false` for intentionally stored public tokens.
17
+ - 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.
18
+ - 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.
19
+ - Enforce HTTPS authentication by default in production, disable query-string credentials in the demo, and remove third-party code from pages that handle tokens.
20
+ - Pin GitHub Actions to immutable commits and add dependency auditing, Brakeman, CodeQL, dependency review, and Dependabot configuration.
21
+ - Remove the unused Claude Code workflow and its third-party CI/OIDC surface.
22
+ - 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.
23
+
24
+ ### Reliability
25
+
26
+ - Execute authentication callbacks exactly once with serializable, credential-free context hashes.
27
+ - Resolve job queues dynamically, prevent out-of-order/future jobs from corrupting usage timestamps, and keep request counters atomic.
28
+ - Validate security-sensitive global and per-owner configuration early and store permission policy as defensive frozen copies.
29
+ - 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.
30
+ - Return `403 Forbidden` when a valid key lacks a required scope while retaining `401 Unauthorized` for missing or invalid credentials.
31
+ - 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.
32
+ - Remove redundant single-column polymorphic-owner indexes from new-install migrations and exclude development-only metadata from built gems.
33
+ - 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.
34
+
35
+ ### Upgrade notes
36
+
37
+ - Existing installations should run `rails generate api_keys:add_authentication_index` and `rails db:migrate` before deploying this version.
38
+ - Invalid expiration presets now raise `ArgumentError` instead of silently creating a key without expiration.
39
+ - 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.
40
+ - Typed keys with blank or retired environments now fail closed. Repair any such legacy rows before deployment; untyped legacy keys are unaffected.
41
+ - 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.
42
+ - 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.
43
+ - Production authentication now requires HTTPS by default and fails closed when a request appears insecure. Verify TLS termination and trusted proxy forwarding before deployment.
44
+ - 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.
45
+ - 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.
46
+ - 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.
47
+ - Missing-scope responses now use HTTP 403 instead of 401. Clients that branch on the previous status should update.
48
+ - 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.
49
+ - `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.
50
+ - Deleting an owner now removes all associated API-key rows, including non-revocable types. Direct user-level `revoke!`, `destroy`, and `destroy!` protections remain unchanged.
51
+
1
52
  ## [0.3.0] - 2026-02-09
2
53
 
3
54
  - 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:**
@@ -260,7 +281,7 @@ module Settings
260
281
  end
261
282
 
262
283
  def success
263
- @token = ApiKeys::TokenSession.retrieve_once(session)
284
+ @token = ApiKeys::TokenSession.retrieve_once(session, api_key: @api_key)
264
285
  redirect_to settings_api_keys_path, alert: "Token can only be shown once." and return if @token.blank?
265
286
  end
266
287
 
@@ -407,20 +428,26 @@ Methods available on `ApiKeys::ApiKey` instances:
407
428
 
408
429
  ### Token Session Helper
409
430
 
410
- Manages the "show token once" pattern for secret keys:
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.
411
432
 
412
433
  ```ruby
413
434
  # Store token after creation
414
435
  ApiKeys::TokenSession.store(session, @api_key)
415
436
 
416
- # Retrieve and clear (returns nil on subsequent calls)
417
- @token = ApiKeys::TokenSession.retrieve_once(session)
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)
418
439
 
419
440
  # With custom session key (if managing multiple token types)
420
441
  ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
421
- @token = ApiKeys::TokenSession.retrieve_once(session, key: :my_custom_key)
442
+ @token = ApiKeys::TokenSession.retrieve_once(
443
+ session,
444
+ key: :my_custom_key,
445
+ api_key: @api_key
446
+ )
422
447
  ```
423
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
+
424
451
  ---
425
452
 
426
453
  ### Expiration Options Helper
@@ -668,7 +695,7 @@ If you want to write your own front-end instead of using the provided dashboard,
668
695
  ```ruby
669
696
  @api_key = @user.create_api_key!(
670
697
  name: "my-key",
671
- scopes: "['read', 'write']",
698
+ scopes: %w[read write],
672
699
  expires_at: 42.days.from_now
673
700
  )
674
701
 
@@ -677,14 +704,14 @@ plaintext_token = @api_key.token
677
704
  # => ak_123abc...
678
705
  ```
679
706
 
680
- For security reasons, the **gem does not store the generated key** in the database.
707
+ For security reasons, the gem does not store generated **secret** keys in the database.
681
708
 
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.
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.
683
710
 
684
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:
685
712
 
686
713
  ```bash
687
- curl -X GET -H "Authorization: Bearer ak_123abc..." "http://example.com/api/endpoint"
714
+ curl -X GET -H "Authorization: Bearer YOUR_API_KEY" "https://example.com/api/endpoint" # gitleaks:allow
688
715
  ```
689
716
 
690
717
  ### Listing all keys for users
@@ -814,6 +841,8 @@ You can require a specific scope for any endpoint like:
814
841
  authenticate_api_key!(scope: "write")
815
842
  ```
816
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
+
817
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:
818
847
 
819
848
  ```ruby
@@ -904,7 +933,7 @@ By default, the `api_key` gem expects API keys to come *exclusively* as HTTP Aut
904
933
  https://example.com/api/endpoint?api_key=ak_123abc...
905
934
  ```
906
935
 
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:
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:
908
937
 
909
938
  ```ruby
910
939
  config.query_param = "api_key"
@@ -912,7 +941,7 @@ config.query_param = "api_key"
912
941
 
913
942
  ### Changing the hashing function to `bcrypt` for maximum security
914
943
 
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.
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.
916
945
 
917
946
  If you need slower, password-grade hashing (e.g., for extremely sensitive tokens), you can switch to bcrypt:
918
947
 
@@ -920,47 +949,53 @@ If you need slower, password-grade hashing (e.g., for extremely sensitive tokens
920
949
  config.hash_strategy = :bcrypt
921
950
  ```
922
951
 
923
- Note: bcrypt is ~50–100x slower than SHA256. For most API use cases, sha256 is more than sufficient.
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.
924
953
 
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.
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.
926
955
 
927
- For 99% of APIs, `sha256` is more than secure enough and far better for performance.
956
+ Use SHA256 for ordinary high-entropy API keys unless your threat model specifically calls for a deliberately expensive verifier.
928
957
 
929
958
  ### Increase cache TTL
930
959
 
931
- We cache token lookups to improve performance, especially for repeated requests. This keeps `bcrypt` and `sha256` strategies fast under load.
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.
932
961
 
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.
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.
934
963
 
935
- If security is your top priority (e.g. rapid revocation after suspected key compromise), you can disable caching entirely:
964
+ You can disable the lookup hint if you prefer not to use Rails.cache:
936
965
 
937
966
  ```ruby
938
967
  config.cache_ttl = 0.seconds # disables caching
939
968
  ```
940
969
 
941
- If performance matters more than real-time revocation, increase the TTL to reduce DB hits:
970
+ Increase the TTL to reduce repeated lookup work without making cached state authoritative:
942
971
 
943
972
  ```ruby
944
- config.cache_ttl = 2.minutes # boosts performance at cost of slower revocation
973
+ config.cache_ttl = 2.minutes
945
974
  ```
946
975
 
947
- ⚠️ Security note: Revoked keys may remain valid for up to cache_ttl. For strict real-time revocation, set cache_ttl = 0.
948
-
949
976
 
950
977
  ## Callbacks: analytics, logging, usage monitoring & auditing
951
978
 
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)
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.
953
980
 
954
981
  You can define logic for them:
955
982
  ```ruby
956
- config.before_authentication = ->(request) { Rails.logger.info "Authenticating request: #{request.uuid}" }
983
+ config.before_authentication = ->(context) do
984
+ Rails.logger.info "Authenticating request: #{context[:request_uuid]}"
985
+ end
957
986
 
958
- config.after_authentication = ->(result) { MyAnalytics.track_auth(result) }
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
959
994
  ```
960
995
 
961
996
  This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem.
962
997
 
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.
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.
964
999
 
965
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.
966
1001
 
@@ -972,6 +1007,15 @@ There's also a `track_requests_count` config option that you can turn on so the
972
1007
 
973
1008
  But again, this is turned off by default for performance purposes, and depends on having a working, well-configured Active Job backend.
974
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
+
975
1019
  ## Key Types: Stripe-style Publishable & Secret Keys
976
1020
 
977
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.
@@ -980,9 +1024,9 @@ For applications that distribute software with embedded API keys (desktop apps,
980
1024
 
981
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:
982
1026
 
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).
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.
984
1028
 
985
- - **Secret keys** (`sk_test_...`, `sk_live_...`): Full access, meant for server-side use only. Can be revoked anytime.
1029
+ - **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime.
986
1030
 
987
1031
  ### Configuration
988
1032
 
@@ -1015,6 +1059,10 @@ ApiKeys.configure do |config|
1015
1059
 
1016
1060
  # Enable strict environment isolation (test keys fail in prod, live keys fail in dev)
1017
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
1018
1066
  end
1019
1067
  ```
1020
1068
 
@@ -1029,7 +1077,7 @@ pk = user.create_api_key!(
1029
1077
  )
1030
1078
  pk.token # => "pk_live_abc123..."
1031
1079
 
1032
- # Create a secret key (full access)
1080
+ # Create a secret key (access is controlled by its scopes/type ceiling)
1033
1081
  sk = user.create_api_key!(
1034
1082
  name: "Admin Dashboard",
1035
1083
  key_type: :secret
@@ -1070,6 +1118,7 @@ pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
1070
1118
  ```
1071
1119
 
1072
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.
1073
1122
 
1074
1123
  ### Public Keys (Viewable Tokens)
1075
1124
 
@@ -1081,7 +1130,7 @@ This is especially problematic when combined with `limit: 1`, which restricts us
1081
1130
 
1082
1131
  #### The Solution: Storing Public Keys
1083
1132
 
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.
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.
1085
1134
 
1086
1135
  The `public: true` option stores the plaintext token in metadata so users can view it again:
1087
1136
 
@@ -1102,23 +1151,24 @@ config.key_types = {
1102
1151
  }
1103
1152
  ```
1104
1153
 
1105
- #### Security: Why This is Safe
1154
+ #### Security constraints
1106
1155
 
1107
1156
  > [!IMPORTANT]
1108
- > The `public` option only works when BOTH conditions are met:
1157
+ > The `public` option only works when all of these conditions are met:
1109
1158
  > - `public: true` is set in the key type configuration
1110
1159
  > - `revocable: false` is set (non-revocable keys only)
1160
+ > - `permissions` is a finite, non-empty array (never `:all`)
1111
1161
 
1112
- This double-check is a deliberate safety measure:
1162
+ These checks are deliberate safety measures:
1113
1163
 
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.
1164
+ 1. **Configuration is validated early** — Public types must explicitly be non-revocable and have a finite, non-empty permission ceiling.
1115
1165
 
1116
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.
1117
1167
 
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.
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.
1119
1169
 
1120
1170
  > [!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.
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.
1122
1172
 
1123
1173
  When a key is public, the dashboard shows a "Show" button to reveal the full token:
1124
1174
 
@@ -1143,6 +1193,10 @@ With `strict_environment_isolation = true`, keys can only authenticate in their
1143
1193
 
1144
1194
  This prevents accidentally using test keys in production (or vice versa).
1145
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
+
1146
1200
  ### Key Limits
1147
1201
 
1148
1202
  The `limit` option restricts how many keys of a type can exist per owner per environment:
@@ -1181,7 +1235,7 @@ Existing keys without `key_type`/`environment` continue to work normally (backwa
1181
1235
  ## Enterprise-ready by design
1182
1236
  The `api_keys` gem ships with:
1183
1237
 
1184
- - Flexible storage
1238
+ - Active Record storage across supported Rails databases
1185
1239
  - Async hooks
1186
1240
  - ActiveJob support
1187
1241
  - Polymorphic ownership (User, Org, etc.)
@@ -1203,11 +1257,17 @@ There's a demo Rails app showcasing the features in the `api_keys` gem under `te
1203
1257
 
1204
1258
  ## Testing
1205
1259
 
1206
- 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
+ ```
1207
1267
 
1208
1268
  ## Development
1209
1269
 
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.
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.
1211
1271
 
1212
1272
  To install this gem onto your local machine, run `bundle exec rake install`.
1213
1273
 
@@ -1215,6 +1275,8 @@ To install this gem onto your local machine, run `bundle exec rake install`.
1215
1275
 
1216
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.
1217
1277
 
1278
+ Please report vulnerabilities privately as described in [SECURITY.md](SECURITY.md), not in a public issue.
1279
+
1218
1280
  ## License
1219
1281
 
1220
1282
  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.
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module ApiKeys
4
6
  # Base controller for the ApiKeys engine.
5
7
  # Inherits from the host application's configured controller
6
8
  # (defaults to ::ApplicationController).
7
9
  # Includes common engine functionality.
8
- class ApplicationController < ApiKeys::Engine.config.parent_controller.constantize
10
+ class ApplicationController < ApiKeys.configuration.parent_controller_class
9
11
  # Protect from forgery if the parent controller does
10
12
  # This ensures CSRF protection behaves consistently with the host app.
11
13
  protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
@@ -13,28 +15,61 @@ module ApiKeys
13
15
  # Include the main controller concern which bundles authentication and tenant resolution
14
16
  include ApiKeys::Controller
15
17
 
18
+ # The dashboard renders credential material, so give it an enforcing,
19
+ # self-contained CSP even when the host application has not configured one.
20
+ # Preserve any host directives we do not explicitly tighten.
21
+ before_action :prepare_api_keys_content_security_policy_nonce
22
+ if respond_to?(:content_security_policy)
23
+ content_security_policy do |policy|
24
+ policy.default_src :none
25
+ policy.base_uri :none
26
+ policy.object_src :none
27
+ policy.frame_ancestors :none
28
+ policy.frame_src :none
29
+ policy.form_action :self
30
+ # Rails appends the per-request nonce to these directives. `none` leaves
31
+ # the nonce as the only effective source instead of trusting every
32
+ # same-origin script or stylesheet.
33
+ policy.script_src :none
34
+ policy.style_src :none
35
+ policy.img_src :self, :data
36
+ policy.connect_src :self
37
+ end
38
+ content_security_policy_report_only false
39
+ end
40
+
16
41
  # Ensure the owner is authenticated for all actions within this engine
17
42
  # This uses the configured authentication method (defaults to authenticate_user!)
18
43
  before_action :authenticate_api_keys_owner!
44
+ after_action :set_api_keys_security_headers
19
45
 
20
46
  private
21
47
 
48
+ def prepare_api_keys_content_security_policy_nonce
49
+ return unless request.respond_to?(:content_security_policy_nonce_generator=)
50
+
51
+ request.content_security_policy_nonce_generator ||= ->(_request) { SecureRandom.base64(16) }
52
+ directives = Array(request.content_security_policy_nonce_directives)
53
+ request.content_security_policy_nonce_directives = directives | %w[script-src style-src]
54
+ request.content_security_policy_nonce
55
+ end
56
+
22
57
  # Authenticates the owner accessing the engine.
23
58
  # Uses the configured authentication method from ApiKeys.configuration
24
59
  def authenticate_api_keys_owner!
25
60
  auth_method = ApiKeys.configuration.authenticate_owner_method
26
61
 
27
- # Try to call the configured authentication method
28
- if auth_method && respond_to?(auth_method, true)
29
- send(auth_method)
30
- elsif auth_method && defined?(auth_method)
31
- send(auth_method)
32
- else
33
- # Fallback: check if owner is present
34
- unless current_api_keys_owner
35
- redirect_to main_app.root_path, alert: "You need to sign in before continuing." rescue render plain: "Unauthorized", status: :unauthorized
62
+ if auth_method.present?
63
+ unless respond_to?(auth_method, true)
64
+ log_error "[ApiKeys Security] Configured owner authentication method is unavailable."
65
+ return render_api_keys_unauthorized
36
66
  end
67
+
68
+ send(auth_method)
69
+ return if performed?
37
70
  end
71
+
72
+ render_api_keys_unauthorized unless current_api_keys_owner
38
73
  end
39
74
 
40
75
  # Helper method to access the current owner from the host application.
@@ -52,5 +87,18 @@ module ApiKeys
52
87
  # Expose current_api_keys_owner as a helper method for views
53
88
  helper_method :current_api_keys_owner
54
89
 
90
+ def render_api_keys_unauthorized
91
+ render plain: "Unauthorized", status: :unauthorized
92
+ end
93
+
94
+ def set_api_keys_security_headers
95
+ response.headers["Cache-Control"] = "no-store, private"
96
+ response.headers["Pragma"] = "no-cache"
97
+ response.headers["Referrer-Policy"] = "no-referrer"
98
+ response.headers["X-Content-Type-Options"] = "nosniff"
99
+ response.headers["X-Frame-Options"] = "DENY"
100
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
101
+ end
102
+
55
103
  end
56
104
  end