Skip to content

Commit 4db277e

Browse files
committed
Implement full RFC 7616 HTTP Digest Authentication compliance
1 parent 05c437e commit 4db277e

File tree

11 files changed

+1074
-94
lines changed

11 files changed

+1074
-94
lines changed

client/src/main/java/org/asynchttpclient/Dsl.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ public static Realm.Builder realm(Realm prototype) {
112112
.setServicePrincipalName(prototype.getServicePrincipalName())
113113
.setUseCanonicalHostname(prototype.isUseCanonicalHostname())
114114
.setCustomLoginConfig(prototype.getCustomLoginConfig())
115-
.setLoginContextName(prototype.getLoginContextName());
115+
.setLoginContextName(prototype.getLoginContextName())
116+
.setUserhash(prototype.isUserhash());
117+
// Note: stale is NOT copied — it's challenge-specific, always starts false
116118
}
117119

118120
public static Realm.Builder realm(AuthScheme scheme, String principal, String password) {

client/src/main/java/org/asynchttpclient/Realm.java

Lines changed: 144 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@
4444
public class Realm {
4545

4646
private static final String DEFAULT_NC = "00000001";
47-
// MD5("")
48-
private static final String EMPTY_ENTITY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
4947

5048
private final @Nullable String principal;
5149
private final @Nullable String password;
@@ -69,6 +67,8 @@ public class Realm {
6967
private final @Nullable String servicePrincipalName;
7068
private final boolean useCanonicalHostname;
7169
private final @Nullable String loginContextName;
70+
private final boolean stale;
71+
private final boolean userhash;
7272

7373
private Realm(@Nullable AuthScheme scheme,
7474
@Nullable String principal,
@@ -91,7 +91,9 @@ private Realm(@Nullable AuthScheme scheme,
9191
@Nullable String servicePrincipalName,
9292
boolean useCanonicalHostname,
9393
@Nullable Map<String, String> customLoginConfig,
94-
@Nullable String loginContextName) {
94+
@Nullable String loginContextName,
95+
boolean stale,
96+
boolean userhash) {
9597

9698
this.scheme = requireNonNull(scheme, "scheme");
9799
this.principal = principal;
@@ -115,6 +117,8 @@ private Realm(@Nullable AuthScheme scheme,
115117
this.useCanonicalHostname = useCanonicalHostname;
116118
this.customLoginConfig = customLoginConfig;
117119
this.loginContextName = loginContextName;
120+
this.stale = stale;
121+
this.userhash = userhash;
118122
}
119123

120124
public @Nullable String getPrincipal() {
@@ -220,6 +224,14 @@ public boolean isUseCanonicalHostname() {
220224
return loginContextName;
221225
}
222226

227+
public boolean isStale() {
228+
return stale;
229+
}
230+
231+
public boolean isUserhash() {
232+
return userhash;
233+
}
234+
223235
@Override
224236
public String toString() {
225237
return "Realm{" +
@@ -285,6 +297,9 @@ public static class Builder {
285297
private boolean useCanonicalHostname;
286298
private @Nullable String loginContextName;
287299
private @Nullable String cs;
300+
private boolean stale;
301+
private boolean userhash;
302+
private @Nullable String entityBodyHash;
288303

289304
public Builder() {
290305
principal = null;
@@ -398,6 +413,33 @@ public Builder setLoginContextName(@Nullable String loginContextName) {
398413
return this;
399414
}
400415

416+
public Builder setStale(boolean stale) {
417+
this.stale = stale;
418+
return this;
419+
}
420+
421+
public boolean isStale() {
422+
return stale;
423+
}
424+
425+
public Builder setUserhash(boolean userhash) {
426+
this.userhash = userhash;
427+
return this;
428+
}
429+
430+
public Builder setEntityBodyHash(@Nullable String entityBodyHash) {
431+
this.entityBodyHash = entityBodyHash;
432+
return this;
433+
}
434+
435+
public @Nullable String getQopValue() {
436+
return qop;
437+
}
438+
439+
public @Nullable String getNonceValue() {
440+
return nonce;
441+
}
442+
401443
private static @Nullable String parseRawQop(String rawQop) {
402444
String[] rawServerSupportedQops = rawQop.split(",");
403445
String[] serverSupportedQops = new String[rawServerSupportedQops.length];
@@ -422,56 +464,122 @@ public Builder setLoginContextName(@Nullable String loginContextName) {
422464
}
423465

424466
public Builder parseWWWAuthenticateHeader(String headerLine) {
425-
setRealmName(match(headerLine, "realm"))
426-
.setNonce(match(headerLine, "nonce"))
427-
.setOpaque(match(headerLine, "opaque"))
467+
setRealmName(matchParam(headerLine, "realm"))
468+
.setNonce(matchParam(headerLine, "nonce"))
469+
.setOpaque(matchParam(headerLine, "opaque"))
428470
.setScheme(isNonEmpty(nonce) ? AuthScheme.DIGEST : AuthScheme.BASIC);
429-
String algorithm = match(headerLine, "algorithm");
430-
String cs = match(headerLine, "charset");
471+
String algorithm = matchParam(headerLine, "algorithm");
472+
String cs = matchParam(headerLine, "charset");
431473
if ("UTF-8".equalsIgnoreCase(cs)) {
432474
this.digestCharset = UTF_8;
433475
}
434476
if (isNonEmpty(algorithm)) {
435477
setAlgorithm(algorithm);
436478
}
437479

438-
// FIXME qop is different with proxy?
439-
String rawQop = match(headerLine, "qop");
480+
String rawQop = matchParam(headerLine, "qop");
440481
if (rawQop != null) {
441482
setQop(parseRawQop(rawQop));
442483
}
443484

485+
// Parse stale flag
486+
String staleStr = matchParam(headerLine, "stale");
487+
this.stale = "true".equalsIgnoreCase(staleStr);
488+
489+
// Parse userhash flag
490+
String userhashStr = matchParam(headerLine, "userhash");
491+
this.userhash = "true".equalsIgnoreCase(userhashStr);
492+
444493
return this;
445494
}
446495

447496
public Builder parseProxyAuthenticateHeader(String headerLine) {
448-
setRealmName(match(headerLine, "realm"))
449-
.setNonce(match(headerLine, "nonce"))
450-
.setOpaque(match(headerLine, "opaque"))
497+
setRealmName(matchParam(headerLine, "realm"))
498+
.setNonce(matchParam(headerLine, "nonce"))
499+
.setOpaque(matchParam(headerLine, "opaque"))
451500
.setScheme(isNonEmpty(nonce) ? AuthScheme.DIGEST : AuthScheme.BASIC);
452-
String algorithm = match(headerLine, "algorithm");
501+
String algorithm = matchParam(headerLine, "algorithm");
453502
if (isNonEmpty(algorithm)) {
454503
setAlgorithm(algorithm);
455504
}
456-
// FIXME qop is different with proxy?
457-
setQop(match(headerLine, "qop"));
505+
506+
String rawQop = matchParam(headerLine, "qop");
507+
if (rawQop != null) {
508+
setQop(parseRawQop(rawQop));
509+
}
510+
511+
String cs = matchParam(headerLine, "charset");
512+
if ("UTF-8".equalsIgnoreCase(cs)) {
513+
this.digestCharset = UTF_8;
514+
}
515+
516+
// Parse stale flag
517+
String staleStr = matchParam(headerLine, "stale");
518+
this.stale = "true".equalsIgnoreCase(staleStr);
519+
520+
// Parse userhash flag
521+
String userhashStr = matchParam(headerLine, "userhash");
522+
this.userhash = "true".equalsIgnoreCase(userhashStr);
458523

459524
return this;
460525
}
461526

462527
/**
463528
* Extracts the value of a token from a WWW-Authenticate or Proxy-Authenticate header line.
464-
* Example: match('Digest realm="test", nonce="abc"', "realm") returns "test"
529+
* Handles both quoted values (token="value") and unquoted values (token=value).
530+
* Example: matchParam('Digest realm="test", algorithm=SHA-256', "realm") returns "test"
531+
* Example: matchParam('Digest algorithm=SHA-256', "algorithm") returns "SHA-256"
465532
*/
466-
private static @Nullable String match(String headerLine, String token) {
467-
if (headerLine == null || token == null) return null;
468-
String pattern = token + "=\"";
469-
int start = headerLine.indexOf(pattern);
470-
if (start == -1) return null;
471-
start += pattern.length();
472-
int end = headerLine.indexOf('"', start);
473-
if (end == -1) return null;
474-
return headerLine.substring(start, end);
533+
public static @Nullable String matchParam(String headerLine, String token) {
534+
if (headerLine == null || token == null) {
535+
return null;
536+
}
537+
// Look for token= (case-insensitive token match)
538+
int len = headerLine.length();
539+
int tokenLen = token.length();
540+
int i = 0;
541+
while (i < len) {
542+
int idx = headerLine.indexOf('=', i);
543+
if (idx == -1) {
544+
return null;
545+
}
546+
// Walk backwards from '=' to find the start of the key (skip whitespace)
547+
int keyEnd = idx;
548+
while (keyEnd > i && headerLine.charAt(keyEnd - 1) == ' ') {
549+
keyEnd--;
550+
}
551+
int keyStart = keyEnd - tokenLen;
552+
if (keyStart >= 0
553+
&& headerLine.regionMatches(true, keyStart, token, 0, tokenLen)
554+
&& (keyStart == 0 || headerLine.charAt(keyStart - 1) == ' ' || headerLine.charAt(keyStart - 1) == ',')) {
555+
// Found matching token, now extract value
556+
int valStart = idx + 1;
557+
// skip whitespace after '='
558+
while (valStart < len && headerLine.charAt(valStart) == ' ') {
559+
valStart++;
560+
}
561+
if (valStart < len && headerLine.charAt(valStart) == '"') {
562+
// Quoted value
563+
int valEnd = headerLine.indexOf('"', valStart + 1);
564+
if (valEnd == -1) {
565+
return null;
566+
}
567+
return headerLine.substring(valStart + 1, valEnd);
568+
} else {
569+
// Unquoted value — terminated by ',' or end-of-string
570+
int valEnd = valStart;
571+
while (valEnd < len && headerLine.charAt(valEnd) != ',' && headerLine.charAt(valEnd) != ' ') {
572+
valEnd++;
573+
}
574+
if (valEnd > valStart) {
575+
return headerLine.substring(valStart, valEnd);
576+
}
577+
return null;
578+
}
579+
}
580+
i = idx + 1;
581+
}
582+
return null;
475583
}
476584

477585
private void newCnonce(MessageDigest md) {
@@ -530,11 +638,13 @@ private byte[] ha2(StringBuilder sb, String digestUri, MessageDigest md) {
530638
// if qop is "auth-int" => A2 = Method ":" digest-uri-value ":" H(entity-body)
531639
sb.append(methodName).append(':').append(digestUri);
532640
if ("auth-int".equals(qop)) {
533-
// when qop == "auth-int", A2 = Method ":" digest-uri-value ":" H(entity-body)
534-
// but we don't have the request body here
535-
// we would need a new API
536-
sb.append(':').append(EMPTY_ENTITY_MD5);
537-
641+
sb.append(':');
642+
if (entityBodyHash != null) {
643+
sb.append(entityBodyHash);
644+
} else {
645+
// Hash of empty body using the current algorithm
646+
sb.append(toHexString(md.digest()));
647+
}
538648
} else if (qop != null && !"auth".equals(qop)) {
539649
throw new UnsupportedOperationException("Digest qop not supported: " + qop);
540650
}
@@ -608,7 +718,9 @@ public Realm build() {
608718
servicePrincipalName,
609719
useCanonicalHostname,
610720
customLoginConfig,
611-
loginContextName);
721+
loginContextName,
722+
stale,
723+
userhash);
612724
}
613725
}
614726
}

client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,22 @@
3232
import org.asynchttpclient.netty.channel.ChannelManager;
3333
import org.asynchttpclient.netty.request.NettyRequestSender;
3434
import org.asynchttpclient.proxy.ProxyServer;
35+
import org.asynchttpclient.util.AuthenticatorUtils;
36+
import org.asynchttpclient.util.NonceCounter;
37+
import org.slf4j.Logger;
38+
import org.slf4j.LoggerFactory;
3539

3640
import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
41+
import static org.asynchttpclient.Dsl.realm;
3742
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.CONTINUE_100;
3843
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.OK_200;
3944
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.PROXY_AUTHENTICATION_REQUIRED_407;
4045
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.UNAUTHORIZED_401;
4146

4247
public class Interceptors {
4348

49+
private static final Logger LOGGER = LoggerFactory.getLogger(Interceptors.class);
50+
4451
private final AsyncHttpClientConfig config;
4552
private final Unauthorized401Interceptor unauthorized401Interceptor;
4653
private final ProxyUnauthorized407Interceptor proxyUnauthorized407Interceptor;
@@ -50,13 +57,15 @@ public class Interceptors {
5057
private final ResponseFiltersInterceptor responseFiltersInterceptor;
5158
private final boolean hasResponseFilters;
5259
private final ClientCookieDecoder cookieDecoder;
60+
private final NonceCounter nonceCounter;
5361

5462
public Interceptors(AsyncHttpClientConfig config,
5563
ChannelManager channelManager,
5664
NettyRequestSender requestSender) {
5765
this.config = config;
58-
unauthorized401Interceptor = new Unauthorized401Interceptor(channelManager, requestSender);
59-
proxyUnauthorized407Interceptor = new ProxyUnauthorized407Interceptor(channelManager, requestSender);
66+
nonceCounter = new NonceCounter();
67+
unauthorized401Interceptor = new Unauthorized401Interceptor(channelManager, requestSender, nonceCounter);
68+
proxyUnauthorized407Interceptor = new ProxyUnauthorized407Interceptor(channelManager, requestSender, nonceCounter);
6069
continue100Interceptor = new Continue100Interceptor(requestSender);
6170
redirect30xInterceptor = new Redirect30xInterceptor(channelManager, config, requestSender);
6271
connectSuccessInterceptor = new ConnectSuccessInterceptor(channelManager, requestSender);
@@ -109,6 +118,52 @@ public boolean exitAfterIntercept(Channel channel, NettyResponseFuture<?> future
109118
if (httpRequest.method() == HttpMethod.CONNECT && statusCode == OK_200) {
110119
return connectSuccessInterceptor.exitAfterHandlingConnect(channel, future, request, proxyServer);
111120
}
121+
122+
// Process Authentication-Info / Proxy-Authentication-Info headers (RFC 7616 Section 3.5)
123+
if (realm != null && realm.getScheme() == Realm.AuthScheme.DIGEST) {
124+
processAuthenticationInfo(future, responseHeaders, realm, false);
125+
}
126+
Realm proxyRealm = future.getProxyRealm();
127+
if (proxyRealm != null && proxyRealm.getScheme() == Realm.AuthScheme.DIGEST) {
128+
processAuthenticationInfo(future, responseHeaders, proxyRealm, true);
129+
}
130+
112131
return false;
113132
}
133+
134+
private void processAuthenticationInfo(NettyResponseFuture<?> future, HttpHeaders responseHeaders,
135+
Realm currentRealm, boolean proxy) {
136+
String headerName = proxy ? "Proxy-Authentication-Info" : "Authentication-Info";
137+
String authInfoHeader = responseHeaders.get(headerName);
138+
if (authInfoHeader == null) {
139+
return;
140+
}
141+
142+
String nextnonce = Realm.Builder.matchParam(authInfoHeader, "nextnonce");
143+
if (nextnonce != null) {
144+
// Rotate to the new nonce
145+
String oldNonce = currentRealm.getNonce();
146+
if (oldNonce != null) {
147+
nonceCounter.reset(oldNonce);
148+
}
149+
Realm newRealm = realm(currentRealm)
150+
.setNonce(nextnonce)
151+
.setNc("00000001")
152+
.build();
153+
if (proxy) {
154+
future.setProxyRealm(newRealm);
155+
} else {
156+
future.setRealm(newRealm);
157+
}
158+
LOGGER.debug("Rotated to nextnonce from {} header", headerName);
159+
}
160+
161+
String rspauth = Realm.Builder.matchParam(authInfoHeader, "rspauth");
162+
if (rspauth != null) {
163+
String expectedRspauth = AuthenticatorUtils.computeRspAuth(currentRealm);
164+
if (!rspauth.equalsIgnoreCase(expectedRspauth)) {
165+
LOGGER.warn("Server rspauth mismatch: expected={}, got={}", expectedRspauth, rspauth);
166+
}
167+
}
168+
}
114169
}

0 commit comments

Comments
 (0)