end_point_blank 0.6.1 → 0.12.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +608 -0
  3. data/README.md +236 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  17. data/lib/end_point_blank/rails/authorized.rb +9 -13
  18. data/lib/end_point_blank/target_url.rb +57 -0
  19. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  20. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  21. data/lib/end_point_blank/version.rb +1 -1
  22. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  23. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  24. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  25. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  26. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  27. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  28. data/lib/end_point_blank/writers/shared.rb +35 -4
  29. data/lib/end_point_blank.rb +239 -2
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'singleton'
4
4
  require "time"
5
+ require_relative "target_url"
5
6
 
6
7
  module EndPointBlank
7
8
  # Thread-safe singleton holding this process's access tokens, one per
@@ -13,9 +14,17 @@ module EndPointBlank
13
14
  # environment that URL belongs to, and subsequent calls anywhere under that
14
15
  # base URL reuse the entry.
15
16
  #
17
+ # Every public entry point strips the caller's URL to scheme, host, port
18
+ # and path first ({TargetUrl.strip}), so userinfo, a query or a fragment
19
+ # never reaches intake, a cache or failure key, or a log line. A URL that
20
+ # cannot be parsed into an http or https URL with a host is refused
21
+ # without a request (sc-1469).
22
+ #
16
23
  # 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.
24
+ # winning. Beyond {TargetUrl.strip} (which also lowercases the scheme and
25
+ # host, as intake does) the SDK deliberately does not normalize: intake
26
+ # owns that rule, and a miss costs one extra request rather than a wrong
27
+ # answer.
19
28
  #
20
29
  # A lookup has to scan the keys, and the fast path deliberately does not
21
30
  # take the mutex, so every write **replaces** the entries Hash instead of
@@ -38,24 +47,120 @@ module EndPointBlank
38
47
  # How long to hold a token whose expiry the intake sent unreadably.
39
48
  DEFAULT_LIFETIME = 3600
40
49
 
50
+ # How many distinct base URLs to remember a failure for.
51
+ #
52
+ # DO NOT REMOVE THIS BOUND. A failure record is cleared only by a
53
+ # SUCCESSFUL mint, and the headline failure this whole class now
54
+ # distinguishes -- a revoked credential -- is precisely the case where a
55
+ # successful mint never comes. Every call fails, forever, so a service
56
+ # walking /orders/1, /orders/2, /orders/3 would record one entry per
57
+ # resource URL and clear none of them: an unbounded leak inside a gem
58
+ # embedded in someone else's long-lived process. It is the same trap the
59
+ # token cache avoids by keying on the environment intake resolves to
60
+ # rather than on the caller's URL (see the class comment), and the
61
+ # failure path must not reintroduce it.
62
+ #
63
+ # The cap is enforced on INSERT, not only on a successful mint, for the
64
+ # same reason. Hashes are insertion-ordered, so the oldest record is the
65
+ # one that goes. A caller only ever asks about a URL it just called, so
66
+ # a bound this size is never in practice the reason an answer is missing.
67
+ MAX_FAILURES = 64
68
+
69
+ # Why the last mint for a base URL did not produce a token.
70
+ #
71
+ # Immutable and frozen (a Data), so it can be published straight out of
72
+ # {AccessTokens#last_failure} without a copy and without any chance of a
73
+ # caller editing the record the cache is holding.
74
+ Failure = Data.define(:base_url, :outcome, :status, :reason, :at) do
75
+ # 401. Permanent until the API credential itself is re-issued.
76
+ def credential_rejected?
77
+ outcome == :credential_rejected
78
+ end
79
+
80
+ # Any other 4xx -- intake's 400 and 422. Permanent, but the credential
81
+ # is fine: the request or the environment registration is not.
82
+ def request_rejected?
83
+ outcome == :request_rejected
84
+ end
85
+
86
+ # 5xx, any other unexpected non-2xx, and a 2xx that carried nothing the
87
+ # cache could use (no token, or no base_url to key one under; an
88
+ # empty string is neither).
89
+ def server_error?
90
+ outcome == :server_error
91
+ end
92
+
93
+ # No usable HTTP status was obtained at all: timeout, refused
94
+ # connection, DNS failure. Note what is NOT here -- a body that would
95
+ # not parse is classified by the status that carried it.
96
+ def transport_error?
97
+ outcome == :transport_error
98
+ end
99
+ end
100
+
41
101
  def initialize
