4444public 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}
0 commit comments