end_point_blank 0.6.1 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +653 -0
  3. data/README.md +424 -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/management/client.rb +228 -0
  17. data/lib/end_point_blank/management/configuration.rb +56 -0
  18. data/lib/end_point_blank/management/error.rb +134 -0
  19. data/lib/end_point_blank/management/error_codes.rb +115 -0
  20. data/lib/end_point_blank/management/idempotency_key.rb +33 -0
  21. data/lib/end_point_blank/management/page.rb +55 -0
  22. data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
  23. data/lib/end_point_blank/management/resources/applications.rb +167 -0
  24. data/lib/end_point_blank/management/resources/base.rb +117 -0
  25. data/lib/end_point_blank/management/resources/clients.rb +152 -0
  26. data/lib/end_point_blank/management/retry_policy.rb +65 -0
  27. data/lib/end_point_blank/management/transport.rb +167 -0
  28. data/lib/end_point_blank/management/url_path.rb +18 -0
  29. data/lib/end_point_blank/management.rb +52 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  31. data/lib/end_point_blank/rails/authorized.rb +9 -13
  32. data/lib/end_point_blank/target_url.rb +57 -0
  33. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  34. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  35. data/lib/end_point_blank/version.rb +1 -1
  36. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  37. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  38. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  39. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  40. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  41. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  42. data/lib/end_point_blank/writers/shared.rb +35 -4
  43. data/lib/end_point_blank.rb +240 -2
  44. metadata +29 -10
  45. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../version"
7
+ require_relative "error"
8
+ require_relative "retry_policy"
9
+ require_relative "idempotency_key"
10
+ require_relative "url_path"
11
+
12
+ module EndPointBlank
13
+ module Management
14
+ # The HTTP layer under {Client}: one request to the management API, with
15
+ # its headers, its Idempotency-Key and its retries. Built on Excon, the
16
+ # HTTP library the rest of this gem already uses.
17
+ #
18
+ # Retries (at most +max_retries+ after the first attempt, each wait passed
19
+ # to +sleeper+):
20
+ #
21
+ # - 429 +rate_limited+, any method: after Retry-After seconds. Nothing was
22
+ # done, so even a PATCH is safe to send again. A Retry-After longer than
23
+ # +max_retry_wait+ is not waited out; the error is raised instead.
24
+ # - 409 +idempotency_request_in_progress+ (POST only): shortly, with the
25
+ # same key, so the first request's answer is replayed once it finishes.
26
+ # - 5xx (+internal_server_error+, +audit_unavailable+,
27
+ # +intake_unavailable+, or a proxy's 502/503/504) and requests that never
28
+ # got an answer: for GET and DELETE, which are idempotent, and for POST,
29
+ # which always carries an Idempotency-Key. Never for PATCH.
30
+ #
31
+ # Every POST carries an Idempotency-Key (a random UUID v4 unless the
32
+ # caller passed one), and every retry of it sends the same key.
33
+ #
34
+ # 409 +idempotency_replay_unavailable+ is never retried: the first POST
35
+ # succeeded and its answer held a secret shown only once.
36
+ class Transport
37
+ API_PREFIX = "/api/v1"
38
+
39
+ attr_reader :base_url
40
+
41
+ # rubocop:disable Metrics/ParameterLists
42
+ def initialize(api_key:, base_url:, max_retries:, max_retry_wait:, connect_timeout:, read_timeout:,
43
+ sleeper:, excon_options: {})
44
+ @api_key = api_key
45
+ @base_url = base_url
46
+ @base_path = UrlPath.strip_trailing_slashes(URI.parse(base_url).path)
47
+ @retry_policy = RetryPolicy.new(max_retries: max_retries, max_retry_wait: max_retry_wait)
48
+ @connect_timeout = connect_timeout
49
+ @read_timeout = read_timeout
50
+ @sleeper = sleeper
51
+ @excon_options = excon_options
52
+ end
53
+ # rubocop:enable Metrics/ParameterLists
54
+
55
+ # Sends one API request and answers its decoded JSON body (nil for an
56
+ # empty body). +path+ is under /api/v1, e.g. "/organization".
57
+ #
58
+ # @raise [Error] for any answer outside 2xx, once retries are spent
59
+ def request(method, path, query: nil, body: nil, idempotency_key: nil)
60
+ method = method.to_s.upcase
61
+ idempotency_key = IdempotencyKey.resolve(method, idempotency_key)
62
+ attempt = 0
63
+
64
+ loop do
65
+ result = attempt_request(method, path, query, body, idempotency_key)
66
+ return result unless result.is_a?(Error)
67
+
68
+ wait_before_retry(method, path, result, attempt)
69
+ attempt += 1
70
+ end
71
+ end
72
+
73
+ def self.user_agent
74
+ "end_point_blank-ruby/#{EndPointBlank::VERSION} (management)"
75
+ end
76
+
77
+ def inspect
78
+ "#<#{self.class.name} base_url=#{base_url.inspect}>"
79
+ end
80
+
81
+ private
82
+
83
+ # The decoded body on success, or the Error to raise or retry on.
84
+ def attempt_request(method, path, query, body, idempotency_key)
85
+ response = perform(method, path, query, body, idempotency_key)
86
+ return Error.from_response(**error_fields(response, method, path)) unless success?(response)
87
+
88
+ decode(response, method, path)
89
+ rescue Excon::Error::StubNotFound
90
+ raise
91
+ rescue Excon::Error => e
92
+ connection_error(e, method, path)
93
+ end
94
+
95
+ def perform(method, path, query, body, idempotency_key)
96
+ connection = Excon.new(base_url, connect_timeout: @connect_timeout, read_timeout: @read_timeout,
97
+ write_timeout: @read_timeout, persistent: false, **@excon_options)
98
+ params = { method: method, path: "#{@base_path}#{API_PREFIX}#{path}",
99
+ headers: headers(body, idempotency_key) }
100
+ params[:query] = query if query && !query.empty?
101
+ params[:body] = JSON.generate(body) unless body.nil?
102
+ connection.request(params)
103
+ end
104
+
105
+ def headers(body, idempotency_key)
106
+ headers = {
107
+ "Authorization" => "Bearer #{@api_key}",
108
+ "Accept" => "application/json",
109
+ "User-Agent" => self.class.user_agent
110
+ }
111
+ headers["Content-Type"] = "application/json" unless body.nil?
112
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
113
+ headers
114
+ end
115
+
116
+ def success?(response)
117
+ (200..299).cover?(response.status)
118
+ end
119
+
120
+ def decode(response, method, path)
121
+ text = response.body.to_s
122
+ return nil if text.strip.empty?
123
+
124
+ JSON.parse(text)
125
+ rescue JSON::ParserError
126
+ Error.new("The management API answered HTTP #{response.status} with a body that is not JSON.",
127
+ code: ErrorCodes::INVALID_RESPONSE, status: response.status, http_method: method, path: path)
128
+ end
129
+
130
+ def error_fields(response, method, path)
131
+ { status: response.status, headers: response.headers, body: response.body,
132
+ http_method: method, path: path }
133
+ end
134
+
135
+ def connection_error(error, method, path)
136
+ detail = redact("#{error.class.name}: #{error.message}")
137
+ Error.new("Could not reach the management API at #{base_url} (#{detail}).",
138
+ code: ErrorCodes::CONNECTION_ERROR, http_method: method, path: path)
139
+ end
140
+
141
+ # Sleeps before the next attempt, or raises +error+ when it is not to
142
+ # be retried.
143
+ def wait_before_retry(method, path, error, attempt)
144
+ delay = @retry_policy.delay(method, error, attempt)
145
+ raise error if delay.nil?
146
+
147
+ log_retry(method, path, error, delay)
148
+ @sleeper.call(delay)
149
+ end
150
+
151
+ # Method, path, status and code only: never headers, query or bodies,
152
+ # which carry the key, ids and credential secrets.
153
+ def log_retry(method, path, error, delay)
154
+ EndPointBlank.logger.debug(
155
+ "[EndPointBlank] management API #{method} #{path} answered " \
156
+ "#{error.status || "no response"} (#{error.code}); retrying in #{delay}s"
157
+ )
158
+ end
159
+
160
+ def redact(text)
161
+ return text if @api_key.nil? || @api_key.empty?
162
+
163
+ text.gsub(@api_key, "[REDACTED]")
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ module Management
5
+ # URL string helpers for the management client.
6
+ module UrlPath
7
+ SLASH = "/".ord
8
+
9
+ # +text+ without trailing slashes. One backward scan over the bytes, not
10
+ # a regex, so its cost is linear however the input is shaped.
11
+ def self.strip_trailing_slashes(text)
12
+ stop = text.bytesize
13
+ stop -= 1 while stop.positive? && text.getbyte(stop - 1) == SLASH
14
+ text.byteslice(0, stop)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration_error"
4
+ require_relative "management/configuration"
5
+ require_relative "management/error_codes"
6
+ require_relative "management/error"
7
+ require_relative "management/page"
8
+ require_relative "management/retry_policy"
9
+ require_relative "management/idempotency_key"
10
+ require_relative "management/url_path"
11
+ require_relative "management/transport"
12
+ require_relative "management/client"
13
+
14
+ module EndPointBlank
15
+ # The organization management API client. See {Management::Client}.
16
+ #
17
+ # Configure defaults once, e.g. in config/initializers/end_point_blank.rb,
18
+ # apart from the runtime's EndPointBlank.configure:
19
+ #
20
+ # EndPointBlank::Management.configure do |m|
21
+ # m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
22
+ # end
23
+ #
24
+ # EndPointBlank::Management.client.organization
25
+ module Management
26
+ @configuration_mutex = Mutex.new
27
+
28
+ class << self
29
+ # The defaults {Client.new} reads. @return [Configuration]
30
+ def configuration
31
+ @configuration_mutex.synchronize { @configuration ||= Configuration.new }
32
+ end
33
+
34
+ # Yields {configuration} to change the defaults.
35
+ def configure
36
+ yield configuration
37
+ configuration
38
+ end
39
+
40
+ # A new {Client} from {configuration}; +options+ override it.
41
+ # @return [Client]
42
+ def client(**options)
43
+ Client.new(**options)
44
+ end
45
+
46
+ # Back to the built-in defaults (for tests).
47
+ def reset_configuration!
48
+ @configuration_mutex.synchronize { @configuration = Configuration.new }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -2,20 +2,75 @@ require "active_support/concern"
2
2
 