42
102
  @mutex = Mutex.new
43
103
  @entries = {}
104
+ @failures = {}
44
105
  end
45
106
 
46
107
  def self.token(base_url)
47
108
  instance.token(base_url)
48
109
  end
49
110
 
111
+ def self.last_failure(base_url)
112
+ instance.last_failure(base_url)
113
+ end
114
+
115
+ def self.token_result(base_url)
116
+ instance.token_result(base_url)
117
+ end
118
+
50
119
  # Retrieve a token covering base_url, generating one if no usable entry
51
120
  # 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
121
+ #
122
+ # When this answers nil and the caller wants to know why, it must not ask
123
+ # {last_failure} afterwards if it needs the reason for THIS call: the
124
+ # mutex is already released by then, so another thread's successful mint
125
+ # may have cleared the record, or its own failed mint overwritten it.
126
+ # {token_result} hands back the reason captured under the mutex instead.
127
+ #
128
+ # @param base_url [String] the URL you are about to call. Its userinfo,
129
+ # query and fragment are removed and its scheme and host lowercased
130
+ # ({TargetUrl.strip}); the rest is sent as-is, and intake normalizes it
54
131
  # and matches it against registered base URLs by longest path prefix.
55
132
  # @return [String, nil] The access token string, or nil if generation
56
133
  # failed -- which includes a response that carried a token but no
57
134
  # base_url.
135
+ # @raise [StandardError] anything the mint raises that is not a
136
+ # transport error, as itself; see {token_result}.
58
137
  def token(base_url)
138
+ result = token_result(base_url)
139
+ result.is_a?(Failure) ? nil : result
140
+ end
141
+
142
+ # {token}, but answering the {Failure} for this call instead of nil.
143
+ #
144
+ # The Failure is the one recorded inside the mutex by this call's own
145
+ # mint, so it describes this call and nothing else. {last_failure} is a
146
+ # shared, per-URL slot read after the lock is gone; between the two,
147
+ # another thread can clear it (a successful mint for the same URL) or
148
+ # replace it (its own failed mint), and a caller building an error from
149
+ # it would report someone else's reason, or none (sc-1469).
150
+ #
151
+ # @param base_url [String] the URL you are about to call; see {token}.
152
+ # @return [String, Failure] the access token string, or why this call
153
+ # could not obtain one. A URL that cannot be parsed into an http or
154
+ # https URL with a host answers a :request_rejected Failure with no
155
+ # status and no base_url, and nothing is sent to intake or recorded.
156
+ # @raise [StandardError] anything {Commands::GenerateAccessToken.token_result}
157
+ # raises -- a ConfigurationError, or a bug that is not a transport
158
+ # error -- as itself; nothing is recorded for it. Only
159
+ # {Authorization.header} turns it into a TokenUnavailableError.
160
+ def token_result(base_url)
161
+ base_url = TargetUrl.strip(base_url)
162
+ return unparseable_url_failure if base_url.nil?
163
+
59
164
  entry = match(base_url)
60
165
  return entry[:token] if usable?(entry)
61
166
 
@@ -64,17 +169,27 @@ module EndPointBlank
64
169
  entry = match(base_url)
65
170
  return entry[:token] if usable?(entry)
66
171
 
