api_keys 0.4.3 → 0.5.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +195 -27
  4. data/SECURITY.md +21 -2
  5. data/app/controllers/api_keys/keys_controller.rb +137 -11
  6. data/app/views/api_keys/keys/_form.html.erb +80 -17
  7. data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
  8. data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
  9. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  10. data/app/views/layouts/api_keys/application.html.erb +9 -1
  11. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  12. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  13. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  14. data/lib/api_keys/authentication.rb +7 -1
  15. data/lib/api_keys/configuration.rb +54 -8
  16. data/lib/api_keys/engine.rb +6 -0
  17. data/lib/api_keys/errors.rb +11 -0
  18. data/lib/api_keys/form_builder_extensions.rb +40 -3
  19. data/lib/api_keys/helpers/token_session.rb +92 -3
  20. data/lib/api_keys/models/api_key.rb +240 -11
  21. data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
  22. data/lib/api_keys/restrictions.rb +430 -0
  23. data/lib/api_keys/services/authenticator.rb +110 -12
  24. data/lib/api_keys/version.rb +1 -1
  25. data/lib/api_keys.rb +1 -0
  26. data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
  27. data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
  28. data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
  29. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
  30. data/lib/generators/api_keys/templates/initializer.rb +49 -6
  31. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6396edccad39aa17e98758d5219a3640755173bee395740b18bcf27eef9fa68b
4
- data.tar.gz: a07b09b4765d970d8d7ee8c68c897aae7c6721a5bfb9e9c53b15362551072b4a
3
+ metadata.gz: 54964496d525a092f7d1c9759e10bc4e73cce2633040c3f1e3b9a172609d02c3
4
+ data.tar.gz: 4cf7aabd74db8edf00e6ba06fc3cfe1536c26dc606e0485e452b7e920750ad1a
5
5
  SHA512:
