http 5.3.1 → 6.0.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 +241 -41
- data/LICENSE.txt +1 -1
- data/README.md +110 -13
- data/UPGRADING.md +491 -0
- data/http.gemspec +32 -29
- data/lib/http/base64.rb +11 -1
- data/lib/http/chainable/helpers.rb +62 -0
- data/lib/http/chainable/verbs.rb +136 -0
- data/lib/http/chainable.rb +232 -136
- data/lib/http/client.rb +158 -127
- data/lib/http/connection/internals.rb +141 -0
- data/lib/http/connection.rb +126 -97
- data/lib/http/content_type.rb +61 -6
- data/lib/http/errors.rb +25 -1
- data/lib/http/feature.rb +65 -5
- data/lib/http/features/auto_deflate.rb +124 -17
- data/lib/http/features/auto_inflate.rb +38 -15
- data/lib/http/features/caching/entry.rb +178 -0
- data/lib/http/features/caching/in_memory_store.rb +63 -0
- data/lib/http/features/caching.rb +216 -0
- data/lib/http/features/digest_auth.rb +234 -0
- data/lib/http/features/instrumentation.rb +97 -17
- data/lib/http/features/logging.rb +183 -5
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/features/raise_error.rb +18 -3
- data/lib/http/form_data/composite_io.rb +106 -0
- data/lib/http/form_data/file.rb +95 -0
- data/lib/http/form_data/multipart/param.rb +62 -0
- data/lib/http/form_data/multipart.rb +106 -0
- data/lib/http/form_data/part.rb +52 -0
- data/lib/http/form_data/readable.rb +58 -0
- data/lib/http/form_data/urlencoded.rb +175 -0
- data/lib/http/form_data/version.rb +8 -0
- data/lib/http/form_data.rb +102 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/headers/normalizer.rb +17 -36
- data/lib/http/headers.rb +172 -65
- data/lib/http/mime_type/adapter.rb +24 -9
- data/lib/http/mime_type/json.rb +19 -4
- data/lib/http/mime_type.rb +21 -3
- data/lib/http/options/definitions.rb +189 -0
- data/lib/http/options.rb +172 -125
- data/lib/http/redirector.rb +80 -75
- data/lib/http/request/body.rb +87 -6
- data/lib/http/request/builder.rb +184 -0
- data/lib/http/request/proxy.rb +83 -0
- data/lib/http/request/writer.rb +76 -16
- data/lib/http/request.rb +214 -98
- data/lib/http/response/body.rb +103 -18
- data/lib/http/response/inflater.rb +35 -7
- data/lib/http/response/parser.rb +98 -4
- data/lib/http/response/status/reasons.rb +2 -4
- data/lib/http/response/status.rb +141 -31
- data/lib/http/response.rb +219 -61
- data/lib/http/retriable/delay_calculator.rb +38 -11
- data/lib/http/retriable/errors.rb +21 -0
- data/lib/http/retriable/performer.rb +82 -38
- data/lib/http/session.rb +280 -0
- data/lib/http/timeout/global.rb +147 -34
- data/lib/http/timeout/null.rb +155 -9
- data/lib/http/timeout/per_operation.rb +139 -18
- data/lib/http/uri/normalizer.rb +82 -0
- data/lib/http/uri/parsing.rb +182 -0
- data/lib/http/uri.rb +289 -124
- data/lib/http/version.rb +2 -1
- data/lib/http.rb +11 -2
- data/sig/deps.rbs +122 -0
- data/sig/http.rbs +1619 -0
- data/test/http/base64_test.rb +28 -0
- data/test/http/client_test.rb +739 -0
- data/test/http/connection_test.rb +1533 -0
- data/test/http/content_type_test.rb +190 -0
- data/test/http/errors_test.rb +28 -0
- data/test/http/feature_test.rb +49 -0
- data/test/http/features/auto_deflate_test.rb +317 -0
- data/test/http/features/auto_inflate_test.rb +213 -0
- data/test/http/features/caching_test.rb +942 -0
- data/test/http/features/digest_auth_test.rb +996 -0
- data/test/http/features/instrumentation_test.rb +246 -0
- data/test/http/features/logging_test.rb +654 -0
- data/test/http/features/normalize_uri_test.rb +41 -0
- data/test/http/features/raise_error_test.rb +77 -0
- data/test/http/form_data/composite_io_test.rb +215 -0
- data/test/http/form_data/file_test.rb +255 -0
- data/test/http/form_data/fixtures/the-http-gem.info +1 -0
- data/test/http/form_data/multipart_test.rb +303 -0
- data/test/http/form_data/part_test.rb +90 -0
- data/test/http/form_data/urlencoded_test.rb +164 -0
- data/test/http/form_data_test.rb +232 -0
- data/test/http/headers/normalizer_test.rb +93 -0
- data/test/http/headers_test.rb +888 -0
- data/test/http/mime_type/json_test.rb +39 -0
- data/test/http/mime_type_test.rb +150 -0
- data/test/http/options/base_uri_test.rb +148 -0
- data/test/http/options/body_test.rb +21 -0
- data/test/http/options/features_test.rb +38 -0
- data/test/http/options/form_test.rb +21 -0
- data/test/http/options/headers_test.rb +32 -0
- data/test/http/options/json_test.rb +21 -0
- data/test/http/options/merge_test.rb +78 -0
- data/test/http/options/new_test.rb +37 -0
- data/test/http/options/proxy_test.rb +32 -0
- data/test/http/options_test.rb +575 -0
- data/test/http/redirector_test.rb +639 -0
- data/test/http/request/body_test.rb +318 -0
- data/test/http/request/builder_test.rb +623 -0
- data/test/http/request/writer_test.rb +391 -0
- data/test/http/request_test.rb +1733 -0
- data/test/http/response/body_test.rb +292 -0
- data/test/http/response/parser_test.rb +105 -0
- data/test/http/response/status_test.rb +322 -0
- data/test/http/response_test.rb +502 -0
- data/test/http/retriable/delay_calculator_test.rb +194 -0
- data/test/http/retriable/errors_test.rb +71 -0
- data/test/http/retriable/performer_test.rb +551 -0
- data/test/http/session_test.rb +424 -0
- data/test/http/timeout/global_test.rb +239 -0
- data/test/http/timeout/null_test.rb +218 -0
- data/test/http/timeout/per_operation_test.rb +220 -0
- data/test/http/uri/normalizer_test.rb +89 -0
- data/test/http/uri_test.rb +1140 -0
- data/test/http/version_test.rb +15 -0
- data/test/http_test.rb +818 -0
- data/test/regression_tests.rb +27 -0
- data/test/support/dummy_server/encoding_routes.rb +47 -0
- data/test/support/dummy_server/routes.rb +201 -0
- data/test/support/dummy_server/servlet.rb +81 -0
- data/test/support/dummy_server.rb +200 -0
- data/{spec → test}/support/fakeio.rb +2 -2
- data/test/support/http_handling_shared/connection_reuse_tests.rb +97 -0
- data/test/support/http_handling_shared/timeout_tests.rb +134 -0
- data/test/support/http_handling_shared.rb +11 -0
- data/test/support/proxy_server.rb +207 -0
- data/test/support/servers/runner.rb +67 -0
- data/{spec → test}/support/simplecov.rb +11 -2
- data/test/support/ssl_helper.rb +108 -0
- data/test/test_helper.rb +38 -0
- metadata +108 -168
- data/.github/workflows/ci.yml +0 -67
- data/.gitignore +0 -15
- data/.rspec +0 -1
- data/.rubocop/layout.yml +0 -8
- data/.rubocop/metrics.yml +0 -4
- data/.rubocop/rspec.yml +0 -9
- data/.rubocop/style.yml +0 -32
- data/.rubocop.yml +0 -11
- data/.rubocop_todo.yml +0 -219
- data/.yardopts +0 -2
- data/CHANGES_OLD.md +0 -1002
- data/Gemfile +0 -51
- data/Guardfile +0 -18
- data/Rakefile +0 -64
- data/lib/http/headers/mixin.rb +0 -34
- data/lib/http/retriable/client.rb +0 -37
- data/logo.png +0 -0
- data/spec/lib/http/client_spec.rb +0 -556
- data/spec/lib/http/connection_spec.rb +0 -88
- data/spec/lib/http/content_type_spec.rb +0 -47
- data/spec/lib/http/features/auto_deflate_spec.rb +0 -77
- data/spec/lib/http/features/auto_inflate_spec.rb +0 -86
- data/spec/lib/http/features/instrumentation_spec.rb +0 -81
- data/spec/lib/http/features/logging_spec.rb +0 -65
- data/spec/lib/http/features/raise_error_spec.rb +0 -62
- data/spec/lib/http/headers/mixin_spec.rb +0 -36
- data/spec/lib/http/headers/normalizer_spec.rb +0 -52
- data/spec/lib/http/headers_spec.rb +0 -527
- data/spec/lib/http/options/body_spec.rb +0 -15
- data/spec/lib/http/options/features_spec.rb +0 -33
- data/spec/lib/http/options/form_spec.rb +0 -15
- data/spec/lib/http/options/headers_spec.rb +0 -24
- data/spec/lib/http/options/json_spec.rb +0 -15
- data/spec/lib/http/options/merge_spec.rb +0 -68
- data/spec/lib/http/options/new_spec.rb +0 -30
- data/spec/lib/http/options/proxy_spec.rb +0 -20
- data/spec/lib/http/options_spec.rb +0 -13
- data/spec/lib/http/redirector_spec.rb +0 -530
- data/spec/lib/http/request/body_spec.rb +0 -211
- data/spec/lib/http/request/writer_spec.rb +0 -121
- data/spec/lib/http/request_spec.rb +0 -234
- data/spec/lib/http/response/body_spec.rb +0 -85
- data/spec/lib/http/response/parser_spec.rb +0 -74
- data/spec/lib/http/response/status_spec.rb +0 -253
- data/spec/lib/http/response_spec.rb +0 -262
- data/spec/lib/http/retriable/delay_calculator_spec.rb +0 -69
- data/spec/lib/http/retriable/performer_spec.rb +0 -302
- data/spec/lib/http/uri/normalizer_spec.rb +0 -95
- data/spec/lib/http/uri_spec.rb +0 -71
- data/spec/lib/http_spec.rb +0 -535
- data/spec/regression_specs.rb +0 -24
- data/spec/spec_helper.rb +0 -89
- data/spec/support/black_hole.rb +0 -13
- data/spec/support/dummy_server/servlet.rb +0 -203
- data/spec/support/dummy_server.rb +0 -44
- data/spec/support/fuubar.rb +0 -21
- data/spec/support/http_handling_shared.rb +0 -190
- data/spec/support/proxy_server.rb +0 -39
- data/spec/support/servers/config.rb +0 -11
- data/spec/support/servers/runner.rb +0 -19
- data/spec/support/ssl_helper.rb +0 -104
- /data/{spec → test}/support/capture_warning.rb +0 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
class HTTPFeaturesDigestAuthTest < Minitest::Test
|
|
7
|
+
cover "HTTP::Features::DigestAuth*"
|
|
8
|
+
|
|
9
|
+
def feature
|
|
10
|
+
@feature ||= HTTP::Features::DigestAuth.new(user: "admin", pass: "secret")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def connection
|
|
14
|
+
@connection ||= fake
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request
|
|
18
|
+
@request ||= HTTP::Request.new(
|
|
19
|
+
verb: :get,
|
|
20
|
+
uri: "https://example.com/protected",
|
|
21
|
+
headers: { "Accept" => "text/html" }
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_response(status:, headers: {})
|
|
26
|
+
HTTP::Response.new(
|
|
27
|
+
version: "1.1",
|
|
28
|
+
status: status,
|
|
29
|
+
headers: headers,
|
|
30
|
+
body: "",
|
|
31
|
+
request: request
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Helper to perform a digest challenge round-trip and capture the retried request
|
|
36
|
+
def perform_digest_challenge(feat, req, challenge_header)
|
|
37
|
+
retried_request = nil
|
|
38
|
+
call_count = 0
|
|
39
|
+
|
|
40
|
+
challenge_resp = HTTP::Response.new(
|
|
41
|
+
version: "1.1", status: 401, body: "",
|
|
42
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
43
|
+
request: req
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
feat.around_request(req) do |r|
|
|
47
|
+
call_count += 1
|
|
48
|
+
if call_count == 1
|
|
49
|
+
challenge_resp
|
|
50
|
+
else
|
|
51
|
+
retried_request = r
|
|
52
|
+
HTTP::Response.new(version: "1.1", status: 200, body: "", request: req)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
retried_request
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def challenge_header
|
|
60
|
+
'Digest realm="testrealm", nonce="abc123", qop="auth", opaque="xyz789"'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def challenge_response
|
|
64
|
+
build_response(
|
|
65
|
+
status: 401,
|
|
66
|
+
headers: { "WWW-Authenticate" => challenge_header }
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# -- #around_request: when response is not 401 --
|
|
71
|
+
|
|
72
|
+
def test_around_request_when_not_401_returns_response_unchanged
|
|
73
|
+
response = build_response(status: 200)
|
|
74
|
+
result = feature.around_request(request) { response }
|
|
75
|
+
|
|
76
|
+
assert_same response, result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# -- #around_request: when 401 without WWW-Authenticate --
|
|
80
|
+
|
|
81
|
+
def test_around_request_when_401_without_www_authenticate_returns_response_unchanged
|
|
82
|
+
response = build_response(status: 401)
|
|
83
|
+
result = feature.around_request(request) { response }
|
|
84
|
+
|
|
85
|
+
assert_same response, result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# -- #around_request: when 401 with Basic WWW-Authenticate --
|
|
89
|
+
|
|
90
|
+
def test_around_request_when_401_with_basic_returns_response_unchanged
|
|
91
|
+
response = build_response(
|
|
92
|
+
status: 401,
|
|
93
|
+
headers: { "WWW-Authenticate" => "Basic realm=\"test\"" }
|
|
94
|
+
)
|
|
95
|
+
result = feature.around_request(request) { response }
|
|
96
|
+
|
|
97
|
+
assert_same response, result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -- #around_request: when 200 with Digest WWW-Authenticate --
|
|
101
|
+
|
|
102
|
+
def test_around_request_when_200_with_digest_returns_response_unchanged
|
|
103
|
+
response = build_response(
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { "WWW-Authenticate" => 'Digest realm="test", nonce="abc"' }
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
call_count = 0
|
|
109
|
+
result = feature.around_request(request) do |_req|
|
|
110
|
+
call_count += 1
|
|
111
|
+
response
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
assert_same response, result
|
|
115
|
+
assert_equal 1, call_count, "should not retry for non-401 responses"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# -- #around_request: when 401 with Digest challenge --
|
|
119
|
+
|
|
120
|
+
def test_around_request_with_digest_challenge_retries_with_digest_authorization
|
|
121
|
+
calls = []
|
|
122
|
+
feature.around_request(request) do |req|
|
|
123
|
+
calls << req
|
|
124
|
+
calls.length == 1 ? challenge_response : build_response(status: 200)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
assert_equal 2, calls.length
|
|
128
|
+
assert_nil calls[0].headers["Authorization"]
|
|
129
|
+
assert_includes calls[1].headers["Authorization"], "Digest "
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_around_request_with_digest_challenge_flushes_401_body_before_retrying
|
|
133
|
+
flushed = false
|
|
134
|
+
body_mock = Minitest::Mock.new
|
|
135
|
+
body_mock.expect(:to_s, "")
|
|
136
|
+
|
|
137
|
+
challenge_resp = HTTP::Response.new(
|
|
138
|
+
version: "1.1", status: 401,
|
|
139
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
140
|
+
body: body_mock,
|
|
141
|
+
request: request
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
call_count = 0
|
|
145
|
+
feature.around_request(request) do |_req|
|
|
146
|
+
call_count += 1
|
|
147
|
+
if call_count == 1
|
|
148
|
+
challenge_resp
|
|
149
|
+
else
|
|
150
|
+
flushed = body_mock.verify
|
|
151
|
+
build_response(status: 200)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
assert flushed, "response body should be flushed (read) before retry"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_around_request_with_digest_challenge_returns_the_retried_response
|
|
159
|
+
ok_response = build_response(status: 200)
|
|
160
|
+
|
|
161
|
+
call_count = 0
|
|
162
|
+
result = feature.around_request(request) do |_req|
|
|
163
|
+
call_count += 1
|
|
164
|
+
call_count == 1 ? challenge_response : ok_response
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
assert_same ok_response, result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_around_request_with_digest_challenge_preserves_original_request_headers
|
|
171
|
+
retried_request = nil
|
|
172
|
+
call_count = 0
|
|
173
|
+
|
|
174
|
+
feature.around_request(request) do |req|
|
|
175
|
+
call_count += 1
|
|
176
|
+
if call_count == 1
|
|
177
|
+
challenge_response
|
|
178
|
+
else
|
|
179
|
+
retried_request = req
|
|
180
|
+
build_response(status: 200)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
assert_equal "text/html", retried_request.headers["Accept"]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def test_around_request_with_digest_challenge_preserves_original_request_verb
|
|
188
|
+
post_request = HTTP::Request.new(
|
|
189
|
+
verb: :post,
|
|
190
|
+
uri: "https://example.com/protected",
|
|
191
|
+
body: "data"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
retried_request = nil
|
|
195
|
+
call_count = 0
|
|
196
|
+
|
|
197
|
+
feature.around_request(post_request) do |req|
|
|
198
|
+
call_count += 1
|
|
199
|
+
if call_count == 1
|
|
200
|
+
HTTP::Response.new(
|
|
201
|
+
version: "1.1", status: 401, body: "",
|
|
202
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
203
|
+
request: post_request
|
|
204
|
+
)
|
|
205
|
+
else
|
|
206
|
+
retried_request = req
|
|
207
|
+
build_response(status: 200)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
assert_equal :post, retried_request.verb
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_around_request_with_digest_challenge_preserves_original_request_body
|
|
215
|
+
post_request = HTTP::Request.new(
|
|
216
|
+
verb: :post,
|
|
217
|
+
uri: "https://example.com/protected",
|
|
218
|
+
body: "request body data"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
retried_request = nil
|
|
222
|
+
call_count = 0
|
|
223
|
+
|
|
224
|
+
feature.around_request(post_request) do |req|
|
|
225
|
+
call_count += 1
|
|
226
|
+
if call_count == 1
|
|
227
|
+
HTTP::Response.new(
|
|
228
|
+
version: "1.1", status: 401, body: "",
|
|
229
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
230
|
+
request: post_request
|
|
231
|
+
)
|
|
232
|
+
else
|
|
233
|
+
retried_request = req
|
|
234
|
+
build_response(status: 200)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
assert_equal "request body data", retried_request.body.source
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_around_request_with_digest_challenge_preserves_original_request_version
|
|
242
|
+
versioned_request = HTTP::Request.new(
|
|
243
|
+
verb: :get,
|
|
244
|
+
uri: "https://example.com/protected",
|
|
245
|
+
version: "1.0"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
retried_request = nil
|
|
249
|
+
call_count = 0
|
|
250
|
+
|
|
251
|
+
feature.around_request(versioned_request) do |req|
|
|
252
|
+
call_count += 1
|
|
253
|
+
if call_count == 1
|
|
254
|
+
HTTP::Response.new(
|
|
255
|
+
version: "1.0", status: 401, body: "",
|
|
256
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
257
|
+
request: versioned_request
|
|
258
|
+
)
|
|
259
|
+
else
|
|
260
|
+
retried_request = req
|
|
261
|
+
build_response(status: 200)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
assert_equal "1.0", retried_request.version
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_around_request_with_digest_challenge_preserves_original_request_uri_normalizer
|
|
269
|
+
normalizer = ->(uri) { HTTP::URI::NORMALIZER.call(uri) }
|
|
270
|
+
custom_request = HTTP::Request.new(
|
|
271
|
+
verb: :get,
|
|
272
|
+
uri: "https://example.com/protected",
|
|
273
|
+
uri_normalizer: normalizer
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
retried_request = nil
|
|
277
|
+
call_count = 0
|
|
278
|
+
|
|
279
|
+
feature.around_request(custom_request) do |req|
|
|
280
|
+
call_count += 1
|
|
281
|
+
if call_count == 1
|
|
282
|
+
HTTP::Response.new(
|
|
283
|
+
version: "1.1", status: 401, body: "",
|
|
284
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
285
|
+
request: custom_request
|
|
286
|
+
)
|
|
287
|
+
else
|
|
288
|
+
retried_request = req
|
|
289
|
+
build_response(status: 200)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
assert_same normalizer, retried_request.uri_normalizer
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def test_around_request_with_digest_challenge_preserves_original_request_proxy
|
|
297
|
+
proxy_hash = { proxy_address: "proxy.example.com", proxy_port: 8080 }
|
|
298
|
+
proxy_request = HTTP::Request.new(
|
|
299
|
+
verb: :get,
|
|
300
|
+
uri: "https://example.com/protected",
|
|
301
|
+
proxy: proxy_hash
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
retried_request = nil
|
|
305
|
+
call_count = 0
|
|
306
|
+
|
|
307
|
+
feature.around_request(proxy_request) do |req|
|
|
308
|
+
call_count += 1
|
|
309
|
+
if call_count == 1
|
|
310
|
+
HTTP::Response.new(
|
|
311
|
+
version: "1.1", status: 401, body: "",
|
|
312
|
+
headers: { "WWW-Authenticate" => challenge_header },
|
|
313
|
+
request: proxy_request
|
|
314
|
+
)
|
|
315
|
+
else
|
|
316
|
+
retried_request = req
|
|
317
|
+
build_response(status: 200)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
assert_equal proxy_hash, retried_request.proxy
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# -- digest computation --
|
|
325
|
+
|
|
326
|
+
def rfc_feature
|
|
327
|
+
@rfc_feature ||= HTTP::Features::DigestAuth.new(user: "Mufasa", pass: "Circle Of Life")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def rfc_challenge
|
|
331
|
+
'Digest realm="testrealm@host.com", qop="auth", ' \
|
|
332
|
+
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", ' \
|
|
333
|
+
'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def rfc_request
|
|
337
|
+
@rfc_request ||= HTTP::Request.new(verb: :get, uri: "http://www.nowhere.org/dir/index.html")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def rfc_response
|
|
341
|
+
HTTP::Response.new(
|
|
342
|
+
version: "1.1", status: 401, body: "",
|
|
343
|
+
headers: { "WWW-Authenticate" => rfc_challenge },
|
|
344
|
+
request: rfc_request
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def test_digest_computation_produces_correct_ha1_for_md5
|
|
349
|
+
expected = "939e7578ed9e3c518a452acee763bce9"
|
|
350
|
+
ha1 = rfc_feature.send(:compute_ha1, "MD5", "testrealm@host.com",
|
|
351
|
+
"dcd98b7102dd2f0e8b11d0f600bfb0c093", "0a4f113b")
|
|
352
|
+
|
|
353
|
+
assert_equal expected, ha1
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def test_digest_computation_produces_correct_ha1_for_md5_sess
|
|
357
|
+
base = Digest::MD5.hexdigest("Mufasa:testrealm@host.com:Circle Of Life")
|
|
358
|
+
expected = Digest::MD5.hexdigest("#{base}:servernonce:clientnonce")
|
|
359
|
+
ha1 = rfc_feature.send(:compute_ha1, "MD5-sess", "testrealm@host.com",
|
|
360
|
+
"servernonce", "clientnonce")
|
|
361
|
+
|
|
362
|
+
assert_equal expected, ha1
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def test_digest_computation_produces_different_ha1_for_sess_vs_non_sess
|
|
366
|
+
ha1_md5 = rfc_feature.send(:compute_ha1, "MD5", "realm", "nonce", "cnonce")
|
|
367
|
+
ha1_sess = rfc_feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
|
|
368
|
+
|
|
369
|
+
refute_equal ha1_md5, ha1_sess
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def test_digest_computation_produces_correct_ha2_for_md5
|
|
373
|
+
expected = "39aff3a2bab6126f332b942af96d3366"
|
|
374
|
+
ha2 = rfc_feature.send(:compute_ha2, "MD5", "GET", "/dir/index.html")
|
|
375
|
+
|
|
376
|
+
assert_equal expected, ha2
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def test_digest_computation_produces_correct_response_with_qop_auth
|
|
380
|
+
ha1 = "939e7578ed9e3c518a452acee763bce9"
|
|
381
|
+
ha2 = "39aff3a2bab6126f332b942af96d3366"
|
|
382
|
+
expected = "6629fae49393a05397450978507c4ef1"
|
|
383
|
+
|
|
384
|
+
result = rfc_feature.send(:compute_response, "MD5", ha1, ha2,
|
|
385
|
+
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
|
386
|
+
nonce_count: "00000001", cnonce: "0a4f113b", qop: "auth")
|
|
387
|
+
|
|
388
|
+
assert_equal expected, result
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def test_digest_computation_includes_all_required_fields_in_authorization_header
|
|
392
|
+
retried_request = nil
|
|
393
|
+
call_count = 0
|
|
394
|
+
|
|
395
|
+
rfc_feature.around_request(rfc_request) do |req|
|
|
396
|
+
call_count += 1
|
|
397
|
+
if call_count == 1
|
|
398
|
+
rfc_response
|
|
399
|
+
else
|
|
400
|
+
retried_request = req
|
|
401
|
+
HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
auth = retried_request.headers["Authorization"]
|
|
406
|
+
|
|
407
|
+
assert_includes auth, 'username="Mufasa"'
|
|
408
|
+
assert_includes auth, 'realm="testrealm@host.com"'
|
|
409
|
+
assert_includes auth, 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"'
|
|
410
|
+
assert_includes auth, 'uri="/dir/index.html"'
|
|
411
|
+
assert_includes auth, "qop=auth,"
|
|
412
|
+
assert_includes auth, "nc=00000001"
|
|
413
|
+
assert_match(/cnonce="[0-9a-f]{32}"/, auth)
|
|
414
|
+
assert_match(/response="[0-9a-f]{32}"/, auth)
|
|
415
|
+
assert_includes auth, 'opaque="5ccc069c403ebaf9f0171e9517f40e41"'
|
|
416
|
+
assert_includes auth, "algorithm=MD5"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def test_digest_computation_uses_correct_field_ordering_in_header
|
|
420
|
+
retried_request = nil
|
|
421
|
+
call_count = 0
|
|
422
|
+
|
|
423
|
+
rfc_feature.around_request(rfc_request) do |req|
|
|
424
|
+
call_count += 1
|
|
425
|
+
if call_count == 1
|
|
426
|
+
rfc_response
|
|
427
|
+
else
|
|
428
|
+
retried_request = req
|
|
429
|
+
HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
auth = retried_request.headers["Authorization"]
|
|
434
|
+
|
|
435
|
+
username_pos = auth.index("username=")
|
|
436
|
+
realm_pos = auth.index("realm=")
|
|
437
|
+
nonce_pos = auth.index("nonce=")
|
|
438
|
+
uri_pos = auth.index("uri=")
|
|
439
|
+
qop_pos = auth.index("qop=")
|
|
440
|
+
nc_pos = auth.index("nc=")
|
|
441
|
+
cnonce_pos = auth.index("cnonce=")
|
|
442
|
+
response_pos = auth.index("response=")
|
|
443
|
+
opaque_pos = auth.index("opaque=")
|
|
444
|
+
algo_pos = auth.index("algorithm=")
|
|
445
|
+
|
|
446
|
+
assert_operator username_pos, :<, realm_pos
|
|
447
|
+
assert_operator realm_pos, :<, nonce_pos
|
|
448
|
+
assert_operator nonce_pos, :<, uri_pos
|
|
449
|
+
assert_operator uri_pos, :<, qop_pos
|
|
450
|
+
assert_operator qop_pos, :<, nc_pos
|
|
451
|
+
assert_operator nc_pos, :<, cnonce_pos
|
|
452
|
+
assert_operator cnonce_pos, :<, response_pos
|
|
453
|
+
assert_operator response_pos, :<, opaque_pos
|
|
454
|
+
assert_operator opaque_pos, :<, algo_pos
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def test_digest_computation_produces_deterministic_digest_with_fixed_cnonce
|
|
458
|
+
SecureRandom.stub(:hex, "0a4f113b00000000000000000a4f113b") do
|
|
459
|
+
retried_request = nil
|
|
460
|
+
call_count = 0
|
|
461
|
+
|
|
462
|
+
rfc_feature.around_request(rfc_request) do |req|
|
|
463
|
+
call_count += 1
|
|
464
|
+
if call_count == 1
|
|
465
|
+
rfc_response
|
|
466
|
+
else
|
|
467
|
+
retried_request = req
|
|
468
|
+
HTTP::Response.new(version: "1.1", status: 200, body: "", request: rfc_request)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
auth = retried_request.headers["Authorization"]
|
|
473
|
+
ha1 = Digest::MD5.hexdigest("Mufasa:testrealm@host.com:Circle Of Life")
|
|
474
|
+
ha2 = Digest::MD5.hexdigest("GET:/dir/index.html")
|
|
475
|
+
expected_response = Digest::MD5.hexdigest(
|
|
476
|
+
"#{ha1}:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:0a4f113b00000000000000000a4f113b:auth:#{ha2}"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
assert_includes auth, %(response="#{expected_response}")
|
|
480
|
+
assert_includes auth, 'cnonce="0a4f113b00000000000000000a4f113b"'
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# -- algorithm support --
|
|
485
|
+
|
|
486
|
+
def test_algorithm_support_sha256
|
|
487
|
+
challenge = 'Digest realm="test", nonce="abc", algorithm=SHA-256'
|
|
488
|
+
|
|
489
|
+
SecureRandom.stub(:hex, "fixed_cnonce_value_xx") do
|
|
490
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
491
|
+
auth = retried.headers["Authorization"]
|
|
492
|
+
|
|
493
|
+
assert_includes auth, "algorithm=SHA-256"
|
|
494
|
+
|
|
495
|
+
ha1 = Digest::SHA256.hexdigest("admin:test:secret")
|
|
496
|
+
ha2 = Digest::SHA256.hexdigest("GET:/protected")
|
|
497
|
+
expected = Digest::SHA256.hexdigest("#{ha1}:abc:#{ha2}")
|
|
498
|
+
|
|
499
|
+
assert_includes auth, %(response="#{expected}")
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def test_algorithm_support_md5_sess
|
|
504
|
+
challenge = 'Digest realm="test", nonce="abc", algorithm=MD5-sess, qop="auth"'
|
|
505
|
+
|
|
506
|
+
SecureRandom.stub(:hex, "fixedcnonce0000x") do
|
|
507
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
508
|
+
auth = retried.headers["Authorization"]
|
|
509
|
+
|
|
510
|
+
assert_includes auth, "algorithm=MD5-sess"
|
|
511
|
+
|
|
512
|
+
base_ha1 = Digest::MD5.hexdigest("admin:test:secret")
|
|
513
|
+
ha1 = Digest::MD5.hexdigest("#{base_ha1}:abc:fixedcnonce0000x")
|
|
514
|
+
ha2 = Digest::MD5.hexdigest("GET:/protected")
|
|
515
|
+
expected = Digest::MD5.hexdigest("#{ha1}:abc:00000001:fixedcnonce0000x:auth:#{ha2}")
|
|
516
|
+
|
|
517
|
+
assert_includes auth, %(response="#{expected}")
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def test_algorithm_support_sha256_sess
|
|
522
|
+
challenge = 'Digest realm="test", nonce="abc", algorithm=SHA-256-sess, qop="auth"'
|
|
523
|
+
|
|
524
|
+
SecureRandom.stub(:hex, "fixedcnonce0000x") do
|
|
525
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
526
|
+
auth = retried.headers["Authorization"]
|
|
527
|
+
|
|
528
|
+
assert_includes auth, "algorithm=SHA-256-sess"
|
|
529
|
+
|
|
530
|
+
base_ha1 = Digest::SHA256.hexdigest("admin:test:secret")
|
|
531
|
+
ha1 = Digest::SHA256.hexdigest("#{base_ha1}:abc:fixedcnonce0000x")
|
|
532
|
+
ha2 = Digest::SHA256.hexdigest("GET:/protected")
|
|
533
|
+
expected = Digest::SHA256.hexdigest("#{ha1}:abc:00000001:fixedcnonce0000x:auth:#{ha2}")
|
|
534
|
+
|
|
535
|
+
assert_includes auth, %(response="#{expected}")
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def test_algorithm_support_raises_for_unsupported_algorithm
|
|
540
|
+
challenge = 'Digest realm="test", nonce="abc", algorithm=UNSUPPORTED'
|
|
541
|
+
response = build_response(
|
|
542
|
+
status: 401,
|
|
543
|
+
headers: { "WWW-Authenticate" => challenge }
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
call_count = 0
|
|
547
|
+
assert_raises(KeyError) do
|
|
548
|
+
feature.around_request(request) do |_req|
|
|
549
|
+
call_count += 1
|
|
550
|
+
call_count == 1 ? response : build_response(status: 200)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def test_algorithm_support_defaults_to_md5_when_algorithm_not_specified
|
|
556
|
+
challenge = 'Digest realm="test", nonce="abc"'
|
|
557
|
+
|
|
558
|
+
SecureRandom.stub(:hex, "fixedcnonce0000x") do
|
|
559
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
560
|
+
auth = retried.headers["Authorization"]
|
|
561
|
+
|
|
562
|
+
assert_includes auth, "algorithm=MD5"
|
|
563
|
+
|
|
564
|
+
ha1 = Digest::MD5.hexdigest("admin:test:secret")
|
|
565
|
+
ha2 = Digest::MD5.hexdigest("GET:/protected")
|
|
566
|
+
expected = Digest::MD5.hexdigest("#{ha1}:abc:#{ha2}")
|
|
567
|
+
|
|
568
|
+
assert_includes auth, %(response="#{expected}")
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# -- qop handling --
|
|
573
|
+
|
|
574
|
+
def test_qop_selects_auth_when_present_among_multiple_values
|
|
575
|
+
result = feature.send(:select_qop, "auth-int,auth")
|
|
576
|
+
|
|
577
|
+
assert_equal "auth", result
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def test_qop_returns_first_value_when_auth_not_available
|
|
581
|
+
result = feature.send(:select_qop, "auth-int,other")
|
|
582
|
+
|
|
583
|
+
assert_equal "auth-int", result
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def test_qop_returns_nil_when_qop_str_is_nil
|
|
587
|
+
result = feature.send(:select_qop, nil)
|
|
588
|
+
|
|
589
|
+
assert_nil result
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def test_qop_handles_spaces_around_commas
|
|
593
|
+
result = feature.send(:select_qop, "auth-int , auth")
|
|
594
|
+
|
|
595
|
+
assert_equal "auth", result
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def test_qop_handles_leading_space_after_comma
|
|
599
|
+
result = feature.send(:select_qop, "auth-int, auth")
|
|
600
|
+
|
|
601
|
+
assert_equal "auth", result
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def test_qop_handles_no_spaces_around_comma
|
|
605
|
+
result = feature.send(:select_qop, "other,auth")
|
|
606
|
+
|
|
607
|
+
assert_equal "auth", result
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def test_qop_returns_single_value_as_is
|
|
611
|
+
result = feature.send(:select_qop, "auth")
|
|
612
|
+
|
|
613
|
+
assert_equal "auth", result
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def test_qop_strips_trailing_whitespace_from_first_when_auth_not_available
|
|
617
|
+
result = feature.send(:select_qop, "auth-int ,other")
|
|
618
|
+
|
|
619
|
+
assert_equal "auth-int", result
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def test_qop_prefers_auth_when_multiple_values_offered_in_header
|
|
623
|
+
challenge = 'Digest realm="test", nonce="abc", qop="auth-int,auth"'
|
|
624
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
625
|
+
auth = retried.headers["Authorization"]
|
|
626
|
+
|
|
627
|
+
assert_match(/qop=auth,/, auth)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def test_qop_uses_first_value_when_auth_not_available_in_header
|
|
631
|
+
challenge = 'Digest realm="test", nonce="abc", qop="auth-int"'
|
|
632
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
633
|
+
|
|
634
|
+
assert_match(/qop=auth-int,/, retried.headers["Authorization"])
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def test_qop_omits_fields_when_server_does_not_specify_qop
|
|
638
|
+
challenge = 'Digest realm="test", nonce="abc"'
|
|
639
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
640
|
+
auth = retried.headers["Authorization"]
|
|
641
|
+
|
|
642
|
+
refute_includes auth, "qop="
|
|
643
|
+
refute_includes auth, "nc="
|
|
644
|
+
refute_includes auth, "cnonce="
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def test_qop_computes_response_without_qop_correctly
|
|
648
|
+
ha1 = "ha1value"
|
|
649
|
+
ha2 = "ha2value"
|
|
650
|
+
expected = Digest::MD5.hexdigest("ha1value:testnonce:ha2value")
|
|
651
|
+
result = feature.send(:compute_response, "MD5", ha1, ha2,
|
|
652
|
+
nonce: "testnonce", nonce_count: "00000001",
|
|
653
|
+
cnonce: "cnonce", qop: nil)
|
|
654
|
+
|
|
655
|
+
assert_equal expected, result
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def test_qop_computes_response_with_qop_correctly_using_all_components
|
|
659
|
+
ha1 = "ha1hex"
|
|
660
|
+
ha2 = "ha2hex"
|
|
661
|
+
expected = Digest::MD5.hexdigest("ha1hex:nonce1:00000001:cnonce1:auth:ha2hex")
|
|
662
|
+
result = feature.send(:compute_response, "MD5", ha1, ha2,
|
|
663
|
+
nonce: "nonce1", nonce_count: "00000001",
|
|
664
|
+
cnonce: "cnonce1", qop: "auth")
|
|
665
|
+
|
|
666
|
+
assert_equal expected, result
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# -- opaque handling --
|
|
670
|
+
|
|
671
|
+
def test_opaque_omits_when_not_in_challenge
|
|
672
|
+
challenge = 'Digest realm="test", nonce="abc", qop="auth"'
|
|
673
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
674
|
+
|
|
675
|
+
refute_includes retried.headers["Authorization"], "opaque="
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def test_opaque_includes_when_present_in_challenge
|
|
679
|
+
challenge = 'Digest realm="test", nonce="abc", qop="auth", opaque="opq123"'
|
|
680
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
681
|
+
|
|
682
|
+
assert_includes retried.headers["Authorization"], 'opaque="opq123"'
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# -- challenge parsing --
|
|
686
|
+
|
|
687
|
+
def test_challenge_parsing_parses_quoted_values
|
|
688
|
+
header = 'Digest realm="test realm", nonce="abc123"'
|
|
689
|
+
result = feature.send(:parse_challenge, header)
|
|
690
|
+
|
|
691
|
+
assert_equal "test realm", result["realm"]
|
|
692
|
+
assert_equal "abc123", result["nonce"]
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def test_challenge_parsing_parses_unquoted_values
|
|
696
|
+
header = 'Digest realm="test", algorithm=SHA-256'
|
|
697
|
+
result = feature.send(:parse_challenge, header)
|
|
698
|
+
|
|
699
|
+
assert_equal "SHA-256", result["algorithm"]
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def test_challenge_parsing_parses_mixed_quoted_and_unquoted_values
|
|
703
|
+
header = 'Digest realm="test", nonce="n1", qop="auth", algorithm=MD5, opaque="op1"'
|
|
704
|
+
result = feature.send(:parse_challenge, header)
|
|
705
|
+
|
|
706
|
+
assert_equal "test", result["realm"]
|
|
707
|
+
assert_equal "n1", result["nonce"]
|
|
708
|
+
assert_equal "auth", result["qop"]
|
|
709
|
+
assert_equal "MD5", result["algorithm"]
|
|
710
|
+
assert_equal "op1", result["opaque"]
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def test_challenge_parsing_handles_empty_quoted_values
|
|
714
|
+
header = 'Digest realm="", nonce="abc"'
|
|
715
|
+
result = feature.send(:parse_challenge, header)
|
|
716
|
+
|
|
717
|
+
assert_equal "", result["realm"]
|
|
718
|
+
assert_equal "abc", result["nonce"]
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def test_challenge_parsing_ignores_digest_scheme_prefix
|
|
722
|
+
header = 'Digest realm="test", nonce="abc"'
|
|
723
|
+
result = feature.send(:parse_challenge, header)
|
|
724
|
+
|
|
725
|
+
assert_nil result["Digest"]
|
|
726
|
+
assert_equal 2, result.size
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def test_challenge_parsing_handles_values_containing_percent_characters
|
|
730
|
+
header = 'Digest realm="test%20realm", nonce="abc"'
|
|
731
|
+
result = feature.send(:parse_challenge, header)
|
|
732
|
+
|
|
733
|
+
assert_equal "test%20realm", result["realm"]
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# -- #hex_digest --
|
|
737
|
+
|
|
738
|
+
def test_hex_digest_uses_md5_for_md5_algorithm
|
|
739
|
+
expected = Digest::MD5.hexdigest("test_data")
|
|
740
|
+
result = feature.send(:hex_digest, "MD5", "test_data")
|
|
741
|
+
|
|
742
|
+
assert_equal expected, result
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def test_hex_digest_uses_sha256_for_sha256_algorithm
|
|
746
|
+
expected = Digest::SHA256.hexdigest("test_data")
|
|
747
|
+
result = feature.send(:hex_digest, "SHA-256", "test_data")
|
|
748
|
+
|
|
749
|
+
assert_equal expected, result
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def test_hex_digest_strips_sess_suffix_for_algorithm_lookup
|
|
753
|
+
md5_result = feature.send(:hex_digest, "MD5-sess", "test_data")
|
|
754
|
+
expected = Digest::MD5.hexdigest("test_data")
|
|
755
|
+
|
|
756
|
+
assert_equal expected, md5_result
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def test_hex_digest_strips_sess_suffix_case_insensitively
|
|
760
|
+
result = feature.send(:hex_digest, "MD5-SESS", "test_data")
|
|
761
|
+
expected = Digest::MD5.hexdigest("test_data")
|
|
762
|
+
|
|
763
|
+
assert_equal expected, result
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def test_hex_digest_does_not_match_partial_sess_in_algorithm_name
|
|
767
|
+
assert_raises(KeyError) do
|
|
768
|
+
feature.send(:hex_digest, "-sessMD5", "test_data")
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# -- #compute_ha1 --
|
|
773
|
+
|
|
774
|
+
def test_compute_ha1_returns_base_ha1_for_non_sess_algorithms
|
|
775
|
+
expected = Digest::MD5.hexdigest("admin:realm:secret")
|
|
776
|
+
result = feature.send(:compute_ha1, "MD5", "realm", "nonce", "cnonce")
|
|
777
|
+
|
|
778
|
+
assert_equal expected, result
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def test_compute_ha1_computes_session_ha1_for_sess_algorithms
|
|
782
|
+
base = Digest::MD5.hexdigest("admin:realm:secret")
|
|
783
|
+
expected = Digest::MD5.hexdigest("#{base}:servernonce:clientnonce")
|
|
784
|
+
result = feature.send(:compute_ha1, "MD5-sess", "realm", "servernonce", "clientnonce")
|
|
785
|
+
|
|
786
|
+
assert_equal expected, result
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def test_compute_ha1_uses_nonce_in_session_ha1_computation
|
|
790
|
+
result1 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce1", "cnonce")
|
|
791
|
+
result2 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce2", "cnonce")
|
|
792
|
+
|
|
793
|
+
refute_equal result1, result2
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def test_compute_ha1_uses_cnonce_in_session_ha1_computation
|
|
797
|
+
result1 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce1")
|
|
798
|
+
result2 = feature.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce2")
|
|
799
|
+
|
|
800
|
+
refute_equal result1, result2
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def test_compute_ha1_uses_base_ha1_in_session_ha1_computation
|
|
804
|
+
feat1 = HTTP::Features::DigestAuth.new(user: "user1", pass: "pass1")
|
|
805
|
+
feat2 = HTTP::Features::DigestAuth.new(user: "user2", pass: "pass2")
|
|
806
|
+
result1 = feat1.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
|
|
807
|
+
result2 = feat2.send(:compute_ha1, "MD5-sess", "realm", "nonce", "cnonce")
|
|
808
|
+
|
|
809
|
+
refute_equal result1, result2
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def test_compute_ha1_computes_sha256_sess_correctly
|
|
813
|
+
base = Digest::SHA256.hexdigest("admin:realm:secret")
|
|
814
|
+
expected = Digest::SHA256.hexdigest("#{base}:nonce:cnonce")
|
|
815
|
+
result = feature.send(:compute_ha1, "SHA-256-sess", "realm", "nonce", "cnonce")
|
|
816
|
+
|
|
817
|
+
assert_equal expected, result
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# -- #compute_auth_header --
|
|
821
|
+
|
|
822
|
+
def test_compute_auth_header_passes_correct_ha1_and_ha2_to_compute_response
|
|
823
|
+
ha1 = "correctha1"
|
|
824
|
+
ha2 = "correctha2"
|
|
825
|
+
challenge = { "realm" => "test" }
|
|
826
|
+
result = feature.send(:compute_auth_header,
|
|
827
|
+
algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
|
|
828
|
+
nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
829
|
+
expected_response = Digest::MD5.hexdigest("correctha1:nonce:00000001:cnonce:auth:correctha2")
|
|
830
|
+
|
|
831
|
+
assert_includes result, %(response="#{expected_response}")
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def test_compute_auth_header_passes_nonce_to_compute_response
|
|
835
|
+
ha1 = "ha1val"
|
|
836
|
+
ha2 = "ha2val"
|
|
837
|
+
challenge = { "realm" => "test" }
|
|
838
|
+
result = feature.send(:compute_auth_header,
|
|
839
|
+
algorithm: "MD5", qop: "auth", nonce: "testnonce", cnonce: "cnonce",
|
|
840
|
+
nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
841
|
+
expected_response = Digest::MD5.hexdigest("ha1val:testnonce:00000001:cnonce:auth:ha2val")
|
|
842
|
+
|
|
843
|
+
assert_includes result, %(response="#{expected_response}")
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def test_compute_auth_header_passes_cnonce_to_compute_response
|
|
847
|
+
ha1 = "ha1val"
|
|
848
|
+
ha2 = "ha2val"
|
|
849
|
+
challenge = { "realm" => "test" }
|
|
850
|
+
result = feature.send(:compute_auth_header,
|
|
851
|
+
algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "testcnonce",
|
|
852
|
+
nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
853
|
+
expected_response = Digest::MD5.hexdigest("ha1val:nonce:00000001:testcnonce:auth:ha2val")
|
|
854
|
+
|
|
855
|
+
assert_includes result, %(response="#{expected_response}")
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def test_compute_auth_header_passes_nonce_count_to_compute_response
|
|
859
|
+
ha1 = "ha1val"
|
|
860
|
+
ha2 = "ha2val"
|
|
861
|
+
challenge = { "realm" => "test" }
|
|
862
|
+
result = feature.send(:compute_auth_header,
|
|
863
|
+
algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
|
|
864
|
+
nonce_count: "00000002", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
865
|
+
expected_response = Digest::MD5.hexdigest("ha1val:nonce:00000002:cnonce:auth:ha2val")
|
|
866
|
+
|
|
867
|
+
assert_includes result, %(response="#{expected_response}")
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def test_compute_auth_header_passes_qop_to_compute_response
|
|
871
|
+
ha1 = "ha1val"
|
|
872
|
+
ha2 = "ha2val"
|
|
873
|
+
challenge = { "realm" => "test" }
|
|
874
|
+
|
|
875
|
+
result_auth = feature.send(:compute_auth_header,
|
|
876
|
+
algorithm: "MD5", qop: "auth", nonce: "nonce", cnonce: "cnonce",
|
|
877
|
+
nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
878
|
+
|
|
879
|
+
result_nil = feature.send(:compute_auth_header,
|
|
880
|
+
algorithm: "MD5", qop: nil, nonce: "nonce", cnonce: "cnonce",
|
|
881
|
+
nonce_count: "00000001", uri: "/uri", ha1: ha1, ha2: ha2, challenge: challenge)
|
|
882
|
+
|
|
883
|
+
refute_equal result_auth, result_nil
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# -- #build_auth integration --
|
|
887
|
+
|
|
888
|
+
def test_build_auth_uses_select_qop_to_process_qop_from_challenge
|
|
889
|
+
challenge = 'Digest realm="test", nonce="abc", qop="auth-int,auth"'
|
|
890
|
+
retried = perform_digest_challenge(feature, request, challenge)
|
|
891
|
+
auth = retried.headers["Authorization"]
|
|
892
|
+
|
|
893
|
+
refute_includes auth, "auth-int,auth"
|
|
894
|
+
assert_match(/qop=auth,/, auth)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def test_build_auth_generates_cnonce_of_correct_length
|
|
898
|
+
retried = perform_digest_challenge(feature, request,
|
|
899
|
+
'Digest realm="test", nonce="abc", qop="auth"')
|
|
900
|
+
auth = retried.headers["Authorization"]
|
|
901
|
+
|
|
902
|
+
assert_match(/cnonce="[0-9a-f]{32}"/, auth)
|
|
903
|
+
cnonce = auth[/cnonce="([0-9a-f]+)"/, 1]
|
|
904
|
+
|
|
905
|
+
assert_equal 32, cnonce.length
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def test_build_auth_includes_uri_from_request_in_header
|
|
909
|
+
retried = perform_digest_challenge(feature, request,
|
|
910
|
+
'Digest realm="test", nonce="abc"')
|
|
911
|
+
auth = retried.headers["Authorization"]
|
|
912
|
+
|
|
913
|
+
assert_includes auth, 'uri="/protected"'
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def test_build_auth_uses_request_uri_in_digest_computation
|
|
917
|
+
req1 = HTTP::Request.new(verb: :get, uri: "https://example.com/path1")
|
|
918
|
+
req2 = HTTP::Request.new(verb: :get, uri: "https://example.com/path2")
|
|
919
|
+
challenge = 'Digest realm="test", nonce="abc"'
|
|
920
|
+
|
|
921
|
+
SecureRandom.stub(:hex, "fixedcnonce0000x") do
|
|
922
|
+
retried1 = perform_digest_challenge(feature, req1, challenge)
|
|
923
|
+
retried2 = perform_digest_challenge(feature, req2, challenge)
|
|
924
|
+
|
|
925
|
+
resp1 = retried1.headers["Authorization"][/response="([^"]+)"/, 1]
|
|
926
|
+
resp2 = retried2.headers["Authorization"][/response="([^"]+)"/, 1]
|
|
927
|
+
|
|
928
|
+
refute_equal resp1, resp2
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def test_build_auth_uses_verb_in_digest_computation
|
|
933
|
+
get_req = HTTP::Request.new(verb: :get, uri: "https://example.com/protected")
|
|
934
|
+
post_req = HTTP::Request.new(verb: :post, uri: "https://example.com/protected", body: "data")
|
|
935
|
+
challenge = 'Digest realm="test", nonce="abc"'
|
|
936
|
+
|
|
937
|
+
SecureRandom.stub(:hex, "fixedcnonce0000x") do
|
|
938
|
+
retried_get = perform_digest_challenge(feature, get_req, challenge)
|
|
939
|
+
retried_post = perform_digest_challenge(feature, post_req, challenge)
|
|
940
|
+
|
|
941
|
+
resp_get = retried_get.headers["Authorization"][/response="([^"]+)"/, 1]
|
|
942
|
+
resp_post = retried_post.headers["Authorization"][/response="([^"]+)"/, 1]
|
|
943
|
+
|
|
944
|
+
refute_equal resp_get, resp_post
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# -- #build_header --
|
|
949
|
+
|
|
950
|
+
def test_build_header_formats_header_with_qop_fields_in_correct_order
|
|
951
|
+
result = feature.send(:build_header,
|
|
952
|
+
username: "user", realm: "realm", nonce: "nonce",
|
|
953
|
+
uri: "/path", qop: "auth", nonce_count: "00000001",
|
|
954
|
+
cnonce: "cn", response: "resp", opaque: "op",
|
|
955
|
+
algorithm: "MD5")
|
|
956
|
+
|
|
957
|
+
expected = 'Digest username="user", realm="realm", nonce="nonce", uri="/path", ' \
|
|
958
|
+
'qop=auth, nc=00000001, cnonce="cn", response="resp", opaque="op", algorithm=MD5'
|
|
959
|
+
|
|
960
|
+
assert_equal expected, result
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
def test_build_header_formats_header_without_qop_fields_when_qop_is_nil
|
|
964
|
+
result = feature.send(:build_header,
|
|
965
|
+
username: "user", realm: "realm", nonce: "nonce",
|
|
966
|
+
uri: "/path", qop: nil, nonce_count: "00000001",
|
|
967
|
+
cnonce: "cn", response: "resp", opaque: nil,
|
|
968
|
+
algorithm: "MD5")
|
|
969
|
+
|
|
970
|
+
expected = 'Digest username="user", realm="realm", nonce="nonce", uri="/path", ' \
|
|
971
|
+
'response="resp", algorithm=MD5'
|
|
972
|
+
|
|
973
|
+
assert_equal expected, result
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def test_build_header_formats_header_without_opaque_when_opaque_is_nil
|
|
977
|
+
result = feature.send(:build_header,
|
|
978
|
+
username: "user", realm: "realm", nonce: "nonce",
|
|
979
|
+
uri: "/path", qop: "auth", nonce_count: "00000001",
|
|
980
|
+
cnonce: "cn", response: "resp", opaque: nil,
|
|
981
|
+
algorithm: "MD5")
|
|
982
|
+
|
|
983
|
+
refute_includes result, "opaque="
|
|
984
|
+
assert_includes result, "algorithm=MD5"
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# -- feature registration --
|
|
988
|
+
|
|
989
|
+
def test_feature_registration_is_registered_as_digest_auth
|
|
990
|
+
assert_equal HTTP::Features::DigestAuth, HTTP::Options.available_features[:digest_auth]
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def test_feature_registration_is_a_feature
|
|
994
|
+
assert_kind_of HTTP::Feature, feature
|
|
995
|
+
end
|
|
996
|
+
end
|