67
- payload = Commands::GenerateAccessToken.token(base_url)
172
+ result = Commands::GenerateAccessToken.token_result(base_url)
173
+ payload = result.payload
68
174
 
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]
175
+ # What counts as a mint is decided once, in AccessTokenResult, next to
176
+ # the status it depends on -- so this layer and a caller reading the
177
+ # outcome can never disagree about whether a token exists. #success?
178
+ # already means "a 2xx carrying a usable token and the base_url to
179
+ # cache it under", so both are simply read below rather than tested
180
+ # again here; a 2xx missing either arrives already classified as a
181
+ # server error and takes the failure branch, exactly as a 500 does.
182
+ #
183
+ # Only a 2xx mints: a 4xx that happened to echo a token back is a
184
+ # broken server, not a credential, and is never cached.
185
+ if result.success?
186
+ # The key is what intake resolved to, and only that. There is no
187
+ # fallback to the requested URL: that would key on the resource the
188
+ # caller happened to ask about, so a service walking /orders/1,
189
+ # /orders/2, /orders/3 would mint and store a token per resource,
190
+ # and nothing here evicts.
191
+ key = payload[:base_url]
76
192
 
77
- if payload && payload[:token] && key
78
193
  # The match that led here may have resolved under a different key
79
194
  # than the one intake just returned -- an environment's base URL
80
195
  # can change to a shorter path in the portal. Drop that stale key
@@ -90,6 +205,7 @@ module EndPointBlank
90
205
  )
91
206
  new_entries = new_entries.reject { |k, _| k == stale } if stale && stale != key
92
207
  @entries = new_entries.freeze
208
+ clear_failure(base_url, key)
93
209
  payload[:token]
94
210
  else
95
211
  # A failed refresh must not leave an expiring token behind claiming
@@ -100,16 +216,54 @@ module EndPointBlank
100
216
  stale = match_key(base_url, @entries)
101
217
  @entries = @entries.reject { |k, _| k == stale }.freeze if stale
102
218
 
103
- EndPointBlank.logger.error "Failed to generate access token for #{base_url}: #{failure_reason(payload)}"
104
- nil
219
+ record_failure(base_url, result)
105
220
  end
106
221
  end
107
222
  end
108
223
 
109
- # Discard every held token
224
+ # Why the last attempt to mint a token for base_url failed, or nil if the
225
+ # last attempt succeeded -- or if there has never been one.
226
+ #
227
+ # Additive: nothing else changed shape for this. `token` still answers
228
+ # with a token String or nil, so an existing caller sees no difference;
229
+ # one that wants to know whether to give up or try again asks here.
230
+ #
231
+ # Scope: one record per base URL, keyed on the URL as it was passed to
232
+ # `token` (stripped) rather than on whatever intake resolved it to -- a
233
+ # failed mint often has no resolved base URL to speak of, and the caller
234
+ # has only the URL it asked with. The map is bounded; see MAX_FAILURES.
235
+ #
236
+ # Reads @failures exactly the way `match` reads @entries: one atomic read
237
+ # of the ivar, no mutex, and every write inside the mutex REPLACES the
238
+ # Hash rather than mutating it. A reader therefore iterates (or here,
239
+ # indexes) a snapshot nobody can change underneath it, and can never see
240
+ # a half-built map. Being a frozen Data, the Failure handed back needs no
241
+ # defensive copy.
242
+ #
243
+ # @param base_url [String] the URL that was asked about; stripped the
244
+ # same way {token_result} strips it before keying the record.
245
+ # @return [Failure, nil]
246
+ def last_failure(base_url)
247
+ base_url = TargetUrl.strip(base_url)
248
+ return nil if base_url.nil?
249
+
250
+ @failures[base_url]
251
+ end
252
+
253
+ # How many failure records are held. Exposed so the MAX_FAILURES bound is
254
+ # testable without reaching into the ivars.
255
+ # @return [Integer]
256
+ def failure_count
257
+ @failures.size
258
+ end
259
+
260
+ # Discard every held token, and every record of why one could not be held
110
261
  # @return [nil]
111
262
  def clear
112
- @mutex.synchronize { @entries = {}.freeze }
263
+ @mutex.synchronize do
264
+ @entries = {}.freeze
265
+ @failures = {}.freeze
266
+ end
113
267
  end
114
268
 
115
269
  # Discard the held token, but only if it is still the one the caller had
@@ -138,7 +292,7 @@ module EndPointBlank
138
292
  # @param base_url [String] the URL to check coverage for
