Skip to content

Commit 58aa513

Browse files
committed
Add HTTP Digest Authentication feature (RFC 2617 / RFC 7616)
Implement digest auth as a chainable feature via HTTP.digest_auth(user:, pass:). When a server responds with 401 and a Digest WWW-Authenticate challenge, the feature automatically computes digest credentials and retries the request with the correct Authorization header. Supports MD5, SHA-256, MD5-sess, and SHA-256-sess algorithms with quality-of-protection (qop) negotiation. Closes #448.
1 parent 996bf86 commit 58aa513

8 files changed

Lines changed: 753 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4444

4545
### Added
4646

47+
- `HTTP.digest_auth(user:, pass:)` for HTTP Digest Authentication (RFC 2617 /
48+
RFC 7616). Automatically handles 401 challenges with digest credentials,
49+
supporting MD5, SHA-256, MD5-sess, and SHA-256-sess algorithms with
50+
quality-of-protection negotiation. Works as a chainable feature:
51+
`HTTP.digest_auth(user: "admin", pass: "secret").get(url)` ([#448])
4752
- `HTTP.base_uri` for setting a base URI that resolves relative request paths
4853
per RFC 3986. Supports chaining (`HTTP.base_uri("https://api.example.com/v1")
4954
.get("users")`), and integrates with `persistent` connections by deriving the

Steepfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ target :lib do
1313
library "socket"
1414
library "tempfile"
1515
library "timeout"
16+
library "securerandom"
1617
library "uri"
1718
library "zlib"
1819
end

lib/http/chainable.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,23 @@ def basic_auth(user:, pass:)
251251
auth("Basic #{encode64("#{user}:#{pass}")}")
252252
end
253253

254+
# Enable HTTP Digest authentication
255+
#
256+
# Automatically handles 401 Digest challenges by computing the digest
257+
# response and retrying the request with proper credentials.
258+
#
259+
# @example
260+
# HTTP.digest_auth(user: "admin", pass: "secret").get("http://example.com")
261+
#
262+
# @see https://datatracker.ietf.org/doc/html/rfc2617
263+
# @param [#to_s] user
264+
# @param [#to_s] pass
265+
# @return [HTTP::Session]
266+
# @api public
267+
def digest_auth(user:, pass:)
268+
use(digest_auth: { user: user, pass: pass })
269+
end
270+
254271
# Get options for HTTP
255272
#
256273
# @example

lib/http/feature.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def on_error(_request, _error); end
7878

7979
require "http/features/auto_inflate"
8080
require "http/features/auto_deflate"
81+
require "http/features/digest_auth"
8182
require "http/features/instrumentation"
8283
require "http/features/logging"
8384
require "http/features/normalize_uri"

lib/http/features/digest_auth.rb

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# frozen_string_literal: true
2+
3+
require "digest"
4+
require "securerandom"
5+
6+
module HTTP
7+
module Features
8+
# Implements HTTP Digest Authentication (RFC 2617 / RFC 7616)
9+
#
10+
# When a server responds with 401 and a Digest challenge, this feature
11+
# automatically computes the digest response and retries the request
12+
# with the correct Authorization header.
13+
class DigestAuth < Feature
14+
# Supported hash algorithms
15+
ALGORITHMS = {
16+
"MD5" => Digest::MD5,
17+
"SHA-256" => Digest::SHA256,
18+
"MD5-sess" => Digest::MD5,
19+
"SHA-256-sess" => Digest::SHA256
20+
}.freeze
21+
22+
# @api private
23+
WWW_AUTHENTICATE = "WWW-Authenticate"
24+
25+
# Initialize the DigestAuth feature
26+
#
27+
# @example
28+
# DigestAuth.new(user: "admin", pass: "secret")
29+
#
30+
# @param user [String] username for authentication
31+
# @param pass [String] password for authentication
32+
# @return [DigestAuth]
33+
# @api public
34+
def initialize(user:, pass:)
35+
@user = user
36+
@pass = pass
37+
end
38+
39+
# Wraps the HTTP exchange to handle digest authentication challenges
40+
#
41+
# On a 401 with a Digest WWW-Authenticate header, flushes the error
42+
# response, computes digest credentials, and retries the request.
43+
#
44+
# @example
45+
# feature.around_request(request) { |req| perform(req) }
46+
#
47+
# @param request [HTTP::Request]
48+
# @yield [HTTP::Request] the request to perform
49+
# @yieldreturn [HTTP::Response]
50+
# @return [HTTP::Response]
51+
# @api public
52+
def around_request(request)
53+
response = yield request
54+
return response unless digest_challenge?(response)
55+
56+
response.flush
57+
yield authorize(request, response)
58+
end
59+
60+
private
61+
62+
# Check if the response contains a digest authentication challenge
63+
#
64+
# @param response [HTTP::Response]
65+
# @return [Boolean]
66+
# @api private
67+
def digest_challenge?(response)
68+
www_auth = response.headers[WWW_AUTHENTICATE] #: String?
69+
response.status.code == 401 && www_auth&.start_with?("Digest ") == true
70+
end
71+
72+
# Build an authorized copy of the request using the digest challenge
73+
#
74+
# @param request [HTTP::Request] the original request
75+
# @param response [HTTP::Response] the 401 response with challenge
76+
# @return [HTTP::Request] a new request with Authorization header
77+
# @api private
78+
def authorize(request, response)
79+
www_auth = response.headers[WWW_AUTHENTICATE] #: String
80+
challenge = parse_challenge(www_auth)
81+
headers = request.headers.dup
82+
headers.set Headers::AUTHORIZATION, build_auth(request, challenge)
83+
84+
Request.new(
85+
verb: request.verb,
86+
uri: request.uri,
87+
headers: headers,
88+
proxy: request.proxy,
89+
body: request.body.source,
90+
version: request.version,
91+
uri_normalizer: request.uri_normalizer
92+
)
93+
end
94+
95+
# Parse the WWW-Authenticate header into a parameter hash
96+
#
97+
# @param header [String] the WWW-Authenticate header value
98+
# @return [Hash{String => String}] parsed challenge parameters
99+
# @api private
100+
def parse_challenge(header)
101+
params = {} #: Hash[String, String]
102+
header.sub(/^Digest\s+/i, "").scan(/(\w+)=(?:"([^"]*)"|([\w-]+))/) do |match|
103+
key = match[0] #: String
104+
params[key] = format("%s", match[1] || match[2])
105+
end
106+
params
107+
end
108+
109+
# Build the Authorization header value
110+
#
111+
# @param request [HTTP::Request] the request being authorized
112+
# @param challenge [Hash{String => String}] parsed challenge params
113+
# @return [String] the Digest authorization header value
114+
# @api private
115+
def build_auth(request, challenge)
116+
algorithm = challenge.fetch("algorithm", "MD5")
117+
qop = select_qop(challenge["qop"])
118+
nonce = challenge.fetch("nonce")
119+
cnonce = SecureRandom.hex(16)
120+
nonce_count = "00000001"
121+
uri = request.uri.request_uri.to_s
122+
ha1 = compute_ha1(algorithm, challenge.fetch("realm"), nonce, cnonce)
123+
ha2 = compute_ha2(algorithm, request.verb.to_s.upcase, uri)
124+
125+
compute_auth_header(algorithm, qop, nonce, cnonce, nonce_count, uri, ha1, ha2, challenge)
126+
end
127+
128+
# Compute digest and build the Authorization header string
129+
#
130+
# @return [String] formatted authorization header
131+
# @api private
132+
def compute_auth_header(algorithm, qop, nonce, cnonce, nonce_count, uri, ha1, ha2, challenge) # rubocop:disable Metrics/ParameterLists
133+
response = compute_response(algorithm, ha1, ha2, nonce: nonce,
134+
nonce_count: nonce_count, cnonce: cnonce, qop: qop)
135+
136+
build_header(username: @user, realm: challenge.fetch("realm"), nonce: nonce, uri: uri,
137+
qop: qop, nonce_count: nonce_count, cnonce: cnonce, response: response,
138+
opaque: challenge["opaque"], algorithm: algorithm)
139+
end
140+
141+
# Select the best qop value from the challenge
142+
#
143+
# @param qop_str [String, nil] comma-separated qop options
144+
# @return [String, nil] selected qop value
145+
# @api private
146+
def select_qop(qop_str)
147+
return unless qop_str
148+
149+
qops = qop_str.split(",").map(&:strip)
150+
return "auth" if qops.include?("auth")
151+
152+
qops.first
153+
end
154+
155+
# Compute HA1 per RFC 2617
156+
#
157+
# @return [String] hex digest
158+
# @api private
159+
def compute_ha1(algorithm, realm, nonce, cnonce)
160+
base = hex_digest(algorithm, "#{@user}:#{realm}:#{@pass}")
161+
162+
if algorithm.end_with?("-sess")
163+
hex_digest(algorithm, "#{base}:#{nonce}:#{cnonce}")
164+
else
165+
base
166+
end
167+
end
168+
169+
# Compute HA2 per RFC 2617
170+
#
171+
# @return [String] hex digest
172+
# @api private
173+
def compute_ha2(algorithm, method, uri)
174+
hex_digest(algorithm, "#{method}:#{uri}")
175+
end
176+
177+
# Compute the final digest response value
178+
#
179+
# @param algorithm [String] algorithm name
180+
# @param ha1 [String] HA1 hex digest
181+
# @param ha2 [String] HA2 hex digest
182+
# @param nonce [String] server nonce
183+
# @param nonce_count [String] request counter
184+
# @param cnonce [String] client nonce
185+
# @param qop [String, nil] quality of protection
186+
# @return [String] hex digest
187+
# @api private
188+
def compute_response(algorithm, ha1, ha2, nonce:, nonce_count:, cnonce:, qop:)
189+
if qop
190+
hex_digest(algorithm, "#{ha1}:#{nonce}:#{nonce_count}:#{cnonce}:#{qop}:#{ha2}")
191+
else
192+
hex_digest(algorithm, "#{ha1}:#{nonce}:#{ha2}")
193+
end
194+
end
195+
196+
# Compute a hex digest using the specified algorithm
197+
#
198+
# @param algorithm [String] algorithm name
199+
# @param data [String] data to digest
200+
# @return [String] hex digest
201+
# @api private
202+
def hex_digest(algorithm, data)
203+
ALGORITHMS.fetch(algorithm.sub(/-sess$/i, "")).hexdigest(data)
204+
end
205+
206+
# Build the Digest Authorization header string
207+
#
208+
# @return [String] formatted header value
209+
# @api private
210+
def build_header(username:, realm:, nonce:, uri:, qop:, nonce_count:, cnonce:,
211+
response:, opaque:, algorithm:)
212+
parts = [
213+
%(username="#{username}"),
214+
%(realm="#{realm}"),
215+
%(nonce="#{nonce}"),
216+
%(uri="#{uri}")
217+
]
218+
219+
parts.push(%(qop=#{qop}), %(nc=#{nonce_count}), %(cnonce="#{cnonce}")) if qop
220+
221+
parts << %(response="#{response}")
222+
parts << %(opaque="#{opaque}") if opaque
223+
parts << %(algorithm=#{algorithm})
224+
225+
"Digest #{parts.join(', ')}"
226+
end
227+
228+
HTTP::Options.register_feature(:digest_auth, self)
229+
end
230+
end
231+
end

sig/http.rbs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ module HTTP
7373
def accept: (String | Symbol type) -> Session
7474
def auth: (String value) -> Session
7575
def basic_auth: (user: String, pass: String) -> Session
76+
def digest_auth: (user: String, pass: String) -> Session
7677
def default_options: () -> Options
7778
def default_options=: (Hash[Symbol, untyped] | Options opts) -> Options
7879
def nodelay: () -> Session
@@ -418,6 +419,31 @@ module HTTP
418419
def initialize: (?ignore: Array[Integer]) -> void
419420
def wrap_response: (Response response) -> Response
420421
end
422+
423+
class DigestAuth < Feature
424+
ALGORITHMS: Hash[String, singleton(Digest::MD5) | singleton(Digest::SHA256)]
425+
WWW_AUTHENTICATE: String
426+
427+
@user: String
428+
@pass: String
429+
430+
def initialize: (user: String, pass: String) -> void
431+
def around_request: (Request request) { (Request) -> Response } -> Response
432+
433+
private
434+
435+
def digest_challenge?: (Response response) -> bool
436+
def authorize: (Request request, Response response) -> Request
437+
def parse_challenge: (String header) -> Hash[String, String]
438+
def build_auth: (Request request, Hash[String, String] challenge) -> String
439+
def compute_auth_header: (String algorithm, String? qop, String nonce, String cnonce, String nonce_count, String uri, String ha1, String ha2, Hash[String, String] challenge) -> String
440+
def select_qop: (String? qop_str) -> String?
441+
def compute_ha1: (String algorithm, String realm, String nonce, String cnonce) -> String
442+
def compute_ha2: (String algorithm, String method, String uri) -> String
443+
def compute_response: (String algorithm, String ha1, String ha2, nonce: String, nonce_count: String, cnonce: String, qop: String?) -> String
444+
def hex_digest: (String algorithm, String data) -> String
445+
def build_header: (username: String, realm: String, nonce: String, uri: String, qop: String?, nonce_count: String, cnonce: String, response: String, opaque: String?, algorithm: String) -> String
446+
end
421447
end
422448

423449
module MimeType

0 commit comments

Comments
 (0)