Skip to content

DigestAuthenticator silently falls through to qop-unaware mode when server sends qop="auth,auth-int" #2882

@haskiindahouse

Description

@haskiindahouse

RFC 7616 lets a server advertise multiple supported QoPs in a single qop directive, e.g. qop="auth,auth-int". sttp's DigestAuthenticator compares the raw header value against the strings "auth" and "auth-int" with ==, so a comma-separated list never matches and the authenticator falls through to the legacy "no qop" branch — no cnonce, no nc — and then the server rejects the request with 401.

Reproducer (no network — pure logic):

//> using scala 3.8.3

@main def run(): Unit =
  val QualityOfProtectionAuth    = "auth"
  val QualityOfProtectionAuthInt = "auth-int"
  val qop: Option[String] = Some("auth,auth-int") // common server response

  val mode = qop match
    case Some(v) if v == QualityOfProtectionAuth || v == QualityOfProtectionAuthInt => "qop-aware"
    case _                                                                          => "qop-unaware (no cnonce, no nc)"

  println(s"sttp picks: $mode")  // qop-unaware

Output:

sttp picks: qop-unaware (no cnonce, no nc)

Source — the three places that branch on qop all use the same single-value comparison:

qop match {
case Some(v) if v == QualityOfProtectionAuth || v == QualityOfProtectionAuthInt =>
md5HexString(s"$ha1:$nonce:$nonceCount:$clientNonce:$v:$ha2", messageDigest)
case _ => md5HexString(s"$ha1:$nonce:$ha2", messageDigest)
}
private def calculateHa2(
request: GenericRequest[_, _],
qop: Option[String],
digestUri: String,
messageDigest: MessageDigestCompatibility
) =
qop match {
case Some(QualityOfProtectionAuth) => md5HexString(s"${request.method.method}:$digestUri", messageDigest)
case None => md5HexString(s"${request.method.method}:$digestUri", messageDigest)
case Some(QualityOfProtectionAuthInt) =>
val body = request.body match {

qop match {
  case Some(v) if v == QualityOfProtectionAuth || v == QualityOfProtectionAuthInt => ...
  ...
}
qop match {
  case Some(QualityOfProtectionAuth)    => ...
  case Some(QualityOfProtectionAuthInt) => ...
  case None                             => ...
  case Some(q) => throw new IllegalArgumentException(s"Unknown qop: $q")
}

The fix is to split on , and pick the first supported value as soon as the header is parsed (before the matches), or normalise the qop Option once at the top:

val effectiveQop = wwwAuthHeader.qop.flatMap { s =>
  s.split(",").map(_.trim).find(q =>
    q == QualityOfProtectionAuth || q == QualityOfProtectionAuthInt
  )
}

Then the three matches keep working unchanged. Preference for auth over auth-int matches the RFC's recommendation when both are offered.

Happy to PR with a parser test against qop="auth,auth-int", qop="auth-int,auth", and qop="auth-int".

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions