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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +653 -0
- data/README.md +424 -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/management/client.rb +228 -0
- data/lib/end_point_blank/management/configuration.rb +56 -0
- data/lib/end_point_blank/management/error.rb +134 -0
- data/lib/end_point_blank/management/error_codes.rb +115 -0
- data/lib/end_point_blank/management/idempotency_key.rb +33 -0
- data/lib/end_point_blank/management/page.rb +55 -0
- data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
- data/lib/end_point_blank/management/resources/applications.rb +167 -0
- data/lib/end_point_blank/management/resources/base.rb +117 -0
- data/lib/end_point_blank/management/resources/clients.rb +152 -0
- data/lib/end_point_blank/management/retry_policy.rb +65 -0
- data/lib/end_point_blank/management/transport.rb +167 -0
- data/lib/end_point_blank/management/url_path.rb +18 -0
- data/lib/end_point_blank/management.rb +52 -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 +240 -2
- metadata +29 -10
- 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
|
-
|
|
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::
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|