3
3
  module EndPointBlank
4
4
  module Rails
5
+ # Installs a `before_action :authenticate!` that asks intake whether the
6
+ # calling credential may reach this controller at all, refusing the request
7
+ # with an `UnauthorizedError` when it may not.
8
+ #
9
+ # == Why this concern exists rather than being deleted
10
+ #
11
+ # Until now it could not have worked once: it called
12
+ # `EndPointBlank::Commands::EndpointAuthenticate`, a constant that has never
13
+ # been defined anywhere in this gem, so every action of every controller
14
+ # including it raised `NameError` before authentication could succeed or
15
+ # fail. Nothing in this project included it, and the one test-application
16
+ # controller named for it (`epb_test_rails`'s `AuthenticatedController`)
17
+ # actually includes `Authorized`, so nothing ever noticed.
18
+ #
19
+ # That made "implement or delete" a real question. It is implemented,
20
+ # because the authenticate path is not a Rails-only idea that Rails
21
+ # happened not to need: the JS, Java and Python SDKs each expose one, and
22
+ # each of their commands documents itself as "equivalent to the Ruby gem's
23
+ # `EndPointBlank::Commands::BasicAuthenticate`". Three SDKs were ported
24
+ # from a Ruby original that was here the whole time and simply never wired
25
+ # up. (Elixir has no authenticate path at all, so "four other SDKs expose
26
+ # one", as sc-306 puts it, is three -- but three of three name this gem.) A
27
+ # missing authenticate path in Ruby is a gap, not a decision.
28
+ #
29
+ # == Why `BasicAuthenticate` and not a new `EndpointAuthenticate`
30
+ #
31
+ # Writing a `Commands::EndpointAuthenticate` to satisfy the old call site
32
+ # would have invented a second command for a job this gem already has a
33
+ # command for, and left `BasicAuthenticate` -- required by `end_point_blank.rb`,
34
+ # ported into three other SDKs, and carrying the request body shape intake
35
+ # actually reads -- dead in the tree next to it. Nothing can be depending on
36
+ # the name `EndpointAuthenticate`, because it never resolved; no public
37
+ # constant is being removed here, only one that was never there is being
38
+ # left absent.
39
+ #
40
+ # == Deliberately no cache
41
+ #
42
+ # `Authorized` caches intake's answer per credential/route/method/version.
43
+ # This path does not, matching every other SDK's authenticate command.
44
+ # Adding one here would be a behaviour change none of the four share and
45
+ # neither story asked for; it belongs in its own story if it is wanted.
5
46
  module Authenticated
6
- extend ActiveSupport::Concern
47
+ extend ActiveSupport::Concern
7
48
 
8
49
  included do
9
50
  before_action :authenticate!
10
51
  end
11
52
 
12
53
  def authenticate!
13
- result = EndPointBlank::Commands::EndpointAuthenticate.authenticate(request)
14
- result_json = JSON.parse(result.body)
15
- if !result || result.status != 201
16
- raise UnauthorizedError, "Authentication failed: #{result_json['error']}"
17
- end
54
+ result = EndPointBlank::Commands::BasicAuthenticate.authenticate(request)
55
+
56
+ # The guard comes first, and reads `result` before anything is asked of
57
+ # it. It used to run second: the body was parsed on the line above,
58
+ # so `result.nil?` could never be reached -- a nil result raised
59
+ # `NoMethodError` on nil instead of taking the branch written for it.
60
+ # Fixing the missing constant alone would have exposed exactly that,
61
+ # which is why both were fixed in one pass.
62
+ return if result && result.status == 201
63
+
64
+ # `raise UnauthorizedError.refusal_from(...)`, raising an instance,
65
+ # rather than `raise UnauthorizedError, "message"`. The two-argument
66
+ # `raise Class, message` form calls `Class.exception(message)` and can
67
+ # pass nothing else, so it structurally could not carry intake's status
68
+ # however willing `UnauthorizedError` was to accept one -- which is how
69
+ # every refusal from this path was going to arrive as the class's 401
70
+ # default, including the 403 that means "your credential is fine, you
71
+ # have no grant for this endpoint".
72
+ raise UnauthorizedError.refusal_from(result, "Authentication")
18
73
  end
19
74
  end
20
75
  end
21
- end
76
+ end
@@ -3,7 +3,7 @@ require "active_support/concern"
3
3
  module EndPointBlank
4
4
  module Rails
5
5
  module Authorized
6
- extend ActiveSupport::Concern
6
+ extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
9
  before_action :authorize!
@@ -12,7 +12,13 @@ module EndPointBlank
12
12
  def authorize!
13
13
  result = EndPointBlank::Commands::EndpointAuthorize.authorize(request)
14
14
  if result.nil? || result.status != 201
15
- raise UnauthorizedError.new(authorize_error_message(result), result&.status || 503)
15
+ # Shared with `Authenticated#authenticate!`. This path has always
16
+ # carried intake's status and that one dropped it; two copies of one
17
+ # decision is how that happens, so there is now one copy. Nothing
18
+ # this path produces for any input has changed -- the message and the
19
+ # status are the same for a refusal, for a 5xx, and for an intake
20
+ # that did not answer.
21
+ raise UnauthorizedError.refusal_from(result, "Authorization")
16
22
  end
17
23
  result_json = JSON.parse(result.body)
18
24
  app_env_id = result_json['data'][0]['source_application_environment_id']
@@ -21,16 +27,6 @@ module EndPointBlank
21
27
  # turns it into Deprecation / Sunset headers on the way out.
22
28
  ::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
23
29
  end
24
-
25
- private
26
-
27
- def authorize_error_message(result)
28
- return "Authorization service unavailable" if result.nil?
29
-
30
- parsed = JSON.parse(result.body) rescue nil
31
- detail = parsed.is_a?(Hash) ? parsed["error"] : nil
32
- "Authorization failed: #{detail || result.body}"
33
- end
34
30
  end
35
31
  end
36
- end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module EndPointBlank
6
+ # The form of a caller's target URL that the SDK is allowed to use:
7
+ # scheme, host, port and path, nothing else (sc-1469).
8
+ #
9
+ # The caller controls the URL, and its userinfo, query or fragment can
10
+ # carry a secret. None of them is needed -- intake resolves the environment
11
+ # from scheme, host, port and path alone, and its BaseUrl.normalize refuses
12
+ # a URL carrying any of them (an empty `?` or `#` included), so sending them
13
+ # would leak them to intake AND guarantee the mint fails. So every URL is
14
+ # stripped here on the way in, and only the stripped form is sent to
15
+ # intake, used as a cache or failure key, logged, or kept on an error.
16
+ #
17
+ # Built from the parsed parts, never by splitting the string, so an empty
18
+ # `?` or `#` cannot slip through.
19
+ #
20
+ # Only http and https are accepted, because only they can name a provider.
21
+ # Any other scheme is refused rather than rebuilt: Ruby parses it with a
22
+ # scheme-specific class whose parts need not fit "scheme://host/path"
23
+ # (URI::FTP's path has no leading slash, so "ftp://h:21/x" would come out
24
+ # as "ftp://hx").
25
+ module TargetUrl
26
+ HTTP_SCHEMES = %w[http https].freeze
27
+ # intake's BaseUrl refuses any other port, so asking would only cost a
28
+ # request and a recorded failure.
29
+ VALID_PORTS = (1..65_535).freeze
30
+ private_constant :HTTP_SCHEMES, :VALID_PORTS
31
+
32
+ # @param url [String, nil] the URL a caller is about to call
33
+ # @return [String, nil] "scheme://host[:port]/path" (scheme and host
34
+ # lowercased as intake's BaseUrl does, IPv6 in brackets, the port only
35
+ # when it is not the scheme default, the path as given), or nil when url
36
+ # cannot be parsed, is not http or https, has no host, or has a port
37
+ # outside 1..65535 -- the caller must then refuse it without making any
38
+ # request.
39
+ def self.strip(url)
40
+ return nil if url.nil?
41
+
42
+ uri = URI.parse(url.to_s)
43
+ return nil unless acceptable?(uri)
44
+
45
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
46
+ "#{uri.scheme}://#{uri.host.downcase}#{port}#{uri.path}"
47
+ rescue URI::Error
48
+ nil
49
+ end
50
+
51
+ # URI lowercases the scheme, so "HTTPS://..." is accepted here too.
52
+ def self.acceptable?(uri)
53
+ HTTP_SCHEMES.include?(uri.scheme) && !uri.host.to_s.empty? && VALID_PORTS.cover?(uri.port)
54
+ end
55
+ private_class_method :acceptable?
56
+ end
57
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "target_url"
4
+
5
+ module EndPointBlank
6
+ # Reopened with the same superclass in end_point_blank.rb; declared here too
7
+ # so this file can be required on its own.
8
+ class Error < StandardError; end
9
+
10
+ # Raised by {Authorization.header} when no access token can be obtained for
11
+ # an outbound call to a provider.
12
+ #
13
+ # There is deliberately no fallback. Before sc-1469 a failed mint silently
14
+ # produced "Basic base64(client_id:client_secret)" instead, which sent this
15
+ # service's own credential to whichever provider it was calling (and to
16
+ # that provider's intake). A client must never do that, so the call cannot
17
+ # be authorized and the caller has to decide what to do: retry, degrade, or
18
+ # fail its own request.
19
+ #
20
+ # `base_url` is the URL the token was wanted for with its userinfo, query
21
+ # and fragment removed ({TargetUrl.strip}), or nil when it could not be
22
+ # parsed. The raw value is never kept: the caller already has it, and a
23
+ # field on an exception reaches error reporting as surely as the message
24
+ # does.
25
+ #
26
+ # `failure` is the {AccessTokens::Failure} recorded for the mint, when one
27
+ # was recorded, so a handler can branch on `failure.outcome`
28
+ # (:credential_rejected, :request_rejected, :server_error,
29
+ # :transport_error) exactly as it would after calling
30
+ # {AccessTokens.last_failure} itself.
31
+ #
32
+ # A mint that raised is constructed with `unexpected: true` and reported as
33
+ # :transport_error, with the exception as `cause` (Ruby sets it when this
34
+ # is raised from the rescue). Its message is deliberately not copied into
35
+ # this one.
36
+ class TokenUnavailableError < Error
37
+ attr_reader :base_url, :failure
38
+
39
+ # @param base_url [String] the URL the token was wanted for
40
+ # @param failure [AccessTokens::Failure, nil] why the mint failed
41
+ # @param unexpected [Boolean] true when the mint raised rather than
42
+ # reporting a failure
43
+ def initialize(base_url, failure = nil, unexpected: false)
44
+ @base_url = TargetUrl.strip(base_url)
45
+ @failure = failure
46
+ @unexpected = unexpected
47
+ super(build_message)
48
+ end
49
+
50
+ # @return [Boolean] true when the mint raised; `cause` holds what it raised
51
+ def unexpected?
52
+ @unexpected
53
+ end
54
+
55
+ # @return [Symbol, nil] the failure outcome, or nil when none was recorded
56
+ def outcome
57
+ failure&.outcome
58
+ end
59
+
60
+ # @return [Integer, nil] intake's HTTP status, or nil when it never answered
61
+ def status
62
+ failure&.status
63
+ end
64
+
65
+ private
66
+
67
+ UNPARSEABLE_URL = "the requested URL (not shown: it could not be parsed)"
68
+ private_constant :UNPARSEABLE_URL
69
+
70
+ # One fixed text per outcome, the same in every EndPointBlank SDK. Never
71
+ # intake's response body (Failure#reason) or an exception message: the
72
+ # message is what reaches logs and error reporting, and neither is ours
73
+ # to vouch for. Failure#reason stays available on #failure.
74
+ def reason # rubocop:disable Metrics/MethodLength
75
+ # Before the outcome, because a mint that raised is also :transport_error.
76
+ return "the token request failed unexpectedly" if unexpected?
77
+
78
+ http = status ? " (HTTP #{status})" : ""
79
+
80
+ case outcome
81
+ when :credential_rejected
82
+ "intake rejected this application's client credential#{http}; " \
83
+ "retrying cannot help -- re-issue the credential"
84
+ when :request_rejected
85
+ "intake refused the token request#{http}; check the URL and that a grant covers the target"
86
+ when :server_error
87
+ "intake failed to issue a token#{http}; this may be transient"
88
+ when :transport_error
89
+ "intake could not be reached (timeout, connection refused or retries exhausted); " \
90
+ "this may be transient"
91
+ else
92
+ "the token request failed for an unknown reason"
93
+ end
94
+ end
95
+
96
+ def build_message
97
+ "Could not mint an EndPointBlank access token for #{base_url || UNPARSEABLE_URL}: #{reason}. " \
98
+ "EndPointBlank never sends this service's client_id/client_secret to a provider, " \
99
+ "so there is no Basic-auth fallback and the call must not be made without a token."
100
+ end
101
+ end
102
+ end
@@ -1,4 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
1
5
  module EndPointBlank
