Class: HTTP::Features::DigestAuth

Inherits:
HTTP::Feature show all
Defined in:
lib/http/features/digest_auth.rb,
sig/http.rbs

Overview

Implements HTTP Digest Authentication (RFC 2617 / RFC 7616)

When a server responds with 401 and a Digest challenge, this feature automatically computes the digest response and retries the request with the correct Authorization header.

Constant Summary collapse

ALGORITHMS =

Supported hash algorithms

Returns:

  • (Hash[String, singleton(Digest::MD5) | singleton(Digest::SHA256)])
{
  "MD5"          => Digest::MD5,
  "SHA-256"      => Digest::SHA256,
  "MD5-sess"     => Digest::MD5,
  "SHA-256-sess" => Digest::SHA256
}.freeze
WWW_AUTHENTICATE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

WWW-Authenticate header name

Returns:

  • (String)
"WWW-Authenticate"

Instance Method Summary collapse

Methods inherited from HTTP::Feature

#on_error, #on_request, #wrap_request, #wrap_response

Constructor Details

#initialize(user:, pass:) ⇒ DigestAuth

Initialize the DigestAuth feature

Examples:

DigestAuth.new(user: "admin", pass: "secret")

Parameters:

  • user (String)

    username for authentication

  • pass (String)

    password for authentication

  • user: (String)
  • pass: (String)


35
36
37
38
# File 'lib/http/features/digest_auth.rb', line 35

def initialize(user:, pass:)
  @user = user
  @pass = pass
end

Instance Method Details

#around_request(request) {|HTTP::Request| ... } ⇒ HTTP::Response

Wraps the HTTP exchange to handle digest authentication challenges

On a 401 with a Digest WWW-Authenticate header, flushes the error response, computes digest credentials, and retries the request.

Examples:

feature.around_request(request) { |req| perform(req) }

Parameters:

Yields:

Yield Parameters:

Yield Returns:

Returns:



53
54
55
56
57
58
59
# File 'lib/http/features/digest_auth.rb', line 53

def around_request(request)
  response = yield request
  return response unless digest_challenge?(response)

  response.flush
  yield authorize(request, response)
end

#authorize(request, response) ⇒ HTTP::Request

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build an authorized copy of the request using the digest challenge

Parameters:

Returns:



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/http/features/digest_auth.rb', line 79

def authorize(request, response)
  www_auth = response.headers[WWW_AUTHENTICATE] #: String
  challenge = parse_challenge(www_auth)
  headers   = request.headers.dup
  headers.set Headers::AUTHORIZATION, build_auth(request, challenge)

  Request.new(
    verb:           request.verb,
    uri:            request.uri,
    headers:        headers,
    proxy:          request.proxy,
    body:           request.body.source,
    version:        request.version,
    uri_normalizer: request.uri_normalizer
  )
end

#build_auth(request, challenge) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build the Authorization header value

Parameters:

  • request (HTTP::Request)

    the request being authorized

  • challenge (Hash{String => String})

    parsed challenge params

Returns:

  • (String)

    the Digest authorization header value



116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/http/features/digest_auth.rb', line 116

def build_auth(request, challenge)
  algorithm   = challenge.fetch("algorithm", "MD5")
  qop         = select_qop(challenge["qop"])
  nonce       = challenge.fetch("nonce")
  cnonce      = SecureRandom.hex(16)
  nonce_count = "00000001"
  uri         = String(request.uri.request_uri)
  ha1 = compute_ha1(algorithm, challenge.fetch("realm"), nonce, cnonce)
  ha2 = compute_ha2(algorithm, String(request.verb).upcase, uri)

  compute_auth_header(algorithm: algorithm, qop: qop, nonce: nonce, cnonce: cnonce,
                      nonce_count: nonce_count, uri: uri, ha1: ha1, ha2: ha2,
                      challenge: challenge)
end

#build_header(username:, realm:, nonce:, uri:, qop:, nonce_count:, cnonce:, response:, opaque:, algorithm:) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build the Digest Authorization header string

Parameters:

  • username: (String)
  • realm: (String)
  • nonce: (String)
  • uri: (String)
  • qop: (String, nil)
  • nonce_count: (String)
  • cnonce: (String)
  • response: (String)
  • opaque: (String, nil)
  • algorithm: (String)

Returns:

  • (String)

    formatted header value



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/http/features/digest_auth.rb', line 213

