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".
RFC 7616 lets a server advertise multiple supported QoPs in a single
qopdirective, e.g.qop="auth,auth-int". sttp'sDigestAuthenticatorcompares 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 — nocnonce, nonc— and then the server rejects the request with 401.Reproducer (no network — pure logic):
Output:
Source — the three places that branch on
qopall use the same single-value comparison:sttp/core/src/main/scala/sttp/client4/internal/DigestAuthenticator.scala
Lines 147 to 163 in 6b817f7
The fix is to split on
,and pick the first supported value as soon as the header is parsed (before the matches), or normalise theqopOptiononce at the top:Then the three matches keep working unchanged. Preference for
authoverauth-intmatches the RFC's recommendation when both are offered.Happy to PR with a parser test against
qop="auth,auth-int",qop="auth-int,auth", andqop="auth-int".