6
+ # Raised when a request fails authentication or authorization.
7
+ #
8
+ # This error is intentionally not logged by the middleware, as unauthorized
9
+ # access attempts are expected to occur.
10
+ #
11
+ # `status` carries the status intake answered the authenticate or authorize
12
+ # call with, so a handler can tell the two refusals apart: 401 means the
13
+ # credential was not accepted and the integrator should check it, 403 means
14
+ # the credential was fine but no grant covers this endpoint and the
15
+ # integrator should ask for one. Collapsing both to 401 sends them to debug
16
+ # the wrong thing. The other SDKs each carry the same value under their own
17
+ # idiomatic name -- `statusCode` in JS, `getStatusCode()` in Java,
18
+ # `status_code` in Python; this is Ruby's.
19
+ #
20
+ # It defaults to 401 so that `UnauthorizedError.new(message)` keeps working
21
+ # unchanged, and because 401 is the safe reading of a refusal that arrives
22
+ # with no status attached. The concerns never lean on that default: they pass
23
+ # intake's status, or 503 when intake did not answer at all.
2
24
  class UnauthorizedError < StandardError
3
25
  attr_reader :status
4
26
 
@@ -6,5 +28,63 @@ module EndPointBlank
6
28
  super(message)
7
29
  @status = status
8
30
  end
