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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +195 -27
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +137 -11
- data/app/views/api_keys/keys/_form.html.erb +80 -17
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +54 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +240 -11
- data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
- data/lib/api_keys/restrictions.rb +430 -0
- data/lib/api_keys/services/authenticator.rb +110 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +49 -6
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54964496d525a092f7d1c9759e10bc4e73cce2633040c3f1e3b9a172609d02c3
|
|
4
|
+
data.tar.gz: 4cf7aabd74db8edf00e6ba06fc3cfe1536c26dc606e0485e452b7e920750ad1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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" }
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1090
|
-
limit: 1
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
####
|
|
1231
|
+
#### Why Public Tokens Are Viewable
|
|
1173
1232
|
|
|
1174
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1268
|
+
1. **Configuration is validated early** — Public types must have a finite, non-empty permission ceiling.
|
|
1212
1269
|
|
|
1213
|
-
2. **
|
|
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.
|