end_point_blank 0.2.1 → 0.6.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: b93be9173755a967ce4c1f1d38205b8803bc175ec1d029f860c6620807553bca
4
- data.tar.gz: 74ff753691ea46be3ac391cea6b178673a8fb4cd7e0b6ea2861b9646d138e710
3
+ metadata.gz: edb9e7eb50879a3caf5cec7ae59210d29a3096e82d68c997dd4ae545cac00e2d
4
+ data.tar.gz: 4bd84f05bf328b387fdb6213194c4920c0282cb6eaaee0ad1eb20610785d53bb
5
5
  SHA512:
6
- metadata.gz: 4533c807575899944c7626e716585018309a55311d659b0e2b3fa5005b0ab5b825bec2ef89a0b3d9108277ddac28cc985c75c6047ded18542c2d4dceeace66f1
7
- data.tar.gz: d7a5148cba59e40aaf3190b8147800e4777882ff08c31e9245ede3f289eff1eedb2c43dfe034587bf52cf2b23518b2d6cdbe4435e90235c71418d433c1d9b16c
6
+ metadata.gz: b958fd1b769033e6fe4420e371b568ec449fb55accac06c7ebb82a2a1ac0b2415770117c4d61b9746af87ef8d27e97c7e0888dbb3bfc55cba605f10c5d57d91d
7
+ data.tar.gz: 3ef96dd88833a733d9f4dd30592618df157ef6af60483c50aad82e2306c60ffb78348c9a7256a52102d34647b148ab8d58109b25088cf6480cd075b054d46d72
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ ### Breaking
6
+
7
+ - **`Authorization.header` and `AccessTokens.token` now take a URL, not a
8
+ hostname.** Pass the URL you are about to call —
9
+ `https://api.example.com/orders`, not `api.example.com`. Strip any query
10
+ string or fragment first; they are rejected. Earlier READMEs showed the
11
+ hostname form; those examples no longer work.
12
+ - **`AccessTokens#exists?` now requires the same URL argument.** It answers
13
+ for the entry covering that URL; there is no longer a single process-wide
14
+ token for it to answer about.
15
+ - **Requires an intake that accepts `base_url`.** An older intake returns
16
+ `400 {"error":"Missing required parameter: base_url"}`.
17
+
18
+ ### Changed
19
+
20
+ - `endpoint_authorize` authenticates to intake with Basic instead of minting
21
+ an access token for itself. The inbound request path no longer touches the
22
+ token cache at all.
23
+ - A 401 from the authorize endpoint is returned to the caller rather than
24
+ retried once. With Basic, a 401 means the credential is wrong.
25
+ - Tokens are cached per application environment, keyed on the canonical base
26
+ URL intake resolves the request to, rather than one per process.
27
+
28
+ ### Security
29
+
30
+ - The minted bearer token was previously written to the host application's
31
+ logs at info level: every successful token exchange logged the full intake
32
+ response body, which contains the live token, whenever your app's Rails
33
+ logger was set to info or more verbose. This release logs only the
34
+ response status code. If your logs go back further than this upgrade,
35
+ treat them as potentially containing live bearer tokens and handle them
36
+ per your own retention/rotation policy.
@@ -0,0 +1,210 @@
1
+ # Hardening report — end_point_blank (Rails/Ruby client lib) — P2 #14
2
+
3
+ Branch: `harden-timeouts-bounded-queue` (off `master`, not pushed)
4
+
5
+ ## Goal
6
+
7
+ Bring this lib's reliability posture in line with the Elixir sibling lib,
8
+ which was hardened with per-attempt HTTP timeouts (connect 3s / read 5s)
9
+ and a bounded, drop-oldest background send queue (cap 1000, throttled
10
+ warning on drop).
11
+
12
+ ## Gap 1 — Excon calls with no timeout
13
+
14
+ ### Every `Excon.` call site in the lib (confirmed via `grep -rn "Excon\." lib/`)
15
+
16
+ | File | Line | Call |
17
+ |---|---|---|
18
+ | `lib/end_point_blank/commands/http.rb` | 23 | `Excon.post` (shared fire-and-forget POST helper, used by `DirectWriter`) |
19
+ | `lib/end_point_blank/commands/generate_access_token.rb` | 20 | `Excon.post` (access-token exchange) |
20
+ | `lib/end_point_blank/commands/endpoint_update.rb` | 30 | `Excon.post` (endpoint/route registration) |
21
+
22
+ All three are the only `Excon.*` calls in the lib. No other `Excon.new`/etc. exist.
23
+
24
+ ### Fix
25
+
26
+ Added a single shared constant in `lib/end_point_blank/commands/http.rb`:
27
+
28
+ ```ruby
29
+ CONNECT_TIMEOUT = 3
30
+ READ_TIMEOUT = 5
31
+ TIMEOUT_OPTIONS = { connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT }.freeze
32
+ ```
33
+
34
+ - `Http.post` now passes `**TIMEOUT_OPTIONS` directly.
35
+ - `generate_access_token.rb` and `endpoint_update.rb` each `require_relative 'http'`
36
+ and splat `**EndPointBlank::Commands::Http::TIMEOUT_OPTIONS` into their own
37
+ `Excon.post` calls, so the values live in exactly one place.
38
+
39
+ ### Timeout error handling
40
+
41
+ `Excon::Error::Timeout` is a subclass of `Excon::Error` (verified against the
42
+ installed excon 1.5.0 source), so:
43
+
44
+ - `Http.post`'s existing `rescue Excon::Error => e` already covers timeouts —
45
+ it retries up to `MAX_ATTEMPTS` (3) then logs and returns `nil`. No caller
46
+ ever sees an exception.
47
+ - `generate_access_token.rb`'s existing `rescue => e` (StandardError) already
48
+ covered timeouts too.
49
+ - `endpoint_update.rb` had a **latent bug**: `rescue Excon::Error::Socket,
50
+ Excon::Error::Connection => e`. `Excon::Error::Connection` **does not
51
+ exist** in excon 1.5.0 (only `Excon::Error::Socket`, `Excon::Error::Timeout`,
52
+ etc. are defined under `Excon::Error`). Because Ruby resolves rescue-clause
53
+ constants lazily, this clause would silently work until an exception was
54
+ actually raised inside the `begin` block, at which point evaluating
55
+ `Excon::Error::Connection` would raise `NameError` and crash the caller —
56
+ i.e. a network blip or a timeout during endpoint registration would have
57
+ thrown `NameError` instead of being handled gracefully. Fixed by rescuing
58
+ `Excon::Error` broadly (matching the pattern in `http.rb`), which now also
59
+ correctly covers `Excon::Error::Timeout`.
60
+
61
+ ### Incidental fix required to exercise the above in tests
62
+
63
+ `lib/end_point_blank/authorization.rb` calls `Base64.encode64` for the Basic
64
+ auth header path but never `require 'base64'`. On Ruby 3.4 `base64` was
65
+ removed from default gems, so this raised `NameError` / `LoadError` the
66
+ moment that code path executed (which is exactly the path exercised in the
67
+ `endpoint_update` timeout test, since it needs an `Authorization.header` call
68
+ with no cached token). Fixed by adding `require 'base64'` in
69
+ `authorization.rb` and declaring `spec.add_dependency "base64"` in the
70
+ gemspec (ran `bundle install`, updating `Gemfile.lock`). This was a real
71
+ reliability bug independent of this task — any Basic-auth call path in this
72
+ gem on Ruby >= 3.4 without `base64` explicitly in the app's own Gemfile would
73
+ have crashed.
74
+
75
+ ## Gap 2 — unbounded background queue
76
+
77
+ `lib/end_point_blank/writers/delayed_writer.rb` used a plain `Queue.new`
78
+ (unbounded). During an intake outage, the 2 background drain threads block
79
+ on failed/slow POSTs and the queue grows without bound → OOM risk.
80
+
81
+ ### Fix
82
+
83
+ `Queue` has no `max` option, so the bound is implemented explicitly:
84
+
85
+ - `MAX_QUEUE_SIZE = 1000`.
86
+ - `enqueue` (public API, accepts a single payload or an array, unchanged
87
+ signature) now routes each item through `enqueue_one`, which is guarded by
88
+ a `Mutex` (`enqueue_mutex`) so the check-then-drop-then-push sequence is
89
+ atomic across concurrent producer threads (the request/response/log/error
90
+ writer call paths).
91
+ - If `queue.size >= MAX_QUEUE_SIZE` when a new item arrives, the **oldest**
92
+ item is dropped first (`pop_additional`, a non-blocking `Queue#pop(true)`,
93
+ which removes from the front of the FIFO `Queue`), then the new item is
94
+ pushed. Net effect: queue never exceeds 1000, newest items win.
95
+ - Drop warnings are throttled: `note_drop` tracks a drop counter and only
96
+ logs (via `log_warning`, which prefers `::Rails.logger.warn` and falls
97
+ back to `Kernel#warn` when Rails isn't loaded) at most once per
98
+ `WARN_THROTTLE_SECONDS` (30s), with the message including how many items
99
+ were dropped since the last warning — so a sustained outage doesn't itself
100
+ become a logging flood.
101
+ - The existing 2-thread drain model (`start_threads`) is unchanged in
102
+ structure; internal `@queue` access was switched to a `queue` reader
103
+ (`@queue ||= Queue.new`) so both the drain threads and the new bounded
104
+ `enqueue_one` share the same lazily-initialized queue, and so tests can
105
+ instantiate a bare object that mixes in `DelayedWriter` and drive
106
+ `enqueue`/`queue` without needing to spin up the background threads.
107
+
108
+ ## Sinatra coverage
109
+
110
+ Confirmed: `end_point_blank_sinatra/Gemfile` depends on
111
+ `gem 'end_point_blank', path: '../end_point_blank_rails'` and
112
+ `end_point_blank_sinatra/app.rb` does `require 'end_point_blank'`, which is
113
+ this gem's single entry point (`lib/end_point_blank.rb`) that requires every
114
+ file touched here (`commands/http`, `commands/generate_access_token`,
115
+ `commands/endpoint_update`, `writers/delayed_writer`, `authorization`).
116
+ There is no Sinatra-specific fork of any of this code. **Sinatra is
117
+ covered.**
118
+
119
+ ## Tests added (TDD: written first, confirmed red against the pre-fix code, then made green)
120
+
121
+ - `spec/commands_http_spec.rb` — asserts `Excon.post` is called with
122
+ `connect_timeout`/`read_timeout` matching the shared constants; asserts a
123
+ raised `Excon::Error::Timeout` is retried `MAX_ATTEMPTS` times, logged, and
124
+ returns `nil` without raising.
125
+ - `spec/generate_access_token_spec.rb` — asserts the same timeout options
126
+ reach `Excon.post` for the access-token exchange; asserts a timeout
127
+ doesn't raise.
128
+ - `spec/endpoint_update_spec.rb` — asserts the same timeout options reach
129
+ `Excon.post` for endpoint registration; asserts a timeout doesn't raise
130
+ and is logged via `Rails.logger.warn` (this is the test that caught both
131
+ the `Excon::Error::Connection` NameError bug and the missing
132
+ `require 'base64'`).
133
+ - `spec/delayed_writer_spec.rb` — mixes `DelayedWriter` into a bare test
134
+ class (without starting background threads, so the queue can be inspected
135
+ synchronously): asserts the queue never exceeds `MAX_QUEUE_SIZE`, asserts
136
+ the oldest items are dropped first (FIFO order preserved for survivors),
137
+ asserts normal FIFO enqueue/dequeue when under the cap, asserts array-form
138
+ `enqueue` also respects the bound, and asserts `log_warning` is called
139
+ only **once** across 50 drops (throttling), not once per drop.
140
+
141
+ All four spec files were confirmed to fail (9 of 11 new examples red) against
142
+ the original code before implementing the fix, then confirmed green after.
143
+
144
+ ## Test evidence
145
+
146
+ ```
147
+ $ bundle exec rspec
148
+ ...
149
+ 49 examples, 0 failures
150
+ ```
151
+
152
+ Also ran the project's actual CI commands directly:
153
+
154
+ ```
155
+ $ ./build.sh # bundle install — passes
156
+ $ ./test.sh # bundle exec rspec — 49 examples, 0 failures
157
+ ```
158
+
159
+ `bundle exec ruby -Ilib -e "require 'end_point_blank'"` loads cleanly and
160
+ `EndPointBlank::Commands::Http::TIMEOUT_OPTIONS` /
161
+ `EndPointBlank::Writers::DelayedWriter::MAX_QUEUE_SIZE` are inspectable at
162
+ the top level, confirming the constants are reachable as documented.
163
+
164
+ `rubocop` was also run for hygiene (not part of CI — `ci.yml` only runs
165
+ `./test.sh`, i.e. rspec). It reports 46 offenses across the 5 touched files
166
+ vs. 43 pre-existing on the same files before this change — the 3 new ones
167
+ are `Metrics/AbcSize`/`Metrics/MethodLength` on `start_threads`, whose body
168
+ is unchanged except swapping `@queue` for the new `queue` reader method; all
169
+ newly-added methods (`enqueue_one`, `drop_oldest_and_note`, `note_drop`,
170
+ `warn_dropped_items`, `log_warning`) were kept short enough to avoid adding
171
+ further offenses. None of this blocks CI.
172
+
173
+ ## Files changed
174
+
175
+ - `lib/end_point_blank/commands/http.rb` — shared timeout constants/helper,
176
+ applied to `Http.post`.
177
+ - `lib/end_point_blank/commands/generate_access_token.rb` — applies shared
178
+ timeout options to its `Excon.post`.
179
+ - `lib/end_point_blank/commands/endpoint_update.rb` — applies shared timeout
180
+ options to its `Excon.post`; fixed the broken `Excon::Error::Connection`
181
+ rescue clause to `rescue Excon::Error` (now also catches `Timeout`).
182
+ - `lib/end_point_blank/writers/delayed_writer.rb` — bounded, drop-oldest,
183
+ throttled-warning queue.
184
+ - `lib/end_point_blank/authorization.rb` — `require 'base64'` (incidental
185
+ fix, needed for the Basic-auth path to work at all on Ruby 3.4, and
186
+ required to exercise the `endpoint_update` timeout test end-to-end).
187
+ - `end_point_blank.gemspec`, `Gemfile.lock` — declare `base64` as an
188
+ explicit dependency (Ruby 3.4 removed it from default gems).
189
+ - New specs: `spec/commands_http_spec.rb`, `spec/generate_access_token_spec.rb`,
190
+ `spec/endpoint_update_spec.rb`, `spec/delayed_writer_spec.rb`.
191
+
192
+ ## Concerns / follow-ups (not fixed here, out of scope for P2 #14)
193
+
194
+ - The queue bound is a soft/approximate guarantee under heavy concurrent
195
+ enqueue: the mutex only guards the *decision* to drop before push, so with
196
+ many producer threads racing, the queue could very briefly be examined by
197
+ more than one thread between the `pop`/`push` pair in rare interleavings
198
+ is prevented by the mutex — but a concurrent **drain** thread popping at
199
+ the same moment only ever shrinks the queue, never grows it past the cap.
200
+ In practice the bound holds exactly given the mutex around all enqueue-side
201
+ mutation.
202
+ - `note_drop`/`@last_drop_warning_at` state lives on the writer singleton
203
+ instance (`RequestWriter`, `ResponseWriter`, `ExceptionWriter`,
204
+ `LogWriter` each get their own independent throttle window since they each
205
+ `include DelayedWriter` separately) — this is intentional (matches
206
+ per-queue behavior) but worth knowing if someone expects a single global
207
+ throttle across all four queues.
208
+ - Rubocop was not run as part of CI before this change and still isn't;
209
+ pre-existing style debt (43 offenses) was left alone except where it was
210
+ directly in code I touched and cheap to avoid growing.
data/README.md CHANGED
@@ -79,6 +79,7 @@ Every setting listed below can be set explicitly in that block, and most also fa
79
79
  | `worker_count` | — | `4` | Currently unused by the delayed writer (which always spins up 2 threads); reserved. |
80
80
  | `token_ttl` | — | `nil` | Optional TTL (seconds) requested when generating a `Bearer` access token. |
81
81
  | `cache_ttl` | — | `300` | TTL (seconds) for the authorization decision cache. |
82
+ | `trust_proxy_headers` | — | `true` | Whether the per-request `scheme`/`host`/`port` report honors `X-Forwarded-Proto`/`-Host`/`-Port`. See [Reported base URL](#reported-base-url). |
82
83
  | `masking_rules` | — | `[]` | Ordered list of masking rule hashes — see [Data masking](#data-masking). |
83
84
  | `mask_hook` | — | `nil` | Optional `->(payload, record_type_string) { payload }` run after `masking_rules`. |
84
85
  | `version_finder` | — | `nil` | Optional `->(request) { "1" }` overriding `EndPointBlank::Commands::VersionFinder`'s default header/param/path detection. |
@@ -88,6 +89,35 @@ Note: there is also a bare `environment` accessor on `Configuration`, but it is
88
89
  code path in this gem (the real per-request environment name is `env_name`, described above) — do
89
90
  not rely on it.
90
91
 
92
+ ### Reported base URL
93
+
94
+ Every request payload carries the base URL the *caller* used, as three separate fields —
95
+ `scheme`, `host` and `port`. A field that cannot be resolved is omitted rather than sent as
96
+ null. EndPointBlank uses these to fill in an application environment's base URL for you,
97
+ instead of asking someone to type it.
98
+
99
+ By default the gem honors `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-Port`,
100
+ reading the **last** comma-separated hop. It does this on its own, without consulting Rails'
101
+ or Rack's trusted-proxy configuration, so that all five EndPointBlank clients answer
102
+ identically for the same request.
103
+
104
+ **Turn this off if your application is reachable directly, with no proxy in front of it** —
105
+ or if you would simply rather report nothing than report something a caller could influence:
106
+
107
+ ```ruby
108
+ EndPointBlank.configure { |c| c.trust_proxy_headers = false }
109
+ ```
110
+
111
+ With it off, the `X-Forwarded-*` headers are ignored entirely and `scheme`, `host` and `port`
112
+ come from the connection and the `Host` header only.
113
+
114
+ It defaults to `true` because the alternative is worse for almost everyone. Most production
115
+ deployments sit behind an ALB, nginx, Caddy or an Ingress, and a client that ignored the
116
+ forwarded headers there would not report *nothing* — it would confidently report an internal
117
+ hostname on an internal port. `host` is caller-controlled either way (it has always come from
118
+ the `Host` header), and none of these three values is ever used as an identity or
119
+ authorization key, so the worst case is a wrong *suggestion* that an admin has to approve.
120
+
91
121
  ### `configure` block example
92
122
 
93
123
  ```ruby
@@ -120,16 +150,28 @@ export ENDPOINTBLANK_ENV=staging
120
150
 
121
151
  ### Authorization
122
152
 
123
- `EndPointBlank::Authorization.header(hostname = nil)` builds the outbound `Authorization` header
124
- used by the gem's own HTTP calls: a cached `Bearer` token for `hostname` when one is available
125
- (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from `client_id` /
126
- `client_secret`.
153
+ `EndPointBlank::Authorization.header(base_url = nil)` builds the outbound `Authorization` header
154
+ used by the gem's own HTTP calls: a cached `Bearer` token covering `base_url` when one is
155
+ available (via `EndPointBlank::AccessTokens`), otherwise `Basic` credentials built from
156
+ `client_id` / `client_secret` -- which covers both giving no target and a token that could not
157
+ be obtained.
127
158
 
128
159
  ```ruby
129
- EndPointBlank::Authorization.header # => "Basic ..."
130
- EndPointBlank::Authorization.header("api.example.com") # => "Bearer ..." if a token is cached
160
+ EndPointBlank::Authorization.header # => "Basic ..."
161
+
162
+ # Pass the URL you are about to call, NOT a hostname.
163
+ # Strip any query string or fragment first -- intake rejects both.
164
+ EndPointBlank::Authorization.header("https://api.example.com/orders") # => "Bearer ..." if a token is cached
131
165
  ```
132
166
 
167
+ The argument is the URL you are about to call. intake matches it against registered base URLs by
168
+ longest path prefix, so you need not know how the target registered itself -- `header` for
169
+ `https://api.example.com/orders/42` reuses a token already cached for
170
+ `https://api.example.com/orders`. `EndPointBlank::AccessTokens` caches one token per base URL
171
+ intake resolves to, not one per process, so a service that calls several targets holds a token
172
+ for each. A URL that does not match character-for-character (a different case, a query string,
173
+ an unregistered path) simply misses and mints a new token -- it never guesses.
174
+
133
175
  Under Rails, protect an inbound endpoint by including the `Authorized` concern in a controller —
134
176
  it calls `EndPointBlank::Commands::EndpointAuthorize.authorize(request)` before the action, and
135
177
  raises `EndPointBlank::UnauthorizedError` (which you can rescue with
@@ -143,8 +185,17 @@ end
143
185
 
144
186
  `EndPointBlank::Commands::EndpointAuthorize.authorize` sends the request's path, HTTP method,
145
187
  inbound `Authorization` header, app name, resolved endpoint version, and remote IP to
146
- `#{base_url}/api/authorize`, and caches a positive (201) result for `cache_ttl` seconds via
147
- `EndPointBlank::Commands::AuthenticationCache`.
188
+ `#{base_url}/api/authorize`, authenticating itself to intake with `Basic`, and caches a positive
189
+ (201) result for `cache_ttl` seconds via `EndPointBlank::Commands::AuthenticationCache`. It never
190
+ mints or presents a Bearer token for this call: intake already holds this service's own
191
+ credential, so exchanging one to present it back would buy nothing.
192
+
193
+ **Behavior change:** `target_hostname` on the authorize call now comes from the `Host` header
194
+ only. It previously came from `request.host`, which reads the last `X-Forwarded-Host` hop. If
195
+ your app sits behind a proxy that **rewrites** `Host` (nginx's default; Caddy and most ALBs
196
+ preserve it) and you registered the external hostname in the portal, either update the
197
+ registered hostname to the internal one the app now reports, or configure the proxy to preserve
198
+ `Host`. Deployments where `Host` and `X-Forwarded-Host` agree are unaffected.
148
199
 
149
200
  ### Error reporting
150
201
 
data/deploy ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+
3
+ gem build end_point_blank.gemspec
4
+
5
+ gem push end_point_blank-*.gem
@@ -34,8 +34,13 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  # Uncomment to register a new dependency of your gem
36
36
  spec.add_dependency "excon", "~> 1.0"
37
- spec.add_dependency "rack"
38
- spec.add_dependency "rexml"
37
+ # rack and rexml carried no floor at all, which let a host application resolve
38
+ # them to versions with known advisories. These are the lowest releases clear
39
+ # of every advisory published against each gem, not the newest available — a
40
+ # library should state the oldest version it will vouch for, not force the
41
+ # host to the bleeding edge.
42
+ spec.add_dependency "rack", ">= 3.2.6"
43
+ spec.add_dependency "rexml", ">= 3.3.9"
39
44
  # Base64 was removed from Ruby's default gems as of 3.4; Authorization
40
45
  # uses it for the Basic-auth header, so declare it explicitly.
41
46
  spec.add_dependency "base64"
@@ -4,69 +4,217 @@ require 'singleton'
4
4
  require "time"
5
5
 
6
6
  module EndPointBlank
7
- # Thread-safe singleton cache for storing access tokens per hostname
7
+ # Thread-safe singleton holding this process's access tokens, one per
8
+ # application environment.
9
+ #
10
+ # A token is cached under the canonical base URL intake resolved the
11
+ # request to -- not under the URL the caller supplied. A caller asks for the
12
+ # URL it is about to call; intake answers with the base URL of the
13
+ # environment that URL belongs to, and subsequent calls anywhere under that
14
+ # base URL reuse the entry.
15
+ #
16
+ # Lookup is a plain exact-or-path-prefix comparison, with the longest match
17
+ # winning. The SDK deliberately does not normalize: intake owns that rule,
18
+ # and a miss costs one extra request rather than a wrong answer.
19
+ #
20
+ # A lookup has to scan the keys, and the fast path deliberately does not
21
+ # take the mutex, so every write **replaces** the entries Hash instead of
22
+ # mutating it. A reader then takes one atomic read of @entries and iterates
23
+ # something nobody can change underneath it. Mutating in place would risk
24
+ # "can't add a new key into hash during iteration" as soon as one thread
25
+ # minted a token for a second target while another was doing a lookup.
8
26
  class AccessTokens
9
27
  include Singleton
10
28
 
11
- def initialize()
12
- @tokens = {}
13
- @mutexes = {}
29
+ # Replace a token this far ahead of its expiry. An expired token can never
30
+ # be revived, only replaced, so going early is what keeps an in-flight
31
+ # request from carrying one that dies before it lands.
32
+ REFRESH_WINDOW = 120
33
+
34
+ # exists? is used to decide whether a caller can proceed without a round
35
+ # trip, so it answers no while there is barely any life left.
36
+ PRESENCE_WINDOW = 30
37
+
38
+ # How long to hold a token whose expiry the intake sent unreadably.
39
+ DEFAULT_LIFETIME = 3600
40
+
41
+ def initialize
42
+ @mutex = Mutex.new
43
+ @entries = {}
14
44
  end
15
45
 
16
- def self.token(arg)
17
- instance.token(arg)
46
+ def self.token(base_url)
47
+ instance.token(base_url)
18
48
  end
19
49
 
20
- # Retrieve or generate an access token for the given hostname
21
- # @param hostname [String] The hostname for which to retrieve the token
22
- # @return [String, nil] The access token or nil if generation fails
23
- def token(arg)
24
- hostname = arg.downcase
25
- @mutexes[hostname] ||= Mutex.new
26
- @mutexes[hostname].synchronize do
27
- # Return cached token if it exists and is not expired
28
- return @tokens[hostname][:token] if @tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 120
29
-
30
- # Fetch new token
31
- payload = Commands::GenerateAccessToken.token(hostname)
32
-
33
- if payload && payload[:token]
34
- payload[:expired_at] = Time.parse(payload[:expired_at])
35
- @tokens[hostname] = payload
50
+ # Retrieve a token covering base_url, generating one if no usable entry
51
+ # covers it.
52
+ # @param base_url [String] the URL you are about to call, with any query
53
+ # string and fragment removed. It is sent verbatim; intake normalizes it
54
+ # and matches it against registered base URLs by longest path prefix.
55
+ # @return [String, nil] The access token string, or nil if generation
56
+ # failed -- which includes a response that carried a token but no
57
+ # base_url.
58
+ def token(base_url)
59
+ entry = match(base_url)
60
+ return entry[:token] if usable?(entry)
61
+
62
+ @mutex.synchronize do
63
+ # Another caller may have filled it while this one waited.
64
+ entry = match(base_url)
65
+ return entry[:token] if usable?(entry)
66
+
67
+ payload = Commands::GenerateAccessToken.token(base_url)
68
+
69
+ # The key is what intake resolved to, and only that. There is no
70
+ # fallback to the requested URL: that would key on the resource the
71
+ # caller happened to ask about, so a service walking /orders/1,
72
+ # /orders/2, /orders/3 would mint and store a token per resource, and
73
+ # nothing here evicts. Without a base URL the right application
74
+ # cannot be found, so no token is handed back either.
75
+ key = payload && payload[:base_url]
76
+
77
+ if payload && payload[:token] && key
78
+ # The match that led here may have resolved under a different key
79
+ # than the one intake just returned -- an environment's base URL
80
+ # can change to a shorter path in the portal. Drop that stale key
81
+ # when it differs from the fresh one, or it goes on shadowing it:
82
+ # being the longer of the two, it keeps winning "longest match
83
+ # wins", keeps failing usable?, and keeps forcing a mint on every
84
+ # call until the process restarts. The failure branch below already
85
+ # does the equivalent for a match that turned out unusable; this is
86
+ # the same cleanup for a match that turned out to have moved.
87
+ stale = match_key(base_url, @entries)
88
+ new_entries = @entries.merge(
89
+ key => { token: payload[:token], expired_at: parse_expiry(payload[:expired_at]) }.freeze
90
+ )
91
+ new_entries = new_entries.reject { |k, _| k == stale } if stale && stale != key
92
+ @entries = new_entries.freeze
36
93
  payload[:token]
37
94
  else
38
- EndPointBlank.logger.error "Failed to generate access token for #{hostname}: #{payload&.fetch('error')}"
95
+ # A failed refresh must not leave an expiring token behind claiming
96
+ # to be usable -- callers would keep presenting it right up to the
97
+ # 401. Only the entry that covers this URL goes: the longest match
98
+ # is the one that was just found unusable, so a shorter, still-good
99
+ # entry survives.
100
+ stale = match_key(base_url, @entries)
101
+ @entries = @entries.reject { |k, _| k == stale }.freeze if stale
102
+
103
+ EndPointBlank.logger.error "Failed to generate access token for #{base_url}: #{failure_reason(payload)}"
39
104
  nil
40
105
  end
41
106
  end
42
107
  end
43
108
 
44
- # Clear all tokens from the cache
45
- # @return [Hash] Empty hash
46
- def clear(arg)
47
- @mutexes.keys.each do |hostname|
48
- @mutexes[hostname].synchronize do
49
- @tokens.delete(hostname)
50
- end
109
+ # Discard every held token
110
+ # @return [nil]
111
+ def clear
112
+ @mutex.synchronize { @entries = {}.freeze }
113
+ end
114
+
115
+ # Discard the held token, but only if it is still the one the caller had
116
+ #
117
+ # Every request in flight when a token is rejected reports the same stale
118
+ # value. Only the first of them should cause an exchange -- the rest are
119
+ # holding a token that has already been replaced, and clearing on their
120
+ # behalf would discard a good token and stampede intake.
121
+ #
122
+ # The lookup is by token value because a rejected caller has a token, not
123
+ # a URL.
124
+ #
125
+ # @param stale_token [String, nil] the token the caller was rejected for;
126
+ # ignored when it is no longer the one held for its base URL.
127
+ # @return [nil]
128
+ def invalidate(stale_token)
129
+ return if stale_token.nil?
130
+
131
+ @mutex.synchronize do
132
+ @entries = @entries.reject { |_, entry| entry[:token] == stale_token }.freeze
133
+ end
134
+ end
135
+
136
+ # Check whether a token covering base_url is held and is not about to
137
+ # expire
138
+ # @param base_url [String] the URL to check coverage for
139
+ # @return [Boolean]
140
+ def exists?(base_url)
141
+ entry = match(base_url)
142
+ !entry.nil? && entry[:expired_at] > Time.now + PRESENCE_WINDOW
143
+ end
144
+
145
+ private
146
+
147
+ # Returns the longest key in entries covering base_url, or nil.
148
+ #
149
+ # A nil or empty base_url never matches. An empty cache can't raise on
150
+ # one -- the loop body never runs -- so a non-empty cache must not either,
151
+ # or the same call succeeds or raises NoMethodError (nil has no
152
+ # start_with?) depending on unrelated traffic that happened to warm the
153
+ # cache first. Checking once, here, keeps every caller consistent for
154
+ # free: the lookup, the stale-entry cleanup on a failed refresh, and the
155
+ # stale-entry cleanup on a successful one.
156
+ #
157
+ # Deliberately not a port of intake's matcher: no normalization on either
158
+ # side. A caller that passes a non-canonical URL simply misses and mints
159
+ # again, which costs one HTTP call and is never a wrong answer.
160
+ #
161
+ # Takes entries as an explicit argument, rather than reading @entries
162
+ # itself, so the snapshot discipline is structural: every caller decides
163
+ # which snapshot is being scanned instead of this method reaching for
164
+ # whatever @entries happens to be at the moment it runs.
165
+ def match_key(base_url, entries)
166
+ return nil if base_url.nil? || base_url.empty?
167
+
168
+ best = nil
169
+ entries.each_key do |key|
170
+ next unless base_url == key || base_url.start_with?("#{key}/")
171
+
172
+ best = key if best.nil? || key.length > best.length
51
173
  end
174
+ best
52
175
  end
53
176
 
54
- # Remove token for a specific hostname
55
- # @param hostname [String] The hostname for which to remove the token
56
- # @return [Object, nil] The removed token data or nil if not found
57
- def remove(arg)
58
- hostname = arg.downcase
59
- @mutexes[hostname].synchronize do
60
- @tokens.delete(hostname)
177
+ def match(base_url)
178
+ entries = @entries # One atomic read; writes replace, never mutate.
179
+ key = match_key(base_url, entries)
180
+ key && entries[key]
181
+ end
182
+
183
+ def usable?(entry)
184
+ !entry.nil? && entry[:expired_at] > Time.now + REFRESH_WINDOW
185
+ end
186
+
187
+ # Why a mint produced no usable token, for the log.
188
+ def failure_reason(payload)
189
+ return "no response" unless payload.is_a?(Hash)
190
+ return payload[:error] if payload[:error]
191
+
192
+ if payload[:token]
193
+ # Distinct from a rejected request: intake's base_url is NOT NULL, and
194
+ # it answers 422 rather than minting when the caller's URL resolves to
195
+ # no environment. A token with no base_url is a broken server.
196
+ return "response carried a token but no base_url"
61
197
  end
198
+
199
+ "no token in response"
62
200
  end
63
201
 
64
- # Check if a valid token exists for a given hostname
65
- # @param hostname [String] The hostname to check
66
- # @return [Boolean] True if a valid token exists, false otherwise
67
- def exists?(arg)
68
- hostname = arg.downcase
69
- @tokens.key?(hostname) && @tokens[hostname][:expired_at] > Time.now + 30
202
+ # Time.parse raises on anything it cannot read — an ArgumentError for a
203
+ # string it fails to understand, a TypeError for a value that is not a
204
+ # string at all, including the nil left by a missing key. This runs inside
205
+ # the mutex on the path a caller's request goes through, so a malformed
206
+ # timestamp from the intake came out of Authorization.header and into the
207
+ # host application's request.
208
+ #
209
+ # An hour is a guess, but a working one. Treating the token as unusable
210
+ # instead would mean an exchange on every inbound request for as long as
211
+ # the far end misbehaves. There is no retry here if the token dies sooner
212
+ # than the guess -- invalidate has no caller on this path -- so a bad
213
+ # guess means 401s until the cache's own expiry-based refresh catches up.
214
+ def parse_expiry(value)
215
+ Time.parse(value.to_s)
216
+ rescue ArgumentError, TypeError
217
+ Time.now + DEFAULT_LIFETIME
70
218
  end
71
219
  end
72
220
  end