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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +374 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- 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 +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3183ee0be98a005fd2fc318937f9f8aeb6d8a6c0528a0b75d7234e55cde80480
|
|
4
|
+
data.tar.gz: 67080c1cddf08ee647657c8845a9332c61279249d8c9765ea0ad6fda4e4961a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 (
|
|
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(
|
|
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:
|
|
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
|
|
707
|
+
For security reasons, the gem does not store generated **secret** keys in the database.
|
|
681
708
|
|
|
682
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = ->(
|
|
983
|
+
config.before_authentication = ->(context) do
|
|
984
|
+
Rails.logger.info "Authenticating request: #{context[:request_uuid]}"
|
|
985
|
+
end
|
|
957
986
|
|
|
958
|
-
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
|
|
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
|
-
|
|
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_...`):
|
|
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_...`):
|
|
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 (
|
|
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
|
|
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
|
|
1154
|
+
#### Security constraints
|
|
1106
1155
|
|
|
1107
1156
|
> [!IMPORTANT]
|
|
1108
|
-
> The `public` option only works when
|
|
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
|
-
|
|
1162
|
+
These checks are deliberate safety measures:
|
|
1113
1163
|
|
|
1114
|
-
1. **
|
|
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. **
|
|
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.**
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|