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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6396edccad39aa17e98758d5219a3640755173bee395740b18bcf27eef9fa68b
4
- data.tar.gz: a07b09b4765d970d8d7ee8c68c897aae7c6721a5bfb9e9c53b15362551072b4a
3
+ metadata.gz: fc838865e865c63ea4e694fc1d413ec5e6f4cad1237ec2441568d9252074af29
4
+ data.tar.gz: 420baff40ec1495f7c2b8dfb704e8856105dca708c379382a092323cb6781d2c
5
5
  SHA512:
6
- metadata.gz: e6ab972672ad010c5bdcf83f4d1866e4140d604ffa2ec90118bd60742ac9686d81c29b0b061bd2442879ec4efe012f5dc002accb7a1179bbdbbda0e10dd337d2
7
- data.tar.gz: ae0da1c536629537979cd1e9d5b1e85e535fb8f9e82a9fc3b93013ffb759ea45086aea7fcc1a702a82595bfa1040bf2d0b92fa8a260e8f80124d8efa996d7106
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, 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:
@@ -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" } # 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
@@ -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 cannot be revoked individually.
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
- revocable: false, # Cannot be revoked or deleted
1090
- limit: 1 # Max 1 per owner per environment
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 # No scope restrictions
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, cannot be revoked)
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
- pk = user.create_api_key!(key_type: :publishable)
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
- pk.revocable? # => false
1163
- pk.revoke! # Raises ApiKeys::Errors::KeyNotRevocableError
1164
- pk.destroy! # Raises ApiKeys::Errors::KeyNotRevocableError
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
- #### The Problem: Non-Revocable Key Lockout
1210
+ #### Why Public Tokens Are Viewable
1173
1211
 
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.
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
- 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.
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 all of these conditions are met:
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 explicitly be non-revocable and have a finite, non-empty permission ceiling.
1247
+ 1. **Configuration is validated early** — Public types must have a finite, non-empty permission ceiling.
1212
1248
 
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.
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, scopes: [])
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 scopes.
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), local: true) do |form| %>
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>Your API key will only be shown once after creation. <strong>Your key cannot be recovered:</strong> copy it immediately and store it securely.</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
- render_unauthorized(error_code: result.error_code, message: result.message)
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