139
293
  # @return [Boolean]
140
294
  def exists?(base_url)
141
- entry = match(base_url)
295
+ entry = match(TargetUrl.strip(base_url))
142
296
  !entry.nil? && entry[:expired_at] > Time.now + PRESENCE_WINDOW
143
297
  end
144
298
 
@@ -155,7 +309,7 @@ module EndPointBlank
155
309
  # stale-entry cleanup on a successful one.
156
310
  #
157
311
  # 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
312
+ # side beyond {TargetUrl.strip}. A caller that passes a non-canonical URL simply misses and mints
159
313
  # again, which costs one HTTP call and is never a wrong answer.
160
314
  #
161
315
  # Takes entries as an explicit argument, rather than reading @entries
@@ -184,19 +338,84 @@ module EndPointBlank
184
338
  !entry.nil? && entry[:expired_at] > Time.now + REFRESH_WINDOW
185
339
  end
186
340
 
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]
341
+ # What {token_result} answers for a URL {TargetUrl.strip} refused. Not
342
+ # logged and not recorded: there is no stripped URL to key or name it
343
+ # by, and the raw one may carry a secret.
344
+ def unparseable_url_failure
345
+ Failure.new(base_url: nil, outcome: :request_rejected, status: nil,
346
+ reason: "the URL could not be parsed, so no token was requested", at: Time.now)
347
+ end
348
+
349
+ # Log the failure and remember it, and return the Failure recorded.
350
+ # Runs inside the mutex.
351
+ #
352
+ # The 401 gets its own line, and it is loud: it is the one failure that
353
+ # will not clear on its own, and the one whose remedy is a human action.
354
+ # Folding it into the generic line -- which is what happened before -- let
355
+ # a revoked credential scroll past looking exactly like a blip.
356
+ def record_failure(base_url, result)
357
+ reason = failure_reason(result)
358
+
359
+ if result.credential_rejected?
360
+ EndPointBlank.logger.error(
361
+ "ACCESS TOKEN CREDENTIAL REJECTED for #{base_url}: intake answered 401 (#{reason}). " \
362
+ "This will not recover by retrying -- re-issue the API credential and update " \
363
+ "client_id/client_secret."
364
+ )
365
+ else
366
+ EndPointBlank.logger.error "Failed to generate access token for #{base_url}: #{reason}"
367
+ end
368
+
369
+ failures = @failures.reject { |k, _| k == base_url }
370
+ # Bounded on insert, oldest evicted first -- see MAX_FAILURES for why
371
+ # this cannot be left to the clear-on-success path. Hash#shift removes
372
+ # the oldest insertion, and `failures` is a fresh copy no other thread
373
+ # can see yet, so mutating it here is safe; only the finished, frozen
374
+ # Hash is published to @failures.
375
+ failures.shift while failures.size >= MAX_FAILURES
376
+ failure = Failure.new(base_url: base_url, outcome: result.outcome, status: result.status,
377
+ reason: reason, at: Time.now)
378
+ @failures = failures.merge(base_url => failure).freeze
379
+ failure
380
+ end
381
+
382
+ # A success wipes the record, so `last_failure` never reports a problem
383
+ # that has already resolved itself. Both keys go: the URL the caller
384
+ # asked with, and the canonical one intake resolved it to, which a later
385
+ # call may well ask about instead. Runs inside the mutex, and replaces
386
+ # rather than mutates, like every other write here.
387
+ def clear_failure(base_url, key)
388
+ return if @failures.empty?
389
+
390
+ @failures = @failures.reject { |k, _| k == base_url || k == key }.freeze
391
+ end
392
+
393
+ # Why a mint produced no usable token, in words, for the log and for
394
+ # {Failure#reason}.
395
+ def failure_reason(result)
396
+ payload = result.payload
397
+
398
+ return "no response" if result.transport_error?
399
+ return payload[:error] if payload.is_a?(Hash) && payload[:error]
400
+ return "unreadable response body (HTTP #{result.status})" if payload.nil?
401
+
402
+ # A 2xx is classified as a server error when it carried nothing usable,
403
+ # so the outcome alone does not say which way it was useless. Say it
404
+ # here: this is the only place the distinction still exists. The wording
405
+ # comes from the same predicate that refused to call the response a
406
+ # mint, so this line cannot claim a token the classification did not
407
+ # find -- an empty one included, which reads as a token to Ruby and to
408
+ # nobody else.
409
+ if (200..299).cover?(result.status)
410
+ return "no token in response" unless Commands::AccessTokenResult.usable_string?(payload[:token])
191
411
 