6
- metadata.gz: e6ab972672ad010c5bdcf83f4d1866e4140d604ffa2ec90118bd60742ac9686d81c29b0b061bd2442879ec4efe012f5dc002accb7a1179bbdbbda0e10dd337d2
7
- data.tar.gz: ae0da1c536629537979cd1e9d5b1e85e535fb8f9e82a9fc3b93013ffb759ea45086aea7fcc1a702a82595bfa1040bf2d0b92fa8a260e8f80124d8efa996d7106
6
+ metadata.gz: 05f939482ae7258c7bc57c2cab1b4c971e58b5db4a74338640ae063b53862cbfdbc8d5e8b4385444595d21edf6c6754842d40ba0c42b6e7afc50fc543e9ca542
7
+ data.tar.gz: 03addc813dbd92704cc1dce4adcc2db22c44d5cd364c2949d83ac19c903e390e1521d389ff95b2d0536652700d16b58a91bf357133bdab55ab655a3e4fc5b5be
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## [0.5.1] - 2026-10-18
2
+
3
+ ### Security
4
+
5
+ - Clearing every permission in the dashboard now removes the key's scopes. With every box unchecked the form submitted no `scopes` parameter, so an update kept the old scopes and a create applied the default scopes. Both checkbox groups now render an empty `api_key[scopes][]` sentinel: an empty selection means "no scopes" and the model's validations decide, while defaults still apply when the parameter is absent. The edit form changes only the scopes it offers (the owner's default scopes) and keeps every other scope a key holds, so a rename no longer strips a scope the form never showed. A failed create re-renders the boxes as submitted instead of re-checking all of them.
6
+ - `FormBuilderExtensions#api_key_scopes_checkboxes` renders the same sentinel before the boxes when a block is given. Host forms will see one extra hidden input. By default it is rendered only when there is at least one box and the form's key enforces its scopes (new `ApiKey#scopes_enforced?`), because where scopes are not enforced an empty list still means "unrestricted". Per-owner default scopes are read through the key's owner, so a form with no object or on a bare `ApiKey.new` renders no sentinel unless you pass `include_hidden: true`; build the key with `owner.api_keys.build`. `include_hidden: false` turns it off.
7
+ - `ApiKey#revoke!` no longer depends on unrelated validations. A key issued before its type's permission or restriction ceiling was narrowed kept authenticating but could not be revoked. Revocation keeps the revocability guard and the save callbacks, writes only `revoked_at` (and `updated_at`), and still validates any other unsaved edits on the same instance. The next request is refused as `revoked_key`, including through the token cache.
8
+ - Malformed restriction values fail closed. Coercion dropped `false`, `nil`, and blank entries, so stored `{"ips": false}`, `{"ips": [false]}`, or `{"ips": [null]}` read as unrestricted and `update!(allowed_ips: false)` stored `{}`; the same applied to origins. Such values are now kept, rejected by validation, and refuse authentication with `restriction_misconfigured` when found in the database; a stored `null` or blank kind is refused too. Input keeps its 0.5.0 meaning where it was unambiguous: a kind given as `nil` or blank text to `restrictions=`, `create_api_key!(restrictions:)`, or the `allowed_origins=` / `allowed_ips=` writers clears that list, so `create_api_key!(restrictions: { ips: nil })` still creates an unrestricted key. `false`, wrong types, and `null` or blank members inside a list are rejected on input. A malformed `restrictions:` argument to `create_api_key!` now reaches validation instead of being normalized to `{}`. The dashboard shows a stored policy's malformed entries instead of empty fields and leaves an untouched field's list as stored, so a rename can no longer turn a malformed policy into an unrestricted one; the save is refused until the owner corrects it.
9
+ - The authentication header is checked for encoding and size before it is parsed. An invalid UTF-8 header raised `ArgumentError` from the Bearer match; it now answers `invalid_token`, and a header longer than the token limit plus 64 bytes is refused without being scanned.
10
+ - The one-time token handoff is now one-time across copies of the session. Retrieval deleted the payload only from the session it was given, so a copied pre-retrieval cookie, or two concurrent requests, could recover the token twice within its ten-minute lifetime. Each handoff now carries a random ID, and the first retrieval records it with an atomic `Rails.cache.write(..., unless_exist: true)`; later retrievals get `nil` while the cache keeps that record, and a cache error withholds the token. The guarantee needs both a shared store with atomic `unless_exist:` and retention of the record for the ten-minute handoff lifetime: if the cache is flushed, restarted, or evicts the record within that window, a copied pre-retrieval cookie recovers the token again. Durable consumption state is planned for 0.6.0. With `ActiveSupport::Cache::NullStore` or no cache, the session deletion remains the only protection. With Solid Cache on PostgreSQL, two simultaneous first retrievals can both succeed; sequential replays are stopped. The handoff format is unchanged, so 0.5.0 and 0.5.1 processes can read each other's handoffs during a rolling deploy.
11
+ - IP restrictions: the README, SECURITY.md, the generated initializer, and the `add_restrictions` generator now state that the default `request.remote_ip` resolver trusts `X-Forwarded-For`. Behind a proxy that is the only way in, keep it; when the Rails server is directly reachable, use `->(request) { request.remote_addr }`. `trusted_proxies` alone is not a boundary. The suite covers both shapes through Rails' `ActionDispatch::RemoteIp`. The default is unchanged.
12
+
13
+ ### Fixed
14
+
15
+ - The dashboard's security page no longer says publishable keys cannot be revoked individually; since 0.5.0 they can, unless their key type sets `revocable: false`.
16
+ - The README's prefix table no longer says a key can be created without a type when key types are configured. Such a key takes `default_key_type`, or creation raises.
17
+
18
+ ## [0.5.0] - 2026-08-25
19
+
20
+ ### Added
21
+
22
+ - Request restrictions: per-key `allowed_origins` (exact hosts and `*.subdomain` wildcards, matched against the browser's Origin with Referer fallback) and `allowed_ips` (IPv4/IPv6, exact or CIDR). Enforced inside `Authenticator.call` for every key on every request, including token-cache hits, so no controller can forget to check and a tightened allowlist takes effect on the next call. Failures answer 403 with `origin_not_allowed` / `ip_not_allowed`. Empty restrictions mean unrestricted, so existing keys are unaffected.
23
+ - `ApiKeys::Restrictions` value object: normalization, matching, and the forgiving parsers dashboards want (`.normalize_origins`, `.normalize_ips`, `.extract_origin_host`). Host applications can delete their own origin parsers.
24
+ - Per-key-type restriction ceilings via `key_types[...][:restrictions]`, mirroring the way `permissions:` caps scopes. Omitted allows both kinds; `[]` forbids restrictions for that type.
25
+ - `config.client_ip_resolver` (defaults to `request.remote_ip`, which honors Rails' trusted proxies).
26
+ - `rails generate api_keys:add_restrictions` for existing installations; new installs create the column from the start.
27
+ - Dashboard: origin and IP fields on the key form (shown dynamically per the selected key type's ceiling), expiration only for expirable key types, preserved form values after errors, and a "Restricted" badge.
28
+ - Model surface: `restrictions`, `restricted?`, `allowed_origins`/`allowed_ips` readers and raw-string writers, `restricted`/`unrestricted` scopes, and `create_api_key!(restrictions:, allowed_origins:, allowed_ips:)`.
29
+ - Refusal attribution: every failure after a key has been identified (revoked, expired, type/environment configuration, isolation, and request restrictions) carries its `api_key_id`; lookup failures do not.
30
+
31
+ ### Changed
32
+
33
+ - `public: true` is now independent of `revocable:`. Public key types remain subject to a finite non-empty permission ceiling, but can use the normal rotation, revocation, deletion, and expiration lifecycle.
34
+
35
+ ### Security
36
+
37
+ - Restriction failures never echo the configured allowlist back to the caller.
38
+ - Every restriction failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, an unknown kind, a scalar policy, or an unparseable stored entry refuses the request. Generated migrations add a database check that the policy is a JSON object where the adapter supports it.
39
+
1
40
  ## [0.4.3] - 2026-08-24
2
41
 
3
42
  - Republish of 0.4.2 with a clean package: the 0.4.2 gem shipped carrying a stray 200 KB `api_keys-0.4.1.gem` blob at its root (committed by accident during the release, harmless but dead weight). No code changes. Prefer this over 0.4.2.
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, 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.
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 and limit to a finite permission set.
9
9
 
10
10
  [ 🟢 [Live interactive demo website](https://apikeys.rameerez.com) ]
11
11
 
@@ -41,6 +41,15 @@ rails db:migrate
41
41
 
42
42
  The generated migration is idempotent and uses a concurrent PostgreSQL index where supported.
43
43
 
44
+ To lock keys to specific web origins or IP addresses (see [Restrict where a key can be used](#restrict-where-a-key-can-be-used-origins-and-ips)), add the restrictions column:
45
+
46
+ ```bash
47
+ rails generate api_keys:add_restrictions
48
+ rails db:migrate
49
+ ```
50
+
51
+ New installations get this column from the start, so this is only for upgrades.
52
+
44
53
  ## Quick Start
45
54
 
46
55
  Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have:
@@ -150,8 +159,9 @@ config.authenticate_owner_method = :ensure_team_access!
150
159
  Once configured, your users can:
151
160
  - self-issue new API keys
152
161
  - set expiration dates
153
- - attach scopes / permissions to individual keys
162
+ - attach scopes / permissions to individual keys (the edit form changes only the scopes it shows, the owner's default scopes, and keeps any other scope a key holds)
154
163
  - add and edit the key names
164
+ - lock a key to specific web origins or IP addresses
155
165
  - revoke instantly
156
166
  - see the status of all their keys
157
167
 
@@ -354,6 +364,10 @@ Filter keys by type and status:
354
364
  @org.api_keys.expired # Past expiration date
355
365
  @org.api_keys.revoked # Manually revoked
356
366
 
367
+ # By request restrictions
368
+ @org.api_keys.restricted # Locked to specific origins and/or IPs
369
+ @org.api_keys.unrestricted # Usable from anywhere
370
+
357
371
  # Chain them
358
372
  @org.api_keys.publishable.active
359
373
  @org.api_keys.secret.inactive.order(created_at: :desc)
@@ -378,11 +392,13 @@ current_org.can_create_api_key?(key_type: :publishable)
378
392
  @api_key = current_org.create_api_key!(
379
393
  name: "My Key",
380
394
  key_type: :secret, # or :publishable
381
- scopes: ["read", "write"], # Blank values auto-removed
395
+ scopes: ["read", "write"], # Blank values auto-removed; [] means none, omit for defaults
382
396
  expires_at: 30.days.from_now, # Explicit date
383
397
  expires_at_preset: "30_days", # OR use preset (takes precedence)
384
398
  environment: :live, # Defaults to current_environment
385
- metadata: { team: "backend" } # Optional JSON metadata
399
+ metadata: { team: "backend" }, # Optional JSON metadata
400
+ allowed_origins: "example.com", # Optional: lock to web origins
401
+ allowed_ips: "10.0.0.0/8" # Optional: lock to IP addresses
386
402
  )
387
403
  ```
388
404
 
@@ -418,6 +434,12 @@ Methods available on `ApiKeys::ApiKey` instances:
418
434
  @api_key.scopes # => ["read", "write"]
419
435
  @api_key.allows_scope?("read") # => true
420
436
 
437
+ # Request restrictions (where the key may be used from)
438
+ @api_key.allowed_origins # => ["example.com", "*.example.com"]
439
+ @api_key.allowed_ips # => ["203.0.113.7", "10.0.0.0/8"]
440
+ @api_key.restricted? # => true if either list has entries
441
+ @api_key.restrictions # => ApiKeys::Restrictions value object
442
+
421
443
  # Metadata
422
444
  @api_key.name # => "Production Server"
423
445
  @api_key.created_at
@@ -448,7 +470,23 @@ ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
448
470
  )
449
471
  ```
450
472
 
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.
473
+ 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. Any retrieval attempt uses the handoff up, including one bound to the wrong key.
474
+
475
+ #### What "one-time" guarantees
476
+
477
+ Deleting the payload makes the handoff one-time within that session: a refresh, or a second visit with the same session, shows nothing. A copy of the session taken *before* the token was shown is a different session. That is what a copied `CookieStore` cookie is, and what two requests racing with the same cookie are. Deleting the payload from one copy does nothing to the other.
478
+
479
+ So each handoff also carries a random ID inside its encrypted payload, and the first retrieval records that ID in `Rails.cache` with `write(key, true, unless_exist: true, expires_in: 10.minutes)`. While the cache still holds that record, any later retrieval of the same handoff, from any copy of the session, finds it and returns `nil`.
480
+
481
+ - **The token is released at most once only when both conditions hold:** every application process shares a store that implements `unless_exist:` atomically (Redis, Memcached), and that store keeps the record for the whole ten-minute handoff lifetime.
482
+ - **The record lives only in the cache.** If the cache is flushed, restarted without persistence, or evicts the record within those ten minutes, a copy of the session taken before retrieval can recover the token again for the rest of that window. Recording consumption durably, in a database column, is the stronger design and is planned for 0.6.0.
483
+ - **`MemoryStore`** only covers the process that holds it. **`FileStore`** checks and then writes, so it stops a later replay on the same host but not two requests that arrive at the same instant.
484
+ - **Solid Cache** (Rails 8's default production store) implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert. On PostgreSQL that locks nothing while the key does not exist yet, so two simultaneous first retrievals can both succeed. Sequential replays are still stopped.
485
+ - **With `ActiveSupport::Cache::NullStore`** (what Rails' generated development and test environments use unless you toggle caching on), or with no `Rails.cache` at all, nothing can be recorded. The gem keeps the pre-0.5.1 behaviour: the session deletion is the only protection, and a copy of the session taken before retrieval can show the token again during its ten-minute lifetime.
486
+ - **If the cache raises or refuses the write**, the token is withheld. The owner sees the "shown only once" message and creates a new key, which is safer than risking a second release.
487
+ - **With a shared cache, a second request for the token page with the same cookie gets nothing**, even if the first response never reached the browser. A client that drops the first response (a timeout or a cancelled load) sees "shown only once" and needs a new key. That is the intended trade-off: a token can be lost, never shown twice.
488
+
489
+ None of this protects a session that has been stolen. Whoever holds a copy of the owner's session cookie can use the dashboard as that owner; the handoff is one-time, the session is not.
452
490
 
453
491
  ---
454
492
 
@@ -505,7 +543,9 @@ Renders a select dropdown with all expiration presets:
505
543
 
506
544
  #### `api_key_scopes_checkboxes`
507
545
 
508
- Renders scope checkboxes with a block for custom markup:
546
+ Renders scope checkboxes with a block for custom markup. Before the boxes it can render one empty `api_key[scopes][]` field: browsers omit unchecked boxes, so without it a form with every box cleared would send no scopes at all, and a new key would silently get the default scopes while an edited key would keep its old ones. With it, clearing every box submits `[""]`, which `create_api_key!` and `ApiKey#scopes=` read as "no scopes". The submitted list replaces the key's scopes, so render a box for every scope the key may hold.
547
+
548
+ By default the field is rendered only when there is at least one box and the form's key enforces its scopes (`@api_key.scopes_enforced?`). That is true for typed keys, whenever `key_types` or the global `config.default_scopes` are configured, and when the owner model's `has_api_keys` declares `default_scopes`. The key reads that last setting through its owner, so build it from the owner (`current_organization.api_keys.build`). A form with no object (`form_with scope: :api_key`) or on a bare `ApiKeys::ApiKey.new` cannot see per-owner defaults and renders no field: pass `include_hidden: true` there to render it whenever there is at least one box. Where scopes are not enforced at all, an empty list still means "unrestricted", so the default leaves the key's scopes as they were rather than widening it. Pass `include_hidden: false` only if you render the field yourself. When you iterate the returned data without a block, render `hidden_field_tag "api_key[scopes][]", "", id: nil` before the boxes under the same conditions.
509
549
 
510
550
  ```erb
511
551
  <%# With block - you control the HTML, gem handles the logic %>
@@ -794,6 +834,8 @@ You can check if an API key is allowed to do actions by checking its scopes:
794
834
  # => true
795
835
  ```
796
836
 
837
+ An empty scope list grants nothing whenever scopes are enforced: on typed keys, and when `key_types` or `default_scopes` are configured. On an untyped key in an app that configures neither, an empty list keeps its legacy meaning and allows every scope. So a key saved from the dashboard with every permission cleared becomes unrestricted if you later remove the owner's `default_scopes` (and the global ones) without using key types.
838
+
797
839
  ## Controllers: secure your API endpoints
798
840
 
799
841
  To add the `api_keys` functionality to your controllers, just use the `ApiKeys::Controller` concern and you'll have all controller methods available:
@@ -917,11 +959,12 @@ When using key types, here's how the prefix is determined:
917
959
  | `key_types` Config | `key_type` Param | Resulting Prefix |
918
960
  |-------------------|------------------|------------------|
919
961
  | `{}` (disabled) | any | Uses `token_prefix` → `"ak_..."` |
920
- | Configured | `nil` | Uses `token_prefix` → `"ak_..."` |
962
+ | Configured | `nil`, with `default_key_type` set | Uses the default type's config → e.g. `"sk_live_..."` |
963
+ | Configured | `nil`, no `default_key_type` | Not created: `create_api_key!` raises `ArgumentError` ("key_type is required when key types are configured") |
921
964
  | Configured | `:publishable` | Uses type config → `"pk_test_..."` |
922
965
  | Configured | `:secret` | Uses type config → `"sk_live_..."` |
923
966
 
924
- In short: `token_prefix` is only used when key types are not configured OR when creating a key without specifying a `key_type`. When you specify both `key_types` config and a `key_type` parameter, the prefix comes entirely from the key type configuration.
967
+ In short: `token_prefix` is only used when key types are not configured. Once you configure `key_types`, every new key needs a type, either passed as `key_type` or taken from `config.default_key_type`, and its prefix comes entirely from that type's configuration. Untyped keys issued before you configured key types keep the prefix they were issued with and keep authenticating.
925
968
 
926
969
  See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section below for full details.
927
970
 
@@ -1042,7 +1085,7 @@ end
1042
1085
 
1043
1086
  This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem.
1044
1087
 
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.
1088
+ 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`. `api_key_id` is present on success and on every refusal where the key was identified but a policy said no (missing scope, environment isolation, origin or IP restrictions), so refused traffic stays attributable to the key that sent it; only lookup failures leave it `nil`. 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.
1046
1089
 
1047
1090
  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.
1048
1091
 
@@ -1071,10 +1114,12 @@ For applications that distribute software with embedded API keys (desktop apps,
1071
1114
 
1072
1115
  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:
1073
1116
 
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.
1117
+ - **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 may be revoked, rotated, and expired unless you explicitly configure `revocable: false`.
1075
1118
 
1076
1119
  - **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime.
1077
1120
 
1121
+ Publishable keys pair naturally with [request restrictions](#restrict-where-a-key-can-be-used-origins-and-ips): lock them to your customers' domains so a lifted key is useless on anyone else's site.
1122
+
1078
1123
  ### Configuration
1079
1124
 
1080
1125
  Enable key types in your initializer:
@@ -1086,13 +1131,16 @@ ApiKeys.configure do |config|
1086
1131
  publishable: {
1087
1132
  prefix: "pk", # Token prefix → pk_test_, pk_live_
1088
1133
  permissions: %w[read validate], # Scope ceiling (max permissions allowed)
1089
- revocable: false, # Cannot be revoked or deleted
1090
- limit: 1 # Max 1 per owner per environment
1134
+ public: true, # Store token so it remains viewable
1135
+ limit: 1, # Max 1 per owner per environment
1136
+ restrictions: [:origins] # May be locked to domains, not to IPs
1091
1137
  },
1092
1138
  secret: {
1093
1139
  prefix: "sk",
1094
- permissions: :all # No scope restrictions
1140
+ permissions: :all, # No scope restrictions
1141
+ restrictions: [:ips] # May be locked to IPs, not to domains
1095
1142
  # revocable defaults to true, limit defaults to nil (unlimited)
1143
+ # restrictions defaults to both kinds allowed
1096
1144
  }
1097
1145
  }
1098
1146
 
@@ -1116,7 +1164,7 @@ end
1116
1164
  ### Creating Typed Keys
1117
1165
 
1118
1166
  ```ruby
1119
- # Create a publishable key (limited permissions, cannot be revoked)
1167
+ # Create a publishable key (limited permissions, viewable and revocable)
1120
1168
  pk = user.create_api_key!(
1121
1169
  name: "Production App",
1122
1170
  key_type: :publishable,
@@ -1154,14 +1202,25 @@ sk.scopes # => ["read", "validate", "issue_license", "admin"]
1154
1202
 
1155
1203
  ### Non-Revocable Keys
1156
1204
 
1157
- Keys with `revocable: false` protect against accidental deletion:
1205
+ Keys with `revocable: false` protect against accidental deletion. Configure
1206
+ that lifecycle explicitly on the key type that needs it:
1158
1207
 
1159
1208
  ```ruby
1160
- pk = user.create_api_key!(key_type: :publishable)
1209
+ ApiKeys.configure do |config|
1210
+ config.key_types = {
1211
+ permanent_server: {
1212
+ prefix: "skp",
1213
+ permissions: :all,
1214
+ revocable: false
1215
+ }
1216
+ }
1217
+ end
1161
1218
 
1162
- pk.revocable? # => false
1163
- pk.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
1164
- pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
1219
+ key = user.create_api_key!(key_type: :permanent_server)
1220
+
1221
+ key.revocable? # => false
1222
+ key.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
1223
+ key.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
1165
1224
  ```
1166
1225
 
1167
1226
  The dashboard UI automatically hides the revoke button for non-revocable keys.
@@ -1169,11 +1228,11 @@ Deleting the owning record still cascades deletion to all of its API keys, inclu
1169
1228
 
1170
1229
  ### Public Keys (Viewable Tokens)
1171
1230
 
1172
- #### The Problem: Non-Revocable Key Lockout
1231
+ #### Why Public Tokens Are Viewable
1173
1232
 
1174
- Non-revocable keys create a potential UX nightmare: if a user creates a publishable key, doesn't copy it immediately, and closes the page—they're locked out. The token is gone forever (we only store the hash), and they can't delete the key to create a new one (it's non-revocable). They're stuck with a useless key slot they can never use or remove.
1233
+ Ordinary secret keys cannot be recovered after their one-time display. That is the right default for confidential credentials, but it provides no secrecy benefit for a token deliberately embedded in public client code. It can also lock an owner out when a non-revocable public key is combined with `limit: 1`.
1175
1234
 
1176
- This is especially problematic when combined with `limit: 1`, which restricts users to a single publishable key per environment. A user who loses their token would be permanently locked out of creating publishable keys.
1235
+ Public keys solve that display problem independently of lifecycle policy: they can be revocable (the default) or explicitly non-revocable.
1177
1236
 
1178
1237
  #### The Solution: Storing Public Keys
1179
1238
 
@@ -1186,7 +1245,6 @@ config.key_types = {
1186
1245
  publishable: {
1187
1246
  prefix: "pk",
1188
1247
  permissions: %w[read validate],
1189
- revocable: false,
1190
1248
  public: true, # Store token for later viewing
1191
1249
  limit: 1
1192
1250
  },
@@ -1201,16 +1259,15 @@ config.key_types = {
1201
1259
  #### Security constraints
1202
1260
 
1203
1261
  > [!IMPORTANT]
1204
- > The `public` option only works when all of these conditions are met:
1262
+ > The `public` option only works when both of these conditions are met:
1205
1263
  > - `public: true` is set in the key type configuration
1206
- > - `revocable: false` is set (non-revocable keys only)
1207
1264
  > - `permissions` is a finite, non-empty array (never `:all`)
1208
1265
 
1209
1266
  These checks are deliberate safety measures:
1210
1267
 
1211
- 1. **Configuration is validated early** — Public types must explicitly be non-revocable and have a finite, non-empty permission ceiling.
1268
+ 1. **Configuration is validated early** — Public types must have a finite, non-empty permission ceiling.
1212
1269
 
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.
1270
+ 2. **Revocability is independent** — Public keys may remain viewable while also being revocable and expirable. `revocable: false` is available only when a permanently deployed identifier is truly required.
1214
1271
 
1215
1272
  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.
1216
1273
 
@@ -1279,6 +1336,117 @@ rails db:migrate
1279
1336
 
1280
1337
  Existing keys without `key_type`/`environment` continue to work normally (backwards compatible).
1281
1338
 
1339
+ ## Restrict where a key can be used (origins and IPs)
1340
+
1341
+ A publishable key lives in your customer's page source, in plain sight. Without any control over *where* it can be used, anyone can lift it and use it from their own website. Lock the key to your customers' domains and a stolen key is useless anywhere else. The same applies to secret keys on the server side: lock them to the addresses your customer's servers actually call from.
1342
+
1343
+ Any key can carry two lists:
1344
+
1345
+ - **Allowed web origins**: bare hosts, matched against the browser's `Origin` header (falling back to `Referer`). Supports `*.` subdomain wildcards.
1346
+ - **Allowed IP addresses**: single IPv4/IPv6 addresses or CIDR ranges.
1347
+
1348
+ Both are enforced inside the gem, on every authenticated request, for every key. There is no controller to opt in and no endpoint that can forget.
1349
+
1350
+ ### Upgrading an existing installation
1351
+
1352
+ New installations already have the column. To add it to an existing app:
1353
+
1354
+ ```bash
1355
+ rails generate api_keys:add_restrictions
1356
+ rails db:migrate
1357
+ ```
1358
+
1359
+ ### Usage
1360
+
1361
+ ```ruby
1362
+ # At creation time
1363
+ key = user.create_api_key!(
1364
+ name: "Widget key",
1365
+ key_type: :publishable,
1366
+ allowed_origins: "example.com, *.example.com"
1367
+ )
1368
+
1369
+ # Or any time after: raw strings are parsed and normalized for you
1370
+ key.allowed_ips = "203.0.113.7, 10.0.0.0/8"
1371
+ key.save!
1372
+
1373
+ key.allowed_origins # => ["example.com", "*.example.com"]
1374
+ key.allowed_ips # => ["203.0.113.7", "10.0.0.0/8"]
1375
+ key.restricted? # => true
1376
+ ```
1377
+
1378
+ Origin input is deliberately forgiving: full URLs, trailing slashes, ports, commas, and newlines are all accepted and reduced to bare lowercase hosts. `https://Shop.example/` becomes `shop.example`. Your dashboard never needs its own parser.
1379
+
1380
+ ### Semantics
1381
+
1382
+ | Rule | Behavior |
1383
+ |---|---|
1384
+ | Within one list | **OR** — any entry that matches admits the request |
1385
+ | Across both lists | **AND** — every list that has entries must pass |
1386
+ | Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected. A kind given as `nil` or blank text (to `allowed_origins=` / `allowed_ips=`, `restrictions=`, or `create_api_key!(restrictions:)`) clears that list. In a stored row only `[]` or an absent kind means empty: a stored `null` is malformed |
1387
+ | Malformed values | It depends on the path. The `allowed_origins=` / `allowed_ips=` writers parse raw text, so `nil`, blank text, and blank members such as `[""]` clear the list, while `false`, numbers, objects, and `nil` members fail validation. A restrictions hash (`restrictions=`, `create_api_key!(restrictions:)`) clears a kind given as `nil` or blank text, and refuses a kind given as `false`, a number, or an object, and `null`, `false`, numbers, objects, or blank strings inside a list. Stored rows are read strictly: a row holding any of these, or a `null` or blank kind, refuses every request with `restriction_misconfigured`, and the dashboard shows the stored entries so a save is refused until they are corrected. Malformed never means unrestricted |
1388
+ | `example.com` | Matches that exact host. Case-insensitive, port-blind, scheme-blind |
1389
+ | `*.example.com` | Matches `a.example.com` and `a.b.example.com`, but **not** the apex `example.com`. List both to cover both |
1390
+ | `*` alone | Invalid. An empty list already means "anywhere" |
1391
+ | IP entries | `203.0.113.7` matches exactly; `10.0.0.0/8` and `2001:db8::/32` match their whole range |
1392
+ | No readable origin on an origins-locked key | **Refused.** Every failure mode fails closed |
1393
+ | Refusal response | `403 Forbidden` with `origin_not_allowed`, `ip_not_allowed`, or `restriction_misconfigured` for damaged policy data |
1394
+
1395
+ An origins-locked key is therefore unusable from origin-less server code, which is exactly the point of locking a browser key.
1396
+
1397
+ ### Per-key-type restriction ceilings
1398
+
1399
+ Key types can cap which kinds of restrictions their keys may carry, the same way `permissions:` caps scopes:
1400
+
1401
+ ```ruby
1402
+ config.key_types = {
1403
+ publishable: { prefix: "pk", permissions: %w[read], public: true,
1404
+ restrictions: [:origins] }, # Browser keys lock to domains
1405
+ secret: { prefix: "sk", permissions: :all,
1406
+ restrictions: [:ips] } # Server keys lock to addresses
1407
+ }
1408
+ ```
1409
+
1410
+ Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restrictions for that type. A key carrying a kind its type forbids fails validation.
1411
+
1412
+ ### Resolving the client IP
1413
+
1414
+ An IP allowlist is only as truthful as the address it is checked against, and the gem cannot see how your servers are deployed. The default resolver is `request.remote_ip`. Rails computes it from the `X-Forwarded-For` header whenever that header is present, so it is right in one deployment shape and spoofable in the other.
1415
+
1416
+ **Behind a proxy (keep the default).** Your load balancer, reverse proxy, or CDN is the only way to reach Rails. It overwrites `X-Forwarded-For`, or appends the address it actually saw, and its addresses are covered by `config.action_dispatch.trusted_proxies` (Rails already trusts private and loopback ranges). Rails then skips the trusted hops and resolves the real client, and an entry the client forged ends up behind the one your proxy added.
1417
+
1418
+ **Directly reachable (use the socket peer).** Clients connect straight to Puma or another Rails server, or your proxy can be bypassed. Then a client can send `X-Forwarded-For: 203.0.113.7` and `request.remote_ip` answers `203.0.113.7`, passing an allowlist that contains it. Resolve the TCP peer instead, which a client cannot choose:
1419
+
1420
+ ```ruby
1421
+ # config/initializers/api_keys.rb
1422
+ config.client_ip_resolver = ->(request) { request.remote_addr }
1423
+ ```
1424
+
1425
+ **`trusted_proxies` alone is not a boundary.** It tells Rails which hops to skip; it does not stop a client that reaches Rails directly from sending the header. If your proxy can be bypassed, close that path at the network layer (firewall or security group) or use the socket-peer resolver.
1426
+
1427
+ The test suite exercises both shapes through Rails' real `ActionDispatch::RemoteIp` middleware.
1428
+
1429
+ Only read a vendor header directly when your network ingress rejects requests that did not come through that vendor. Otherwise a client can send the same header and choose the address your allowlist sees:
1430
+
1431
+ ```ruby
1432
+ # config/initializers/api_keys.rb
1433
+ config.client_ip_resolver = ->(request) do
1434
+ request.headers.fetch("CF-Connecting-IP")
1435
+ end
1436
+ ```
1437
+
1438
+ ### Dashboard
1439
+
1440
+ The mounted dashboard renders only the expiration and request-restriction fields supported by the selected key type, and a **Restricted** badge next to keys carrying a policy. Restriction edits remain available on non-revocable keys so an owner can still tighten an allowlist.
1441
+
1442
+ ### Security notes
1443
+
1444
+ - `Origin` and `Referer` are **browser-enforced** headers. They are trustworthy coming from a real browser and trivially forged by `curl`. Origin restrictions are a browser-context control: they stop a lifted public key from working on someone else's *website*. They are not secrecy. Pair them with keys that cannot spend anything dangerous.
1445
+ - IP restrictions inherit the truthfulness of their resolver. Behind a proxy that is the only way in, keep `request.remote_ip` and list the proxy in `trusted_proxies`. If Rails is reachable directly, resolve `request.remote_addr`, or callers can choose the address being checked with `X-Forwarded-For` (see [Resolving the client IP](#resolving-the-client-ip)). Only trust a CDN-supplied header when direct access to the origin is blocked.
1446
+ - Everything fails closed: a locked list plus an unreadable request context, an unknown policy kind, or malformed stored policy data is a refusal, never a pass.
1447
+ - Refusals never echo the configured allowlist back to the caller. Reflecting your domains to an unauthenticated attacker would be a reconnaissance gift. If you want a more explicit message, override it through i18n (`api_keys.errors.origin_not_allowed`).
1448
+ - Restriction checks read the current database row on every request, cache or no cache. Tightening the origins of a leaked publishable key takes effect on the very next call.
1449
+
1282
1450
  ## Enterprise-ready by design
1283
1451
  The `api_keys` gem ships with:
1284
1452
 
data/SECURITY.md CHANGED
@@ -23,11 +23,30 @@ Do not open a public issue for an undisclosed vulnerability. We will acknowledge
23
23
 
24
24
  Applications remain responsible for:
25
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;
26
+ - trusted TLS/proxy configuration, including a `client_ip_resolver` that matches how clients reach the Rails server (see below), endpoint authorization, and request rate limiting;
27
+ - encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff, and a shared cache store if that handoff must be one-time across copies of a session (see below);
28
28
  - database, cache, queue, log, backup, and observability access controls;
29
29
  - filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
30
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
31
  - prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
32
32
 
33
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.
34
+
35
+ ### Client IP restrictions depend on your deployment
36
+
37
+ `allowed_ips` is checked against `config.client_ip_resolver`, which defaults to `request.remote_ip`. Rails derives that value from `X-Forwarded-For`, so it is only truthful in one of two shapes:
38
+
39
+ - **Behind a proxy that is the only way in.** The proxy overwrites `X-Forwarded-For` or appends the address it saw, and its addresses are covered by `config.action_dispatch.trusted_proxies`. Keep the default resolver.
40
+ - **Directly reachable.** If clients can connect to the Rails server itself, a client can send `X-Forwarded-For: <an allowed address>` and pass the check. Set `config.client_ip_resolver = ->(request) { request.remote_addr }` so the socket peer is checked.
41
+
42
+ Configuring `trusted_proxies` alone is not a boundary: it tells Rails which hops to skip, and does nothing about a client that bypasses the proxy. Block that path at the network layer or use the socket-peer resolver. The gem does not change the default, because the right answer depends on a network it cannot see.
43
+
44
+ ### What the one-time token handoff guarantees
45
+
46
+ The dashboard shows a new secret key once, through an encrypted session handoff that expires after ten minutes and is deleted on first retrieval. Deletion covers only the session that retrieved it. From 0.5.1 the first retrieval also records the handoff's random ID in `Rails.cache` with an atomic `write(..., unless_exist: true)`, so a copy of the session taken before retrieval (a copied cookie, or concurrent requests) gets nothing while the cache keeps that record.
47
+
48
+ - That holds only when both conditions are met: every application process shares a cache store that implements `unless_exist:` atomically, such as Redis or Memcached, and the store retains the record for the ten-minute handoff lifetime. The record exists only in the cache, so a flush, a restart without persistence, or an eviction within that window lets a copy of the session taken before retrieval recover the token again until the handoff expires. Durable consumption state (a database column) is the stronger design and is planned for 0.6.0.
49
+ - `MemoryStore` covers one process; `FileStore` stops later replays on one host but not simultaneous requests. Solid Cache (Rails 8's default production store) implements `unless_exist:` as `SELECT … FOR UPDATE` then an upsert, which on PostgreSQL locks nothing while the key is absent, so two simultaneous first retrievals can both succeed; sequential replays are still stopped.
50
+ - With `ActiveSupport::Cache::NullStore`, or no `Rails.cache`, the gem cannot record anything and the session deletion is the only protection, as before 0.5.1.
51
+ - If the cache raises or refuses the write, the token is withheld rather than risk a second release.
52
+ - It does not protect a stolen session: whoever holds the owner's session cookie can use the dashboard as that owner.