From 6f53acff468807b713d2127cffecc7d06a97aefc Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Thu, 13 Aug 2026 11:40:25 +0200 Subject: [PATCH] Verify the server authentication (rspauth) provided in the Digest Authentication-Info response per RFC 7616 The client recomputes rspauth from the session state of the last request and compares it to the value returned by the server in constant time, so mutual authentication no longer depends solely on the request being accepted. For qop=auth the cnonce and nc echoed by the server are required and must match the values used for that request, and an Authentication-Info that states qop=auth must carry rspauth, cnonce and nc. Verification is confined to qop=auth; the rspauth of qop=auth-int hashes the response body, which is not available to the scheme. An Authentication-Info without rspauth remains acceptable unless the server states qop=auth. Authentication-Info is a list field that a server may split across multiple field lines; AuthenticationHandler now combines the parameters from all of them into a single challenge so the scheme sees the complete set rather than only the last line. --- .../http/impl/auth/AuthenticationHandler.java | 16 +- .../client5/http/impl/auth/DigestScheme.java | 129 ++++++++++++ .../impl/auth/TestAuthenticationHandler.java | 34 ++++ .../http/impl/auth/TestDigestScheme.java | 190 ++++++++++++++++++ 4 files changed, 367 insertions(+), 2 deletions(-) diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java index 4c7c485fc8..89e749aa50 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.http.impl.auth; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -52,6 +53,7 @@ import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.MessageSupport; @@ -230,14 +232,24 @@ public boolean handleResponse( if (challengeMap.isEmpty() && !challenged && isChallengeExpected) { final AuthScheme authScheme = authExchange.getAuthScheme(); if (authScheme != null) { + final String schemeName = authScheme.getName(); + // Authentication-Info is a comma-separated list that a server may split across several + // field lines; combine the parameters from all of them into a single challenge so the + // scheme sees the complete set (e.g. rspauth together with cnonce and nc). + final List authInfoParams = new ArrayList<>(); MessageSupport.parseHeaders( response, challengeType == ChallengeType.PROXY ? "Proxy-Authentication-Info" : "Authentication-Info", (buffer, cursor) -> { - final String schemeName = authScheme.getName(); final AuthChallenge authChallenge = parser.parse(challengeType, schemeName, buffer, cursor); - challengeMap.put(schemeName.toLowerCase(Locale.ROOT), authChallenge); + if (authChallenge.getParams() != null) { + authInfoParams.addAll(authChallenge.getParams()); + } }); + if (!authInfoParams.isEmpty()) { + challengeMap.put(schemeName.toLowerCase(Locale.ROOT), + new AuthChallenge(challengeType, schemeName, null, authInfoParams)); + } } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java index 7f57e2f78a..d2484d1245 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java @@ -36,6 +36,7 @@ import java.security.Principal; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.Formatter; import java.util.HashMap; import java.util.HashSet; @@ -139,6 +140,24 @@ private enum QualityOfProtection { private byte[] a1; private byte[] a2; + /** + * The qop value ("auth" or "auth-int") used by the last generated response, or {@code null} when + * no qop was negotiated. Retained to recompute the server's {@code rspauth} for mutual authentication. + */ + private String lastQop; + + /** + * Set when the last generated response used a qop, so an {@code Authentication-Info} response with + * {@code rspauth} is expected and should be verified. + */ + private boolean expectAuthInfo; + + /** + * Set once an {@code Authentication-Info} response has been processed without a mismatch, so the + * exchange is not mistaken for a failed challenge. + */ + private boolean mutualAuthComplete; + private UsernamePasswordCredentials credentials; public DigestScheme() { @@ -182,12 +201,36 @@ public String getRealm() { return this.paramMap.get("realm"); } + @Override + public boolean isChallengeExpected() { + return this.expectAuthInfo; + } + @Override public void processChallenge( final AuthChallenge authChallenge, final HttpContext context) throws MalformedChallengeException { + parseChallenge(authChallenge); + } + + @Override + public void processChallenge( + final HttpHost host, + final boolean challenged, + final AuthChallenge authChallenge, + final HttpContext context) throws MalformedChallengeException, AuthenticationException { + if (!challenged) { + verifyAuthenticationInfo(authChallenge); + this.mutualAuthComplete = true; + return; + } + parseChallenge(authChallenge); + } + + private void parseChallenge(final AuthChallenge authChallenge) throws MalformedChallengeException { Args.notNull(authChallenge, "AuthChallenge"); this.paramMap.clear(); + this.mutualAuthComplete = false; final List params = authChallenge.getParams(); if (params != null) { for (final NameValuePair param: params) { @@ -204,8 +247,88 @@ public void processChallenge( this.complete = true; } + /** + * Verifies the {@code rspauth} value carried by an {@code Authentication-Info} response against the + * value recomputed from the session state of the last generated response, providing mutual + * authentication. When no {@code Authentication-Info} or no {@code rspauth} + * is present the response is accepted, since a server is not required to send it. + */ + private void verifyAuthenticationInfo(final AuthChallenge authChallenge) throws AuthenticationException { + if (authChallenge == null) { + return; + } + final Map params = new HashMap<>(); + final List list = authChallenge.getParams(); + if (list != null) { + for (final NameValuePair param: list) { + params.put(param.getName().toLowerCase(Locale.ROOT), param.getValue()); + } + } + final String qop = params.get("qop"); + final String rspauth = params.get("rspauth"); + final String serverCnonce = params.get("cnonce"); + final String serverNc = params.get("nc"); + if ("auth".equalsIgnoreCase(qop)) { + // when Authentication-Info states qop=auth it must carry rspauth, cnonce and nc. + if (rspauth == null || serverCnonce == null || serverNc == null) { + throw new AuthenticationException( + "Digest Authentication-Info with qop=auth must include rspauth, cnonce and nc"); + } + } else if (rspauth == null) { + return; + } + if (a1 == null || a2 == null || lastNonce == null || cnonce == null || lastQop == null) { + throw new AuthenticationException("Cannot verify rspauth: missing digest session state"); + } + // For qop=auth the Authentication-Info must echo the exact cnonce and nc used for the request. + final StringBuilder ncBuilder = new StringBuilder(8); + try (final Formatter formatter = new Formatter(ncBuilder, Locale.ROOT)) { + formatter.format("%08x", this.nounceCount); + } + final String nc = ncBuilder.toString(); + if (serverCnonce == null || !serverCnonce.equals(this.cnonce)) { + throw new AuthenticationException("Digest Authentication-Info cnonce mismatch"); + } + if (serverNc == null || !serverNc.equals(nc)) { + throw new AuthenticationException("Digest Authentication-Info nc mismatch"); + } + final String algorithm = this.paramMap.get("algorithm"); + final MessageDigest digester; + try { + digester = createMessageDigest(DigestAlgorithm.fromString(algorithm == null ? "MD5" : algorithm) + .getBaseAlgorithm()); + } catch (final UnsupportedDigestAlgorithmException ex) { + throw new AuthenticationException("Unsupported digest algorithm: " + algorithm); + } + final Charset charset = AuthSchemeSupport.parseCharset(this.paramMap.get("charset"), this.defaultCharset); + + final String hasha1 = formatHex(digester.digest(a1)); + // The rspauth A2 uses an empty method, so reuse the request A2 from its first ':' onwards. + int colon = -1; + for (int i = 0; i < a2.length; i++) { + if (a2[i] == ':') { + colon = i; + break; + } + } + final byte[] a2rsp = colon < 0 ? a2 : Arrays.copyOfRange(a2, colon, a2.length); + final String hasha2 = formatHex(digester.digest(a2rsp)); + + final String kd = hasha1 + ":" + this.lastNonce + ":" + nc + ":" + this.cnonce + ":" + this.lastQop + + ":" + hasha2; + final String expected = formatHex(digester.digest(kd.getBytes(charset))); + if (!MessageDigest.isEqual( + expected.getBytes(StandardCharsets.US_ASCII), + rspauth.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII))) { + throw new AuthenticationException("Digest response authentication failed (rspauth mismatch)"); + } + } + @Override public boolean isChallengeComplete() { + if (this.mutualAuthComplete) { + return false; + } final String s = this.paramMap.get("stale"); return !"true".equalsIgnoreCase(s) && this.complete; } @@ -447,6 +570,12 @@ private String createDigestResponse(final HttpRequest request) throws Authentica final String digest = formatHex(digester.digest(digestInput)); + this.lastQop = qop == QualityOfProtection.MISSING ? null + : qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"; + // rspauth for qop=auth-int hashes the response body, which is not available here, so only + // qop=auth is verified. + this.expectAuthInfo = qop == QualityOfProtection.AUTH; + final CharArrayBuffer buffer = new CharArrayBuffer(128); buffer.append(StandardAuthScheme.DIGEST + " "); diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java index 48f5b82056..0d3015557b 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java @@ -527,4 +527,38 @@ void testAuthenticationInfoProcessedOnSuccessResponse() throws Exception { Assertions.assertEquals("dj1hYmM", getParam(challenge, "data")); } + @Test + void testAuthenticationInfoCombinesMultipleFieldLines() throws Exception { + final HttpHost host = new HttpHost("somehost", 80); + final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK, "OK"); + // A server may split the Authentication-Info list across multiple field lines. + response.addHeader(new BasicHeader("Authentication-Info", "rspauth=\"abc\"")); + response.addHeader(new BasicHeader("Authentication-Info", "cnonce=\"xyz\", nc=00000001")); + + final AuthScheme authScheme = Mockito.mock(AuthScheme.class); + Mockito.when(authScheme.getName()).thenReturn(StandardAuthScheme.DIGEST); + Mockito.when(authScheme.isChallengeExpected()).thenReturn(Boolean.TRUE); + Mockito.when(authScheme.isChallengeComplete()).thenReturn(Boolean.FALSE); + + this.authExchange.select(authScheme); + this.authExchange.setState(AuthExchange.State.HANDSHAKE); + + final DefaultAuthenticationStrategy authStrategy = new DefaultAuthenticationStrategy(); + + this.httpAuthenticator.handleResponse( + host, ChallengeType.TARGET, response, authStrategy, this.authExchange, this.context); + + final ArgumentCaptor challengeCaptor = ArgumentCaptor.forClass(AuthChallenge.class); + Mockito.verify(authScheme).processChallenge( + Mockito.eq(host), + Mockito.eq(false), + challengeCaptor.capture(), + Mockito.same(this.context)); + + final AuthChallenge challenge = challengeCaptor.getValue(); + Assertions.assertEquals("abc", getParam(challenge, "rspauth")); + Assertions.assertEquals("xyz", getParam(challenge, "cnonce")); + Assertions.assertEquals("00000001", getParam(challenge, "nc")); + } + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java index e44fe89558..5dce86aa64 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java @@ -35,6 +35,7 @@ import java.security.MessageDigest; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.apache.hc.client5.http.auth.AuthChallenge; @@ -57,6 +58,7 @@ import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.util.CharArrayBuffer; import org.junit.jupiter.api.Assertions; @@ -1159,6 +1161,194 @@ void testDigestSHA512256SessA1AndCnonceConsistency() throws Exception { Assertions.assertNotEquals(sessionKey1, sessionKey4); } + private static final HttpHost RSPAUTH_HOST = new HttpHost("somehost", 80); + private DigestScheme digestSchemeWithResponse(final String qop, final String algorithm) throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + " realm=\"realm\", nonce=\"TestNonce\", " + + "algorithm=" + algorithm + ", qop=\"" + qop + "\""); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicHttpRequest("GET", "/path"), null); + return authscheme; + } + + private static String computeRspauth(final DigestScheme authscheme, final String algorithm, final String qop) + throws Exception { + final MessageDigest md = MessageDigest.getInstance(algorithm); + final byte[] a1 = authscheme.getA1().getBytes(StandardCharsets.US_ASCII); + final String a2 = authscheme.getA2(); + final byte[] a2rsp = a2.substring(a2.indexOf(':')).getBytes(StandardCharsets.US_ASCII); + final String ha1 = DigestScheme.formatHex(md.digest(a1)); + final String ha2 = DigestScheme.formatHex(md.digest(a2rsp)); + final String nc = String.format("%08x", authscheme.getNounceCount()); + final String kd = ha1 + ":" + authscheme.getNonce() + ":" + nc + ":" + authscheme.getCnonce() + + ":" + qop + ":" + ha2; + return DigestScheme.formatHex(md.digest(kd.getBytes(StandardCharsets.UTF_8))); + } + + private static AuthChallenge authInfo(final DigestScheme authscheme, final String rspauth) { + return new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + } + + @Test + void testRspauthMutualAuthenticationSucceeds() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + Assertions.assertTrue(authscheme.isChallengeExpected()); + + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + } + + @Test + void testRspauthMismatchFailsMutualAuthentication() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final AuthChallenge authInfo = authInfo(authscheme, "00000000000000000000000000000000"); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testMissingAuthenticationInfoIsAccepted() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + // No Authentication-Info header at all. + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, null, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + // Authentication-Info without rspauth (e.g. only nextnonce) is also accepted. + final AuthChallenge noRspauth = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("nextnonce", "abcdef")); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, noRspauth, HttpClientContext.create())); + } + + @Test + void testNoQopDoesNotExpectAuthenticationInfo() throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5"); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicHttpRequest("GET", "/path"), null); + Assertions.assertFalse(authscheme.isChallengeExpected()); + } + + @Test + void testRspauthUppercaseHexIsAccepted() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth").toUpperCase(Locale.ROOT); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMissingCnonceIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // rspauth is valid but the Authentication-Info omits the cnonce echo. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthCnonceMismatchIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // The echoed cnonce does not match the one used for the request. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", "deadbeef"), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMissingNcIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // rspauth is valid but the Authentication-Info omits the nc echo. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce())); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthNcMismatchIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // The echoed nc does not match the nonce-count used for the request. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", "00000002")); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testQopAuthWithoutRspauthIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + // Authentication-Info states qop=auth but omits rspauth; per RFC rspauth, cnonce and nc are all required. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("qop", "auth"), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMutualAuthenticationSucceedsWithSha256() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "SHA-256"); + final String rspauth = computeRspauth(authscheme, "SHA-256", "auth"); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + } + + @Test + void testAuthIntDoesNotExpectAuthenticationInfo() throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5, qop=\"auth-int\""); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicClassicHttpRequest("POST", "/path"), null); + // rspauth for qop=auth-int hashes the response body, which is unavailable, so it is not verified. + Assertions.assertFalse(authscheme.isChallengeExpected()); + } + + @Test + void testProcessChallengeChallengedParsesChallenge() throws Exception { + final DigestScheme authscheme = new DigestScheme(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5, qop=\"auth\""); + authscheme.processChallenge(RSPAUTH_HOST, true, challenge, HttpClientContext.create()); + Assertions.assertEquals("realm", authscheme.getRealm()); + Assertions.assertTrue(authscheme.isChallengeComplete()); + } }