diff --git a/README.md b/README.md
index 303200eb5a..1829a120ea 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ It supports HTTP/1.1, HTTP/2, and WebSocket protocols.
- **Asynchronous API** — non-blocking I/O with `ListenableFuture`
and `CompletableFuture`
- **Compression** — automatic gzip, deflate, Brotli, and Zstd decompression
-- **Authentication** — Basic, Digest, NTLM, and SPNEGO/Kerberos
+- **Authentication** — Basic, Digest, NTLM, SPNEGO/Kerberos, and SCRAM-SHA-256
- **Proxy** — HTTP, SOCKS4, and SOCKS5 with CONNECT tunneling
- **Native transports** — optional Epoll, KQueue, and io_uring
- **Request/response filters** — intercept and transform at each stage
@@ -99,13 +99,13 @@ AsyncHttpClient client = asyncHttpClient(config().setUseNativeTransport(true));
com.aayushatharva.brotli4j
brotli4j
- 1.18.0
+ 1.20.0
com.github.luben
zstd-jni
- 1.5.7-2
+ 1.5.7-7
```
@@ -385,9 +385,16 @@ Response response = client
.setRealm(digestAuthRealm("user", "password").build())
.execute()
.get();
+
+// SCRAM-SHA-256 (RFC 7804)
+Response response = client
+ .prepareGet("https://api.example.com/protected")
+ .setRealm(scramSha256AuthRealm("user", "password").build())
+ .execute()
+ .get();
```
-Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**.
+Supported schemes: **Basic**, **Digest**, **NTLM**, **SPNEGO/Kerberos**, **SCRAM-SHA-256**.
## Proxy Support
diff --git a/client/src/main/java/org/asynchttpclient/Dsl.java b/client/src/main/java/org/asynchttpclient/Dsl.java
index 159c8c9fae..3295601291 100644
--- a/client/src/main/java/org/asynchttpclient/Dsl.java
+++ b/client/src/main/java/org/asynchttpclient/Dsl.java
@@ -113,7 +113,9 @@ public static Realm.Builder realm(Realm prototype) {
.setUseCanonicalHostname(prototype.isUseCanonicalHostname())
.setCustomLoginConfig(prototype.getCustomLoginConfig())
.setLoginContextName(prototype.getLoginContextName())
- .setUserhash(prototype.isUserhash());
+ .setUserhash(prototype.isUserhash())
+ .setSid(prototype.getSid())
+ .setMaxIterationCount(prototype.getMaxIterationCount());
// Note: stale is NOT copied — it's challenge-specific, always starts false
}
@@ -132,4 +134,9 @@ public static Realm.Builder digestAuthRealm(String principal, String password) {
public static Realm.Builder ntlmAuthRealm(String principal, String password) {
return realm(AuthScheme.NTLM, principal, password);
}
+
+ public static Realm.Builder scramSha256AuthRealm(String principal, String password) {
+ return realm(AuthScheme.SCRAM_SHA_256, principal, password);
+ }
+
}
diff --git a/client/src/main/java/org/asynchttpclient/Realm.java b/client/src/main/java/org/asynchttpclient/Realm.java
index 123e272d1f..768a72f6ac 100644
--- a/client/src/main/java/org/asynchttpclient/Realm.java
+++ b/client/src/main/java/org/asynchttpclient/Realm.java
@@ -69,6 +69,8 @@ public class Realm {
private final @Nullable String loginContextName;
private final boolean stale;
private final boolean userhash;
+ private final @Nullable String sid;
+ private final int maxIterationCount;
private Realm(@Nullable AuthScheme scheme,
@Nullable String principal,
@@ -93,7 +95,9 @@ private Realm(@Nullable AuthScheme scheme,
@Nullable Map customLoginConfig,
@Nullable String loginContextName,
boolean stale,
- boolean userhash) {
+ boolean userhash,
+ @Nullable String sid,
+ int maxIterationCount) {
this.scheme = requireNonNull(scheme, "scheme");
this.principal = principal;
@@ -119,6 +123,8 @@ private Realm(@Nullable AuthScheme scheme,
this.loginContextName = loginContextName;
this.stale = stale;
this.userhash = userhash;
+ this.sid = sid;
+ this.maxIterationCount = maxIterationCount;
}
public @Nullable String getPrincipal() {
@@ -232,6 +238,14 @@ public boolean isUserhash() {
return userhash;
}
+ public @Nullable String getSid() {
+ return sid;
+ }
+
+ public int getMaxIterationCount() {
+ return maxIterationCount;
+ }
+
@Override
public String toString() {
return "Realm{" +
@@ -261,7 +275,7 @@ public String toString() {
}
public enum AuthScheme {
- BASIC, DIGEST, NTLM, SPNEGO, KERBEROS
+ BASIC, DIGEST, NTLM, SPNEGO, KERBEROS, SCRAM_SHA_256
}
/**
@@ -300,6 +314,8 @@ public static class Builder {
private boolean stale;
private boolean userhash;
private @Nullable String entityBodyHash;
+ private @Nullable String sid;
+ private int maxIterationCount = 16_384;
public Builder() {
principal = null;
@@ -432,6 +448,16 @@ public Builder setEntityBodyHash(@Nullable String entityBodyHash) {
return this;
}
+ public Builder setSid(@Nullable String sid) {
+ this.sid = sid;
+ return this;
+ }
+
+ public Builder setMaxIterationCount(int maxIterationCount) {
+ this.maxIterationCount = maxIterationCount;
+ return this;
+ }
+
public @Nullable String getQopValue() {
return qop;
}
@@ -720,7 +746,9 @@ public Realm build() {
customLoginConfig,
loginContextName,
stale,
- userhash);
+ userhash,
+ sid,
+ maxIterationCount);
}
}
}
diff --git a/client/src/main/java/org/asynchttpclient/handler/TransferListener.java b/client/src/main/java/org/asynchttpclient/handler/TransferListener.java
index fd50dcf7e6..b6b8198339 100644
--- a/client/src/main/java/org/asynchttpclient/handler/TransferListener.java
+++ b/client/src/main/java/org/asynchttpclient/handler/TransferListener.java
@@ -36,7 +36,7 @@ public interface TransferListener {
/**
* Invoked every time response's chunk are received.
*
- * @param bytes a {@link byte} array
+ * @param bytes a byte array
*/
void onBytesReceived(byte[] bytes);
diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java
index c29c0f33d9..8cbbcfc503 100755
--- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java
+++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java
@@ -27,6 +27,7 @@
import org.asynchttpclient.netty.request.NettyRequest;
import org.asynchttpclient.netty.timeout.TimeoutsHolder;
import org.asynchttpclient.proxy.ProxyServer;
+import org.asynchttpclient.scram.ScramContext;
import org.asynchttpclient.uri.Uri;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -126,6 +127,7 @@ public final class NettyResponseFuture implements ListenableFuture {
private boolean allowConnect;
private Realm realm;
private Realm proxyRealm;
+ private volatile ScramContext scramContext;
public NettyResponseFuture(Request originalRequest,
AsyncHandler asyncHandler,
@@ -540,6 +542,14 @@ public void setProxyRealm(Realm proxyRealm) {
this.proxyRealm = proxyRealm;
}
+ public ScramContext getScramContext() {
+ return scramContext;
+ }
+
+ public void setScramContext(ScramContext scramContext) {
+ this.scramContext = scramContext;
+ }
+
@Override
public String toString() {
return "NettyResponseFuture{" + //
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java
index f11530b8d0..f974f88b0e 100644
--- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Interceptors.java
@@ -32,11 +32,17 @@
import org.asynchttpclient.netty.channel.ChannelManager;
import org.asynchttpclient.netty.request.NettyRequestSender;
import org.asynchttpclient.proxy.ProxyServer;
+import org.asynchttpclient.scram.ScramContext;
+import org.asynchttpclient.scram.ScramMessageParser;
+import org.asynchttpclient.scram.ScramState;
import org.asynchttpclient.util.AuthenticatorUtils;
import org.asynchttpclient.util.NonceCounter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
import static org.asynchttpclient.Dsl.realm;
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.CONTINUE_100;
@@ -128,6 +134,14 @@ public boolean exitAfterIntercept(Channel channel, NettyResponseFuture> future
processAuthenticationInfo(future, responseHeaders, proxyRealm, true);
}
+ // Process SCRAM Authentication-Info (RFC 7804 §5)
+ if (realm != null && realm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) {
+ processScramAuthenticationInfo(future, responseHeaders, "Authentication-Info");
+ }
+ if (proxyRealm != null && proxyRealm.getScheme() == Realm.AuthScheme.SCRAM_SHA_256) {
+ processScramAuthenticationInfo(future, responseHeaders, "Proxy-Authentication-Info");
+ }
+
return false;
}
@@ -166,4 +180,43 @@ private void processAuthenticationInfo(NettyResponseFuture> future, HttpHeader
}
}
}
+
+ private void processScramAuthenticationInfo(NettyResponseFuture> future, HttpHeaders responseHeaders,
+ String headerName) {
+ ScramContext ctx = future.getScramContext();
+ if (ctx == null || ctx.getState() != ScramState.CLIENT_FINAL_SENT) {
+ return;
+ }
+
+ String authInfo = responseHeaders.get(headerName);
+ if (authInfo == null) {
+ // RFC 7804 §6: may be in chunked trailers (not supported by AHC)
+ LOGGER.warn("SCRAM: response without {} header; "
+ + "ServerSignature cannot be verified (may be in chunked trailers)", headerName);
+ return;
+ }
+
+ String data = Realm.Builder.matchParam(authInfo, "data");
+ if (data == null) {
+ LOGGER.warn("SCRAM: Authentication-Info header missing data attribute");
+ return;
+ }
+
+ String serverFinalMsg;
+ try {
+ serverFinalMsg = new String(Base64.getDecoder().decode(data), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ LOGGER.warn("SCRAM: invalid base64 in {} data attribute: {}", headerName, e.getMessage());
+ ctx.setState(ScramState.FAILED);
+ return;
+ }
+
+ // verifyServerFinal sets state to AUTHENTICATED or FAILED internally
+ if (ctx.verifyServerFinal(serverFinalMsg)) {
+ LOGGER.debug("SCRAM ServerSignature verified successfully");
+ } else {
+ LOGGER.warn("SCRAM ServerSignature verification failed — authentication unsuccessful "
+ + "(RFC 7804 §5: MUST consider unsuccessful)");
+ }
+ }
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java
index 87b08f6905..74f8c728ef 100644
--- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ProxyUnauthorized407Interceptor.java
@@ -32,6 +32,11 @@
import io.netty.handler.codec.http2.Http2StreamChannel;
import org.asynchttpclient.ntlm.NtlmEngine;
import org.asynchttpclient.proxy.ProxyServer;
+import org.asynchttpclient.scram.ScramContext;
+import org.asynchttpclient.scram.ScramException;
+import org.asynchttpclient.scram.ScramMessageFormatter;
+import org.asynchttpclient.scram.ScramMessageParser;
+import org.asynchttpclient.scram.ScramState;
import org.asynchttpclient.spnego.SpnegoEngine;
import org.asynchttpclient.spnego.SpnegoEngineException;
import org.asynchttpclient.util.AuthenticatorUtils;
@@ -39,6 +44,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.List;
import static io.netty.handler.codec.http.HttpHeaderNames.PROXY_AUTHENTICATE;
@@ -214,6 +221,65 @@ public boolean exitAfterHandling407(Channel channel, NettyResponseFuture> futu
}
}
break;
+ case SCRAM_SHA_256:
+ String scramPrefix = "SCRAM-SHA-256";
+ String scramHeader = getHeaderWithPrefix(proxyAuthHeaders, scramPrefix);
+ if (scramHeader == null) {
+ LOGGER.info("Can't handle 407 with SCRAM realm as Proxy-Authenticate headers don't match");
+ return false;
+ }
+
+ try {
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(scramHeader);
+ ScramContext ctx = future.getScramContext();
+
+ if (ctx == null) {
+ ctx = new ScramContext(proxyRealm.getPrincipal(), proxyRealm.getPassword(),
+ params.realm != null ? params.realm : proxyRealm.getRealmName(),
+ scramPrefix);
+ ctx.setInitialChallengeParams(params);
+
+ String base64Data = Base64.getEncoder().encodeToString(
+ ctx.getClientFirstMessage().getBytes(StandardCharsets.UTF_8));
+ String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
+ ctx.getMechanism(), ctx.getRealmName(), null, base64Data);
+
+ requestHeaders.set(PROXY_AUTHORIZATION, authHeader);
+ future.setScramContext(ctx);
+ future.setInProxyAuth(false);
+
+ } else if (ctx.getState() == ScramState.CLIENT_FIRST_SENT) {
+ if (params.sid == null) {
+ LOGGER.warn("SCRAM: missing sid in proxy server-first response");
+ return false;
+ }
+ if (params.data == null) {
+ LOGGER.warn("SCRAM: missing data in proxy server-first response");
+ return false;
+ }
+
+ String serverFirstMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+ ctx.processServerFirst(serverFirstMsg, proxyRealm.getMaxIterationCount());
+ ctx.setSid(params.sid);
+
+ String clientFinalMsg = ctx.computeClientFinal();
+ String base64Data = Base64.getEncoder().encodeToString(
+ clientFinalMsg.getBytes(StandardCharsets.UTF_8));
+ String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
+ ctx.getMechanism(), null, params.sid, base64Data);
+
+ requestHeaders.set(PROXY_AUTHORIZATION, authHeader);
+
+ } else {
+ LOGGER.warn("SCRAM proxy authentication failed: unexpected 407 in state {}", ctx.getState());
+ return false;
+ }
+ } catch (ScramException | IllegalArgumentException e) {
+ LOGGER.warn("SCRAM proxy authentication failed: {}", e.getMessage());
+ return false;
+ }
+ break;
+
default:
throw new IllegalStateException("Invalid Authentication scheme " + proxyRealm.getScheme());
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java
index 01bbb265b8..1016559b89 100644
--- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java
@@ -95,6 +95,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture>
// We must allow auth handling again.
future.setInAuth(false);
future.setInProxyAuth(false);
+ future.setScramContext(null);
String originalMethod = request.getMethod();
boolean switchToGet = !originalMethod.equals(GET) &&
@@ -196,7 +197,8 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole
headers.remove(CONTENT_TYPE);
}
- if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) {
+ if (stripAuthorization || (realm != null && (realm.getScheme() == AuthScheme.NTLM
+ || realm.getScheme() == AuthScheme.SCRAM_SHA_256))) {
headers.remove(AUTHORIZATION)
.remove(PROXY_AUTHORIZATION);
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java
index 7b1dc2ee34..237755687f 100644
--- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Unauthorized401Interceptor.java
@@ -30,6 +30,11 @@
import org.asynchttpclient.netty.request.NettyRequestSender;
import io.netty.handler.codec.http2.Http2StreamChannel;
import org.asynchttpclient.ntlm.NtlmEngine;
+import org.asynchttpclient.scram.ScramContext;
+import org.asynchttpclient.scram.ScramException;
+import org.asynchttpclient.scram.ScramMessageFormatter;
+import org.asynchttpclient.scram.ScramMessageParser;
+import org.asynchttpclient.scram.ScramState;
import org.asynchttpclient.spnego.SpnegoEngine;
import org.asynchttpclient.spnego.SpnegoEngineException;
import org.asynchttpclient.uri.Uri;
@@ -38,6 +43,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.List;
import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION;
@@ -206,6 +213,67 @@ public boolean exitAfterHandling401(Channel channel, NettyResponseFuture> futu
}
}
break;
+ case SCRAM_SHA_256:
+ String scramPrefix = "SCRAM-SHA-256";
+ String scramHeader = getHeaderWithPrefix(wwwAuthHeaders, scramPrefix);
+ if (scramHeader == null) {
+ LOGGER.info("Can't handle 401 with SCRAM realm as WWW-Authenticate headers don't match");
+ return false;
+ }
+
+ try {
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(scramHeader);
+ ScramContext ctx = future.getScramContext();
+
+ if (ctx == null) {
+ // Step 1: First 401 — send client-first
+ ctx = new ScramContext(realm.getPrincipal(), realm.getPassword(),
+ params.realm != null ? params.realm : realm.getRealmName(),
+ scramPrefix);
+ ctx.setInitialChallengeParams(params);
+
+ String base64Data = Base64.getEncoder().encodeToString(
+ ctx.getClientFirstMessage().getBytes(StandardCharsets.UTF_8));
+ String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
+ ctx.getMechanism(), ctx.getRealmName(), null, base64Data);
+
+ requestHeaders.set(AUTHORIZATION, authHeader);
+ future.setScramContext(ctx);
+ future.setInAuth(false); // allow second 401
+
+ } else if (ctx.getState() == ScramState.CLIENT_FIRST_SENT) {
+ // Step 2: Second 401 — parse server-first, send client-final
+ if (params.sid == null) {
+ LOGGER.warn("SCRAM: missing sid in server-first response");
+ return false;
+ }
+ if (params.data == null) {
+ LOGGER.warn("SCRAM: missing data in server-first response");
+ return false;
+ }
+
+ String serverFirstMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+ ctx.processServerFirst(serverFirstMsg, realm.getMaxIterationCount());
+ ctx.setSid(params.sid);
+
+ String clientFinalMsg = ctx.computeClientFinal();
+ String base64Data = Base64.getEncoder().encodeToString(
+ clientFinalMsg.getBytes(StandardCharsets.UTF_8));
+ String authHeader = ScramMessageFormatter.formatAuthorizationHeader(
+ ctx.getMechanism(), null, params.sid, base64Data);
+
+ requestHeaders.set(AUTHORIZATION, authHeader);
+
+ } else {
+ LOGGER.warn("SCRAM authentication failed: unexpected 401 in state {}", ctx.getState());
+ return false;
+ }
+ } catch (ScramException | IllegalArgumentException e) {
+ LOGGER.warn("SCRAM authentication failed: {}", e.getMessage());
+ return false;
+ }
+ break;
+
default:
throw new IllegalStateException("Invalid Authentication scheme " + realm.getScheme());
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
index b60fd8af45..08451ce953 100755
--- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
+++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
@@ -305,8 +305,12 @@ private ListenableFuture sendRequestWithNewChannel(Request request, Proxy
requestFactory.addAuthorizationHeader(headers, perConnectionAuthorizationHeader(request, proxy, realm));
requestFactory.setProxyAuthorizationHeader(headers, perConnectionProxyAuthorizationHeader(request, proxyRealm));
- future.setInAuth(realm != null && realm.isUsePreemptiveAuth() && realm.getScheme() != AuthScheme.NTLM);
- future.setInProxyAuth(proxyRealm != null && proxyRealm.isUsePreemptiveAuth() && proxyRealm.getScheme() != AuthScheme.NTLM);
+ future.setInAuth(realm != null && realm.isUsePreemptiveAuth()
+ && realm.getScheme() != AuthScheme.NTLM
+ && realm.getScheme() != AuthScheme.SCRAM_SHA_256);
+ future.setInProxyAuth(proxyRealm != null && proxyRealm.isUsePreemptiveAuth()
+ && proxyRealm.getScheme() != AuthScheme.NTLM
+ && proxyRealm.getScheme() != AuthScheme.SCRAM_SHA_256);
try {
if (!channelManager.isOpen()) {
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramContext.java b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java
new file mode 100644
index 0000000000..68ba8d06ab
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramContext.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.security.MessageDigest;
+import java.util.Base64;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Per-exchange mutable state for a SCRAM authentication handshake (RFC 7804).
+ * Attached to NettyResponseFuture during a SCRAM exchange.
+ * Not thread-safe: accessed only from EventLoop.
+ */
+public class ScramContext {
+
+ private ScramState state;
+ private final String mechanism;
+ private final String username;
+ private @Nullable String password;
+ private final @Nullable String realmName;
+ private final String clientNonce;
+ private @Nullable String serverNonce;
+ private @Nullable String sid;
+ private @Nullable byte[] salt;
+ private int iterationCount;
+ private final String clientFirstMessage;
+ private final String clientFirstMessageBare;
+ private @Nullable String serverFirstMessage;
+ private @Nullable String clientFinalMessageWithoutProof;
+ private @Nullable byte[] clientKey;
+ private @Nullable byte[] storedKey;
+ private @Nullable byte[] serverKey;
+ private @Nullable ScramMessageParser.ScramChallengeParams initialChallengeParams;
+
+ /**
+ * Create a ScramContext and initialize the client-first step.
+ */
+ public ScramContext(String username, String password, @Nullable String realmName, String mechanism) {
+ this.username = username;
+ this.password = password;
+ this.realmName = realmName;
+ this.mechanism = mechanism;
+ this.clientNonce = ScramEngine.generateNonce(24);
+ this.clientFirstMessage = ScramMessageFormatter.formatClientFirstMessage(username, clientNonce);
+ this.clientFirstMessageBare = ScramMessageFormatter.clientFirstMessageBare(username, clientNonce);
+ this.state = ScramState.CLIENT_FIRST_SENT;
+ }
+
+ /**
+ * Process the server-first-message: validate nonce, extract salt/iterations,
+ * compute derived keys, and zero SaltedPassword.
+ *
+ * @param serverFirstMsg the verbatim server-first-message (decoded from base64)
+ * @param maxIterationCount maximum allowed iteration count for DoS protection
+ */
+ public void processServerFirst(String serverFirstMsg, int maxIterationCount) {
+ this.serverFirstMessage = serverFirstMsg;
+
+ ScramMessageParser.ServerFirstMessage parsed = ScramMessageParser.parseServerFirst(serverFirstMsg);
+
+ // Validate nonce prefix
+ ScramMessageParser.validateNoncePrefix(clientNonce, parsed.fullNonce);
+
+ // Validate iteration count
+ if (parsed.iterationCount > maxIterationCount) {
+ throw new ScramException("Server iteration count " + parsed.iterationCount
+ + " exceeds maximum allowed " + maxIterationCount);
+ }
+
+ this.serverNonce = parsed.fullNonce;
+ this.salt = parsed.salt;
+ this.iterationCount = parsed.iterationCount;
+
+ // Compute derived keys
+ String pwd = requireNonNull(password, "password already consumed");
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(pwd, parsed.salt, iterationCount);
+ try {
+ this.clientKey = ScramEngine.computeClientKey(saltedPassword);
+ this.storedKey = ScramEngine.computeStoredKey(this.clientKey);
+ this.serverKey = ScramEngine.computeServerKey(saltedPassword);
+ } finally {
+ // Zero SaltedPassword immediately after deriving keys (RFC 7804 §8)
+ ScramEngine.zeroBytes(saltedPassword);
+ // Zero password in memory
+ this.password = null;
+ }
+
+ this.state = ScramState.SERVER_FIRST_RECEIVED;
+ }
+
+ /**
+ * Compute the client-final-message with proof.
+ *
+ * @return the full client-final-message string
+ */
+ public String computeClientFinal() {
+ String fullNonce = requireNonNull(serverNonce, "serverNonce not set");
+ String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set");
+ byte[] currentStoredKey = requireNonNull(storedKey, "storedKey not set");
+ byte[] currentClientKey = requireNonNull(clientKey, "clientKey not set");
+
+ this.clientFinalMessageWithoutProof = ScramMessageFormatter.clientFinalMessageWithoutProof(fullNonce);
+
+ // AuthMessage = client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof
+ String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalMessageWithoutProof;
+
+ byte[] clientSignature = ScramEngine.computeClientSignature(currentStoredKey, authMessage);
+ byte[] clientProof = ScramEngine.computeClientProof(currentClientKey, clientSignature);
+
+ String clientFinal = ScramMessageFormatter.formatClientFinalMessage(fullNonce, clientProof);
+ this.state = ScramState.CLIENT_FINAL_SENT;
+ return clientFinal;
+ }
+
+ /**
+ * Verify the server-final-message (ServerSignature).
+ *
+ * @param serverFinalMsg the decoded server-final-message
+ * @return true if ServerSignature is valid, false otherwise
+ */
+ public boolean verifyServerFinal(String serverFinalMsg) {
+ ScramMessageParser.ServerFinalMessage parsed = ScramMessageParser.parseServerFinal(serverFinalMsg);
+
+ if (parsed.error != null) {
+ this.state = ScramState.FAILED;
+ return false;
+ }
+
+ if (parsed.verifier == null) {
+ this.state = ScramState.FAILED;
+ return false;
+ }
+
+ String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set");
+ String clientFinalNoProof = requireNonNull(clientFinalMessageWithoutProof, "clientFinalMessageWithoutProof not set");
+ byte[] currentServerKey = requireNonNull(serverKey, "serverKey not set");
+
+ // Reconstruct AuthMessage
+ String authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalNoProof;
+
+ byte[] expectedServerSignature = ScramEngine.computeServerSignature(currentServerKey, authMessage);
+ byte[] receivedSignature;
+ try {
+ receivedSignature = Base64.getDecoder().decode(parsed.verifier);
+ } catch (IllegalArgumentException e) {
+ this.state = ScramState.FAILED;
+ return false;
+ }
+
+ // Constant-time comparison to prevent timing side-channel attacks
+ if (MessageDigest.isEqual(expectedServerSignature, receivedSignature)) {
+ this.state = ScramState.AUTHENTICATED;
+ return true;
+ } else {
+ this.state = ScramState.FAILED;
+ return false;
+ }
+ }
+
+ /**
+ * Create a session cache entry from the current context after successful authentication.
+ */
+ public ScramSessionCache.Entry toSessionCacheEntry(@Nullable String serverNoncePart, int ttl) {
+ byte[] currentSalt = requireNonNull(salt, "salt not set");
+ byte[] currentClientKey = requireNonNull(clientKey, "clientKey not set");
+ byte[] currentStoredKey = requireNonNull(storedKey, "storedKey not set");
+ byte[] currentServerKey = requireNonNull(serverKey, "serverKey not set");
+ String serverFirst = requireNonNull(serverFirstMessage, "serverFirstMessage not set");
+
+ return new ScramSessionCache.Entry(
+ realmName,
+ currentSalt,
+ iterationCount,
+ currentClientKey,
+ currentStoredKey,
+ currentServerKey,
+ serverNoncePart,
+ ttl,
+ System.nanoTime(),
+ iterationCount, // nonce-count starts at iteration count per RFC 7804 §5.1
+ serverFirst
+ );
+ }
+
+ // Getters and setters
+
+ public ScramState getState() {
+ return state;
+ }
+
+ public void setState(ScramState state) {
+ this.state = state;
+ }
+
+ public String getMechanism() {
+ return mechanism;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public @Nullable String getRealmName() {
+ return realmName;
+ }
+
+ public String getClientNonce() {
+ return clientNonce;
+ }
+
+ public @Nullable String getServerNonce() {
+ return serverNonce;
+ }
+
+ public @Nullable String getSid() {
+ return sid;
+ }
+
+ public void setSid(@Nullable String sid) {
+ this.sid = sid;
+ }
+
+ public String getClientFirstMessage() {
+ return clientFirstMessage;
+ }
+
+ public String getClientFirstMessageBare() {
+ return clientFirstMessageBare;
+ }
+
+ public @Nullable String getServerFirstMessage() {
+ return serverFirstMessage;
+ }
+
+ public @Nullable byte[] getClientKey() {
+ return clientKey != null ? clientKey.clone() : null;
+ }
+
+ public @Nullable byte[] getStoredKey() {
+ return storedKey != null ? storedKey.clone() : null;
+ }
+
+ public @Nullable byte[] getServerKey() {
+ return serverKey != null ? serverKey.clone() : null;
+ }
+
+ public @Nullable ScramMessageParser.ScramChallengeParams getInitialChallengeParams() {
+ return initialChallengeParams;
+ }
+
+ public void setInitialChallengeParams(@Nullable ScramMessageParser.ScramChallengeParams params) {
+ this.initialChallengeParams = params;
+ }
+
+ public int getIterationCount() {
+ return iterationCount;
+ }
+
+ public @Nullable byte[] getSalt() {
+ return salt != null ? salt.clone() : null;
+ }
+
+ public @Nullable String getClientFinalMessageWithoutProof() {
+ return clientFinalMessageWithoutProof;
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java b/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java
new file mode 100644
index 0000000000..97c62aaf8b
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramEngine.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.text.Normalizer;
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * Core SCRAM-SHA-256 cryptographic operations (RFC 5802, RFC 7804).
+ * Thread-safe: all methods are stateless.
+ */
+public final class ScramEngine {
+
+ private static final String PBKDF2_ALGO = "PBKDF2WithHmacSHA256";
+ private static final String HMAC_ALGO = "HmacSHA256";
+ private static final String HASH_ALGO = "SHA-256";
+ private static final int KEY_LENGTH_BITS = 256;
+
+ private static final ThreadLocal SECURE_RANDOM = ThreadLocal.withInitial(SecureRandom::new);
+
+ private ScramEngine() {
+ }
+
+ /**
+ * PBKDF2 / Hi() computation as defined in RFC 5802.
+ * Hi(str, salt, i) = PBKDF2(str, salt, i, dkLen)
+ *
+ * @param normalizedPassword the normalized password (String, UTF-8 encoded internally)
+ * @param salt the salt bytes
+ * @param iterations the iteration count
+ * @return the derived key bytes
+ */
+ public static byte[] hi(String normalizedPassword, byte[] salt, int iterations) {
+ try {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_ALGO);
+ PBEKeySpec spec = new PBEKeySpec(normalizedPassword.toCharArray(), salt, iterations, KEY_LENGTH_BITS);
+ try {
+ return factory.generateSecret(spec).getEncoded();
+ } finally {
+ spec.clearPassword();
+ }
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new ScramException("PBKDF2 computation failed", e);
+ }
+ }
+
+ /**
+ * Compute HMAC-SHA-256.
+ */
+ public static byte[] hmac(byte[] key, byte[] data) {
+ try {
+ Mac mac = Mac.getInstance(HMAC_ALGO);
+ mac.init(new SecretKeySpec(key, HMAC_ALGO));
+ return mac.doFinal(data);
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new ScramException("HMAC computation failed", e);
+ }
+ }
+
+ /**
+ * Compute SHA-256 hash.
+ */
+ public static byte[] hash(byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance(HASH_ALGO);
+ return md.digest(data);
+ } catch (NoSuchAlgorithmException e) {
+ throw new ScramException("Hash computation failed", e);
+ }
+ }
+
+ /**
+ * XOR two equal-length byte arrays. Mutates array {@code a} in-place.
+ */
+ public static byte[] xor(byte[] a, byte[] b) {
+ if (a.length != b.length) {
+ throw new ScramException("XOR operands must have equal length: " + a.length + " vs " + b.length);
+ }
+ for (int i = 0; i < a.length; i++) {
+ a[i] ^= b[i];
+ }
+ return a;
+ }
+
+ /**
+ * Generate a cryptographically random nonce, base64-encoded.
+ *
+ * @param lengthBytes number of random bytes (before base64 encoding)
+ * @return base64-encoded nonce string
+ */
+ public static String generateNonce(int lengthBytes) {
+ byte[] bytes = new byte[lengthBytes];
+ SECURE_RANDOM.get().nextBytes(bytes);
+ return Base64.getEncoder().encodeToString(bytes);
+ }
+
+ /**
+ * Normalize password per RFC 7613 OpaqueString profile.
+ * Steps applied in order: 1) width mapping (preserve), 2) non-ASCII spaces to U+0020,
+ * 3) case preserved, 4) NFC normalization, 5) prohibited char check.
+ */
+ public static String normalizePassword(String password) {
+ if (password == null || password.isEmpty()) {
+ return password;
+ }
+
+ // Steps 1-2: Width mapping (preserve) + map non-ASCII spaces to U+0020
+ StringBuilder sb = new StringBuilder(password.length());
+ for (int i = 0; i < password.length(); ) {
+ int cp = password.codePointAt(i);
+
+ // Step 2: Map non-ASCII spaces (Unicode Zs category except U+0020) to U+0020
+ if (cp != 0x0020 && Character.getType(cp) == Character.SPACE_SEPARATOR) {
+ sb.append(' ');
+ } else {
+ sb.appendCodePoint(cp);
+ }
+
+ i += Character.charCount(cp);
+ }
+
+ // Step 3: Case preserved (no-op)
+
+ // Step 4: Apply NFC normalization
+ String normalized = Normalizer.normalize(sb, Normalizer.Form.NFC);
+
+ // Step 5: Reject prohibited characters (PRECIS FreeformClass) — after NFC
+ for (int i = 0; i < normalized.length(); ) {
+ int cp = normalized.codePointAt(i);
+ if (isProhibited(cp)) {
+ throw new ScramException("Password contains prohibited character: U+" + String.format("%04X", cp));
+ }
+ i += Character.charCount(cp);
+ }
+
+ return normalized;
+ }
+
+ /**
+ * Check if a codepoint is prohibited by PRECIS FreeformClass (RFC 8264 §9.11).
+ */
+ private static boolean isProhibited(int cp) {
+ // Control characters (except HT, LF, CR)
+ if (cp <= 0x001F && cp != 0x0009 && cp != 0x000A && cp != 0x000D) {
+ return true;
+ }
+ if (cp == 0x007F) {
+ return true; // DEL
+ }
+ if (cp >= 0x0080 && cp <= 0x009F) {
+ return true; // C1 control characters
+ }
+
+ // Surrogates (should not appear in valid Java strings, but check anyway)
+ if (cp >= 0xD800 && cp <= 0xDFFF) {
+ return true;
+ }
+
+ // Non-characters
+ if (cp >= 0xFDD0 && cp <= 0xFDEF) {
+ return true;
+ }
+ if ((cp & 0xFFFE) == 0xFFFE && cp <= 0x10FFFF) {
+ return true; // U+xFFFE and U+xFFFF for any plane
+ }
+
+ // Unassigned codepoints
+ int type = Character.getType(cp);
+ if (type == Character.UNASSIGNED) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * SaltedPassword := Hi(Normalize(password), salt, i)
+ */
+ public static byte[] computeSaltedPassword(String password, byte[] salt, int iterations) {
+ String normalized = normalizePassword(password);
+ return hi(normalized, salt, iterations);
+ }
+
+ /**
+ * ClientKey := HMAC(SaltedPassword, "Client Key")
+ */
+ public static byte[] computeClientKey(byte[] saltedPassword) {
+ return hmac(saltedPassword, "Client Key".getBytes(StandardCharsets.US_ASCII));
+ }
+
+ /**
+ * StoredKey := H(ClientKey)
+ */
+ public static byte[] computeStoredKey(byte[] clientKey) {
+ return hash(clientKey);
+ }
+
+ /**
+ * ServerKey := HMAC(SaltedPassword, "Server Key")
+ */
+ public static byte[] computeServerKey(byte[] saltedPassword) {
+ return hmac(saltedPassword, "Server Key".getBytes(StandardCharsets.US_ASCII));
+ }
+
+ /**
+ * ClientSignature := HMAC(StoredKey, AuthMessage)
+ */
+ public static byte[] computeClientSignature(byte[] storedKey, String authMessage) {
+ return hmac(storedKey, authMessage.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * ClientProof := ClientKey XOR ClientSignature
+ */
+ public static byte[] computeClientProof(byte[] clientKey, byte[] clientSignature) {
+ return xor(clientKey.clone(), clientSignature);
+ }
+
+ /**
+ * ServerSignature := HMAC(ServerKey, AuthMessage)
+ */
+ public static byte[] computeServerSignature(byte[] serverKey, String authMessage) {
+ return hmac(serverKey, authMessage.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Zero out a byte array for security.
+ */
+ public static void zeroBytes(byte[] array) {
+ if (array != null) {
+ Arrays.fill(array, (byte) 0);
+ }
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramException.java b/client/src/main/java/org/asynchttpclient/scram/ScramException.java
new file mode 100644
index 0000000000..dd9fd818bc
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+/**
+ * Exception thrown when a SCRAM authentication error occurs.
+ */
+public class ScramException extends RuntimeException {
+
+ public ScramException(String message) {
+ super(message);
+ }
+
+ public ScramException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java b/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java
new file mode 100644
index 0000000000..0b671067f0
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramMessageFormatter.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Base64;
+
+/**
+ * Formats SCRAM protocol messages (RFC 5802) and HTTP headers (RFC 7804).
+ * Thread-safe: all methods are stateless.
+ */
+public final class ScramMessageFormatter {
+
+ private ScramMessageFormatter() {
+ }
+
+ /**
+ * Escape a username per RFC 5802: "=" → "=3D", "," → "=2C".
+ */
+ public static String escapeUsername(String username) {
+ // Order matters: escape '=' first to avoid double-escaping
+ return username.replace("=", "=3D").replace(",", "=2C");
+ }
+
+ /**
+ * Format the bare portion of client-first-message (without gs2-header).
+ * "n=<escaped-user>,r=<c-nonce>"
+ */
+ public static String clientFirstMessageBare(String username, String clientNonce) {
+ return "n=" + escapeUsername(username) + ",r=" + clientNonce;
+ }
+
+ /**
+ * Format the full client-first-message including gs2-header.
+ * "n,,n=<escaped-user>,r=<c-nonce>"
+ */
+ public static String formatClientFirstMessage(String username, String clientNonce) {
+ return "n,," + clientFirstMessageBare(username, clientNonce);
+ }
+
+ /**
+ * Format client-final-message-without-proof.
+ * "c=biws,r=<full-nonce>"
+ */
+ public static String clientFinalMessageWithoutProof(String fullNonce) {
+ return "c=biws,r=" + fullNonce;
+ }
+
+ /**
+ * Format the full client-final-message with proof.
+ * "c=biws,r=<full-nonce>,p=<base64-proof>"
+ */
+ public static String formatClientFinalMessage(String fullNonce, byte[] clientProof) {
+ return clientFinalMessageWithoutProof(fullNonce) + ",p=" + Base64.getEncoder().encodeToString(clientProof);
+ }
+
+ /**
+ * Format the HTTP Authorization header value for SCRAM.
+ * Per Erratum 6558, the data attribute is quoted.
+ *
+ * @param mechanism "SCRAM-SHA-256"
+ * @param realm realm value (may be null)
+ * @param sid session ID (may be null)
+ * @param base64Data base64-encoded SCRAM message
+ * @return formatted header value
+ */
+ public static String formatAuthorizationHeader(String mechanism, @Nullable String realm,
+ @Nullable String sid, String base64Data) {
+ StringBuilder sb = new StringBuilder(mechanism);
+ if (realm != null) {
+ sb.append(" realm=\"").append(realm).append("\",");
+ }
+ if (sid != null) {
+ sb.append(" sid=").append(sid).append(",");
+ }
+ sb.append(" data=\"").append(base64Data).append("\"");
+ return sb.toString();
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java
new file mode 100644
index 0000000000..db2d5c560b
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramMessageParser.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.asynchttpclient.Realm;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Base64;
+
+/**
+ * Parser for SCRAM protocol messages (RFC 5802) and HTTP authentication headers (RFC 7804).
+ * Thread-safe: all methods are stateless.
+ */
+public final class ScramMessageParser {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ScramMessageParser.class);
+
+ private ScramMessageParser() {
+ }
+
+ /**
+ * Parsed server-first-message fields.
+ */
+ public static class ServerFirstMessage {
+ public final String fullNonce; // r= value (client-nonce + server-nonce)
+ public final byte[] salt; // s= value (base64-decoded)
+ public final int iterationCount; // i= value
+
+ public ServerFirstMessage(String fullNonce, byte[] salt, int iterationCount) {
+ this.fullNonce = fullNonce;
+ this.salt = salt;
+ this.iterationCount = iterationCount;
+ }
+ }
+
+ /**
+ * Parsed server-final-message fields.
+ */
+ public static class ServerFinalMessage {
+ public final @Nullable String verifier; // v= value (base64 encoded ServerSignature)
+ public final @Nullable String error; // e= value
+
+ public ServerFinalMessage(@Nullable String verifier, @Nullable String error) {
+ this.verifier = verifier;
+ this.error = error;
+ }
+ }
+
+ /**
+ * Parsed SCRAM HTTP challenge/response parameters from WWW-Authenticate header.
+ */
+ public static class ScramChallengeParams {
+ public final @Nullable String realm;
+ public final @Nullable String data; // base64 encoded SCRAM message
+ public final @Nullable String sid;
+ public final @Nullable String sr; // server nonce for reauthentication
+ public final int ttl; // -1 if absent
+ public final boolean stale;
+
+ public ScramChallengeParams(@Nullable String realm, @Nullable String data,
+ @Nullable String sid, @Nullable String sr,
+ int ttl, boolean stale) {
+ this.realm = realm;
+ this.data = data;
+ this.sid = sid;
+ this.sr = sr;
+ this.ttl = ttl;
+ this.stale = stale;
+ }
+ }
+
+ /**
+ * Parse a server-first-message (RFC 5802): {@code r=,s=,i=[,extensions]}
+ */
+ public static ServerFirstMessage parseServerFirst(String message) {
+ String fullNonce = null;
+ String saltBase64 = null;
+ int iterationCount = -1;
+
+ String[] parts = message.split(",");
+ for (String part : parts) {
+ if (part.startsWith("r=")) {
+ fullNonce = part.substring(2);
+ } else if (part.startsWith("s=")) {
+ saltBase64 = part.substring(2);
+ } else if (part.startsWith("i=")) {
+ try {
+ iterationCount = Integer.parseInt(part.substring(2));
+ } catch (NumberFormatException e) {
+ throw new ScramException("Invalid iteration count in server-first-message: " + part.substring(2));
+ }
+ }
+ // Extensions after i= are tolerated per RFC 5802
+ }
+
+ if (fullNonce == null) {
+ throw new ScramException("Missing nonce (r=) in server-first-message");
+ }
+ if (saltBase64 == null || saltBase64.isEmpty()) {
+ throw new ScramException("Missing or empty salt (s=) in server-first-message");
+ }
+
+ byte[] salt;
+ try {
+ salt = Base64.getDecoder().decode(saltBase64);
+ } catch (IllegalArgumentException e) {
+ throw new ScramException("Invalid base64 salt in server-first-message", e);
+ }
+ if (salt.length == 0) {
+ throw new ScramException("Empty salt in server-first-message");
+ }
+
+ if (iterationCount < 1) {
+ throw new ScramException("Invalid iteration count: " + iterationCount);
+ }
+
+ return new ServerFirstMessage(fullNonce, salt, iterationCount);
+ }
+
+ /**
+ * Parse a server-final-message (RFC 5802): {@code v=} OR {@code e=}
+ */
+ public static ServerFinalMessage parseServerFinal(String message) {
+ if (message.startsWith("v=")) {
+ return new ServerFinalMessage(message.substring(2), null);
+ } else if (message.startsWith("e=")) {
+ return new ServerFinalMessage(null, message.substring(2));
+ } else {
+ throw new ScramException("Invalid server-final-message: must start with v= or e=");
+ }
+ }
+
+ /**
+ * Parse SCRAM-specific parameters from a WWW-Authenticate or Authentication-Info header value.
+ * The header value should have the SCRAM-SHA-xxx prefix already stripped (or the full header).
+ */
+ public static ScramChallengeParams parseWwwAuthenticateScram(String headerValue) {
+ String realm = Realm.Builder.matchParam(headerValue, "realm");
+ String data = Realm.Builder.matchParam(headerValue, "data");
+ String sid = Realm.Builder.matchParam(headerValue, "sid");
+ if (sid == null) {
+ // sid may be unquoted token
+ sid = matchUnquotedToken(headerValue, "sid");
+ }
+ String sr = Realm.Builder.matchParam(headerValue, "sr");
+ if (sr == null) {
+ sr = matchUnquotedToken(headerValue, "sr");
+ }
+
+ int ttl = -1;
+ String ttlStr = Realm.Builder.matchParam(headerValue, "ttl");
+ if (ttlStr == null) {
+ ttlStr = matchUnquotedToken(headerValue, "ttl");
+ }
+ if (ttlStr != null) {
+ try {
+ ttl = Integer.parseInt(ttlStr);
+ } catch (NumberFormatException e) {
+ LOGGER.warn("SCRAM: invalid ttl value: {}", ttlStr);
+ }
+ }
+
+ String staleStr = Realm.Builder.matchParam(headerValue, "stale");
+ if (staleStr == null) {
+ staleStr = matchUnquotedToken(headerValue, "stale");
+ }
+ boolean stale = "true".equalsIgnoreCase(staleStr);
+
+ // Check for duplicate realm (RFC 7804 §5: MUST NOT appear more than once)
+ checkDuplicateRealm(headerValue);
+
+ return new ScramChallengeParams(realm, data, sid, sr, ttl, stale);
+ }
+
+ /**
+ * Match an unquoted token value in a header (e.g., sid=AAAABBBB).
+ */
+ private static @Nullable String matchUnquotedToken(String headerLine, String token) {
+ String prefix = token + "=";
+ int idx = headerLine.indexOf(prefix);
+ while (idx >= 0) {
+ // Verify the character before is a boundary
+ if (idx == 0 || headerLine.charAt(idx - 1) == ' ' || headerLine.charAt(idx - 1) == ',') {
+ int valStart = idx + prefix.length();
+ if (valStart < headerLine.length() && headerLine.charAt(valStart) != '"') {
+ int valEnd = valStart;
+ while (valEnd < headerLine.length() && headerLine.charAt(valEnd) != ',' && headerLine.charAt(valEnd) != ' ') {
+ valEnd++;
+ }
+ if (valEnd > valStart) {
+ return headerLine.substring(valStart, valEnd);
+ }
+ }
+ }
+ idx = headerLine.indexOf(prefix, idx + 1);
+ }
+ return null;
+ }
+
+ private static void checkDuplicateRealm(String headerValue) {
+ int first = indexOfParam(headerValue, "realm");
+ if (first >= 0) {
+ int second = indexOfParam(headerValue, "realm", first + 1);
+ if (second >= 0) {
+ LOGGER.warn("SCRAM: duplicate realm attribute detected (RFC 7804 §5: MUST NOT appear more than once)");
+ }
+ }
+ }
+
+ private static int indexOfParam(String headerValue, String param) {
+ return indexOfParam(headerValue, param, 0);
+ }
+
+ private static int indexOfParam(String headerValue, String param, int fromIndex) {
+ String search = param + "=";
+ int idx = headerValue.indexOf(search, fromIndex);
+ while (idx >= 0) {
+ if (idx == 0 || headerValue.charAt(idx - 1) == ' ' || headerValue.charAt(idx - 1) == ',') {
+ return idx;
+ }
+ idx = headerValue.indexOf(search, idx + 1);
+ }
+ return -1;
+ }
+
+ /**
+ * Validate that the full nonce starts with the client nonce.
+ */
+ public static void validateNoncePrefix(String clientNonce, String fullNonce) {
+ if (!fullNonce.startsWith(clientNonce)) {
+ throw new ScramException("Server nonce does not contain client nonce prefix");
+ }
+ if (fullNonce.length() <= clientNonce.length()) {
+ throw new ScramException("Server nonce part is empty");
+ }
+ }
+
+ /**
+ * Validate the gs2-header starts with "n" (no channel binding).
+ */
+ public static void validateGs2Header(String message) {
+ if (!message.startsWith("n,")) {
+ throw new ScramException("Invalid gs2-header: channel binding not supported, must start with 'n'");
+ }
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java
new file mode 100644
index 0000000000..ce93ca11ae
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramSessionCache.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Thread-safe cache for SCRAM reauthentication data (RFC 7804 §5.1).
+ * Keyed by (host, port, realm). Stores derived keys (NOT SaltedPassword).
+ */
+public class ScramSessionCache {
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+ private final int maxEntries;
+
+ public ScramSessionCache() {
+ this(1000);
+ }
+
+ public ScramSessionCache(int maxEntries) {
+ this.maxEntries = maxEntries;
+ }
+
+ /**
+ * Cache key: (host, port, realm).
+ */
+ public static class CacheKey {
+ private final String host;
+ private final int port;
+ private final @Nullable String realm;
+
+ public CacheKey(String host, int port, @Nullable String realm) {
+ this.host = host;
+ this.port = port;
+ this.realm = realm;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof CacheKey)) return false;
+ CacheKey that = (CacheKey) o;
+ return port == that.port && host.equals(that.host) && Objects.equals(realm, that.realm);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(host, port, realm);
+ }
+ }
+
+ /**
+ * Cached session entry for reauthentication.
+ * SECURITY: No SaltedPassword stored — only derived keys.
+ */
+ public static class Entry {
+ public final @Nullable String realmName;
+ public final byte[] salt;
+ public final int iterationCount;
+ public final byte[] clientKey;
+ public final byte[] storedKey;
+ public final byte[] serverKey;
+ public volatile @Nullable String sr;
+ public volatile int ttl; // -1 = no expiration
+ public volatile long srTimestampNanos;
+ public final AtomicInteger nonceCount;
+ public final String originalServerFirstMessage;
+
+ public Entry(@Nullable String realmName, byte[] salt, int iterationCount,
+ byte[] clientKey, byte[] storedKey, byte[] serverKey,
+ @Nullable String sr, int ttl, long srTimestampNanos,
+ int initialNonceCount, String originalServerFirstMessage) {
+ this.realmName = realmName;
+ this.salt = salt;
+ this.iterationCount = iterationCount;
+ this.clientKey = clientKey;
+ this.storedKey = storedKey;
+ this.serverKey = serverKey;
+ this.sr = sr;
+ this.ttl = ttl;
+ this.srTimestampNanos = srTimestampNanos;
+ this.nonceCount = new AtomicInteger(initialNonceCount);
+ this.originalServerFirstMessage = originalServerFirstMessage;
+ }
+ }
+
+ public void put(CacheKey key, Entry entry) {
+ // Size-bounded eviction: remove oldest entry by sr timestamp
+ if (cache.size() >= maxEntries) {
+ CacheKey oldest = null;
+ long oldestTimestamp = Long.MAX_VALUE;
+ for (Map.Entry e : cache.entrySet()) {
+ if (e.getValue().srTimestampNanos < oldestTimestamp) {
+ oldestTimestamp = e.getValue().srTimestampNanos;
+ oldest = e.getKey();
+ }
+ }
+ if (oldest != null) {
+ cache.remove(oldest);
+ }
+ }
+ cache.put(key, entry);
+ }
+
+ public @Nullable Entry get(CacheKey key) {
+ return cache.get(key);
+ }
+
+ /**
+ * Update the server nonce (sr) after a stale response.
+ */
+ public void updateSr(CacheKey key, String newSr, int newTtl) {
+ Entry entry = cache.get(key);
+ if (entry != null) {
+ entry.sr = newSr;
+ entry.ttl = newTtl;
+ entry.srTimestampNanos = System.nanoTime();
+ }
+ }
+
+ /**
+ * Check if the sr value is still fresh.
+ * If ttl == -1: always fresh (no expiration).
+ * If ttl >= 0: check elapsed time since sr was received.
+ */
+ public boolean isSrFresh(Entry entry) {
+ if (entry.sr == null) {
+ return false;
+ }
+ if (entry.ttl < 0) {
+ return true; // No expiration — rely on server stale=true
+ }
+ long elapsedNanos = System.nanoTime() - entry.srTimestampNanos;
+ long ttlNanos = (long) entry.ttl * 1_000_000_000L;
+ return elapsedNanos < ttlNanos;
+ }
+
+ /**
+ * Atomically reserve and increment the nonce-count for use in a request.
+ * Call rollbackNonceCount on failure to undo.
+ */
+ public int reserveNonceCount(CacheKey key) {
+ Entry entry = cache.get(key);
+ if (entry == null) {
+ return -1;
+ }
+ return entry.nonceCount.getAndIncrement();
+ }
+
+ /**
+ * Confirm nonce-count after successful reauthentication. No-op since already incremented.
+ */
+ public void confirmNonceCount(CacheKey key) {
+ // No-op: nonce-count was already atomically incremented in reserveNonceCount
+ }
+
+ /**
+ * Rollback nonce-count on failure or stale response by decrementing.
+ */
+ public void rollbackNonceCount(CacheKey key) {
+ Entry entry = cache.get(key);
+ if (entry != null) {
+ entry.nonceCount.decrementAndGet();
+ }
+ }
+
+ public void clear() {
+ cache.clear();
+ }
+
+ public int size() {
+ return cache.size();
+ }
+}
diff --git a/client/src/main/java/org/asynchttpclient/scram/ScramState.java b/client/src/main/java/org/asynchttpclient/scram/ScramState.java
new file mode 100644
index 0000000000..eb81b11e0c
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/scram/ScramState.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+/**
+ * States for a SCRAM authentication exchange (RFC 7804).
+ */
+public enum ScramState {
+ INITIAL,
+ CLIENT_FIRST_SENT,
+ SERVER_FIRST_RECEIVED,
+ CLIENT_FINAL_SENT,
+ AUTHENTICATED,
+ FAILED
+}
diff --git a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java
index fb3a77a83f..444ef85032 100644
--- a/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java
+++ b/client/src/main/java/org/asynchttpclient/util/AuthenticatorUtils.java
@@ -511,6 +511,9 @@ private static void append(StringBuilder builder, String name, @Nullable String
proxyAuthorization = "NTLM " + msg;
}
+ break;
+ case SCRAM_SHA_256:
+ // SCRAM auth is per-request, not per-connection
break;
default:
}
@@ -550,6 +553,9 @@ private static void append(StringBuilder builder, String name, @Nullable String
// NTLM, KERBEROS and SPNEGO are only set on the first request with a connection,
// see perConnectionProxyAuthorizationHeader
break;
+ case SCRAM_SHA_256:
+ // SCRAM reauthentication for proxy — handled via interceptor
+ break;
default:
throw new IllegalStateException("Invalid Authentication scheme " + proxyRealm.getScheme());
}
@@ -592,6 +598,9 @@ private static void append(StringBuilder builder, String name, @Nullable String
throw new RuntimeException(e);
}
break;
+ case SCRAM_SHA_256:
+ // SCRAM auth is per-request, not per-connection
+ break;
default:
break;
}
@@ -630,6 +639,9 @@ private static void append(StringBuilder builder, String name, @Nullable String
// NTLM, KERBEROS and SPNEGO are only set on the first request with a connection,
// see perConnectionAuthorizationHeader
break;
+ case SCRAM_SHA_256:
+ // SCRAM reauthentication — handled via interceptor
+ break;
default:
throw new IllegalStateException("Invalid Authentication " + realm);
}
diff --git a/client/src/test/java/org/asynchttpclient/ScramAuthTest.java b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java
new file mode 100644
index 0000000000..65810161f5
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/ScramAuthTest.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient;
+
+import io.github.artsok.RepeatedIfExceptionsTest;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.asynchttpclient.scram.ScramEngine;
+import org.asynchttpclient.scram.ScramMessageParser;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.asynchttpclient.Dsl.asyncHttpClient;
+import static org.asynchttpclient.Dsl.proxyServer;
+import static org.asynchttpclient.Dsl.scramSha256AuthRealm;
+import static org.asynchttpclient.test.TestUtils.ADMIN;
+import static org.asynchttpclient.test.TestUtils.USER;
+import static org.asynchttpclient.test.TestUtils.addHttpConnector;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class ScramAuthTest extends AbstractBasicTest {
+
+ @Override
+ @BeforeEach
+ public void setUpGlobal() throws Exception {
+ server = new Server();
+ ServerConnector connector = addHttpConnector(server);
+ server.setHandler(new ScramAuthHandler());
+ server.start();
+ port1 = connector.getLocalPort();
+ logger.info("Local HTTP server started successfully");
+ }
+
+ @Override
+ public AbstractHandler configureHandler() throws Exception {
+ return new ScramAuthHandler();
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_fullExchange() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build())
+ .execute();
+ Response resp = f.get(60, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ assertEquals(200, resp.getStatusCode());
+ assertNotNull(resp.getHeader("X-Auth"));
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_wrongPassword() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, "wrongpassword").setRealmName("ScramRealm").build())
+ .execute();
+ Response resp = f.get(20, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ assertEquals(401, resp.getStatusCode());
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_maxIterationCount() throws Exception {
+ // Server uses 4096 iterations, client max is 100 — should fail
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, ADMIN)
+ .setRealmName("ScramRealm")
+ .setMaxIterationCount(100) // Lower than server's 4096
+ .build())
+ .execute();
+ Response resp = f.get(20, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ // Should get 401 because client aborts the exchange
+ assertEquals(401, resp.getStatusCode());
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_missingAuthInfo() throws Exception {
+ // Test with handler that doesn't send Authentication-Info
+ server.stop();
+ server = new Server();
+ ServerConnector connector = addHttpConnector(server);
+ server.setHandler(new ScramAuthHandler(false)); // no auth-info
+ server.start();
+ port1 = connector.getLocalPort();
+
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build())
+ .execute();
+ Response resp = f.get(60, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ assertEquals(200, resp.getStatusCode());
+ // 200 without Authentication-Info — warning logged, but request succeeds
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_malformedBase64InData() throws Exception {
+ server.stop();
+ server = new Server();
+ ServerConnector connector = addHttpConnector(server);
+ server.setHandler(new MalformedBase64ScramHandler());
+ server.start();
+ port1 = connector.getLocalPort();
+
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build())
+ .execute();
+ Response resp = f.get(20, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ // Client should handle malformed base64 gracefully — no crash, returns 401
+ assertEquals(401, resp.getStatusCode());
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_quotedDataAttribute() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost:" + port1 + '/')
+ .setRealm(scramSha256AuthRealm(USER, ADMIN).setRealmName("ScramRealm").build())
+ .execute();
+ Response resp = f.get(60, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ assertEquals(200, resp.getStatusCode());
+ // The handler validates that data is properly quoted (Erratum 6558)
+ assertEquals("quoted", resp.getHeader("X-Data-Quoted"));
+ }
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testScramSha256_proxyFullExchange() throws Exception {
+ server.stop();
+ server = new Server();
+ ServerConnector connector = addHttpConnector(server);
+ server.setHandler(new ScramProxyHandler());
+ server.start();
+ int proxyPort = connector.getLocalPort();
+
+ try (AsyncHttpClient client = asyncHttpClient()) {
+ Future f = client.prepareGet("http://localhost/")
+ .setProxyServer(proxyServer("localhost", proxyPort)
+ .setRealm(scramSha256AuthRealm(USER, ADMIN)
+ .setRealmName("ScramRealm")
+ .build())
+ .build())
+ .execute();
+ Response resp = f.get(60, TimeUnit.SECONDS);
+ assertNotNull(resp);
+ assertEquals(200, resp.getStatusCode());
+ }
+ }
+
+ /**
+ * Server-side SCRAM-SHA-256 authenticator for testing.
+ * Implements the full 3-step SCRAM handshake:
+ * 1. Initial 401 with SCRAM challenge
+ * 2. Second 401 with server-first-message
+ * 3. 200 with Authentication-Info (server-final-message)
+ */
+ private static class ScramAuthHandler extends AbstractHandler {
+
+ private static final String REALM = "ScramRealm";
+ private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ==");
+ private static final int ITERATIONS = 4096;
+
+ private final boolean sendAuthInfo;
+
+ // Session tracking: sid → ServerSession
+ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ ScramAuthHandler() {
+ this(true);
+ }
+
+ ScramAuthHandler(boolean sendAuthInfo) {
+ this.sendAuthInfo = sendAuthInfo;
+ }
+
+ static class ServerSession {
+ final String fullNonce;
+ final String clientFirstBare;
+
+ ServerSession(String clientNonce, String serverNoncePart, String clientFirstBare) {
+ this.fullNonce = clientNonce + serverNoncePart;
+ this.clientFirstBare = clientFirstBare;
+ }
+ }
+
+ @Override
+ public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ String authz = request.getHeader("Authorization");
+
+ if (authz == null) {
+ // Step 1: Send initial challenge
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 realm=\"" + REALM + "\"");
+ response.getOutputStream().close();
+ return;
+ }
+
+ if (!authz.startsWith("SCRAM-SHA-256")) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz);
+
+ // Check if data attribute was properly quoted (Erratum 6558)
+ boolean dataQuoted = authz.contains("data=\"");
+ response.setHeader("X-Data-Quoted", dataQuoted ? "quoted" : "unquoted");
+
+ if (params.sid == null && params.data != null) {
+ // Step 2: Process client-first, send server-first
+ String clientFirstFull = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+
+ // Validate gs2-header
+ if (!clientFirstFull.startsWith("n,,")) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Parse client-first-message
+ String clientFirstBare = clientFirstFull.substring(3); // Remove "n,,"
+ String clientNonce = null;
+ String clientUsername = null;
+ for (String part : clientFirstBare.split(",")) {
+ if (part.startsWith("r=")) {
+ clientNonce = part.substring(2);
+ } else if (part.startsWith("n=")) {
+ clientUsername = part.substring(2);
+ }
+ }
+
+ if (clientNonce == null || clientUsername == null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Verify username
+ if (!USER.equals(clientUsername.replace("=3D", "=").replace("=2C", ","))) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Generate server nonce and sid
+ String serverNoncePart = ScramEngine.generateNonce(18);
+ String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+
+ // Store session
+ sessions.put(sid, new ServerSession(clientNonce, serverNoncePart, clientFirstBare));
+
+ // Build server-first-message
+ String fullNonce = clientNonce + serverNoncePart;
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ String base64ServerFirst = Base64.getEncoder().encodeToString(serverFirstMsg.getBytes(StandardCharsets.UTF_8));
+
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 sid=" + sid + ", data=\"" + base64ServerFirst + "\"");
+ response.getOutputStream().close();
+
+ } else if (params.sid != null && params.data != null) {
+ // Step 3: Process client-final, validate proof, send server-final
+ ServerSession session = sessions.remove(params.sid);
+ if (session == null) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ String clientFinalMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+
+ // Parse client-final
+ String clientFinalNonce = null;
+ String clientProofBase64 = null;
+ for (String part : clientFinalMsg.split(",")) {
+ if (part.startsWith("r=")) {
+ clientFinalNonce = part.substring(2);
+ } else if (part.startsWith("p=")) {
+ clientProofBase64 = part.substring(2);
+ }
+ }
+
+ if (clientFinalNonce == null || clientProofBase64 == null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Validate nonce
+ if (!clientFinalNonce.equals(session.fullNonce)) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Compute server-side values to verify client proof
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(ADMIN, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+
+ // Build AuthMessage
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + session.fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ String clientFinalWithoutProof = "c=biws,r=" + session.fullNonce;
+ String authMessage = session.clientFirstBare + "," + serverFirstMsg + "," + clientFinalWithoutProof;
+
+ // Verify client proof
+ byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage);
+ byte[] receivedProof = Base64.getDecoder().decode(clientProofBase64);
+ byte[] recoveredClientKey = ScramEngine.xor(receivedProof.clone(), clientSignature);
+
+ // StoredKey = H(ClientKey) — verify
+ byte[] recoveredStoredKey = ScramEngine.hash(recoveredClientKey);
+ boolean proofValid = Arrays.equals(storedKey, recoveredStoredKey);
+
+ ScramEngine.zeroBytes(saltedPassword);
+
+ if (!proofValid) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ // Compute ServerSignature for Authentication-Info
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature);
+ String base64ServerFinal = Base64.getEncoder().encodeToString(serverFinal.getBytes(StandardCharsets.UTF_8));
+
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.addHeader("X-Auth", authz);
+ if (sendAuthInfo) {
+ response.setHeader("Authentication-Info", "sid=" + params.sid + ", data=\"" + base64ServerFinal + "\"");
+ }
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+
+ } else {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.getOutputStream().close();
+ }
+ }
+ }
+
+ /**
+ * Proxy-side SCRAM-SHA-256 authenticator for testing the 407 proxy authentication flow.
+ * Uses Proxy-Authorization/Proxy-Authenticate headers and 407 status codes.
+ */
+ private static class ScramProxyHandler extends AbstractHandler {
+
+ private static final String REALM = "ScramRealm";
+ private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ==");
+ private static final int ITERATIONS = 4096;
+
+ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ @Override
+ public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ String authz = request.getHeader("Proxy-Authorization");
+
+ if (authz == null) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setHeader("Proxy-Authenticate", "SCRAM-SHA-256 realm=\"" + REALM + "\"");
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ if (!authz.startsWith("SCRAM-SHA-256")) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz);
+
+ if (params.sid == null && params.data != null) {
+ String clientFirstFull = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+
+ if (!clientFirstFull.startsWith("n,,")) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ String clientFirstBare = clientFirstFull.substring(3);
+ String clientNonce = null;
+ String clientUsername = null;
+ for (String part : clientFirstBare.split(",")) {
+ if (part.startsWith("r=")) {
+ clientNonce = part.substring(2);
+ } else if (part.startsWith("n=")) {
+ clientUsername = part.substring(2);
+ }
+ }
+
+ if (clientNonce == null || clientUsername == null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ if (!USER.equals(clientUsername.replace("=3D", "=").replace("=2C", ","))) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ String serverNoncePart = ScramEngine.generateNonce(18);
+ String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+
+ sessions.put(sid, new ScramAuthHandler.ServerSession(clientNonce, serverNoncePart, clientFirstBare));
+
+ String fullNonce = clientNonce + serverNoncePart;
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ String base64ServerFirst = Base64.getEncoder().encodeToString(serverFirstMsg.getBytes(StandardCharsets.UTF_8));
+
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setHeader("Proxy-Authenticate", "SCRAM-SHA-256 sid=" + sid + ", data=\"" + base64ServerFirst + "\"");
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+
+ } else if (params.sid != null && params.data != null) {
+ ScramAuthHandler.ServerSession session = sessions.remove(params.sid);
+ if (session == null) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ String clientFinalMsg = new String(Base64.getDecoder().decode(params.data), StandardCharsets.UTF_8);
+
+ String clientFinalNonce = null;
+ String clientProofBase64 = null;
+ for (String part : clientFinalMsg.split(",")) {
+ if (part.startsWith("r=")) {
+ clientFinalNonce = part.substring(2);
+ } else if (part.startsWith("p=")) {
+ clientProofBase64 = part.substring(2);
+ }
+ }
+
+ if (clientFinalNonce == null || clientProofBase64 == null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ if (!clientFinalNonce.equals(session.fullNonce)) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(ADMIN, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + session.fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ String clientFinalWithoutProof = "c=biws,r=" + session.fullNonce;
+ String authMessage = session.clientFirstBare + "," + serverFirstMsg + "," + clientFinalWithoutProof;
+
+ byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage);
+ byte[] receivedProof = Base64.getDecoder().decode(clientProofBase64);
+ byte[] recoveredClientKey = ScramEngine.xor(receivedProof.clone(), clientSignature);
+
+ byte[] recoveredStoredKey = ScramEngine.hash(recoveredClientKey);
+ boolean proofValid = Arrays.equals(storedKey, recoveredStoredKey);
+
+ ScramEngine.zeroBytes(saltedPassword);
+
+ if (!proofValid) {
+ response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ return;
+ }
+
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature);
+ String base64ServerFinal = Base64.getEncoder().encodeToString(serverFinal.getBytes(StandardCharsets.UTF_8));
+
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setHeader("Proxy-Authentication-Info", "sid=" + params.sid + ", data=\"" + base64ServerFinal + "\"");
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+
+ } else {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ response.setContentLength(0);
+ response.getOutputStream().flush();
+ response.getOutputStream().close();
+ }
+ }
+ }
+
+ /**
+ * Handler that sends malformed base64 in the SCRAM data attribute (step 2).
+ * Used to test that the client handles IllegalArgumentException from Base64 gracefully.
+ */
+ private static class MalformedBase64ScramHandler extends AbstractHandler {
+
+ @Override
+ public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ String authz = request.getHeader("Authorization");
+
+ if (authz == null) {
+ // Step 1: Send initial challenge
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", "SCRAM-SHA-256 realm=\"ScramRealm\"");
+ response.getOutputStream().close();
+ return;
+ }
+
+ if (!authz.startsWith("SCRAM-SHA-256")) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ return;
+ }
+
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(authz);
+
+ if (params.sid == null && params.data != null) {
+ // Step 2: Send malformed base64 in data attribute
+ String sid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate",
+ "SCRAM-SHA-256 sid=" + sid + ", data=\"!!!invalid-base64!!!\"");
+ response.getOutputStream().close();
+ } else {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getOutputStream().close();
+ }
+ }
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java
new file mode 100644
index 0000000000..b73eb4ef55
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramContextTest.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScramContextTest {
+
+ private static final String USERNAME = "user";
+ private static final String PASSWORD = "pencil";
+ private static final String REALM = "testrealm@example.com";
+ private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ==");
+ private static final int ITERATIONS = 4096;
+ private static final int MAX_ITERATIONS = 16_384;
+
+ @Test
+ void testFullExchange_SHA256() {
+ // Step 1: Client-first
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+
+ assertEquals(ScramState.CLIENT_FIRST_SENT, ctx.getState());
+ assertEquals("SCRAM-SHA-256", ctx.getMechanism());
+ assertEquals(USERNAME, ctx.getUsername());
+ assertEquals(REALM, ctx.getRealmName());
+
+ // Verify client-first-message format
+ String clientFirst = ctx.getClientFirstMessage();
+ assertTrue(clientFirst.startsWith("n,,n=user,r="));
+ String clientFirstBare = ctx.getClientFirstMessageBare();
+ assertTrue(clientFirstBare.startsWith("n=user,r="));
+ assertEquals("n,," + clientFirstBare, clientFirst);
+
+ // Step 2: Server-first — build a valid server-first-message
+ String clientNonce = ctx.getClientNonce();
+ String serverNoncePart = "srvr%hvYDpWUa2RaTCAfuxFIlj)hNlF";
+ String fullNonce = clientNonce + serverNoncePart;
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+
+ ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS);
+
+ assertEquals(ScramState.SERVER_FIRST_RECEIVED, ctx.getState());
+ assertEquals(fullNonce, ctx.getServerNonce());
+ assertNotNull(ctx.getClientKey());
+ assertNotNull(ctx.getStoredKey());
+ assertNotNull(ctx.getServerKey());
+
+ // Step 3: Client-final
+ String clientFinal = ctx.computeClientFinal();
+ assertEquals(ScramState.CLIENT_FINAL_SENT, ctx.getState());
+ assertTrue(clientFinal.startsWith("c=biws,r=" + fullNonce + ",p="));
+
+ // Step 4: Verify server-final
+ // Compute expected ServerSignature
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+ String authMessage = clientFirstBare + "," + serverFirstMsg + ","
+ + ScramMessageFormatter.clientFinalMessageWithoutProof(fullNonce);
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature);
+
+ assertTrue(ctx.verifyServerFinal(serverFinal));
+ assertEquals(ScramState.AUTHENTICATED, ctx.getState());
+
+ ScramEngine.zeroBytes(saltedPassword);
+ }
+
+ @Test
+ void testProcessServerFirst_iterationCountTooHigh() {
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ String clientNonce = ctx.getClientNonce();
+ String serverFirstMsg = "r=" + clientNonce + "srvr,s=c2FsdA==,i=100000";
+
+ assertThrows(ScramException.class, () -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS));
+ }
+
+ @Test
+ void testProcessServerFirst_nonceNotPrefixed() {
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ // Server nonce doesn't start with client nonce
+ String serverFirstMsg = "r=wrongPrefix,s=c2FsdA==,i=4096";
+
+ assertThrows(ScramException.class, () -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS));
+ }
+
+ @Test
+ void testVerifyServerFinal_valid() {
+ ScramContext ctx = createAuthenticatedContext();
+ // Already verified in full exchange test — just verify state
+ assertEquals(ScramState.AUTHENTICATED, ctx.getState());
+ }
+
+ @Test
+ void testVerifyServerFinal_invalid() {
+ ScramContext ctx = createContextAtClientFinalSent();
+ // Wrong server signature
+ assertFalse(ctx.verifyServerFinal("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="));
+ assertEquals(ScramState.FAILED, ctx.getState());
+ }
+
+ @Test
+ void testVerifyServerFinal_serverError() {
+ ScramContext ctx = createContextAtClientFinalSent();
+ assertFalse(ctx.verifyServerFinal("e=invalid-proof"));
+ assertEquals(ScramState.FAILED, ctx.getState());
+ }
+
+ @Test
+ void testProcessServerFirst_minimumIterationCount() {
+ // RFC 5802 doesn't specify a minimum iteration count.
+ // Server sends i=1 — should be accepted (documents that no minimum is enforced).
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ String clientNonce = ctx.getClientNonce();
+ String fullNonce = clientNonce + "serverpart";
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=1";
+
+ // Should not throw — i=1 is accepted
+ assertDoesNotThrow(() -> ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS));
+ assertEquals(ScramState.SERVER_FIRST_RECEIVED, ctx.getState());
+ assertEquals(1, ctx.getIterationCount());
+ }
+
+ @Test
+ void testGetClientFirstMessage_includesGs2Header() {
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ assertTrue(ctx.getClientFirstMessage().startsWith("n,,"));
+ }
+
+ @Test
+ void testToSessionCacheEntry() {
+ ScramContext ctx = createAuthenticatedContext();
+ ScramSessionCache.Entry entry = ctx.toSessionCacheEntry("serverNonce123", 300);
+ assertEquals(REALM, entry.realmName);
+ assertNotNull(entry.clientKey);
+ assertNotNull(entry.storedKey);
+ assertNotNull(entry.serverKey);
+ assertEquals("serverNonce123", entry.sr);
+ assertEquals(300, entry.ttl);
+ assertEquals(ITERATIONS, entry.nonceCount.get());
+ assertNotNull(entry.originalServerFirstMessage);
+ }
+
+ // Helper methods
+
+ private ScramContext createContextAtClientFinalSent() {
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ String clientNonce = ctx.getClientNonce();
+ String fullNonce = clientNonce + "serverpart";
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS);
+ ctx.computeClientFinal();
+ return ctx;
+ }
+
+ private ScramContext createAuthenticatedContext() {
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM, "SCRAM-SHA-256");
+ String clientNonce = ctx.getClientNonce();
+ String fullNonce = clientNonce + "serverpart";
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+ String serverFirstMsg = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+
+ ctx.processServerFirst(serverFirstMsg, MAX_ITERATIONS);
+ ctx.computeClientFinal();
+
+ // Compute correct server signature
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+ String authMessage = ctx.getClientFirstMessageBare() + "," + serverFirstMsg + ","
+ + ctx.getClientFinalMessageWithoutProof();
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature);
+ ctx.verifyServerFinal(serverFinal);
+ ScramEngine.zeroBytes(saltedPassword);
+ return ctx;
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java
new file mode 100644
index 0000000000..c2cf84ea3e
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramEngineTest.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScramEngineTest {
+
+ // RFC 5802 test vector parameters
+ private static final String PASSWORD = "pencil";
+ private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ==");
+ private static final int ITERATIONS = 4096;
+
+ @Test
+ void testHi_SHA256_RFC5802Vector() {
+ // Verify PBKDF2 / Hi() with SHA-256 produces expected output for "pencil" with known salt
+ byte[] result = ScramEngine.hi(PASSWORD, SALT, ITERATIONS);
+ assertNotNull(result);
+ assertEquals(32, result.length); // SHA-256 produces 32 bytes
+ }
+
+ @Test
+ void testHmac_SHA256() {
+ byte[] key = "test-key".getBytes(StandardCharsets.US_ASCII);
+ byte[] data = "test-data".getBytes(StandardCharsets.US_ASCII);
+ byte[] result = ScramEngine.hmac(key, data);
+ assertNotNull(result);
+ assertEquals(32, result.length);
+ }
+
+ @Test
+ void testXor_equalLength() {
+ byte[] a = {0x01, 0x02, 0x03, 0x04};
+ byte[] b = {0x05, 0x06, 0x07, 0x08};
+ byte[] result = ScramEngine.xor(a, b);
+ assertArrayEquals(new byte[]{0x04, 0x04, 0x04, 0x0C}, result);
+ // xor mutates first array in-place
+ assertSame(a, result);
+ }
+
+ @Test
+ void testXor_unequalLength_throws() {
+ byte[] a = {0x01, 0x02};
+ byte[] b = {0x01, 0x02, 0x03};
+ assertThrows(ScramException.class, () -> ScramEngine.xor(a, b));
+ }
+
+ @Test
+ void testComputeSaltedPassword() {
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ assertNotNull(saltedPassword);
+ assertEquals(32, saltedPassword.length);
+ }
+
+ @Test
+ void testComputeClientKey() {
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ assertNotNull(clientKey);
+ assertEquals(32, clientKey.length);
+ }
+
+ @Test
+ void testComputeStoredKey() {
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ assertNotNull(storedKey);
+ assertEquals(32, storedKey.length);
+ }
+
+ @Test
+ void testComputeServerKey() {
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+ assertNotNull(serverKey);
+ assertEquals(32, serverKey.length);
+ }
+
+ @Test
+ void testComputeClientProof() {
+ byte[] clientKey = {0x01, 0x02, 0x03, 0x04};
+ byte[] clientSignature = {0x05, 0x06, 0x07, 0x08};
+ byte[] proof = ScramEngine.computeClientProof(clientKey, clientSignature);
+ // XOR of {01,02,03,04} and {05,06,07,08} = {04,04,04,0C}
+ assertArrayEquals(new byte[]{0x04, 0x04, 0x04, 0x0C}, proof);
+ // Original clientKey should NOT be modified (clone is used)
+ assertArrayEquals(new byte[]{0x01, 0x02, 0x03, 0x04}, clientKey);
+ }
+
+ @Test
+ void testComputeServerSignature() {
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+ String authMessage = "n=user,r=rOprNGfwEbeRWgbNEkqO,r=rOprNGfwEbeRWgbNEkqOsrvr,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,c=biws,r=rOprNGfwEbeRWgbNEkqOsrvr";
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ assertNotNull(serverSignature);
+ assertEquals(32, serverSignature.length);
+ }
+
+ @Test
+ void testNormalizePassword_ascii() {
+ assertEquals("pencil", ScramEngine.normalizePassword("pencil"));
+ assertEquals("hello world", ScramEngine.normalizePassword("hello world"));
+ }
+
+ @Test
+ void testNormalizePassword_nonAscii_NFC() {
+ // U+00E9 (é) is already NFC, should pass through
+ assertEquals("\u00E9", ScramEngine.normalizePassword("\u00E9"));
+ // U+0065 U+0301 (e + combining accent) should be normalized to U+00E9
+ assertEquals("\u00E9", ScramEngine.normalizePassword("e\u0301"));
+ }
+
+ @Test
+ void testNormalizePassword_nonAsciiSpaces() {
+ // U+00A0 (non-breaking space) → U+0020
+ assertEquals("hello world", ScramEngine.normalizePassword("hello\u00A0world"));
+ }
+
+ @Test
+ void testNormalizePassword_controlChars_rejected() {
+ // U+0000 (NUL) should be rejected
+ assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u0000word"));
+ // U+0001 (SOH) should be rejected
+ assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u0001word"));
+ // U+007F (DEL) should be rejected
+ assertThrows(ScramException.class, () -> ScramEngine.normalizePassword("pass\u007Fword"));
+ }
+
+ @Test
+ void testNormalizePassword_tabAndNewlineAllowed() {
+ // HT (U+0009), LF (U+000A), CR (U+000D) are allowed
+ assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\tword"));
+ assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\nword"));
+ assertDoesNotThrow(() -> ScramEngine.normalizePassword("pass\rword"));
+ }
+
+ @Test
+ void testNormalizePassword_vulgarFraction() {
+ // U+00BD (½) - NFC preserves it (unlike NFKC which would decompose to "1/2")
+ String result = ScramEngine.normalizePassword("\u00BD");
+ assertEquals("\u00BD", result);
+ }
+
+ @Test
+ void testNormalizePassword_widthPreserved() {
+ // U+FF21 (fullwidth 'A') - OpaqueString preserves fullwidth chars
+ String result = ScramEngine.normalizePassword("\uFF21");
+ assertEquals("\uFF21", result);
+ }
+
+ @Test
+ void testZeroBytes() {
+ byte[] data = {1, 2, 3, 4, 5};
+ ScramEngine.zeroBytes(data);
+ assertArrayEquals(new byte[]{0, 0, 0, 0, 0}, data);
+ }
+
+ @Test
+ void testZeroBytes_null() {
+ // Should not throw
+ assertDoesNotThrow(() -> ScramEngine.zeroBytes(null));
+ }
+
+ @Test
+ void testGenerateNonce() {
+ String nonce1 = ScramEngine.generateNonce(24);
+ String nonce2 = ScramEngine.generateNonce(24);
+ assertNotNull(nonce1);
+ assertNotNull(nonce2);
+ assertNotEquals(nonce1, nonce2); // cryptographically random should differ
+ // 24 bytes base64 = 32 chars
+ assertEquals(32, nonce1.length());
+ }
+
+ @Test
+ void testHi_passwordIsString() {
+ // Verify Hi accepts String and UTF-8 encodes internally
+ byte[] result = ScramEngine.hi("p\u00E4ssw\u00F6rd", SALT, ITERATIONS);
+ assertNotNull(result);
+ assertEquals(32, result.length);
+ }
+
+ @Test
+ void testHash_SHA256() {
+ byte[] data = "test".getBytes(StandardCharsets.US_ASCII);
+ byte[] hash = ScramEngine.hash(data);
+ assertEquals(32, hash.length);
+ }
+
+ @Test
+ void testFullKeyDerivation_SHA256() {
+ // Verify the full key derivation chain works end-to-end
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+
+ // All should be 32 bytes (SHA-256)
+ assertEquals(32, saltedPassword.length);
+ assertEquals(32, clientKey.length);
+ assertEquals(32, storedKey.length);
+ assertEquals(32, serverKey.length);
+
+ // StoredKey = H(ClientKey) should be deterministic
+ byte[] storedKey2 = ScramEngine.hash(clientKey);
+ assertArrayEquals(storedKey, storedKey2);
+
+ // Clean up
+ ScramEngine.zeroBytes(saltedPassword);
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java
new file mode 100644
index 0000000000..67b468181d
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramMessageFormatterTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScramMessageFormatterTest {
+
+ @Test
+ void testFormatClientFirst() {
+ String result = ScramMessageFormatter.formatClientFirstMessage("user", "rOprNGfwEbeRWgbNEkqO");
+ assertEquals("n,,n=user,r=rOprNGfwEbeRWgbNEkqO", result);
+ }
+
+ @Test
+ void testFormatClientFirst_usernameEscaping() {
+ // "=" → "=3D", "," → "=2C"
+ String result = ScramMessageFormatter.formatClientFirstMessage("us=er,name", "nonce123");
+ assertEquals("n,,n=us=3Der=2Cname,r=nonce123", result);
+ }
+
+ @Test
+ void testClientFirstMessageBare() {
+ String result = ScramMessageFormatter.clientFirstMessageBare("user", "rOprNGfwEbeRWgbNEkqO");
+ assertEquals("n=user,r=rOprNGfwEbeRWgbNEkqO", result);
+ }
+
+ @Test
+ void testGetClientFirstMessage_includesGs2Header() {
+ String full = ScramMessageFormatter.formatClientFirstMessage("user", "nonce");
+ assertTrue(full.startsWith("n,,"), "Full message must start with gs2-header 'n,,'");
+ String bare = ScramMessageFormatter.clientFirstMessageBare("user", "nonce");
+ assertEquals("n,," + bare, full);
+ }
+
+ @Test
+ void testFormatClientFinal() {
+ byte[] proof = {0x01, 0x02, 0x03, 0x04};
+ String result = ScramMessageFormatter.formatClientFinalMessage("fullNonce123", proof);
+ String expectedProof = Base64.getEncoder().encodeToString(proof);
+ assertEquals("c=biws,r=fullNonce123,p=" + expectedProof, result);
+ }
+
+ @Test
+ void testClientFinalMessageWithoutProof() {
+ String result = ScramMessageFormatter.clientFinalMessageWithoutProof("fullNonce123");
+ assertEquals("c=biws,r=fullNonce123", result);
+ }
+
+ @Test
+ void testFormatAuthorizationHeader_initial() {
+ // Erratum 6558: data attribute MUST be quoted
+ String result = ScramMessageFormatter.formatAuthorizationHeader(
+ "SCRAM-SHA-256", "testrealm@example.com", null, "biwsbj11c2VyLHI9bm9uY2U=");
+ assertEquals("SCRAM-SHA-256 realm=\"testrealm@example.com\", data=\"biwsbj11c2VyLHI9bm9uY2U=\"", result);
+ // Verify data is quoted
+ assertTrue(result.contains("data=\""));
+ }
+
+ @Test
+ void testFormatAuthorizationHeader_final() {
+ String result = ScramMessageFormatter.formatAuthorizationHeader(
+ "SCRAM-SHA-256", null, "AAAABBBB", "Yz1iaXdzLHI9bm9uY2U=");
+ assertEquals("SCRAM-SHA-256 sid=AAAABBBB, data=\"Yz1iaXdzLHI9bm9uY2U=\"", result);
+ }
+
+ @Test
+ void testFormatAuthorizationHeader_reauth() {
+ String result = ScramMessageFormatter.formatAuthorizationHeader(
+ "SCRAM-SHA-256", "myrealm", null, "base64data");
+ assertEquals("SCRAM-SHA-256 realm=\"myrealm\", data=\"base64data\"", result);
+ }
+
+ @Test
+ void testEscapeUsername() {
+ assertEquals("user", ScramMessageFormatter.escapeUsername("user"));
+ assertEquals("us=3Der", ScramMessageFormatter.escapeUsername("us=er"));
+ assertEquals("us=2Cer", ScramMessageFormatter.escapeUsername("us,er"));
+ assertEquals("=3D=2C", ScramMessageFormatter.escapeUsername("=,"));
+ }
+
+ @Test
+ void testBase64_standardAlphabet() {
+ // Verify base64 uses standard alphabet (+/) not URL-safe (-_)
+ byte[] data = {(byte) 0xFB, (byte) 0xEF, (byte) 0xBE};
+ String encoded = Base64.getEncoder().encodeToString(data);
+ assertTrue(encoded.contains("+") || encoded.contains("/"),
+ "Standard base64 should use +/ characters, not URL-safe -_");
+ assertFalse(encoded.contains("-"), "Must not use URL-safe base64");
+ assertFalse(encoded.contains("_"), "Must not use URL-safe base64");
+ }
+
+ @Test
+ void testChannelBindingValue() {
+ // "c=biws" is base64 of "n,," (the gs2-header for no channel binding)
+ String decoded = new String(Base64.getDecoder().decode("biws"));
+ assertEquals("n,,", decoded);
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java
new file mode 100644
index 0000000000..6f0b1e414a
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramMessageParserTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScramMessageParserTest {
+
+ @Test
+ void testParseServerFirst_valid() {
+ String message = "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
+ ScramMessageParser.ServerFirstMessage result = ScramMessageParser.parseServerFirst(message);
+ assertEquals("rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF", result.fullNonce);
+ assertArrayEquals(Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ=="), result.salt);
+ assertEquals(4096, result.iterationCount);
+ }
+
+ @Test
+ void testParseServerFirst_missingNonce() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"));
+ }
+
+ @Test
+ void testParseServerFirst_missingSalt() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=rOprNGfwEbeRWgbNEkqOsrvr,i=4096"));
+ }
+
+ @Test
+ void testParseServerFirst_missingIteration() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=rOprNGfwEbeRWgbNEkqOsrvr,s=W22ZaJ0SNY7soEsUEjb6gQ=="));
+ }
+
+ @Test
+ void testParseServerFirst_withExtensions() {
+ // Extensions after i= should be tolerated
+ String message = "r=nonce123srvr,s=c2FsdA==,i=4096,ext=value";
+ ScramMessageParser.ServerFirstMessage result = ScramMessageParser.parseServerFirst(message);
+ assertEquals("nonce123srvr", result.fullNonce);
+ assertEquals(4096, result.iterationCount);
+ }
+
+ @Test
+ void testParseServerFirst_invalidIterationCount() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=nonce,s=c2FsdA==,i=abc"));
+ }
+
+ @Test
+ void testParseServerFirst_zeroIterationCount() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=nonce,s=c2FsdA==,i=0"));
+ }
+
+ @Test
+ void testParseServerFirst_invalidBase64Salt() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=nonce,s=!!!invalid!!!,i=4096"));
+ }
+
+ @Test
+ void testParseServerFinal_verifier() {
+ String message = "v=rmF9pqV8S7suAoZWja4dJRkFsKQ=";
+ ScramMessageParser.ServerFinalMessage result = ScramMessageParser.parseServerFinal(message);
+ assertEquals("rmF9pqV8S7suAoZWja4dJRkFsKQ=", result.verifier);
+ assertNull(result.error);
+ }
+
+ @Test
+ void testParseServerFinal_error() {
+ String message = "e=invalid-proof";
+ ScramMessageParser.ServerFinalMessage result = ScramMessageParser.parseServerFinal(message);
+ assertNull(result.verifier);
+ assertEquals("invalid-proof", result.error);
+ }
+
+ @Test
+ void testParseServerFinal_invalid() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFinal("x=unknown"));
+ }
+
+ @Test
+ void testParseWwwAuthenticate_realmAndData() {
+ String header = "SCRAM-SHA-256 realm=\"testrealm@example.com\", data=\"biwsbj11c2VyLHI9ck9wck5HZndFYmVSV2diTkVrcU8=\"";
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertEquals("testrealm@example.com", params.realm);
+ assertEquals("biwsbj11c2VyLHI9ck9wck5HZndFYmVSV2diTkVrcU8=", params.data);
+ assertNull(params.sid);
+ assertFalse(params.stale);
+ }
+
+ @Test
+ void testParseWwwAuthenticate_sidAndData() {
+ String header = "SCRAM-SHA-256 sid=AAAABBBB, data=\"cj1yT3ByTkdmd0ViZQ==\"";
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertEquals("AAAABBBB", params.sid);
+ assertEquals("cj1yT3ByTkdmd0ViZQ==", params.data);
+ }
+
+ @Test
+ void testParseWwwAuthenticate_srAndTtl() {
+ String header = "SCRAM-SHA-256 realm=\"test\", sr=serverNonce123, ttl=300";
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertEquals("test", params.realm);
+ assertEquals("serverNonce123", params.sr);
+ assertEquals(300, params.ttl);
+ }
+
+ @Test
+ void testParseWwwAuthenticate_staleFlag() {
+ String header = "SCRAM-SHA-256 realm=\"test\", sr=nonce, stale=true";
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertTrue(params.stale);
+
+ header = "SCRAM-SHA-256 realm=\"test\", sr=nonce, stale=false";
+ params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertFalse(params.stale);
+ }
+
+ @Test
+ void testParseWwwAuthenticate_noTtl() {
+ String header = "SCRAM-SHA-256 realm=\"test\"";
+ ScramMessageParser.ScramChallengeParams params = ScramMessageParser.parseWwwAuthenticateScram(header);
+ assertEquals(-1, params.ttl);
+ }
+
+ @Test
+ void testParseServerFirst_emptySalt() {
+ assertThrows(ScramException.class, () ->
+ ScramMessageParser.parseServerFirst("r=nonce,s=,i=4096"));
+ }
+
+ @Test
+ void testValidateGs2Header_valid() {
+ assertDoesNotThrow(() -> ScramMessageParser.validateGs2Header("n,,n=user,r=nonce"));
+ }
+
+ @Test
+ void testValidateGs2Header_invalid_y() {
+ assertThrows(ScramException.class, () -> ScramMessageParser.validateGs2Header("y,,n=user,r=nonce"));
+ }
+
+ @Test
+ void testValidateGs2Header_invalid_p() {
+ assertThrows(ScramException.class, () -> ScramMessageParser.validateGs2Header("p=tls-unique,,n=user,r=nonce"));
+ }
+
+ @Test
+ void testValidateNoncePrefix_valid() {
+ assertDoesNotThrow(() -> ScramMessageParser.validateNoncePrefix("clientNonce", "clientNonceServerPart"));
+ }
+
+ @Test
+ void testValidateNoncePrefix_invalid() {
+ assertThrows(ScramException.class, () -> ScramMessageParser.validateNoncePrefix("clientNonce", "differentNonce"));
+ }
+
+ @Test
+ void testValidateNoncePrefix_empty_server_part() {
+ assertThrows(ScramException.class, () -> ScramMessageParser.validateNoncePrefix("clientNonce", "clientNonce"));
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java
new file mode 100644
index 0000000000..d8d1012e5d
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramRfc7804GoldenTest.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Golden tests verifying SCRAM-SHA-256 against independently computed
+ * values using RFC 7804 §5 and RFC 5802 test vector parameters.
+ *
+ * CRITICAL: Values are independently computed from input parameters, NOT copied from
+ * RFC examples (which may contain trailing newlines per Erratum 6633).
+ */
+class ScramRfc7804GoldenTest {
+
+ // RFC 7804 / RFC 5802 / RFC 7677 shared parameters
+ private static final String USERNAME = "user";
+ private static final String PASSWORD = "pencil";
+ private static final String CLIENT_NONCE = "rOprNGfwEbeRWgbNEkqO";
+ private static final byte[] SALT = Base64.getDecoder().decode("W22ZaJ0SNY7soEsUEjb6gQ==");
+ private static final int ITERATIONS = 4096;
+
+ @Test
+ void testClientFirstMessage_format() {
+ // No trailing newline (Erratum 6633)
+ String clientFirst = ScramMessageFormatter.formatClientFirstMessage(USERNAME, CLIENT_NONCE);
+ assertEquals("n,,n=user,r=rOprNGfwEbeRWgbNEkqO", clientFirst);
+ assertFalse(clientFirst.endsWith("\n"), "Must NOT have trailing newline (Erratum 6633)");
+ }
+
+ @Test
+ void testClientFirstMessageBare_format() {
+ String bare = ScramMessageFormatter.clientFirstMessageBare(USERNAME, CLIENT_NONCE);
+ assertEquals("n=user,r=rOprNGfwEbeRWgbNEkqO", bare);
+ }
+
+ @Test
+ void testClientFirstMessage_base64() {
+ String clientFirst = ScramMessageFormatter.formatClientFirstMessage(USERNAME, CLIENT_NONCE);
+ String base64 = Base64.getEncoder().encodeToString(clientFirst.getBytes(StandardCharsets.UTF_8));
+ // Verify round-trip
+ String decoded = new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8);
+ assertEquals(clientFirst, decoded);
+ // Standard base64 alphabet (not URL-safe)
+ assertFalse(base64.contains("-"), "Must use standard base64, not URL-safe");
+ assertFalse(base64.contains("_"), "Must use standard base64, not URL-safe");
+ }
+
+ @Test
+ void testSHA256_fullKeyDerivation() {
+ // Independently compute all intermediate values
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ assertEquals(32, saltedPassword.length);
+
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ assertEquals(32, clientKey.length);
+
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ assertEquals(32, storedKey.length);
+
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+ assertEquals(32, serverKey.length);
+
+ // Verify StoredKey = H(ClientKey)
+ byte[] recomputedStoredKey = ScramEngine.hash(clientKey);
+ assertArrayEquals(storedKey, recomputedStoredKey);
+
+ ScramEngine.zeroBytes(saltedPassword);
+ }
+
+ @Test
+ void testSHA256_clientProofAndServerSignature() {
+ // Use RFC 7804 nonce suffix
+ String serverNonceSuffix = "%hvYDpWUa2RaTCAfuxFIlj)hNlF";
+ String fullNonce = CLIENT_NONCE + serverNonceSuffix;
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+
+ String clientFirstBare = "n=user,r=" + CLIENT_NONCE;
+ String serverFirst = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ String clientFinalWithoutProof = "c=biws,r=" + fullNonce;
+ String authMessage = clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof;
+
+ // Compute all keys
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] clientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] storedKey = ScramEngine.computeStoredKey(clientKey);
+ byte[] serverKey = ScramEngine.computeServerKey(saltedPassword);
+
+ // Compute ClientProof
+ byte[] clientSignature = ScramEngine.computeClientSignature(storedKey, authMessage);
+ byte[] clientProof = ScramEngine.computeClientProof(clientKey, clientSignature);
+ assertNotNull(clientProof);
+ assertEquals(32, clientProof.length);
+
+ // Compute ServerSignature
+ byte[] serverSignature = ScramEngine.computeServerSignature(serverKey, authMessage);
+ assertNotNull(serverSignature);
+ assertEquals(32, serverSignature.length);
+
+ // Verify: ClientKey XOR ClientSignature = ClientProof
+ byte[] recomputedClientKey = ScramEngine.xor(clientProof.clone(), clientSignature);
+ assertArrayEquals(clientKey, recomputedClientKey);
+
+ ScramEngine.zeroBytes(saltedPassword);
+ }
+
+ @Test
+ void testSHA256_fullExchangeConsistency() {
+ // Verify that ScramContext produces same values as manual computation
+ String serverNonceSuffix = "ServerNonce123";
+ String saltBase64 = Base64.getEncoder().encodeToString(SALT);
+
+ // Manual computation
+ byte[] saltedPassword = ScramEngine.computeSaltedPassword(PASSWORD, SALT, ITERATIONS);
+ byte[] manualClientKey = ScramEngine.computeClientKey(saltedPassword);
+ byte[] manualStoredKey = ScramEngine.computeStoredKey(manualClientKey);
+ byte[] manualServerKey = ScramEngine.computeServerKey(saltedPassword);
+
+ // ScramContext computation (uses same algorithm internally)
+ ScramContext ctx = new ScramContext(USERNAME, PASSWORD, REALM_NAME, "SCRAM-SHA-256");
+ String clientNonce = ctx.getClientNonce();
+ String fullNonce = clientNonce + serverNonceSuffix;
+ String serverFirst = "r=" + fullNonce + ",s=" + saltBase64 + ",i=" + ITERATIONS;
+ ctx.processServerFirst(serverFirst, 16_384);
+
+ // Keys should match
+ assertArrayEquals(manualClientKey, ctx.getClientKey());
+ assertArrayEquals(manualStoredKey, ctx.getStoredKey());
+ assertArrayEquals(manualServerKey, ctx.getServerKey());
+
+ // Compute client-final and verify
+ String clientFinal = ctx.computeClientFinal();
+ assertTrue(clientFinal.startsWith("c=biws,r=" + fullNonce + ",p="));
+
+ // Build and verify server-final
+ String authMessage = ctx.getClientFirstMessageBare() + "," + serverFirst + ","
+ + ctx.getClientFinalMessageWithoutProof();
+ byte[] serverSignature = ScramEngine.computeServerSignature(manualServerKey, authMessage);
+ String serverFinal = "v=" + Base64.getEncoder().encodeToString(serverSignature);
+ assertTrue(ctx.verifyServerFinal(serverFinal));
+
+ ScramEngine.zeroBytes(saltedPassword);
+ }
+
+ @Test
+ void testUsernameEscaping() {
+ assertEquals("user", ScramMessageFormatter.escapeUsername("user"));
+ assertEquals("us=3Der", ScramMessageFormatter.escapeUsername("us=er"));
+ assertEquals("us=2Cer", ScramMessageFormatter.escapeUsername("us,er"));
+ }
+
+ @Test
+ void testChannelBindingField() {
+ // c=biws is base64("n,,") — the gs2-header for no channel binding
+ String decoded = new String(Base64.getDecoder().decode("biws"), StandardCharsets.UTF_8);
+ assertEquals("n,,", decoded);
+ }
+
+ @Test
+ void testBase64_standardAlphabet() {
+ // Verify standard (not URL-safe) base64 is used throughout
+ String encoded = Base64.getEncoder().encodeToString(SALT);
+ // Standard base64 uses + and / (not - and _)
+ for (char c : encoded.toCharArray()) {
+ assertTrue(
+ (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
+ || c == '+' || c == '/' || c == '=',
+ "Character '" + c + "' is not in standard base64 alphabet"
+ );
+ }
+ }
+
+ @Test
+ void testBase64_noTrailingNewlines() {
+ // Erratum 6633: no trailing newlines in base64
+ String clientFirst = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
+ String encoded = Base64.getEncoder().encodeToString(
+ clientFirst.getBytes(StandardCharsets.UTF_8));
+ assertFalse(encoded.contains("\n"), "Base64 must not contain newlines");
+ assertFalse(encoded.contains("\r"), "Base64 must not contain carriage returns");
+ }
+
+ @Test
+ void testDataAttributeQuoting() {
+ // Erratum 6558: data attribute MUST be quoted
+ String header = ScramMessageFormatter.formatAuthorizationHeader(
+ "SCRAM-SHA-256", "test", null, "dGVzdA==");
+ assertTrue(header.contains("data=\"dGVzdA==\""), "data attribute must be quoted");
+ }
+
+ private static final String REALM_NAME = "testrealm@example.com";
+}
diff --git a/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java
new file mode 100644
index 0000000000..e376de463c
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/scram/ScramSessionCacheTest.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.scram;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ScramSessionCacheTest {
+
+ private ScramSessionCache.Entry createEntry(String sr, int ttl, int initialNonceCount) {
+ return new ScramSessionCache.Entry(
+ "testrealm",
+ new byte[]{1, 2, 3}, 4096,
+ new byte[]{4, 5, 6}, new byte[]{7, 8, 9}, new byte[]{10, 11, 12},
+ sr, ttl, System.nanoTime(),
+ initialNonceCount,
+ "r=nonce,s=c2FsdA==,i=4096"
+ );
+ }
+
+ @Test
+ void testPutAndGet() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096);
+
+ cache.put(key, entry);
+ assertSame(entry, cache.get(key));
+ }
+
+ @Test
+ void testGet_missing() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ assertNull(cache.get(key));
+ }
+
+ @Test
+ void testIsSrFresh_withinTtl() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096);
+ assertTrue(cache.isSrFresh(entry));
+ }
+
+ @Test
+ void testIsSrFresh_expired() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.Entry entry = new ScramSessionCache.Entry(
+ "realm",
+ new byte[]{1}, 4096,
+ new byte[]{2}, new byte[]{3}, new byte[]{4},
+ "sr123", 0, // 0 second TTL = immediately expired
+ System.nanoTime() - 1_000_000_000L, // 1 second ago
+ 4096,
+ "r=nonce,s=c2FsdA==,i=4096"
+ );
+ assertFalse(cache.isSrFresh(entry));
+ }
+
+ @Test
+ void testIsSrFresh_noTtl() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.Entry entry = createEntry("sr123", -1, 4096);
+ assertTrue(cache.isSrFresh(entry)); // ttl=-1 → always fresh
+ }
+
+ @Test
+ void testIsSrFresh_nullSr() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.Entry entry = createEntry(null, -1, 4096);
+ assertFalse(cache.isSrFresh(entry));
+ }
+
+ @Test
+ void testNonceCountReserveConfirm() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096);
+ cache.put(key, entry);
+
+ // Reserve: atomically returns current value (4096) and increments
+ assertEquals(4096, cache.reserveNonceCount(key));
+
+ // Reserve again: returns 4097 (already incremented)
+ assertEquals(4097, cache.reserveNonceCount(key));
+
+ // Confirm is a no-op since reserve already incremented
+ cache.confirmNonceCount(key);
+ assertEquals(4098, cache.reserveNonceCount(key));
+ }
+
+ @Test
+ void testNonceCountRollback() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.Entry entry = createEntry("sr123", 300, 4096);
+ cache.put(key, entry);
+
+ // Reserve: atomically returns 4096 and increments to 4097
+ assertEquals(4096, cache.reserveNonceCount(key));
+
+ // Rollback: decrements back to 4096
+ cache.rollbackNonceCount(key);
+
+ // Should be back to 4096
+ assertEquals(4096, cache.reserveNonceCount(key));
+ }
+
+ @Test
+ void testMaxEntries_eviction() {
+ ScramSessionCache cache = new ScramSessionCache(2);
+
+ ScramSessionCache.CacheKey key1 = new ScramSessionCache.CacheKey("host1", 80, "realm");
+ ScramSessionCache.CacheKey key2 = new ScramSessionCache.CacheKey("host2", 80, "realm");
+ ScramSessionCache.CacheKey key3 = new ScramSessionCache.CacheKey("host3", 80, "realm");
+
+ cache.put(key1, createEntry("sr1", 300, 4096));
+ cache.put(key2, createEntry("sr2", 300, 4096));
+ assertEquals(2, cache.size());
+
+ // Adding third should evict oldest entry, keeping 2 entries
+ cache.put(key3, createEntry("sr3", 300, 4096));
+ assertEquals(2, cache.size());
+ assertNotNull(cache.get(key3));
+ }
+
+ @Test
+ void testUpdateSr() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.Entry entry = createEntry("oldSr", 300, 4096);
+ cache.put(key, entry);
+
+ cache.updateSr(key, "newSr", 600);
+
+ ScramSessionCache.Entry updated = cache.get(key);
+ assertNotNull(updated);
+ assertEquals("newSr", updated.sr);
+ assertEquals(600, updated.ttl);
+ }
+
+ @Test
+ void testClear() {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ cache.put(key, createEntry("sr", 300, 4096));
+
+ cache.clear();
+ assertEquals(0, cache.size());
+ assertNull(cache.get(key));
+ }
+
+ @Test
+ void testNoSaltedPasswordStored() {
+ // Verify that Entry has no SaltedPassword field — only derived keys
+ ScramSessionCache.Entry entry = createEntry("sr", 300, 4096);
+ assertNotNull(entry.clientKey);
+ assertNotNull(entry.storedKey);
+ assertNotNull(entry.serverKey);
+ // No saltedPassword field exists — this is verified by compilation
+ }
+
+ @Test
+ void testConcurrentNonceCountReservation() throws Exception {
+ ScramSessionCache cache = new ScramSessionCache();
+ ScramSessionCache.CacheKey key = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.Entry entry = createEntry("sr123", 300, 0);
+ cache.put(key, entry);
+
+ int threadCount = 16;
+ int reservationsPerThread = 1000;
+ int totalReservations = threadCount * reservationsPerThread;
+ Set reservedValues = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ for (int t = 0; t < threadCount; t++) {
+ executor.submit(() -> {
+ try {
+ startLatch.await();
+ for (int i = 0; i < reservationsPerThread; i++) {
+ int value = cache.reserveNonceCount(key);
+ assertTrue(reservedValues.add(value),
+ "Duplicate nonce count reserved: " + value);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads did not complete in time");
+ executor.shutdown();
+
+ assertEquals(totalReservations, reservedValues.size(),
+ "All reserved nonce counts must be unique");
+ }
+
+ @Test
+ void testCacheKey_equality() {
+ ScramSessionCache.CacheKey key1 = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.CacheKey key2 = new ScramSessionCache.CacheKey("host", 80, "realm");
+ ScramSessionCache.CacheKey key3 = new ScramSessionCache.CacheKey("host", 443, "realm");
+
+ assertEquals(key1, key2);
+ assertEquals(key1.hashCode(), key2.hashCode());
+ assertNotEquals(key1, key3);
+ }
+}