api_keys 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +154 -21
- data/app/controllers/api_keys/keys_controller.rb +39 -8
- data/app/views/api_keys/keys/_form.html.erb +45 -4
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +32 -0
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +49 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/models/api_key.rb +184 -10
- data/lib/api_keys/models/concerns/has_api_keys.rb +59 -10
- data/lib/api_keys/restrictions.rb +379 -0
- data/lib/api_keys/services/authenticator.rb +88 -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 +57 -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 +41 -6
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc838865e865c63ea4e694fc1d413ec5e6f4cad1237ec2441568d9252074af29
|
|
4
|
+
data.tar.gz: 420baff40ec1495f7c2b8dfb704e8856105dca708c379382a092323cb6781d2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7acff018339fd77d6007dfe2a18b7bc272420756a4792a8f028973b4eebb40ec518e919cd47d032b27462cc66fdddf7536233fa68fe71dfbd769eb06acb0f167
|
|
7
|
+
data.tar.gz: 36decdf7512ebeb96a729e5353a9e4f66d92f20b4c58e6cdaf4bd280e26749d388d0b7d50b5f1cb2c8065c50408f24f2781a6df50eb7706a045c89e86bb84d85
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [0.5.0] - 2026-08-25
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- `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.
|
|
7
|
+
- Per-key-type restriction ceilings via `key_types[...][:restrictions]`, mirroring the way `permissions:` caps scopes. Omitted allows both kinds; `[]` forbids restrictions for that type.
|
|
8
|
+
- `config.client_ip_resolver` (defaults to `request.remote_ip`, which honors Rails' trusted proxies).
|
|
9
|
+
- `rails generate api_keys:add_restrictions` for existing installations; new installs create the column from the start.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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:)`.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `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.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
|
|
20
|
+
- Restriction failures never echo the configured allowlist back to the caller.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
1
23
|
## [0.4.3] - 2026-08-24
|
|
2
24
|
|
|
3
25
|
- 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:
|
|
@@ -152,6 +161,7 @@ Once configured, your users can:
|
|
|
152
161
|
- set expiration dates
|
|
153
162
|
- attach scopes / permissions to individual keys
|
|
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)
|
|
@@ -382,7 +396,9 @@ current_org.can_create_api_key?(key_type: :publishable)
|
|
|
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
|
|
@@ -1042,7 +1064,7 @@ end
|
|
|
1042
1064
|
|
|
1043
1065
|
This is especially useful if you want to build custom monitoring, usage tracking or auditing systems on top of the `api_keys` gem.
|
|
1044
1066
|
|
|
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.
|
|
1067
|
+
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
1068
|
|
|
1047
1069
|
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
1070
|
|
|
@@ -1071,10 +1093,12 @@ For applications that distribute software with embedded API keys (desktop apps,
|
|
|
1071
1093
|
|
|
1072
1094
|
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
1095
|
|
|
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
|
|
1096
|
+
- **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
1097
|
|
|
1076
1098
|
- **Secret keys** (`sk_test_...`, `sk_live_...`): Sensitive server-side credentials whose exact access depends on their scopes. They can be revoked anytime.
|
|
1077
1099
|
|
|
1100
|
+
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.
|
|
1101
|
+
|
|
1078
1102
|
### Configuration
|
|
1079
1103
|
|
|
1080
1104
|
Enable key types in your initializer:
|
|
@@ -1086,13 +1110,16 @@ ApiKeys.configure do |config|
|
|
|
1086
1110
|
publishable: {
|
|
1087
1111
|
prefix: "pk", # Token prefix → pk_test_, pk_live_
|
|
1088
1112
|
permissions: %w[read validate], # Scope ceiling (max permissions allowed)
|
|
1089
|
-
|
|
1090
|
-
limit: 1
|
|
1113
|
+
public: true, # Store token so it remains viewable
|
|
1114
|
+
limit: 1, # Max 1 per owner per environment
|
|
1115
|
+
restrictions: [:origins] # May be locked to domains, not to IPs
|
|
1091
1116
|
},
|
|
1092
1117
|
secret: {
|
|
1093
1118
|
prefix: "sk",
|
|
1094
|
-
permissions: :all
|
|
1119
|
+
permissions: :all, # No scope restrictions
|
|
1120
|
+
restrictions: [:ips] # May be locked to IPs, not to domains
|
|
1095
1121
|
# revocable defaults to true, limit defaults to nil (unlimited)
|
|
1122
|
+
# restrictions defaults to both kinds allowed
|
|
1096
1123
|
}
|
|
1097
1124
|
}
|
|
1098
1125
|
|
|
@@ -1116,7 +1143,7 @@ end
|
|
|
1116
1143
|
### Creating Typed Keys
|
|
1117
1144
|
|
|
1118
1145
|
```ruby
|
|
1119
|
-
# Create a publishable key (limited permissions,
|
|
1146
|
+
# Create a publishable key (limited permissions, viewable and revocable)
|
|
1120
1147
|
pk = user.create_api_key!(
|
|
1121
1148
|
name: "Production App",
|
|
1122
1149
|
key_type: :publishable,
|
|
@@ -1154,14 +1181,25 @@ sk.scopes # => ["read", "validate", "issue_license", "admin"]
|
|
|
1154
1181
|
|
|
1155
1182
|
### Non-Revocable Keys
|
|
1156
1183
|
|
|
1157
|
-
Keys with `revocable: false` protect against accidental deletion
|
|
1184
|
+
Keys with `revocable: false` protect against accidental deletion. Configure
|
|
1185
|
+
that lifecycle explicitly on the key type that needs it:
|
|
1158
1186
|
|
|
1159
1187
|
```ruby
|
|
1160
|
-
|
|
1188
|
+
ApiKeys.configure do |config|
|
|
1189
|
+
config.key_types = {
|
|
1190
|
+
permanent_server: {
|
|
1191
|
+
prefix: "skp",
|
|
1192
|
+
permissions: :all,
|
|
1193
|
+
revocable: false
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
end
|
|
1161
1197
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1198
|
+
key = user.create_api_key!(key_type: :permanent_server)
|
|
1199
|
+
|
|
1200
|
+
key.revocable? # => false
|
|
1201
|
+
key.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1202
|
+
key.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
|
|
1165
1203
|
```
|
|
1166
1204
|
|
|
1167
1205
|
The dashboard UI automatically hides the revoke button for non-revocable keys.
|
|
@@ -1169,11 +1207,11 @@ Deleting the owning record still cascades deletion to all of its API keys, inclu
|
|
|
1169
1207
|
|
|
1170
1208
|
### Public Keys (Viewable Tokens)
|
|
1171
1209
|
|
|
1172
|
-
####
|
|
1210
|
+
#### Why Public Tokens Are Viewable
|
|
1173
1211
|
|
|
1174
|
-
|
|
1212
|
+
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
1213
|
|
|
1176
|
-
|
|
1214
|
+
Public keys solve that display problem independently of lifecycle policy: they can be revocable (the default) or explicitly non-revocable.
|
|
1177
1215
|
|
|
1178
1216
|
#### The Solution: Storing Public Keys
|
|
1179
1217
|
|
|
@@ -1186,7 +1224,6 @@ config.key_types = {
|
|
|
1186
1224
|
publishable: {
|
|
1187
1225
|
prefix: "pk",
|
|
1188
1226
|
permissions: %w[read validate],
|
|
1189
|
-
revocable: false,
|
|
1190
1227
|
public: true, # Store token for later viewing
|
|
1191
1228
|
limit: 1
|
|
1192
1229
|
},
|
|
@@ -1201,16 +1238,15 @@ config.key_types = {
|
|
|
1201
1238
|
#### Security constraints
|
|
1202
1239
|
|
|
1203
1240
|
> [!IMPORTANT]
|
|
1204
|
-
> The `public` option only works when
|
|
1241
|
+
> The `public` option only works when both of these conditions are met:
|
|
1205
1242
|
> - `public: true` is set in the key type configuration
|
|
1206
|
-
> - `revocable: false` is set (non-revocable keys only)
|
|
1207
1243
|
> - `permissions` is a finite, non-empty array (never `:all`)
|
|
1208
1244
|
|
|
1209
1245
|
These checks are deliberate safety measures:
|
|
1210
1246
|
|
|
1211
|
-
1. **Configuration is validated early** — Public types must
|
|
1247
|
+
1. **Configuration is validated early** — Public types must have a finite, non-empty permission ceiling.
|
|
1212
1248
|
|
|
1213
|
-
2. **
|
|
1249
|
+
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
1250
|
|
|
1215
1251
|
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
1252
|
|
|
@@ -1279,6 +1315,103 @@ rails db:migrate
|
|
|
1279
1315
|
|
|
1280
1316
|
Existing keys without `key_type`/`environment` continue to work normally (backwards compatible).
|
|
1281
1317
|
|
|
1318
|
+
## Restrict where a key can be used (origins and IPs)
|
|
1319
|
+
|
|
1320
|
+
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.
|
|
1321
|
+
|
|
1322
|
+
Any key can carry two lists:
|
|
1323
|
+
|
|
1324
|
+
- **Allowed web origins**: bare hosts, matched against the browser's `Origin` header (falling back to `Referer`). Supports `*.` subdomain wildcards.
|
|
1325
|
+
- **Allowed IP addresses**: single IPv4/IPv6 addresses or CIDR ranges.
|
|
1326
|
+
|
|
1327
|
+
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.
|
|
1328
|
+
|
|
1329
|
+
### Upgrading an existing installation
|
|
1330
|
+
|
|
1331
|
+
New installations already have the column. To add it to an existing app:
|
|
1332
|
+
|
|
1333
|
+
```bash
|
|
1334
|
+
rails generate api_keys:add_restrictions
|
|
1335
|
+
rails db:migrate
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
### Usage
|
|
1339
|
+
|
|
1340
|
+
```ruby
|
|
1341
|
+
# At creation time
|
|
1342
|
+
key = user.create_api_key!(
|
|
1343
|
+
name: "Widget key",
|
|
1344
|
+
key_type: :publishable,
|
|
1345
|
+
allowed_origins: "example.com, *.example.com"
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
# Or any time after: raw strings are parsed and normalized for you
|
|
1349
|
+
key.allowed_ips = "203.0.113.7, 10.0.0.0/8"
|
|
1350
|
+
key.save!
|
|
1351
|
+
|
|
1352
|
+
key.allowed_origins # => ["example.com", "*.example.com"]
|
|
1353
|
+
key.allowed_ips # => ["203.0.113.7", "10.0.0.0/8"]
|
|
1354
|
+
key.restricted? # => true
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
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.
|
|
1358
|
+
|
|
1359
|
+
### Semantics
|
|
1360
|
+
|
|
1361
|
+
| Rule | Behavior |
|
|
1362
|
+
|---|---|
|
|
1363
|
+
| Within one list | **OR** — any entry that matches admits the request |
|
|
1364
|
+
| Across both lists | **AND** — every list that has entries must pass |
|
|
1365
|
+
| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected |
|
|
1366
|
+
| `example.com` | Matches that exact host. Case-insensitive, port-blind, scheme-blind |
|
|
1367
|
+
| `*.example.com` | Matches `a.example.com` and `a.b.example.com`, but **not** the apex `example.com`. List both to cover both |
|
|
1368
|
+
| `*` alone | Invalid. An empty list already means "anywhere" |
|
|
1369
|
+
| IP entries | `203.0.113.7` matches exactly; `10.0.0.0/8` and `2001:db8::/32` match their whole range |
|
|
1370
|
+
| No readable origin on an origins-locked key | **Refused.** Every failure mode fails closed |
|
|
1371
|
+
| Refusal response | `403 Forbidden` with `origin_not_allowed`, `ip_not_allowed`, or `restriction_misconfigured` for damaged policy data |
|
|
1372
|
+
|
|
1373
|
+
An origins-locked key is therefore unusable from origin-less server code, which is exactly the point of locking a browser key.
|
|
1374
|
+
|
|
1375
|
+
### Per-key-type restriction ceilings
|
|
1376
|
+
|
|
1377
|
+
Key types can cap which kinds of restrictions their keys may carry, the same way `permissions:` caps scopes:
|
|
1378
|
+
|
|
1379
|
+
```ruby
|
|
1380
|
+
config.key_types = {
|
|
1381
|
+
publishable: { prefix: "pk", permissions: %w[read], public: true,
|
|
1382
|
+
restrictions: [:origins] }, # Browser keys lock to domains
|
|
1383
|
+
secret: { prefix: "sk", permissions: :all,
|
|
1384
|
+
restrictions: [:ips] } # Server keys lock to addresses
|
|
1385
|
+
}
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restrictions for that type. A key carrying a kind its type forbids fails validation.
|
|
1389
|
+
|
|
1390
|
+
### Resolving the client IP
|
|
1391
|
+
|
|
1392
|
+
IP checks use `request.remote_ip`, which honors Rails' `config.action_dispatch.trusted_proxies`. Configure that Rails setting for your reverse proxy or CDN and keep the default resolver whenever possible.
|
|
1393
|
+
|
|
1394
|
+
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:
|
|
1395
|
+
|
|
1396
|
+
```ruby
|
|
1397
|
+
# config/initializers/api_keys.rb
|
|
1398
|
+
config.client_ip_resolver = ->(request) do
|
|
1399
|
+
request.headers.fetch("CF-Connecting-IP")
|
|
1400
|
+
end
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
### Dashboard
|
|
1404
|
+
|
|
1405
|
+
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.
|
|
1406
|
+
|
|
1407
|
+
### Security notes
|
|
1408
|
+
|
|
1409
|
+
- `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.
|
|
1410
|
+
- IP restrictions inherit the truthfulness of their resolver. Configure Rails' `trusted_proxies`; only trust a CDN-supplied header when direct access to the origin is blocked, or callers can spoof the address being checked.
|
|
1411
|
+
- 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.
|
|
1412
|
+
- 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`).
|
|
1413
|
+
- 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.
|
|
1414
|
+
|
|
1282
1415
|
## Enterprise-ready by design
|
|
1283
1416
|
The `api_keys` gem ships with:
|
|
1284
1417
|
|
|
@@ -4,7 +4,7 @@ module ApiKeys
|
|
|
4
4
|
# Controller for managing API keys belonging to the current owner.
|
|
5
5
|
class KeysController < ApplicationController
|
|
6
6
|
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
|
|
7
|
-
helper_method :key_types_feature_enabled
|
|
7
|
+
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds
|
|
8
8
|
|
|
9
9
|
# GET /keys
|
|
10
10
|
def index
|
|
@@ -48,7 +48,7 @@ module ApiKeys
|
|
|
48
48
|
|
|
49
49
|
# GET /keys/new
|
|
50
50
|
def new
|
|
51
|
-
@api_key = current_api_keys_owner.api_keys.build
|
|
51
|
+
@api_key = current_api_keys_owner.api_keys.build(key_type: ApiKeys.configuration.default_key_type)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# POST /keys
|
|
@@ -62,7 +62,10 @@ module ApiKeys
|
|
|
62
62
|
name: submitted_params[:name],
|
|
63
63
|
scopes: submitted_params[:scopes],
|
|
64
64
|
expires_at: parse_expiration(submitted_params[:expires_at_preset]),
|
|
65
|
-
key_type: submitted_params[:key_type].presence
|
|
65
|
+
key_type: submitted_params[:key_type].presence,
|
|
66
|
+
# The model normalizes these raw strings; no parser needed here.
|
|
67
|
+
allowed_origins: submitted_params[:allowed_origins],
|
|
68
|
+
allowed_ips: submitted_params[:allowed_ips]
|
|
66
69
|
# Metadata could be added here if needed
|
|
67
70
|
)
|
|
68
71
|
|
|
@@ -73,6 +76,7 @@ module ApiKeys
|
|
|
73
76
|
rescue ActiveRecord::RecordInvalid => e
|
|
74
77
|
# If create! fails due to validation (e.g., quota exceeded)
|
|
75
78
|
@api_key = e.record # Get the invalid ApiKey instance
|
|
79
|
+
@api_key.expires_at_preset = submitted_params[:expires_at_preset]
|
|
76
80
|
flash.now[:alert] = "Failed to create API key: #{e.record.errors.full_messages.join(', ')}"
|
|
77
81
|
render :new, status: :unprocessable_entity
|
|
78
82
|
rescue ArgumentError
|
|
@@ -133,18 +137,23 @@ module ApiKeys
|
|
|
133
137
|
submitted = params.require(:api_key)
|
|
134
138
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
135
139
|
|
|
136
|
-
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
140
|
+
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
141
|
+
:allowed_origins, :allowed_ips, scopes: [])
|
|
137
142
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
138
143
|
permitted_params
|
|
139
144
|
end
|
|
140
145
|
|
|
141
|
-
# Only allow updating name and
|
|
146
|
+
# Only allow updating name, scopes, and request restrictions.
|
|
147
|
+
# Restriction edits stay available on non-revocable keys so an owner can
|
|
148
|
+
# still tighten the policy even when lifecycle operations are disabled.
|
|
142
149
|
def api_key_update_params
|
|
143
150
|
submitted = params.require(:api_key)
|
|
144
151
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
145
152
|
|
|
146
|
-
permitted_params = submitted.permit(:name, scopes: [])
|
|
153
|
+
permitted_params = submitted.permit(:name, :allowed_origins, :allowed_ips, scopes: [])
|
|
147
154
|
permitted_params[:scopes]&.reject!(&:blank?) # Filter out blank strings
|
|
155
|
+
permitted_params.delete(:allowed_origins) unless ApiKeys::ApiKey.restrictions_column?
|
|
156
|
+
permitted_params.delete(:allowed_ips) unless ApiKeys::ApiKey.restrictions_column?
|
|
148
157
|
permitted_params
|
|
149
158
|
end
|
|
150
159
|
|
|
@@ -163,10 +172,32 @@ module ApiKeys
|
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
def rebuild_api_key_for_form(submitted_params)
|
|
166
|
-
current_api_keys_owner.api_keys.build(
|
|
175
|
+
api_key = current_api_keys_owner.api_keys.build(
|
|
167
176
|
name: submitted_params[:name],
|
|
168
|
-
scopes: submitted_params[:scopes]
|
|
177
|
+
scopes: submitted_params[:scopes],
|
|
178
|
+
key_type: submitted_params[:key_type]
|
|
169
179
|
)
|
|
180
|
+
api_key.expires_at_preset = submitted_params[:expires_at_preset]
|
|
181
|
+
if ApiKeys::ApiKey.restrictions_column?
|
|
182
|
+
api_key.allowed_origins = submitted_params[:allowed_origins] unless submitted_params[:allowed_origins].nil?
|
|
183
|
+
api_key.allowed_ips = submitted_params[:allowed_ips] unless submitted_params[:allowed_ips].nil?
|
|
184
|
+
end
|
|
185
|
+
api_key
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Which restriction kinds the form may offer for a given key.
|
|
189
|
+
# A typed key answers with its own ceiling; an unsaved key that has not
|
|
190
|
+
# picked a type yet offers everything any configured type allows.
|
|
191
|
+
#
|
|
192
|
+
# @param api_key [ApiKeys::ApiKey]
|
|
193
|
+
# @return [Array<Symbol>]
|
|
194
|
+
def api_keys_allowed_restriction_kinds(api_key)
|
|
195
|
+
return api_key.allowed_restriction_kinds if api_key.persisted?
|
|
196
|
+
return ApiKeys::Restrictions::KINDS.dup unless key_types_feature_enabled?
|
|
197
|
+
|
|
198
|
+
ApiKeys.configuration.key_types.flat_map do |_type, settings|
|
|
199
|
+
ApiKeys::ApiKey.restriction_kinds_for(settings)
|
|
200
|
+
end.uniq
|
|
170
201
|
end
|
|
171
202
|
|
|
172
203
|
# Check if key types feature is enabled
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<%# Shared form for creating and editing API Keys %>
|
|
2
|
-
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path),
|
|
2
|
+
<%= form_with(model: [:keys, api_key], url: (api_key.persisted? ? key_path(api_key) : keys_path),
|
|
3
|
+
local: true, html: { id: "api-keys-key-form" }) do |form| %>
|
|
3
4
|
<% if api_key.errors.any? %>
|
|
4
5
|
<div class="api-keys-form-errors">
|
|
5
6
|
<strong><%= pluralize(api_key.errors.count, "error") %> prohibited this API key from being saved:</strong>
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
<%= form.text_field :name, placeholder: "e.g., myproject-production-key" %>
|
|
48
49
|
</div>
|
|
49
50
|
|
|
50
|
-
<div>
|
|
51
|
+
<div data-api-keys-expiration>
|
|
51
52
|
<%= form.label :expires_at_preset, "Expiration" %>
|
|
52
53
|
<%= form.select :expires_at_preset,
|
|
53
54
|
options_for_select([
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
["60 days", "60_days"],
|
|
58
59
|
["90 days", "90_days"],
|
|
59
60
|
["365 days", "365_days"] # Common presets
|
|
60
|
-
], api_key.expires_at.present? ? nil : "no_expiration"), # Default selection
|
|
61
|
+
], api_key.expires_at_preset.presence || (api_key.expires_at.present? ? nil : "no_expiration")), # Default selection
|
|
61
62
|
{}, # html options
|
|
62
63
|
{} # data attributes
|
|
63
64
|
%>
|
|
@@ -87,6 +88,8 @@
|
|
|
87
88
|
</div>
|
|
88
89
|
<% end %>
|
|
89
90
|
|
|
91
|
+
<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
|
|
92
|
+
|
|
90
93
|
<% end %>
|
|
91
94
|
|
|
92
95
|
<%# Fields editable on EDIT %>
|
|
@@ -119,6 +122,8 @@
|
|
|
119
122
|
<% end %>
|
|
120
123
|
</div>
|
|
121
124
|
<% end %>
|
|
125
|
+
|
|
126
|
+
<%= render "api_keys/keys/restriction_fields", form: form, api_key: api_key %>
|
|
122
127
|
<% end %>
|
|
123
128
|
|
|
124
129
|
<div>
|
|
@@ -127,9 +132,45 @@
|
|
|
127
132
|
<%= link_to "Cancel", keys_path %>
|
|
128
133
|
<% else %>
|
|
129
134
|
<h4><strong>Keep it safe</strong></h4>
|
|
130
|
-
<p>
|
|
135
|
+
<p>Secret API keys are only shown once after creation. Copy the new key immediately and store it securely.</p>
|
|
131
136
|
<%= form.submit "Create API Key" %>
|
|
132
137
|
<%= link_to "Cancel", keys_path %>
|
|
133
138
|
<% end %>
|
|
134
139
|
</div>
|
|
135
140
|
<% end %>
|
|
141
|
+
|
|
142
|
+
<% if !api_key.persisted? && ApiKeys.configuration.key_types.present? %>
|
|
143
|
+
<% key_type_policies = ApiKeys.configuration.key_types.to_h do |type, config| %>
|
|
144
|
+
<% [type.to_s, {
|
|
145
|
+
restrictions: ApiKeys::ApiKey.restriction_kinds_for(config).map(&:to_s),
|
|
146
|
+
expirable: ApiKeys::ApiKey.revocable_for(config)
|
|
147
|
+
}] %>
|
|
148
|
+
<% end %>
|
|
149
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
150
|
+
(() => {
|
|
151
|
+
const form = document.getElementById("api-keys-key-form");
|
|
152
|
+
const keyType = form && form.querySelector("#api_key_key_type");
|
|
153
|
+
if (!form || !keyType) return;
|
|
154
|
+
|
|
155
|
+
const policies = <%= raw json_escape(key_type_policies.to_json) %>;
|
|
156
|
+
const updatePolicyFields = () => {
|
|
157
|
+
const policy = policies[keyType.value];
|
|
158
|
+
|
|
159
|
+
form.querySelectorAll("[data-api-keys-restriction-kind]").forEach((container) => {
|
|
160
|
+
const enabled = policy && policy.restrictions.includes(container.dataset.apiKeysRestrictionKind);
|
|
161
|
+
container.hidden = !enabled;
|
|
162
|
+
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
form.querySelectorAll("[data-api-keys-expiration]").forEach((container) => {
|
|
166
|
+
const enabled = policy && policy.expirable;
|
|
167
|
+
container.hidden = !enabled;
|
|
168
|
+
container.querySelectorAll("input, select, textarea").forEach((input) => { input.disabled = !enabled; });
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
keyType.addEventListener("change", updatePolicyFields);
|
|
173
|
+
updatePolicyFields();
|
|
174
|
+
})();
|
|
175
|
+
</script>
|
|
176
|
+
<% end %>
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
</span>
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
|
+
<% if key.restricted? %>
|
|
13
|
+
<span class="api-keys-badge api-keys-badge-restricted"
|
|
14
|
+
title="Usable only from specific <%= key.restrictions.kinds.map { |kind| kind == :ips ? 'IP addresses' : 'web origins' }.to_sentence %>">
|
|
15
|
+
Restricted
|
|
16
|
+
</span>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
12
19
|
<% if key.environment.present? %>
|
|
13
20
|
<% is_live = key.environment == 'live' %>
|
|
14
21
|
<span class="api-keys-badge api-keys-badge-env <%= is_live ? 'api-keys-badge-live' : 'api-keys-badge-test' %>">
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# Optional request restriction fields: where a key may be used from. %>
|
|
2
|
+
<%# Locals: form (required), api_key (required) %>
|
|
3
|
+
<%# Only rendered when the restrictions column exists and the key's type allows the kind. %>
|
|
4
|
+
|
|
5
|
+
<% if ApiKeys::ApiKey.restrictions_column? %>
|
|
6
|
+
<% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %>
|
|
7
|
+
|
|
8
|
+
<% if allowed_kinds.include?(:origins) %>
|
|
9
|
+
<div data-api-keys-restriction-kind="origins">
|
|
10
|
+
<%= form.label :allowed_origins, "Allowed web origins (optional)" %>
|
|
11
|
+
<%= form.text_field :allowed_origins,
|
|
12
|
+
value: api_key.allowed_origins.join(", "),
|
|
13
|
+
placeholder: "example.com, *.example.com" %>
|
|
14
|
+
<small class="api-keys-form-help">
|
|
15
|
+
Leave empty to allow any origin. Requests from a browser must come from one of these hosts.
|
|
16
|
+
Use <code>*.example.com</code> to allow every subdomain.
|
|
17
|
+
</small>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if allowed_kinds.include?(:ips) %>
|
|
22
|
+
<div data-api-keys-restriction-kind="ips">
|
|
23
|
+
<%= form.label :allowed_ips, "Allowed IP addresses (optional)" %>
|
|
24
|
+
<%= form.text_field :allowed_ips,
|
|
25
|
+
value: api_key.allowed_ips.join(", "),
|
|
26
|
+
placeholder: "203.0.113.7, 10.0.0.0/8" %>
|
|
27
|
+
<small class="api-keys-form-help">
|
|
28
|
+
Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges.
|
|
29
|
+
</small>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% end %>
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
--api-keys-badge-live-color: #155724;
|
|
37
37
|
--api-keys-badge-test-bg: #f8d7da;
|
|
38
38
|
--api-keys-badge-test-color: #721c24;
|
|
39
|
+
--api-keys-badge-restricted-bg: #e2e3e5;
|
|
40
|
+
--api-keys-badge-restricted-color: #383d41;
|
|
39
41
|
|
|
40
42
|
/* Status colors */
|
|
41
43
|
--api-keys-status-active-color: green;
|
|
@@ -152,6 +154,8 @@
|
|
|
152
154
|
--api-keys-badge-live-color: #9ae6b4;
|
|
153
155
|
--api-keys-badge-test-bg: #742a2a;
|
|
154
156
|
--api-keys-badge-test-color: #feb2b2;
|
|
157
|
+
--api-keys-badge-restricted-bg: #2d3748;
|
|
158
|
+
--api-keys-badge-restricted-color: #cbd5e0;
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
body {
|
|
@@ -212,7 +216,7 @@
|
|
|
212
216
|
.api-keys-status-active { color: var(--api-keys-status-active-color); }
|
|
213
217
|
.api-keys-status-revoked { color: var(--api-keys-status-revoked-color); }
|
|
214
218
|
.api-keys-status-expired { color: var(--api-keys-status-expired-color); }
|
|
215
|
-
.api-keys-badge-type, .api-keys-badge-env {
|
|
219
|
+
.api-keys-badge-type, .api-keys-badge-env, .api-keys-badge-restricted {
|
|
216
220
|
margin-left: 0.25rem;
|
|
217
221
|
padding: 0.15rem 0.4rem;
|
|
218
222
|
border-radius: 3px;
|
|
@@ -234,6 +238,10 @@
|
|
|
234
238
|
color: var(--api-keys-badge-test-color);
|
|
235
239
|
background-color: var(--api-keys-badge-test-bg);
|
|
236
240
|
}
|
|
241
|
+
.api-keys-badge-restricted {
|
|
242
|
+
color: var(--api-keys-badge-restricted-color);
|
|
243
|
+
background-color: var(--api-keys-badge-restricted-bg);
|
|
244
|
+
}
|
|
237
245
|
|
|
238
246
|
.api-keys-button-text {
|
|
239
247
|
padding-left: 0.2em;
|
|
@@ -13,6 +13,11 @@ module ApiKeys
|
|
|
13
13
|
extend ActiveSupport::Concern
|
|
14
14
|
include ApiKeys::Logging
|
|
15
15
|
|
|
16
|
+
# Failures where the credential is valid but the request context is refused.
|
|
17
|
+
# The key itself is fine, so these answer 403 rather than 401 — the same
|
|
18
|
+
# distinction `:missing_scope` already makes.
|
|
19
|
+
FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed restriction_misconfigured].freeze
|
|
20
|
+
|
|
16
21
|
included do
|
|
17
22
|
# Helper methods to access the authenticated key and its owner
|
|
18
23
|
helper_method :current_api_key, :current_api_owner, :current_api_user
|
|
@@ -88,7 +93,8 @@ module ApiKeys
|
|
|
88
93
|
else
|
|
89
94
|
# Authentication failed
|
|
90
95
|
log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
|
|
91
|
-
|
|
96
|
+
status = FORBIDDEN_ERROR_CODES.include?(result.error_code) ? :forbidden : :unauthorized
|
|
97
|
+
render_unauthorized(error_code: result.error_code, message: result.message, status: status)
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
# Enqueue after_authentication callback asynchronously regardless of success/failure
|