end_point_blank 0.6.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +621 -0
- data/README.md +236 -19
- data/end_point_blank.gemspec +5 -3
- data/lib/end_point_blank/access_tokens.rb +244 -25
- data/lib/end_point_blank/authorization.rb +105 -20
- data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
- data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
- data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
- data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
- data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
- data/lib/end_point_blank/commands/http.rb +20 -1
- data/lib/end_point_blank/configuration.rb +111 -4
- data/lib/end_point_blank/configuration_error.rb +18 -0
- data/lib/end_point_blank/rails/authenticated.rb +62 -7
- data/lib/end_point_blank/rails/authorized.rb +9 -13
- data/lib/end_point_blank/target_url.rb +57 -0
- data/lib/end_point_blank/token_unavailable_error.rb +102 -0
- data/lib/end_point_blank/unauthorized_error.rb +81 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
- data/lib/end_point_blank/writers/direct_writer.rb +1 -1
- data/lib/end_point_blank/writers/exception_writer.rb +11 -2
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +1 -0
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank/writers/shared.rb +35 -4
- data/lib/end_point_blank.rb +248 -3
- metadata +15 -10
- 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.
|
|
18
|
-
#
|
|
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
|
-
#
|
|
53
|
-
#
|
|
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
|
-
|
|
172
|
+
result = Commands::GenerateAccessToken.token_result(base_url)
|
|
173
|
+
payload = result.payload
|
|
68
174
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
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
|
-
|
|
104
|
-
nil
|
|
219
|
+
record_failure(base_url, result)
|
|
105
220
|
end
|
|
106
221
|
end
|
|
107
222
|
end
|
|
108
223
|
|
|
109
|
-
#
|
|
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
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
"
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|