def build_header(username:, realm:, nonce:, uri:, qop:, nonce_count:, cnonce:,
                 response:, opaque:, algorithm:)
  parts = [
    %(username="#{username}"),
    %(realm="#{realm}"),
    %(nonce="#{nonce}"),
    %(uri="#{uri}")
  ]

  parts.push(%(qop=#{qop}), %(nc=#{nonce_count}), %(cnonce="#{cnonce}")) if qop

  parts << %(response="#{response}")
  parts << %(opaque="#{opaque}") if opaque
  parts << %(algorithm=#{algorithm})

  "Digest #{parts.join(', ')}"
end

#compute_auth_header(algorithm:, qop:, nonce:, cnonce:, nonce_count:, uri:, ha1:, ha2:, challenge:) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compute digest and build the Authorization header string

Parameters:

  • algorithm: (String)
  • qop: (String, nil)
  • nonce: (String)
  • cnonce: (String)
  • nonce_count: (String)
  • uri: (String)
  • ha1: (String)
  • ha2: (String)
  • challenge: (Hash[String, String])

Returns:

  • (String)

    formatted authorization header



135
136
137
138
139
140
141
142
# File 'lib/http/features/digest_auth.rb', line 135

def compute_auth_header(algorithm:, qop:, nonce:, cnonce:, nonce_count:, uri:, ha1:, ha2:, challenge:)
  response = compute_response(algorithm, ha1, ha2, nonce: nonce,
                              nonce_count: nonce_count, cnonce: cnonce, qop: qop)

  build_header(username: @user, realm: challenge.fetch("realm"), nonce: nonce, uri: uri,
               qop: qop, nonce_count: nonce_count, cnonce: cnonce, response: response,
               opaque: challenge["opaque"], algorithm: algorithm)
end

#compute_ha1(algorithm, realm, nonce, cnonce) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compute HA1 per RFC 2617

Parameters:

  • algorithm (String)
  • realm (String)
  • nonce (String)
  • cnonce (String)

Returns:

  • (String)

    hex digest



162
163
164
165
166
167
168
169
170
# File 'lib/http/features/digest_auth.rb', line 162

def compute_ha1(algorithm, realm, nonce, cnonce)
  base = hex_digest(algorithm, "#{@user}:#{realm}:#{@pass}")

  if algorithm.end_with?("-sess")
    hex_digest(algorithm, "#{base}:#{nonce}:#{cnonce}")
  else
    base
  end
end

#compute_ha2(algorithm, method, uri) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compute HA2 per RFC 2617

Parameters:

  • algorithm (String)
  • method (String)
  • uri (String)

Returns:

  • (String)

    hex digest



176
177
178
# File 'lib/http/features/digest_auth.rb', line 176

def compute_ha2(algorithm, method, uri)
  hex_digest(algorithm, "#{method}:#{uri}")
end

#compute_response(algorithm, ha1, ha2, nonce:, nonce_count:, cnonce:, qop:) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compute the final digest response value

Parameters:

  • algorithm (String)

    algorithm name

  • ha1 (String)

    HA1 hex digest

  • ha2 (String)

    HA2 hex digest

  • nonce (String)

    server nonce

  • nonce_count (String)

    request counter

  • cnonce (String)

    client nonce

  • qop (String, nil)

    quality of protection

  • nonce: (String)
  • nonce_count: (String)
  • cnonce: (String)
  • qop: (String, nil)

Returns:

  • (String)

    hex digest



191
192
193
194
195
196
197
# File 'lib/http/features/digest_auth.rb', line 191

def compute_response(algorithm, ha1, ha2, nonce:, nonce_count:, cnonce:, qop:)
  if qop
    hex_digest(algorithm, "#{ha1}:#{nonce}:#{nonce_count}:#{cnonce}:#{qop}:#{ha2}")
  else
    hex_digest(algorithm, "#{ha1}:#{nonce}:#{ha2}")
  end
end

#digest_challenge?(response) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Check if the response contains a digest authentication challenge

Parameters:

Returns:

  • (Boolean)


68
69
70
71
# File 'lib/http/features/digest_auth.rb', line 68

def digest_challenge?(response)
  www_auth = response.headers[WWW_AUTHENTICATE] #: String?
  response.status.code == 401 && www_auth&.start_with?("Digest ") == true
end

#hex_digest(algorithm, data) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compute a hex digest using the specified algorithm

Parameters:

  • algorithm (String)

    algorithm name

  • data (String)

    data to digest

Returns:

  • (String)

    hex digest



205
206
207
# File 'lib/http/features/digest_auth.rb', line 205

def hex_digest(algorithm, data)
  ALGORITHMS.fetch(algorithm.sub(/-sess\z/i, "")).hexdigest(data)
end

#parse_challenge(header) ⇒ Hash{String => String}

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Parse the WWW-Authenticate header into a parameter hash

Parameters:

  • header (String)

    the WWW-Authenticate header value

Returns:

  • (Hash{String => String})

    parsed challenge parameters



101
102
103
104
105
106
107
108
# File 'lib/http/features/digest_auth.rb', line 101

def parse_challenge(header)
  params = {} #: Hash[String, String]
  header.sub(/\ADigest\s+/i, "").scan(/(\w+)=(?:"([^"]*)"|([\w-]+))/) do |match|
    key = match[0] #: String
    params[key] = format("%s", match[1] || match[2])
  end
  params
end

#select_qop(qop_str) ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Select the best qop value from the challenge

Parameters:

  • qop_str (String, nil)

    comma-separated qop options

Returns:

  • (String, nil)

    selected qop value



149
150
151
152
153
154
155
156
# File 'lib/http/features/digest_auth.rb', line 149

def select_qop(qop_str)
  return unless qop_str

  qops = qop_str.split(",").map(&:strip)
  return "auth" if qops.include?("auth")

  qops.first
end