31
+
32
+ # Builds the error for a non-201 answer to intake's authenticate or
33
+ # authorize call. `action` is "Authentication" or "Authorization".
34
+ #
35
+ # One method rather than a copy per concern. `Rails::Authenticated` and
36
+ # `Rails::Authorized` were two transcriptions of one decision, and two
37
+ # copies is how one path acquires a fix the other does not. That is not
38
+ # hypothetical here: `authorize!` passed intake's status and read the body
39
+ # defensively, while `authenticate!` used `raise UnauthorizedError,
40
+ # "message"` -- the two-argument `raise Class, message` form, which
41
+ # structurally cannot pass a status however willing this class is to accept
42
+ # one -- and parsed the body before it had checked there was a body to
43
+ # parse. Both are fixed here, once.
44
+ #
45
+ # @param result [#status, #body, nil] intake's answer, or nil if it did not
46
+ # answer at all.
47
+ # @param action [String] the word naming what was attempted.
48
+ # @return [UnauthorizedError]
49
+ def self.refusal_from(result, action)
50
+ if result.nil?
51
+ # Intake never answered at all, so nothing refused this caller and 401
52
+ # would blame a credential that was never judged. 503 says the true
53
+ # thing -- the check could not be made -- and is what the other four
54
+ # SDKs already send for this same case: JS and Java
55
+ # `response ? response.status : 503`, Python's `refusal_from`, and
56
+ # Elixir's literal `send_resp(503, ...)`. It is also the one case the
57
+ # "service unavailable" wording is actually true for.
58
+ #
59
+ # The wording deliberately has no "#{action} failed:" prefix, because
60
+ # that is what `authorize!` has always sent for this case and this
61
+ # method must not change what the working path produces for any input.
62
+ # The other SDKs prefix it; the status, which is what a handler
63
+ # branches on, agrees with them.
64
+ return new("#{action} service unavailable", 503)
65
+ end
66
+
67
+ # Intake's verdict verbatim. 401 tells an integrator to check the
68
+ # credential, 403 tells them to ask for a grant.
69
+ new("#{action} failed: #{reason_from(result)}", result.status)
70
+ end
71
+
72
+ # intake sends `{"error": "..."}`, but the SDK reaches it through a proxy
73
+ # and a WAF or load balancer can answer with an HTML page intake never
74
+ # generated. An unparseable body is therefore an expected case, not a
75
+ # swallowed failure: it falls back to the raw body so the operator still
76
+ # sees exactly what came back rather than an empty reason.
77
+ def self.reason_from(result)
78
+ parsed =
79
+ begin
80
+ JSON.parse(result.body)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ detail = parsed.is_a?(Hash) ? parsed["error"] : nil
86
+ detail || result.body
87
+ end
88
+ private_class_method :reason_from
9
89
  end
10
- end
90
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.6.1"
4
+ VERSION = "0.13.0"
5
5
  end