192
- if payload[:token]
193
412
  # Distinct from a rejected request: intake's base_url is NOT NULL, and
194
413
  # it answers 422 rather than minting when the caller's URL resolves to
195
414
  # no environment. A token with no base_url is a broken server.
196
415
  return "response carried a token but no base_url"
197
416
  end
198
417
 
199
- "no token in response"
418
+ "HTTP #{result.status}"
200
419
  end
201
420
 
202
421
  # Time.parse raises on anything it cannot read — an ArgumentError for a
@@ -1,6 +1,9 @@
1
1
  #!/bin/ruby
2
2
 
3
3
  require 'base64'
4
+ require_relative "token_unavailable_error"
5
+ require_relative "configuration_error"
6
+ require_relative "target_url"
4
7
 
5
8
  module EndPointBlank
6
9
  module AuthorizationMethods
@@ -9,23 +12,104 @@ module EndPointBlank
9
12
  EndPointBlank::Configuration.instance
10
13
  end
11
14
 
12
- # Builds an outbound Authorization header value.
13
- # @param base_url [String, nil] the URL you are about to call, with any
14
- # query string and fragment removed. If given, a token covering it is
15
- # used (minting one if necessary) and returned as a Bearer header.
16
- # Called with no argument this is the Basic form -- which is what the
17
- # calls to intake itself use, since intake already holds this
18
- # service's own credential.
19
- # @return [String] "Bearer <token>" or "Basic <credentials>"
20
- def header(base_url = nil)
21
- token = nil
22
- token = EndPointBlank::AccessTokens.token(base_url) if base_url
23
-
24
- if token
25
- "Bearer " + token
26
- else
27
- "Basic " + Base64.encode64(configuration.client_id + ":" + configuration.client_secret).gsub("\n", "")
15
+ # Builds the Authorization header for an outbound call to a provider.
16
+ #
17
+ # Always a Bearer token, never this service's own credentials: a client
18
+ # must never send its client_id/client_secret to a provider or to the
19
+ # provider's intake (sc-1469). When no token can be obtained -- the mint
20
+ # was rejected (401, 400/422), intake failed (5xx), or it could not be
21
+ # reached at all (timeout, refused connection) -- this raises rather
22
+ # than falling back to Basic.
23
+ #
24
+ # @param base_url [String] the URL you are about to call. A token
25
+ # covering it is used, minting one if necessary. Its userinfo, query
26
+ # and fragment are removed first ({TargetUrl.strip}): they are never
27
+ # sent to intake, logged, or kept on the error.
28
+ # @return [String] "Bearer <token>"
29
+ # @raise [ArgumentError] when base_url is nil or empty, or cannot be
30
+ # parsed into an http or https URL with a host; nothing is sent
31
+ # anywhere. There is no no-target form any more: the old `header`
32
+ # with no argument returned Basic credentials, and the calls to
33
+ # intake itself now use {intake_header}.
34
+ # @raise [TokenUnavailableError] when no token can be obtained; its
35
+ # `failure` says why. Anything unexpected raised while minting is
36
+ # reported the same way, as a :transport_error with that exception
37
+ # as `cause`.
38
+ # @raise [ConfigurationError] when client_id or client_secret is
39
+ # missing; nothing is sent.
40
+ def header(base_url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
41
+ if base_url.nil? || base_url.to_s.empty?
42
+ raise ArgumentError,
43
+ "EndPointBlank::Authorization.header needs the URL you are about to call; " \
44
+ "outbound calls to a provider are only ever authorized with a Bearer token"
28
45
  end
46
+
47
+ # The raw URL is not repeated in the message: it is what could not
48
+ # be parsed, and it may carry a secret.
49
+ target = TargetUrl.strip(base_url)
50
+ if target.nil?
51
+ raise ArgumentError,
52
+ "EndPointBlank::Authorization.header could not parse the URL it was given " \
53
+ "(not shown); pass an absolute http or https URL with a host"
54
+ end
55
+
56
+ # The reason must come from this call, captured under the cache's
57
+ # mutex -- not from `last_failure` read afterwards, which another
58
+ # thread can clear or overwrite in between.
59
+ begin
60
+ result = EndPointBlank::AccessTokens.token_result(target)
61
+ rescue ConfigurationError
62
+ raise
63
+ rescue StandardError
64
+ # A bug, not a failure intake reported: an unreachable intake
65
+ # arrives as a Failure, not a raise. It still becomes the one
66
+ # error this method documents, so a caller handling
67
+ # TokenUnavailableError is not met by a NoMethodError instead.
68
+ # Ruby sets the exception as `cause`; its message stays out of ours.
69
+ raise TokenUnavailableError.new(target, unexpected_failure(target), unexpected: true)
70
+ end
71
+
72
+ unless result.is_a?(String)
73
+ failure = result.is_a?(EndPointBlank::AccessTokens::Failure) ? result : nil
74
+ raise TokenUnavailableError.new(target, failure)
75
+ end
76
+
77
+ "Bearer #{result}"
78
+ end
79
+
80
+ # The failure {header} reports for a mint that raised. The cache
81
+ # records nothing for one, so there is no recorded Failure to hand on.
82
+ def unexpected_failure(target)
83
+ EndPointBlank::AccessTokens::Failure.new(
84
+ base_url: target, outcome: :transport_error, status: nil,
85
+ reason: "the token request failed unexpectedly", at: Time.now
86
+ )
87
+ end
88
+ private :unexpected_failure
89
+
90
+ # The Basic header for the SDK's own calls to its own intake --
91
+ # authenticate/authorize, token minting, endpoint updates and the
92
+ # log/request/response writers. intake already holds this service's
93
+ # credential, so presenting it there discloses nothing.
94
+ #
95
+ # @api private Not for outbound calls to a provider: use {header}.
96
+ # @return [String] "Basic <credentials>"
97
+ # @raise [ConfigurationError] when client_id or client_secret is nil or
98
+ # empty. Interpolating them would silently send `Basic Og==` instead.
99
+ def intake_header # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
100
+ client_id = configuration.client_id
101
+ client_secret = configuration.client_secret
102
+ missing = []
103
+ missing << "client_id" if client_id.nil? || client_id.to_s.empty?
104
+ missing << "client_secret" if client_secret.nil? || client_secret.to_s.empty?
105
+ unless missing.empty?
106
+ raise ConfigurationError,
107
+ "EndPointBlank is missing #{missing.join(" and ")}: set it with EndPointBlank.configure " \
108
+ "or ENDPOINTBLANK_CLIENT_ID / ENDPOINTBLANK_CLIENT_SECRET. The SDK cannot authenticate " \
109
+ "to its intake without both."
110
+ end
111
+
112
+ "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
29
113
  end
30
114
  end
31
115
 
@@ -34,10 +118,11 @@ module EndPointBlank
34
118
  end
35
119
  end
36
120
 
37
- # Generates HTTP Basic Authorization headers using client credentials.
38
- # Creates a Base64-encoded string from the client_id and client_secret
39
- # configured in EndPointBlank::Configuration.
40
- # Use header class method to get a properly formatted "Basic {credentials}" header.
121
+ # Builds Authorization headers.
122
+ #
123
+ # {header} is for outbound calls to a provider and only ever answers with a
124
+ # Bearer token (raising {TokenUnavailableError} when none can be had);
125
+ # {intake_header} is the SDK's own Basic header for calls to its own intake.
41
126
  class Authorization
42
127
  include AuthorizationMethods
43
128
  end