api_keys 0.6.0 → 0.8.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 +38 -0
- data/README.md +287 -2
- data/lib/api_keys/authentication.rb +51 -0
- data/lib/api_keys/configuration.rb +65 -2
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/helpers/view_helpers.rb +2 -1
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/models/api_key.rb +30 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/services/authenticator.rb +10 -3
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +6 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +4 -0
- data/lib/generators/api_keys/templates/initializer.rb +23 -0
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 465a7a5c57c770d52195e2c8a182b00d60ea19dc8cc879ebfdb83b3e16d810f2
|
|
4
|
+
data.tar.gz: daa04e5c07e2dbfe8200c9a43189a2249b680e00737f6c201646a1495847fb06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a4b8b2a0f2386cbbe52505afc68e6ca972aaf4106efa27d72aaa09441805760eea8772666133734c288c39cd8542db096a712bc17ad514a1be8331875faf741d
|
|
7
|
+
data.tar.gz: 3bd195acb458ff535ed41ceb699d3bf6972ace5cc7d46e80a58ac743849ef3549baaaf29baacf836c5ede49587cd93c1a2532ea002f9d5667802d6c830712de6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
## [0.8.0] - 2026-10-25
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- `config.require_bearer_scheme`. When true, the authentication header is accepted only as `Bearer <token>` (any case, spaces or tabs after the scheme); a bare token, another scheme such as `Basic`, or a lone `Bearer` gets the ordinary 401 `invalid_token`, is never looked up, and never falls back to `config.query_param`. It applies to whichever header `config.header` names. The default, false, keeps accepting a bare token as every earlier version did. Asked for by ReplyBrain, whose public API should parse one credential shape only.
|
|
6
|
+
|
|
7
|
+
## [0.7.0] - 2026-10-24
|
|
8
|
+
|
|
9
|
+
Four features that an invoicing app on the fleet runs in production today, moved into the gem with their tests so that apps on the fleet can delete their own copies. Each one is opt-in: an app that does not use it sees no change.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The device authorization grant (RFC 8628), so an agent or a CLI can ask for an API key and get it after a person approves in the app, with no key copied by hand. `ApiKeys::DeviceAuthorization` (`request!`, `find_undecided_by_user_code`, `find_by_device_code`, `approve!(owner, by:)`, `deny!(by:)`, `poll!(client_id:)`, `prune!`) keeps both codes as SHA-256 digests, lives ten minutes, uses an eight-letter user code from twenty consonants that is case- and dash-insensitive, takes the row lock for polls and decisions, and never decides a decided or expired request again. Each request names the type of its key (`device_key_type` in the controller, `config.default_key_type` by default); with key types and no type, the request raises as the app's error. `approve!` checks the owner's key limit for that type and raises `KeyLimitReached` at the limit; the request stays pending. The type is stored with the request and is never resolved again: an untyped request stays untyped. A `scopes: :ceiling` type grants its whole ceiling, so a request must ask for all of it. A configuration change that makes the approved key impossible (key types turned on for an untyped request, a type removed or renamed, a ceiling that grew) makes the poll answer `invalid_scope` and closes the request (`void`). The first poll after the approval creates an ordinary key for the owner, named "client on device", and answers with its token once. A poll never raises because the key cannot be made. At the owner's key limit it answers `authorization_pending`, and a later poll makes the key once there is room; any other failure closes the request. `ApiKeys::DeviceFlow` gives an API controller the `create` and `token` actions with the RFC 8628 answers, the RFC 6749 error shape and `Cache-Control: no-store`; the host names its approval page in `device_verification_uri`, and may add fields to the token answer in `device_token_response`, which runs in the transaction that creates the key. The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`. `rails generate api_keys:add_device_authorizations` creates the table. Its JSON column has no literal default, which MySQL refuses; the model gives the default.
|
|
14
|
+
- The person behind a key, so a key whose owner is an organization stops when the person who created it no longer stands behind it. `created_by`, an optional polymorphic reference on `api_keys`, is set by `create_api_key!(created_by:)` and cannot change after creation: a validation, and `attr_readonly` for `update_column` and saves without validations. `config.trusted_key_check` is a lambda that `authenticate_api_key!` calls with the key after the scope check; a false answer refuses the request with 403 `untrusted_key` through `render_unauthorized`. The device flow names the approving person as `created_by`. The mounted dashboard leaves `created_by` nil; an app that needs it creates keys in its own controller. New installs get the columns; existing installs run `rails generate api_keys:add_created_by`.
|
|
15
|
+
- Idempotency-Key for writes, so a client can retry a write after a timeout without making a second record. `ApiKeys::IdempotencyKey` (`hold(api_key, key)`, `digest_of`, `answer!`, `answered?`, `prune!`, `Busy`) claims the key's row in a commit of its own, runs the write under the row lock, and commits the write with its answer (the status and the record, never a body), or nothing. Keys are per API key and kept a day. `ApiKeys::Idempotency` gives an API controller the `run_once_per_idempotency_key` around_action and `render_resource`; the host defines `resource_json` and `idempotent_replay_allowed?`. A retry answers the record as it is now with `Idempotent-Replayed: true`, and only a record the API key may still read: one moved to another owner is not found. The request digest counts the query and the body apart, and an upload by its bytes, file name and content type, in an encoding that no ordinary value shares. `hold` refuses every open transaction; transactional tests opt in with the test-only `config.idempotency_in_test_transactions`, which takes `true` only when `Rails.env` is test and counts only while it is test. A hash that names a key both as a symbol and as a text is refused. The claim of a new key runs in a transaction of its own under the lock timeout. The refusals are 400 `idempotency_key_required`, 409 `idempotency_key_in_use` and 422 `idempotency_key_reused`, after draft-ietf-httpapi-idempotency-key-header section 2.7. `rails generate api_keys:add_idempotency_keys` creates the table.
|
|
16
|
+
- Scope by HTTP method, so an API asks reads for one scope and writes for another in one line: `authenticate_api_key!(scope: { read: "read", write: "write" })`. GET and HEAD read; every other method writes. Both sides are required.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `untrusted_key` joins the refusal codes that are stamped on a key (`last_refusal_code`) and explained by `api_key_refusal_reason`. It is stamped only when `config.trusted_key_check` refuses a key.
|
|
21
|
+
- `created_by_type` and `created_by_id` join the attributes that cannot change after a key is created.
|
|
22
|
+
- `ApiKey::MAX_NAME_LENGTH` and `ApiKey::NAME_FORMAT` name the existing key name rule, which the device flow uses to name its keys.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- Device flow: the key's token is never stored, and the poll that creates the key is the only answer that carries it. A poll with another `client_id` changes nothing. Two simultaneous polls of one approved request make one key; this was checked with concurrent connections on PostgreSQL, MySQL and SQLite.
|
|
27
|
+
- Idempotency-Key: a retry waits at most two seconds for the request that holds the key, and two first requests wait at most as long for each other's claim (PostgreSQL `lock_timeout`, MySQL `innodb_lock_wait_timeout`; on SQLite, the database's busy timeout), then answer 409. SQLite has no row locks, so there an UPDATE takes the database's write lock first. The device flow's row lock does the same. A new CI job runs the generated migrations and these locks on PostgreSQL, MySQL and SQLite with real concurrent connections.
|
|
28
|
+
- `config.trusted_key_check` fails closed: an error in the lambda fails the request and never passes it as trusted.
|
|
29
|
+
|
|
30
|
+
### Upgrade notes
|
|
31
|
+
|
|
32
|
+
- Nothing changes until an app opts in. Every 0.7.0 migration is optional: `add_created_by` for the person behind a key, `add_device_authorizations` for the device flow, `add_idempotency_keys` for Idempotency-Key. Run them in any order, before or after the deploy.
|
|
33
|
+
- From 0.4.x, the 0.5.0 and 0.6.0 migrations (`add_restrictions`, `add_key_activity`) stay optional too. The calls of a custom key controller keep working: `create_api_key!`, `TokenSession.store(session, api_key)` and `TokenSession.retrieve_once(session)`, and a handoff stored by 0.4.x stays readable. Read the 0.5.1 and 0.6.0 notes: the scope checkboxes render an empty field before the boxes, a token's handoff is one-time across copies of the session, and a retrieval inside an open transaction is refused. The README section "Upgrading to 0.7.0 from 0.4.x or 0.6.x" lists them.
|
|
34
|
+
- A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning. A 0.4.x or 0.5.x worker errors on every refusal job of 0.6.0 or later: deploy job workers with the web processes.
|
|
35
|
+
- Keys created before `add_created_by` have no `created_by`. If `config.trusted_key_check` needs one, backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key.
|
|
36
|
+
- `config.filter_parameters` gains `device_code`, `user_code` and `token`.
|
|
37
|
+
- An app whose tests are transactional and use `ApiKeys::Idempotency` sets `ApiKeys.configuration.idempotency_in_test_transactions = true` in its test environment.
|
|
38
|
+
|
|
1
39
|
## [0.6.0] - 2026-10-18
|
|
2
40
|
|
|
3
41
|
### Added
|
data/README.md
CHANGED
|
@@ -59,6 +59,41 @@ rails db:migrate
|
|
|
59
59
|
|
|
60
60
|
New installations get these columns from the start too. Until an existing app runs the migration, authentication keeps working exactly as before, refusals are simply not recorded, and the one-time token page relies on the cache marker alone, as in 0.5.1.
|
|
61
61
|
|
|
62
|
+
To name the person behind each key (see [The person behind a key](#the-person-behind-a-key)), add the 0.7.0 `created_by` column. New installations get it from the start:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
rails generate api_keys:add_created_by
|
|
66
|
+
rails db:migrate
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The device flow and Idempotency-Key each need a table of their own. New and existing installations run these only if they use the feature:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
rails generate api_keys:add_device_authorizations # for ApiKeys::DeviceFlow
|
|
73
|
+
rails generate api_keys:add_idempotency_keys # for ApiKeys::Idempotency
|
|
74
|
+
rails db:migrate
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Upgrading to 0.7.0 from 0.4.x or 0.6.x
|
|
78
|
+
|
|
79
|
+
Change the Gemfile line to `gem "api_keys", "~> 0.7.0"` and run `bundle update api_keys`. No migration is required: every migration since 0.5.0 adds an optional column or table, and the gem works without it. Run the ones for the features you want, in any order.
|
|
80
|
+
|
|
81
|
+
| From | Optional migrations | What they turn on |
|
|
82
|
+
|------|---------------------|-------------------|
|
|
83
|
+
| 0.4.x | `add_restrictions` | Origin and IP locks (0.5.0) |
|
|
84
|
+
| 0.4.x | `add_key_activity` | Refused-request stats and the durable one-time token page (0.6.0) |
|
|
85
|
+
| 0.4.x, 0.6.x | `add_created_by` | The person behind a key (0.7.0) |
|
|
86
|
+
| 0.4.x, 0.6.x | `add_device_authorizations` | The device flow (0.7.0) |
|
|
87
|
+
| 0.4.x, 0.6.x | `add_idempotency_keys` | Idempotency-Key for writes (0.7.0) |
|
|
88
|
+
|
|
89
|
+
On 0.6.x, nothing changes until you opt in to a 0.7.0 feature. A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning, so a rolling deploy is safe.
|
|
90
|
+
|
|
91
|
+
On 0.4.x, the calls of a custom key controller keep working unchanged: `create_api_key!(name:, key_type:, expires_at:)`, `ApiKeys::TokenSession.store(session, @api_key)` and `ApiKeys::TokenSession.retrieve_once(session)`. A handoff stored by 0.4.x stays readable after the deploy. Read the [CHANGELOG](CHANGELOG.md) for 0.5.0 to 0.6.0 before you upgrade. These changes are the ones a 0.4.x app sees:
|
|
92
|
+
|
|
93
|
+
- `api_key_scopes_checkboxes` renders an empty `api_key[scopes][]` field before the boxes (0.5.1). A form with every box cleared now saves no scopes, where 0.4.x kept the old ones. If an edit form offers only some scopes, save `ApiKey#scopes_after_form_edit` (see [Form Builder Extensions](#form-builder-extensions-opt-in)).
|
|
94
|
+
- A token's one-time handoff is one-time across copies of the session (0.5.1), and once ever per key after `add_key_activity` (0.6.0). A retrieval inside an open database transaction is refused (0.6.0): exclude the token page from a host `around_action` that wraps requests in a transaction.
|
|
95
|
+
- Deploy job workers with the web processes. A refusal job from 0.6.0 or later errors on a 0.4.x worker.
|
|
96
|
+
|
|
62
97
|
## Quick Start
|
|
63
98
|
|
|
64
99
|
Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have:
|
|
@@ -408,7 +443,8 @@ current_org.can_create_api_key?(key_type: :publishable)
|
|
|
408
443
|
environment: :live, # Defaults to current_environment
|
|
409
444
|
metadata: { team: "backend" }, # Optional JSON metadata
|
|
410
445
|
allowed_origins: "example.com", # Optional: lock to web origins
|
|
411
|
-
allowed_ips: "10.0.0.0/8"
|
|
446
|
+
allowed_ips: "10.0.0.0/8", # Optional: lock to IP addresses
|
|
447
|
+
created_by: current_user # Optional: the person behind the key
|
|
412
448
|
)
|
|
413
449
|
```
|
|
414
450
|
|
|
@@ -451,6 +487,9 @@ Methods available on `ApiKeys::ApiKey` instances:
|
|
|
451
487
|
@api_key.restricted? # => true if either list has entries
|
|
452
488
|
@api_key.restrictions # => ApiKeys::Restrictions value object
|
|
453
489
|
|
|
490
|
+
# The person behind the key (needs the created_by column)
|
|
491
|
+
@api_key.created_by # => #<User id: 1, ...>, or nil
|
|
492
|
+
|
|
454
493
|
# Metadata
|
|
455
494
|
@api_key.name # => "Production Server"
|
|
456
495
|
@api_key.created_at
|
|
@@ -952,6 +991,39 @@ def write_action
|
|
|
952
991
|
end
|
|
953
992
|
```
|
|
954
993
|
|
|
994
|
+
### Require read or write by HTTP method
|
|
995
|
+
|
|
996
|
+
Most APIs give reads one scope and writes another. Pass both by HTTP method, in one line:
|
|
997
|
+
|
|
998
|
+
```ruby
|
|
999
|
+
before_action -> { authenticate_api_key!(scope: { read: "read", write: "write" }) }
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
GET and HEAD requests need the `read` scope. Every other method needs the `write` scope. Each side takes what `scope:` takes: a scope, a list of scopes, or `nil` for none. Both sides are required, so a forgotten one never leaves its requests unchecked.
|
|
1003
|
+
|
|
1004
|
+
An action that needs another scope names it. Put the rule in a method, and let a controller override it:
|
|
1005
|
+
|
|
1006
|
+
```ruby
|
|
1007
|
+
class Api::BaseController < ActionController::API
|
|
1008
|
+
include ApiKeys::Controller
|
|
1009
|
+
before_action -> { authenticate_api_key!(scope: required_scope) }
|
|
1010
|
+
|
|
1011
|
+
private
|
|
1012
|
+
|
|
1013
|
+
def required_scope
|
|
1014
|
+
{ read: "read", write: "write" }
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
class Api::InvoicesController < Api::BaseController
|
|
1019
|
+
private
|
|
1020
|
+
|
|
1021
|
+
def required_scope
|
|
1022
|
+
action_name == "pdf" ? "downloads" : super
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
```
|
|
1026
|
+
|
|
955
1027
|
### Rate limit your API endpoints
|
|
956
1028
|
|
|
957
1029
|
Rails 8 introduced the native, built-in `rate_limit` to easily rate limit your endpoints, so `Rack::Attack` is no longer necessary! While this is not an `api_keys` feature per se, I thought it'd be nice to include an example here because it pairs so well with `api_keys`.
|
|
@@ -975,6 +1047,209 @@ This `rate_limit` feature depends on Rails 8+ and an active, well-configured cac
|
|
|
975
1047
|
If you're still on early versions of Rails, you can still use `api_keys`! No need to implement `rate_limit` – just an idea if you're already on Rails 8!
|
|
976
1048
|
|
|
977
1049
|
|
|
1050
|
+
## The person behind a key
|
|
1051
|
+
|
|
1052
|
+
When keys belong to an organization, a key keeps working after the person who created it has left. Name that person when you create the key:
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
current_organization.create_api_key!(name: "CI", scopes: %w[read], created_by: current_user)
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
Then tell the gem when a key stops being trusted:
|
|
1059
|
+
|
|
1060
|
+
```ruby
|
|
1061
|
+
# config/initializers/api_keys.rb
|
|
1062
|
+
config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
After authentication and the scope check, `authenticate_api_key!` calls the lambda with the key. When it answers `false` or `nil`, the request gets `403` with `error: "untrusted_key"`, the key is not set as `current_api_key`, and the refusal is recorded on the key like every other refusal (with the 0.6.0 columns), never as a use. An error in the lambda fails the request; it never passes as trusted. Without a lambda (the default), every valid key is trusted, as before.
|
|
1066
|
+
|
|
1067
|
+
- `created_by` is polymorphic and optional. It cannot change after the key is created (a validation, and `attr_readonly`), so no update can make another person stand behind a key.
|
|
1068
|
+
- The mounted dashboard leaves `created_by` nil: it knows the key's owner, not the person. An app that needs the person behind each key creates its keys in its own controller and passes `created_by:` there (see [Building Custom Integrations](#building-custom-integrations)).
|
|
1069
|
+
- Keys created before you set `created_by` have none. Decide what the lambda does with them, or backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key, so no save, `update_column` or form can change them.
|
|
1070
|
+
- The refusal renders through `render_unauthorized`, so if you override it to render your own error shape, `untrusted_key` uses that shape too. Change the message with I18n: `api_keys.errors.untrusted_key`.
|
|
1071
|
+
- New installs get the columns from the start. Existing installs run `rails generate api_keys:add_created_by`; until then, passing `created_by:` raises `ApiKeys::Errors::CreatedByMigrationRequiredError`.
|
|
1072
|
+
|
|
1073
|
+
## Idempotent writes (Idempotency-Key)
|
|
1074
|
+
|
|
1075
|
+
A client that retries a write after a timeout must not create a second record. With `ApiKeys::Idempotency`, every write carries an `Idempotency-Key` header and runs once per key ([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)):
|
|
1076
|
+
|
|
1077
|
+
```bash
|
|
1078
|
+
rails generate api_keys:add_idempotency_keys
|
|
1079
|
+
rails db:migrate
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
```ruby
|
|
1083
|
+
class Api::BaseController < ActionController::API
|
|
1084
|
+
include ApiKeys::Controller
|
|
1085
|
+
include ApiKeys::Idempotency
|
|
1086
|
+
|
|
1087
|
+
before_action :authenticate_api_key!
|
|
1088
|
+
around_action :run_once_per_idempotency_key # after authentication
|
|
1089
|
+
|
|
1090
|
+
private
|
|
1091
|
+
|
|
1092
|
+
# The JSON for a record. The first answer and every retry use it.
|
|
1093
|
+
def resource_json(record, **options)
|
|
1094
|
+
record.as_json(only: %i[id name]).merge(options)
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# Whether this API key may read the record now. A retry runs no action,
|
|
1098
|
+
# so this is the check before a record's current state goes back.
|
|
1099
|
+
def idempotent_replay_allowed?(record)
|
|
1100
|
+
record.owner == current_api_owner
|
|
1101
|
+
end
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
class Api::WebhooksController < Api::BaseController
|
|
1105
|
+
def create
|
|
1106
|
+
webhook = current_api_owner.webhooks.create!(url: params.require(:url))
|
|
1107
|
+
render_resource webhook, status: :created, secret: webhook.secret # the secret is in this answer only
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
How it works:
|
|
1113
|
+
|
|
1114
|
+
- GET and HEAD run as they are. Every other request needs the header: a text of 1 to 255 characters, the same when the client retries.
|
|
1115
|
+
- A write answers with `render_resource(record, status:, **options)`. The key keeps the status and the record, never the response body, so a secret shown once is never stored.
|
|
1116
|
+
- The write, any rows it adds (an audit entry), and the key's answer commit in one transaction, or nothing does. A write that answers anything but 2xx, or raises, commits nothing and leaves the key free, so the corrected request can use it. That transaction is on the database of the `api_keys` tables: a write to another database does not join it.
|
|
1117
|
+
- A retry with the same key and the same request answers the record **as it is now**, through `resource_json` without the first answer's options, with the header `Idempotent-Replayed: true`. Before that, `idempotent_replay_allowed?(record)` must answer true: a record that moved to another owner, or no longer exists, raises `ActiveRecord::RecordNotFound` (Rails answers 404). Both methods are required, and the first write raises `NotImplementedError` without them.
|
|
1118
|
+
- The request is the method, the path, the query and the body. The query and the body count apart, so a name that moves from one to the other makes another request. The order of names does not matter. An uploaded file counts by its bytes, its file name and its content type, and never encodes like an ordinary value.
|
|
1119
|
+
- The around_action runs outside every open transaction: the key's claim must commit on its own, so `ApiKeys::IdempotencyKey.hold` raises inside one. Transactional tests, which open a transaction around each test, set a test-only switch in `config/environments/test.rb` or the test helper: `ApiKeys.configuration.idempotency_in_test_transactions = true`. It takes `true` only when `Rails.env` is test, and counts only while it is test.
|
|
1120
|
+
- Keys are per API key and kept for a day. Run `ApiKeys::IdempotencyKey.prune!` once a day, for example from a recurring job.
|
|
1121
|
+
|
|
1122
|
+
The refusals follow the draft (section 2.7). They render through `render_unauthorized`, like the gem's other refusals, and their messages change with I18n (`api_keys.errors.<code>`):
|
|
1123
|
+
|
|
1124
|
+
| Status | `error` | When |
|
|
1125
|
+
|--------|---------|------|
|
|
1126
|
+
| 400 | `idempotency_key_required` | A write without the header, or with one longer than 255 characters |
|
|
1127
|
+
| 409 | `idempotency_key_in_use` | A request with this key is still running |
|
|
1128
|
+
| 422 | `idempotency_key_reused` | The key was used for another request |
|
|
1129
|
+
|
|
1130
|
+
A retry waits at most two seconds for the request that holds the key, then answers 409, and two first requests with the same key wait for each other's claim at most as long. On PostgreSQL (`lock_timeout`) and MySQL (`innodb_lock_wait_timeout`) the gem sets that timeout for the claim and the lock, then puts the old value back. SQLite has no row locks: a retry waits for the database's write lock, as long as the database's busy timeout. CI checks this on PostgreSQL, MySQL and SQLite with real concurrent connections (`test/databases/check.rb`). Every successful write must answer with `render_resource`; one that does not raises and commits nothing.
|
|
1131
|
+
|
|
1132
|
+
The model works outside a controller too, for example in a tool that shares the API's keys:
|
|
1133
|
+
|
|
1134
|
+
```ruby
|
|
1135
|
+
digest = ApiKeys::IdempotencyKey.digest_of("TOOL", "create_webhook", arguments)
|
|
1136
|
+
ApiKeys::IdempotencyKey.hold(api_key, key) do |record|
|
|
1137
|
+
next replay(record) if record.answered? # compare record.request_digest with digest first
|
|
1138
|
+
|
|
1139
|
+
record.update!(request_digest: digest)
|
|
1140
|
+
webhook = create_webhook(arguments)
|
|
1141
|
+
record.answer!(200, webhook)
|
|
1142
|
+
end
|
|
1143
|
+
# raises ApiKeys::IdempotencyKey::Busy while another call holds the key
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
## Let an agent ask for a key: the device flow (RFC 8628)
|
|
1147
|
+
|
|
1148
|
+
An agent or a CLI should not ask a person to copy a key into it. With the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), the agent asks for a key, the person approves in your app, and the agent receives an ordinary API key:
|
|
1149
|
+
|
|
1150
|
+
1. The agent asks for codes and shows the person one link and a user code like `BCDF-GHJK`.
|
|
1151
|
+
2. The person opens the link in your app, signs in, compares the code, sees the agent's name, its device and the scopes, and approves or denies.
|
|
1152
|
+
3. The agent polls. The first poll after the approval creates the key for the owner the person chose, named "client on device", and answers with its token, once.
|
|
1153
|
+
|
|
1154
|
+
```bash
|
|
1155
|
+
rails generate api_keys:add_device_authorizations
|
|
1156
|
+
rails db:migrate
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
**The agent's two endpoints.** Include `ApiKeys::DeviceFlow` in an API controller, name your approval page, and route the two actions:
|
|
1160
|
+
|
|
1161
|
+
```ruby
|
|
1162
|
+
class Api::DeviceAuthorizationsController < ActionController::API
|
|
1163
|
+
include ApiKeys::DeviceFlow
|
|
1164
|
+
|
|
1165
|
+
private
|
|
1166
|
+
|
|
1167
|
+
# The approval page, with the user code filled in when one is given
|
|
1168
|
+
def device_verification_uri(user_code = nil)
|
|
1169
|
+
device_approval_url(user_code: user_code)
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
# config/routes.rb
|
|
1174
|
+
post "api/device", to: "api/device_authorizations#create"
|
|
1175
|
+
post "api/token", to: "api/device_authorizations#token"
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
The agent sends `client_id` (its name), `device_name` (optional) and `scope` (space-separated), and gets the [RFC 8628 §3.2](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) answer:
|
|
1179
|
+
|
|
1180
|
+
```json
|
|
1181
|
+
{ "device_code": "…", "user_code": "BCDF-GHJK",
|
|
1182
|
+
"verification_uri": "https://example.com/device",
|
|
1183
|
+
"verification_uri_complete": "https://example.com/device?user_code=BCDF-GHJK",
|
|
1184
|
+
"expires_in": 600, "interval": 5 }
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
It polls `token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, the `device_code` and the same `client_id`. Until the person decides, it gets the [§3.5](https://www.rfc-editor.org/rfc/rfc8628#section-3.5) errors as `{ "error": …, "error_description": … }` with status 400: `authorization_pending`, `slow_down` (a poll sooner than the interval, which then grows by 5 seconds), `access_denied`, `expired_token`, and `invalid_grant` for an unknown or used device code or another `client_id`. After the approval it gets the key once:
|
|
1188
|
+
|
|
1189
|
+
```json
|
|
1190
|
+
{ "access_token": "ak_…", "token_type": "Bearer", "scope": "read write" }
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
No answer may be cached: every answer has `Cache-Control: no-store`. An agent may ask for any scope in `config.default_scopes`. A request without `scope` fails with `invalid_scope`, unless the controller defines `device_default_scope` (for example `"read"`).
|
|
1194
|
+
|
|
1195
|
+
The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`, so request logs never show the codes or a token. If your approval page takes the user code under another parameter name, add that name too.
|
|
1196
|
+
|
|
1197
|
+
**Key types.** Every request names the type of the key it asks for: `device_key_type` in the controller, `config.default_key_type` by default. With key types configured and no type named, the request raises, because that is the app's error, not the client's. The scopes an agent may ask for are then the ones of `config.default_scopes` that the type's `permissions` allow. A type with `scopes: :ceiling` gives every key its whole ceiling, so a request must ask for all of it. The type is stored with the request and is the type of the key: an untyped request stays untyped, and a new default type never applies to it.
|
|
1198
|
+
|
|
1199
|
+
If the configuration changes after the approval so that the key can no longer be made as the person approved it, the poll answers `invalid_scope` (RFC 6749 §5.2) and closes the request (state `void`): key types turned on for an untyped request, the request's type removed or renamed, or a ceiling that grew. Any other failure to make the key closes it too. An owner at its key limit is the one case that waits: the poll answers `authorization_pending`.
|
|
1200
|
+
|
|
1201
|
+
**The person's page** stays in your app, behind your sign-in. Find the request by the code the person typed or followed, show what the agent asks for, and decide:
|
|
1202
|
+
|
|
1203
|
+
```ruby
|
|
1204
|
+
class DeviceApprovalsController < ApplicationController
|
|
1205
|
+
before_action :authenticate_user!
|
|
1206
|
+
# Look-ups and approvals share one budget, so a guess sent straight to
|
|
1207
|
+
# update costs the same as one typed on the page (RFC 8628 §5.1).
|
|
1208
|
+
rate_limit to: 10, within: 10.minutes, by: -> { current_user.id }, only: %i[show update]
|
|
1209
|
+
|
|
1210
|
+
def show
|
|
1211
|
+
@authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code]) if params[:user_code].present?
|
|
1212
|
+
# Show @authorization.client_id, .device_name, .scopes, .requested_from and the code,
|
|
1213
|
+
# and ask the person to compare the code with the one the agent shows (RFC 8628 §5.4).
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def update
|
|
1217
|
+
authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code])
|
|
1218
|
+
return redirect_to(device_approval_path) unless authorization
|
|
1219
|
+
|
|
1220
|
+
if params[:decision] == "approve"
|
|
1221
|
+
authorization.approve!(current_organization, by: current_user)
|
|
1222
|
+
else
|
|
1223
|
+
authorization.deny!(by: current_user)
|
|
1224
|
+
end
|
|
1225
|
+
redirect_to device_approval_path, notice: "Done. Go back to the agent."
|
|
1226
|
+
rescue ApiKeys::DeviceAuthorization::Closed
|
|
1227
|
+
redirect_to device_approval_path, alert: "This request expired or was decided already."
|
|
1228
|
+
rescue ApiKeys::DeviceAuthorization::KeyLimitReached
|
|
1229
|
+
redirect_to device_approval_path(user_code: params[:user_code]), alert: "You have the most API keys you may have. Revoke one, then approve again."
|
|
1230
|
+
end
|
|
1231
|
+
end
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
The rules the model keeps:
|
|
1235
|
+
|
|
1236
|
+
- Both codes are stored as SHA-256 digests. The key's token is never stored: the poll that creates the key is the only answer that carries it.
|
|
1237
|
+
- A request lives ten minutes. The user code is eight letters from twenty consonants ([§6.1](https://www.rfc-editor.org/rfc/rfc8628#section-6.1)), and case, spaces and dashes do not matter when the person types it.
|
|
1238
|
+
- Polls and decisions take the request's row lock. A decided or expired request cannot be decided again: `approve!` and `deny!` raise `ApiKeys::DeviceAuthorization::Closed`.
|
|
1239
|
+
- `approve!` checks the owner's key limit first (`max_keys`, and the `limit` of the request's key type). At the limit it raises `ApiKeys::DeviceAuthorization::KeyLimitReached` and the request stays pending, so the person can revoke a key and approve again before it expires. It also checks the scopes against the configuration as it is now, and raises `ActiveRecord::RecordInvalid` for scopes that a key of the type no longer grants.
|
|
1240
|
+
- A poll never raises because the key cannot be made. If the owner reached its limit after the approval, the poll answers `authorization_pending`, nothing is kept, and a later poll makes the key once there is room. Every other failure closes the request with `invalid_scope`.
|
|
1241
|
+
- A poll with another `client_id` changes nothing ([§3.4](https://www.rfc-editor.org/rfc/rfc8628#section-3.4)), so the grant waits for its own client.
|
|
1242
|
+
- The approving person becomes the key's `created_by` when the column exists, so `config.trusted_key_check` applies to these keys too. Characters a key name cannot hold become hyphens in the key's name.
|
|
1243
|
+
|
|
1244
|
+
What stays in your app:
|
|
1245
|
+
|
|
1246
|
+
- **Rate limits.** Limit the code look-ups and the approvals on the approval page together, per person, so codes cannot be guessed ([§5.1](https://www.rfc-editor.org/rfc/rfc8628#section-5.1)), and the agent's requests per address. A rate limit's `with:` can answer in the RFC's shape: `with: -> { render_oauth_error("slow_down", "Too many requests.", status: :too_many_requests) }`.
|
|
1247
|
+
- **Who may approve,** and for which owner. When a person can switch between organizations, put the owner the page showed in the form and refuse an approval for another one.
|
|
1248
|
+
- **More fields in the token answer,** or an audit row: override `device_token_response(authorization, api_key)` and call `super`. It runs in the transaction that creates the key, so a row it writes to the same database commits with the key, or neither does.
|
|
1249
|
+
- **Housekeeping:** run `ApiKeys::DeviceAuthorization.prune!` once a day.
|
|
1250
|
+
|
|
1251
|
+
Error descriptions change with I18n: `api_keys.device_flow.errors.<error>`.
|
|
1252
|
+
|
|
978
1253
|
## Configuration and settings
|
|
979
1254
|
|
|
980
1255
|
The gem installation creates an initializer at `config/initializers/api_keys.rb`
|
|
@@ -1023,9 +1298,19 @@ See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section be
|
|
|
1023
1298
|
|
|
1024
1299
|
Some highlights:
|
|
1025
1300
|
|
|
1301
|
+
### Require the `Bearer` scheme
|
|
1302
|
+
|
|
1303
|
+
Clients send `Authorization: Bearer ak_123abc...`. For compatibility, a bare `Authorization: ak_123abc...` is accepted too. A public API should accept one shape only, so turn the bare token off:
|
|
1304
|
+
|
|
1305
|
+
```ruby
|
|
1306
|
+
config.require_bearer_scheme = true
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
Now a header without the scheme (a bare token, `Basic …`, `Token …`) gets the same 401 `invalid_token` as any wrong key, and is never looked up. It applies to whichever header you configured in `config.header`, and a refused header never falls back to the query parameter.
|
|
1310
|
+
|
|
1026
1311
|
### Accept API keys via query params instead of Authentication HTTP headers
|
|
1027
1312
|
|
|
1028
|
-
By default, the `api_key` gem expects API keys to come *exclusively*
|
|
1313
|
+
By default, the `api_key` gem expects API keys to come *exclusively* in the `Authorization` header, for security purposes. But you can allow users to make requests to your endpoints with the API key token passed as a URL query param too, like this:
|
|
1029
1314
|
|
|
1030
1315
|
```
|
|
1031
1316
|
https://example.com/api/endpoint?api_key=ak_123abc...
|
|
@@ -21,6 +21,10 @@ module ApiKeys
|
|
|
21
21
|
# Names the per-key reservation that lets one stats job per kind be
|
|
22
22
|
# enqueued within a debounce interval (see reserve_stats_enqueue?).
|
|
23
23
|
STATS_ENQUEUE_NAMESPACE = "api_keys:v1:stats_enqueue"
|
|
24
|
+
# The methods that read. Every other method writes. RFC 9110 §9.2.1 also
|
|
25
|
+
# names OPTIONS and TRACE safe, but an API action does not serve them,
|
|
26
|
+
# so they count as writes and need the stricter scope.
|
|
27
|
+
READ_METHODS = %w[GET HEAD].freeze
|
|
24
28
|
|
|
25
29
|
included do
|
|
26
30
|
# Helper methods to access the authenticated key and its owner
|
|
@@ -53,7 +57,13 @@ module ApiKeys
|
|
|
53
57
|
private
|
|
54
58
|
|
|
55
59
|
# The core authentication method.
|
|
60
|
+
#
|
|
61
|
+
# @param scope [String, Array<String>, Hash, nil] The scope(s) the key
|
|
62
|
+
# needs. A Hash names them by HTTP method, in one line:
|
|
63
|
+
# `scope: { read: "read", write: "write" }` asks GET and HEAD for the
|
|
64
|
+
# read scope and every other method for the write scope.
|
|
56
65
|
def authenticate_api_key!(scope: nil)
|
|
66
|
+
scope = scope_for_request_method(scope) if scope.is_a?(Hash)
|
|
57
67
|
@current_api_key = nil
|
|
58
68
|
remove_instance_variable(:@current_api_tenant) if instance_variable_defined?(:@current_api_tenant)
|
|
59
69
|
log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
|
|
@@ -91,6 +101,19 @@ module ApiKeys
|
|
|
91
101
|
required_scope: scope
|
|
92
102
|
)
|
|
93
103
|
record_key_refusal(refused_key, :missing_scope)
|
|
104
|
+
elsif !api_key_trusted?(@current_api_key)
|
|
105
|
+
log_debug "[ApiKeys Auth] Trust check failed for key ID #{@current_api_key.id}."
|
|
106
|
+
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
|
107
|
+
after_auth_context[:success] = false
|
|
108
|
+
after_auth_context[:error_code] = :untrusted_key
|
|
109
|
+
refused_key = @current_api_key
|
|
110
|
+
@current_api_key = nil
|
|
111
|
+
render_unauthorized(
|
|
112
|
+
error_code: :untrusted_key,
|
|
113
|
+
message: "API key is no longer trusted: the person behind it no longer stands behind it",
|
|
114
|
+
status: :forbidden
|
|
115
|
+
)
|
|
116
|
+
record_key_refusal(refused_key, :untrusted_key)
|
|
94
117
|
else
|
|
95
118
|
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
|
96
119
|
# Authentication and scope check successful, enqueue stats update
|
|
@@ -123,7 +146,35 @@ module ApiKeys
|
|
|
123
146
|
end
|
|
124
147
|
end
|
|
125
148
|
|
|
149
|
+
# Whether this request reads (GET or HEAD).
|
|
150
|
+
def reading_request?
|
|
151
|
+
READ_METHODS.include?(request.request_method)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The scope of `scope: { read:, write: }` for this request's method. Both
|
|
155
|
+
# are required, so a forgotten one never leaves its requests unchecked.
|
|
156
|
+
# A value of nil asks for no scope.
|
|
157
|
+
def scope_for_request_method(scopes)
|
|
158
|
+
scopes = scopes.to_h.transform_keys(&:to_sym)
|
|
159
|
+
unless scopes.keys.sort == %i[read write]
|
|
160
|
+
raise ArgumentError, "scope: by HTTP method takes exactly { read: ..., write: ... }"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
reading_request? ? scopes[:read] : scopes[:write]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether the person behind the key still stands behind it, as the host's
|
|
167
|
+
# `config.trusted_key_check` answers. Without a check, every valid key is
|
|
168
|
+
# trusted. An error in the check is not rescued: the request fails, and
|
|
169
|
+
# never passes as trusted.
|
|
170
|
+
def api_key_trusted?(api_key)
|
|
171
|
+
check = ApiKeys.configuration.trusted_key_check
|
|
172
|
+
check.nil? || check.call(api_key) ? true : false
|
|
173
|
+
end
|
|
174
|
+
|
|
126
175
|
# Renders a standard JSON error response for authentication failures.
|
|
176
|
+
# Every refusal of the gem renders here, so a host that overrides it
|
|
177
|
+
# renders them all in its own error shape.
|
|
127
178
|
def render_unauthorized(error_code:, message:, status: :unauthorized, required_scope: nil)
|
|
128
179
|
error_message = I18n.t("api_keys.errors.#{error_code}", default: message) rescue message
|
|
129
180
|
response_body = { error: error_code, message: error_message }
|
|
@@ -16,7 +16,13 @@ module ApiKeys
|
|
|
16
16
|
# == Accessors ==
|
|
17
17
|
|
|
18
18
|
# Core Authentication
|
|
19
|
-
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [rw] require_bearer_scheme
|
|
21
|
+
# @return [Boolean] Accept the header only as "Bearer <token>". A value
|
|
22
|
+
# without the scheme is refused like any invalid token (401
|
|
23
|
+
# `invalid_token`). false (the default) also accepts the bare token,
|
|
24
|
+
# as every earlier version did.
|
|
25
|
+
attr_reader :header, :query_param, :require_bearer_scheme
|
|
20
26
|
|
|
21
27
|
# Token Generation
|
|
22
28
|
attr_reader :token_prefix, :token_length, :token_alphabet
|
|
@@ -63,6 +69,31 @@ module ApiKeys
|
|
|
63
69
|
# Tenant Resolution
|
|
64
70
|
attr_reader :tenant_resolver
|
|
65
71
|
|
|
72
|
+
# The person behind a key
|
|
73
|
+
#
|
|
74
|
+
# @!attribute [rw] trusted_key_check
|
|
75
|
+
# @return [#call, nil] Callable receiving the authenticated ApiKey and
|
|
76
|
+
# answering whether the person behind it still stands behind it. When
|
|
77
|
+
# it answers false or nil, `authenticate_api_key!` refuses the request
|
|
78
|
+
# with 403 `untrusted_key`. It runs after the scope check. nil (the
|
|
79
|
+
# default) trusts every valid key.
|
|
80
|
+
# @example A key acts while its creator still manages the organization
|
|
81
|
+
# config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }
|
|
82
|
+
attr_reader :trusted_key_check
|
|
83
|
+
|
|
84
|
+
# Idempotency-Key, for tests only
|
|
85
|
+
#
|
|
86
|
+
# @!attribute [rw] idempotency_in_test_transactions
|
|
87
|
+
# @return [Boolean] Lets ApiKeys::IdempotencyKey.hold run inside an open
|
|
88
|
+
# database transaction, as transactional tests open one around each
|
|
89
|
+
# test. There the key's claim is a savepoint that the test's rollback
|
|
90
|
+
# removes. It takes true only when Rails.env is test, and it counts
|
|
91
|
+
# only while Rails.env is test.
|
|
92
|
+
# Default: false.
|
|
93
|
+
# @example config/environments/test.rb, or the test helper
|
|
94
|
+
# ApiKeys.configuration.idempotency_in_test_transactions = true
|
|
95
|
+
attr_reader :idempotency_in_test_transactions
|
|
96
|
+
|
|
66
97
|
# Callbacks (Placeholders for future extension)
|
|
67
98
|
attr_reader :before_authentication, :after_authentication
|
|
68
99
|
|
|
@@ -167,7 +198,7 @@ module ApiKeys
|
|
|
167
198
|
BOOLEAN_SETTINGS = %i[
|
|
168
199
|
require_key_name track_requests_count https_only_production https_strict_mode
|
|
169
200
|
enable_async_operations debug_logging strict_environment_isolation
|
|
170
|
-
dashboard_allow_cross_environment
|
|
201
|
+
dashboard_allow_cross_environment require_bearer_scheme
|
|
171
202
|
].freeze
|
|
172
203
|
|
|
173
204
|
def header=(value)
|
|
@@ -274,6 +305,27 @@ module ApiKeys
|
|
|
274
305
|
@tenant_resolver = value
|
|
275
306
|
end
|
|
276
307
|
|
|
308
|
+
def idempotency_in_test_transactions=(value)
|
|
309
|
+
raise ArgumentError, "idempotency_in_test_transactions must be true or false" unless [true, false].include?(value)
|
|
310
|
+
if value && !test_environment?
|
|
311
|
+
raise ArgumentError, "idempotency_in_test_transactions is for transactional tests only: Rails.env must be test"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
@idempotency_in_test_transactions = value
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Whether hold may run inside an open transaction: the switch is on, and
|
|
318
|
+
# Rails.env is still test when it is used.
|
|
319
|
+
def idempotency_in_test_transactions?
|
|
320
|
+
@idempotency_in_test_transactions == true && test_environment?
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def trusted_key_check=(value)
|
|
324
|
+
raise ArgumentError, "trusted_key_check must be callable or nil" unless value.nil? || value.respond_to?(:call)
|
|
325
|
+
|
|
326
|
+
@trusted_key_check = value
|
|
327
|
+
end
|
|
328
|
+
|
|
277
329
|
def client_ip_resolver=(value)
|
|
278
330
|
raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call)
|
|
279
331
|
|
|
@@ -402,6 +454,10 @@ module ApiKeys
|
|
|
402
454
|
|
|
403
455
|
private
|
|
404
456
|
|
|
457
|
+
def test_environment?
|
|
458
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
|
|
459
|
+
end
|
|
460
|
+
|
|
405
461
|
def validate_resolved_prefix!(prefix)
|
|
406
462
|
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
|
|
407
463
|
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
@@ -583,6 +639,7 @@ module ApiKeys
|
|
|
583
639
|
# Core Authentication
|
|
584
640
|
@header = "Authorization" # Expects "Bearer <token>"
|
|
585
641
|
@query_param = nil # No query param lookup by default
|
|
642
|
+
@require_bearer_scheme = false # Also accept a bare token in the header
|
|
586
643
|
|
|
587
644
|
# Token Generation
|
|
588
645
|
@token_prefix = -> { "ak_" }
|
|
@@ -657,6 +714,12 @@ module ApiKeys
|
|
|
657
714
|
# Tenant Resolution
|
|
658
715
|
@tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
|
|
659
716
|
|
|
717
|
+
# The person behind a key: no check, every valid key is trusted
|
|
718
|
+
@trusted_key_check = nil
|
|
719
|
+
|
|
720
|
+
# Idempotency-Key: refuse every open transaction (tests may opt out)
|
|
721
|
+
@idempotency_in_test_transactions = false
|
|
722
|
+
|
|
660
723
|
# Key Types & Environments (default to empty/disabled for backwards compatibility)
|
|
661
724
|
@key_types = {}.freeze # Empty = feature disabled, legacy behavior
|
|
662
725
|
@environments = {}.freeze # Empty = no environment-based prefixes
|