diff --git a/.changes/next-release/feature-NettyNIOHTTPClient-febb09f.json b/.changes/next-release/feature-NettyNIOHTTPClient-febb09f.json
new file mode 100644
index 000000000000..64b937ccd63b
--- /dev/null
+++ b/.changes/next-release/feature-NettyNIOHTTPClient-febb09f.json
@@ -0,0 +1,6 @@
+{
+ "type": "feature",
+ "category": "Netty NIO HTTP Client",
+ "contributor": "",
+ "description": "Add support for Kerberos (SPNEGO) proxy authentication via the new `proxyAuthScheme` option on the Netty client's `ProxyConfiguration`. Setting `ProxyAuthScheme.NEGOTIATE` authenticates proxy CONNECT tunnels using the Kerberos ticket cache in the environment; a valid ticket-granting ticket must already exist (for example via `kinit`), and no password or keytab is read. `ProxyAuthScheme.BASIC` may also be set to select Basic authentication explicitly. See [#7033](https://github.com/aws/aws-sdk-java-v2/issues/7033)."
+}
diff --git a/bom-internal/pom.xml b/bom-internal/pom.xml
index 80b6cb99768d..30cd4c250fc1 100644
--- a/bom-internal/pom.xml
+++ b/bom-internal/pom.xml
@@ -519,6 +519,24 @@
pom
import
+
+ org.apache.kerby
+ kerb-simplekdc
+ ${kerby.version}
+ test
+
+
+ org.apache.kerby
+ kerb-client
+ ${kerby.version}
+ test
+
+
+ org.apache.kerby
+ kerb-core
+ ${kerby.version}
+ test
+
diff --git a/http-client-spi/src/main/java/software/amazon/awssdk/http/ProxyAuthScheme.java b/http-client-spi/src/main/java/software/amazon/awssdk/http/ProxyAuthScheme.java
new file mode 100644
index 000000000000..d4857013b2c4
--- /dev/null
+++ b/http-client-spi/src/main/java/software/amazon/awssdk/http/ProxyAuthScheme.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http;
+
+import software.amazon.awssdk.annotations.SdkPublicApi;
+
+/**
+ * Supported auth schemes for authentication with a proxy.
+ */
+@SdkPublicApi
+public enum ProxyAuthScheme {
+ /**
+ * Basic authentication, as defined by RFC 7617. Requires a
+ * username and password.
+ */
+ BASIC("Basic"),
+
+ /**
+ * Kerberos authentication, using SPNEGO as defined by
+ * RFC 4559.
+ *
+ * Credentials are read from the environment Kerberos ticket cache. The client never prompts for a password and never reads a
+ * keytab, so the environment must already hold a valid ticket-granting ticket, typically obtained by running
+ * {@code kinit} and verifiable with {@code klist}. The cache location follows the usual Kerberos conventions, including
+ * the {@code KRB5CCNAME} environment variable. Any username and password configured on the proxy are ignored.
+ */
+ NEGOTIATE("Negotiate"),
+ ;
+
+ private final String value;
+
+ ProxyAuthScheme(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+}
diff --git a/http-clients/netty-nio-client/pom.xml b/http-clients/netty-nio-client/pom.xml
index e6fef64dde88..e886b88a3c63 100644
--- a/http-clients/netty-nio-client/pom.xml
+++ b/http-clients/netty-nio-client/pom.xml
@@ -233,6 +233,21 @@
jetty-util
test
+
+ org.apache.kerby
+ kerb-simplekdc
+ test
+
+
+ org.apache.kerby
+ kerb-client
+ test
+
+
+ org.apache.kerby
+ kerb-core
+ test
+
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java
index 3e99406187ba..5c0b6c54b8a6 100644
--- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/ProxyConfiguration.java
@@ -19,6 +19,8 @@
import java.util.HashSet;
import java.util.Set;
import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+import software.amazon.awssdk.http.nio.netty.internal.utils.NettyClientLogger;
import software.amazon.awssdk.utils.ProxyConfigProvider;
import software.amazon.awssdk.utils.ProxyEnvironmentSetting;
import software.amazon.awssdk.utils.ProxySystemSetting;
@@ -34,6 +36,8 @@
*/
@SdkPublicApi
public final class ProxyConfiguration implements ToCopyableBuilder {
+ private static final NettyClientLogger log = NettyClientLogger.getLogger(ProxyConfiguration.class);
+
private final Boolean useSystemPropertyValues;
private final Boolean useEnvironmentVariablesValues;
private final String scheme;
@@ -41,6 +45,7 @@ public final class ProxyConfiguration implements ToCopyableBuilder nonProxyHosts;
private ProxyConfiguration(BuilderImpl builder) {
@@ -56,7 +61,32 @@ private ProxyConfiguration(BuilderImpl builder) {
this.port = resolvePort(builder, proxyConfigProvider);
this.username = resolveUserName(builder, proxyConfigProvider);
this.password = resolvePassword(builder, proxyConfigProvider);
+ this.proxyAuthScheme = builder.proxyAuthScheme;
this.nonProxyHosts = resolveNonProxyHosts(builder, proxyConfigProvider);
+ validateProxyAuthConfig(proxyAuthScheme, username, password);
+ warnOnIgnoredCredentials(builder);
+ }
+
+ private static void validateProxyAuthConfig(ProxyAuthScheme proxyAuthScheme, String username, String password) {
+ if (proxyAuthScheme == ProxyAuthScheme.BASIC
+ && (StringUtils.isEmpty(username) || StringUtils.isEmpty(password))) {
+ throw new IllegalArgumentException("username and password must be configured when using BASIC proxy auth");
+ }
+ }
+
+ /**
+ * NEGOTIATE reads its credentials from the Kerberos ticket cache, so a username and password are dead configuration. Warn
+ * rather than fail, and only when they were set directly on this builder: values resolved from system properties or
+ * environment variables may not be under the caller's control, and warning about those would be noise.
+ */
+ private static void warnOnIgnoredCredentials(BuilderImpl builder) {
+ if (builder.proxyAuthScheme == ProxyAuthScheme.NEGOTIATE
+ && (builder.username != null || builder.password != null)) {
+ log.warn(null, () -> "A proxy username and/or password was configured alongside the "
+ + ProxyAuthScheme.NEGOTIATE + " proxy auth scheme, and will be ignored. " +
+ ProxyAuthScheme.NEGOTIATE + " authenticates using the Kerberos ticket cache. Configure "
+ + ProxyAuthScheme.BASIC + " to authenticate with a username and password instead.");
+ }
}
private static Set resolveNonProxyHosts(BuilderImpl builder, ProxyConfigProvider proxyConfigProvider) {
@@ -151,6 +181,13 @@ public Set nonProxyHosts() {
return Collections.unmodifiableSet(nonProxyHosts != null ? nonProxyHosts : Collections.emptySet());
}
+ /**
+ * @return The auth scheme to use to authenticate with the proxy.
+ */
+ public ProxyAuthScheme proxyAuthScheme() {
+ return proxyAuthScheme;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -183,6 +220,10 @@ public boolean equals(Object o) {
return false;
}
+ if (proxyAuthScheme != null ? !proxyAuthScheme.equals(that.proxyAuthScheme) : that.proxyAuthScheme != null) {
+ return false;
+ }
+
return nonProxyHosts.equals(that.nonProxyHosts);
}
@@ -195,6 +236,7 @@ public int hashCode() {
result = 31 * result + nonProxyHosts.hashCode();
result = 31 * result + (username != null ? username.hashCode() : 0);
result = 31 * result + (password != null ? password.hashCode() : 0);
+ result = 31 * result + (proxyAuthScheme != null ? proxyAuthScheme.hashCode() : 0);
return result;
}
@@ -247,6 +289,25 @@ public interface Builder extends CopyableBuilder {
*/
Builder nonProxyHosts(Set nonProxyHosts);
+ /**
+ * Configure the auth scheme to use to authenticate with the proxy.
+ *
+ * If unset and {@link #username(String)} and {@link #password(String)} are set, the client will
+ * assume {@link ProxyAuthScheme#BASIC} auth.
+ *
+ * If set to {@link ProxyAuthScheme#BASIC}, {@link #username(String)} and {@link #password(String)} must also be
+ * configured (directly, or resolved from system properties or environment variables), otherwise
+ * {@link Builder#build()} throws {@link IllegalArgumentException}.
+ *
+ * If set to {@link ProxyAuthScheme#NEGOTIATE}, credentials come from the Kerberos ticket cache rather than from this
+ * configuration, and any configured username and password are ignored. See {@link ProxyAuthScheme#NEGOTIATE} for the
+ * environment it requires and for how a missing or expired ticket surfaces.
+ *
+ * @param proxyAuthScheme The auth scheme.
+ * @return This object for method chaining.
+ */
+ Builder proxyAuthScheme(ProxyAuthScheme proxyAuthScheme);
+
/**
* Set the username used to authenticate with the proxy username.
*
@@ -305,6 +366,7 @@ private static final class BuilderImpl implements Builder {
private String scheme = "http";
private String host;
private int port = 0;
+ private ProxyAuthScheme proxyAuthScheme;
private String username;
private String password;
private Set nonProxyHosts;
@@ -322,6 +384,7 @@ private BuilderImpl(ProxyConfiguration proxyConfiguration) {
this.port = proxyConfiguration.port;
this.nonProxyHosts = proxyConfiguration.nonProxyHosts != null ?
new HashSet<>(proxyConfiguration.nonProxyHosts) : null;
+ this.proxyAuthScheme = proxyConfiguration.proxyAuthScheme;
this.username = proxyConfiguration.username;
this.password = proxyConfiguration.password;
}
@@ -354,6 +417,12 @@ public Builder nonProxyHosts(Set nonProxyHosts) {
return this;
}
+ @Override
+ public Builder proxyAuthScheme(ProxyAuthScheme proxyAuthScheme) {
+ this.proxyAuthScheme = proxyAuthScheme;
+ return this;
+ }
+
@Override
public Builder username(String username) {
this.username = username;
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java
index ff5c87e57038..f13b5381e3b1 100644
--- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMap.java
@@ -32,18 +32,25 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
+import javax.security.auth.login.Configuration;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.annotations.SdkTestInternalApi;
import software.amazon.awssdk.http.Protocol;
import software.amazon.awssdk.http.ProtocolNegotiation;
+import software.amazon.awssdk.http.ProxyAuthScheme;
import software.amazon.awssdk.http.nio.netty.ProxyConfiguration;
import software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup;
import software.amazon.awssdk.http.nio.netty.internal.http2.HttpOrHttp2ChannelPool;
import software.amazon.awssdk.http.nio.netty.internal.utils.NettyClientLogger;
+import software.amazon.awssdk.utils.StringUtils;
+import software.amazon.awssdk.utils.ThreadFactoryBuilder;
/**
* Implementation of {@link SdkChannelPoolMap} that awaits channel pools to be closed upon closing.
@@ -87,6 +94,10 @@ public void channelCreated(Channel ch) throws Exception {
private final SslContextProvider sslContextProvider;
private final Boolean useNonBlockingDnsResolver;
+ private final Configuration negotiateAuthConfig;
+ private final ProxyAuthGenerator proxyAuthGenerator;
+ private final ExecutorService proxyAuthExecutor;
+
private AwaitCloseChannelPoolMap(Builder builder, Function createBootStrapProvider) {
this.configuration = builder.configuration;
this.protocol = builder.protocol;
@@ -99,6 +110,11 @@ private AwaitCloseChannelPoolMap(Builder builder, Function "Closing channel pools");
@@ -168,6 +235,10 @@ public void close() {
Collection channelPools = pools().values();
super.close();
+ if (proxyAuthExecutor != null) {
+ proxyAuthExecutor.shutdownNow();
+ }
+
try {
CompletableFuture.allOf(channelPools.stream()
.map(pool -> pool.underlyingSimpleChannelPool().closeFuture())
@@ -293,6 +364,9 @@ public static class Builder {
private ProxyConfiguration proxyConfiguration;
private Boolean useNonBlockingDnsResolver;
+ // testing only
+ private Configuration negotiateAuthConfig;
+
private Builder() {
}
@@ -351,6 +425,12 @@ public Builder useNonBlockingDnsResolver(Boolean useNonBlockingDnsResolver) {
return this;
}
+ @SdkTestInternalApi
+ public Builder negotiateAuthConfig(Configuration negotiateAuthConfig) {
+ this.negotiateAuthConfig = negotiateAuthConfig;
+ return this;
+ }
+
public AwaitCloseChannelPoolMap build() {
return new AwaitCloseChannelPoolMap(this);
}
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java
new file mode 100644
index 000000000000..44a80ab34a4e
--- /dev/null
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGenerator.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.nio.netty.internal;
+
+import io.netty.util.CharsetUtil;
+import java.net.URI;
+import java.util.Base64;
+import java.util.concurrent.CompletableFuture;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * Auth param generator for Basic proxy authentication.
+ *
+ * See https://datatracker.ietf.org/doc/html/rfc7617.
+ */
+@SdkInternalApi
+public class BasicProxyAuthGenerator implements ProxyAuthGenerator {
+ private final String username;
+ private final String password;
+
+ public BasicProxyAuthGenerator(String username, String password) {
+ this.username = Validate.notEmpty(username, "username must not be empty");
+ this.password = Validate.notEmpty(password, "password must not be empty");
+ }
+
+ @Override
+ public ProxyAuthScheme scheme() {
+ return ProxyAuthScheme.BASIC;
+ }
+
+ @Override
+ public CompletableFuture generateAuthParams(URI proxyEndpoint) {
+ // Purely local and cheap, so this completes inline rather than hopping to another thread.
+ String authToken = String.format("%s:%s", this.username, this.password);
+ return CompletableFuture.completedFuture(Base64.getEncoder().encodeToString(authToken.getBytes(CharsetUtil.UTF_8)));
+ }
+}
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java
index cc53ed4da46a..0dafa642d6e4 100644
--- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPool.java
@@ -49,41 +49,30 @@ public class Http1TunnelConnectionPool implements ChannelPool {
private final ChannelPool delegate;
private final SslContext sslContext;
private final URI proxyAddress;
- private final String proxyUser;
- private final String proxyPassword;
+ private final ProxyAuthGenerator proxyAuthGenerator;
private final URI remoteAddress;
private final ChannelPoolHandler handler;
private final InitHandlerSupplier initHandlerSupplier;
private final NettyConfiguration nettyConfiguration;
public Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext,
- URI proxyAddress, String proxyUsername, String proxyPassword,
+ URI proxyAddress, ProxyAuthGenerator proxyAuthGenerator,
URI remoteAddress, ChannelPoolHandler handler, NettyConfiguration nettyConfiguration) {
this(eventLoop, delegate, sslContext,
- proxyAddress, proxyUsername, proxyPassword, remoteAddress, handler,
+ proxyAddress, proxyAuthGenerator, remoteAddress, handler,
ProxyTunnelInitHandler::new, nettyConfiguration);
}
- public Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext,
- URI proxyAddress, URI remoteAddress, ChannelPoolHandler handler,
- NettyConfiguration nettyConfiguration) {
- this(eventLoop, delegate, sslContext,
- proxyAddress, null, null, remoteAddress, handler,
- ProxyTunnelInitHandler::new, nettyConfiguration);
-
- }
-
@SdkTestInternalApi
Http1TunnelConnectionPool(EventLoop eventLoop, ChannelPool delegate, SslContext sslContext,
- URI proxyAddress, String proxyUser, String proxyPassword, URI remoteAddress,
+ URI proxyAddress, ProxyAuthGenerator proxyAuthGenerator, URI remoteAddress,
ChannelPoolHandler handler, InitHandlerSupplier initHandlerSupplier,
NettyConfiguration nettyConfiguration) {
this.eventLoop = eventLoop;
this.delegate = delegate;
this.sslContext = sslContext;
this.proxyAddress = proxyAddress;
- this.proxyUser = proxyUser;
- this.proxyPassword = proxyPassword;
+ this.proxyAuthGenerator = proxyAuthGenerator;
this.remoteAddress = remoteAddress;
this.handler = handler;
this.initHandlerSupplier = initHandlerSupplier;
@@ -138,7 +127,7 @@ private void setupChannel(Channel ch, Promise acquirePromise) {
if (sslHandler != null) {
ch.pipeline().addLast(sslHandler);
}
- ch.pipeline().addLast(initHandlerSupplier.newInitHandler(delegate, proxyUser, proxyPassword, remoteAddress,
+ ch.pipeline().addLast(initHandlerSupplier.newInitHandler(delegate, proxyAddress, proxyAuthGenerator, remoteAddress,
tunnelEstablishedPromise));
tunnelEstablishedPromise.addListener((Future f) -> {
if (f.isSuccess()) {
@@ -180,7 +169,10 @@ private static boolean isTunnelEstablished(Channel ch) {
@SdkTestInternalApi
@FunctionalInterface
interface InitHandlerSupplier {
- ChannelHandler newInitHandler(ChannelPool sourcePool, String proxyUsername, String proxyPassword, URI remoteAddress,
+ ChannelHandler newInitHandler(ChannelPool sourcePool,
+ URI proxyAddress,
+ ProxyAuthGenerator authGenerator,
+ URI remoteAddress,
Promise tunnelInitFuture);
}
}
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java
new file mode 100644
index 000000000000..b2f5bff08d54
--- /dev/null
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGenerator.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.nio.netty.internal;
+
+import java.net.URI;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import javax.security.auth.Subject;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+import software.amazon.awssdk.utils.BinaryUtils;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * Auth generator for Kerberos. This does not login/authentication to Kerberos. It expects the ticket cache to be present and
+ * simply reads that to generate the token.
+ */
+@SdkInternalApi
+public class NegotiateProxyAuthGenerator implements ProxyAuthGenerator {
+ // SPNEGO pseudo-mechanism OID. Lets the proxy negotiate Kerberos over HTTP "Negotiate".
+ // See https://www.ietf.org/rfc/rfc4178.txt for more info
+ private static final String OID = "1.3.6.1.5.5.2";
+ private static final String SERVICE_NAME = "HTTP";
+ private final Configuration config;
+ private final Executor executor;
+
+ /**
+ * @param config The JAAS configuration to log in with, or null to use the default ticket-cache-only configuration.
+ * @param executor Executor to run the blocking Kerberos work on. Must not be a Netty event loop; see
+ * {@link #generateAuthParams(URI)}. Its lifecycle is owned by the caller.
+ */
+ public NegotiateProxyAuthGenerator(Configuration config, Executor executor) {
+ if (config != null) {
+ this.config = config;
+ } else {
+ this.config = createDefaultConfig();
+ }
+ this.executor = Validate.paramNotNull(executor, "executor");
+ }
+
+ @Override
+ public ProxyAuthScheme scheme() {
+ return ProxyAuthScheme.NEGOTIATE;
+ }
+
+ @Override
+ public CompletableFuture generateAuthParams(URI proxyEndpoint) {
+ // Must not run on the caller's thread: the caller is a Netty event loop thread, and the work below reads the ticket
+ // cache from disk and may make a blocking TGS request to the KDC. Blocking the loop would stall every other channel
+ // assigned to it.
+ return CompletableFuture.supplyAsync(() -> generateAuthParamsBlocking(proxyEndpoint), executor);
+ }
+
+ private String generateAuthParamsBlocking(URI proxyEndpoint) {
+ try {
+ Subject subject = getSubject();
+
+ byte[] token = Subject.doAs(subject, (PrivilegedExceptionAction) () -> {
+ GSSContext ctx = createGssContext(getManager(), proxyEndpoint);
+ try {
+ return ctx.initSecContext(new byte[0], 0, 0);
+ } finally {
+ ctx.dispose();
+ }
+ });
+
+ return BinaryUtils.toBase64(token);
+ } catch (PrivilegedActionException e) {
+ throw new RuntimeException(String.format("Unable to generate SPNEGO token for Negotiate proxy authentication "
+ + "with '%s@%s'. This can happen when a service ticket for the proxy "
+ + "cannot be obtained from the KDC, e.g. because the ticket-granting "
+ + "ticket has expired (renew with 'kinit') or the proxy host does not "
+ + "match its Kerberos service principal name.",
+ SERVICE_NAME, proxyEndpoint.getHost()), e);
+ }
+ }
+
+ private Subject getSubject() {
+ try {
+ LoginContext loginContext = new LoginContext("dummy", null, null, config);
+ loginContext.login();
+ return loginContext.getSubject();
+ } catch (LoginException e) {
+ throw new RuntimeException("Unable to perform Kerberos login for Negotiate proxy authentication. This "
+ + "typically means the Kerberos ticket cache is missing, expired, or not readable. "
+ + "Ensure a valid ticket-granting ticket exists (e.g., by running 'kinit'), and that "
+ + "the cache is at the expected location (see the KRB5CCNAME environment variable). "
+ + "Verify with 'klist'.", e);
+ }
+ }
+
+ private GSSContext createGssContext(GSSManager manager, URI endpoint) {
+ try {
+ String name = String.format("%s@%s", SERVICE_NAME, endpoint.getHost());
+ GSSName serverName = manager.createName(name, GSSName.NT_HOSTBASED_SERVICE);
+ Oid spnegoOid = new Oid(OID);
+ return manager.createContext(serverName, spnegoOid, null,
+ GSSContext.DEFAULT_LIFETIME);
+ } catch (GSSException e) {
+ throw new RuntimeException("Unable to create GSSContext", e);
+ }
+ }
+
+ private static GSSManager getManager() {
+ return GSSManager.getInstance();
+ }
+
+ /**
+ * Create a generic {@link Configuration} that instructs the Kerberos login module to simply look in the ticket cache, and
+ * not to prompt for passwords.
+ *
+ * See javadoc for {@code com.sun.security.auth.module.Krb5LoginModule} for additional info on the configuration options.
+ */
+ private static Configuration createDefaultConfig() {
+ return new Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ Map opts = new HashMap<>();
+ opts.put("useTicketCache", "true");
+ opts.put("doNotPrompt", "true");
+ return new AppConfigurationEntry[] {
+ new AppConfigurationEntry(
+ "com.sun.security.auth.module.Krb5LoginModule",
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts)
+ };
+ }
+ };
+ }
+}
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java
new file mode 100644
index 000000000000..901338bd7eb3
--- /dev/null
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyAuthGenerator.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.nio.netty.internal;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+
+/**
+ * Generates the auth params for an {@code Authorization} HTTP header.
+ */
+@SdkInternalApi
+public interface ProxyAuthGenerator {
+ /**
+ * The name of the auth scheme this generator supports.
+ */
+ ProxyAuthScheme scheme();
+
+ /**
+ * Generate the auth params for this request.
+ *
+ * This is asynchronous because generating the params may block - Kerberos, for example, may need to read the ticket cache
+ * from disk and contact the KDC. Implementations that block MUST complete the returned future from a thread other than the
+ * caller's; the caller is a Netty event loop thread, and blocking it would stall every other channel assigned to that loop.
+ */
+ CompletableFuture generateAuthParams(URI proxyEndpoint);
+}
diff --git a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java
index e6f309afbad3..db1be11492e3 100644
--- a/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java
+++ b/http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandler.java
@@ -28,11 +28,11 @@
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpVersion;
-import io.netty.util.CharsetUtil;
import io.netty.util.concurrent.Promise;
import java.io.IOException;
import java.net.URI;
-import java.util.Base64;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.annotations.SdkTestInternalApi;
@@ -47,37 +47,105 @@ public final class ProxyTunnelInitHandler extends ChannelDuplexHandler {
public static final NettyClientLogger log = NettyClientLogger.getLogger(ProxyTunnelInitHandler.class);
private final ChannelPool sourcePool;
- private final String username;
- private final String password;
+ private final URI proxyAddress;
+ private final ProxyAuthGenerator authGenerator;
private final URI remoteHost;
private final Promise initPromise;
private final Supplier httpCodecSupplier;
public ProxyTunnelInitHandler(ChannelPool sourcePool, String proxyUsername, String proxyPassword, URI remoteHost,
Promise initPromise) {
- this(sourcePool, proxyUsername, proxyPassword, remoteHost, initPromise, HttpClientCodec::new);
+ this(sourcePool, null, proxyUsername, proxyPassword, remoteHost, initPromise, HttpClientCodec::new);
}
public ProxyTunnelInitHandler(ChannelPool sourcePool, URI remoteHost, Promise initPromise) {
- this(sourcePool, null, null, remoteHost, initPromise, HttpClientCodec::new);
+ this(sourcePool, null, null, null, remoteHost, initPromise, HttpClientCodec::new);
}
@SdkTestInternalApi
- public ProxyTunnelInitHandler(ChannelPool sourcePool, String prosyUsername, String proxyPassword,
+ public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, String proxyUsername, String proxyPassword,
URI remoteHost, Promise initPromise, Supplier httpCodecSupplier) {
this.sourcePool = sourcePool;
+ this.proxyAddress = proxyAddress;
this.remoteHost = remoteHost;
this.initPromise = initPromise;
- this.username = prosyUsername;
- this.password = proxyPassword;
+ if (!StringUtils.isBlank(proxyUsername) && !StringUtils.isBlank(proxyPassword)) {
+ this.authGenerator = new BasicProxyAuthGenerator(proxyUsername, proxyPassword);
+ } else {
+ this.authGenerator = null;
+ }
this.httpCodecSupplier = httpCodecSupplier;
}
+ public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, ProxyAuthGenerator authGenerator,
+ URI remoteHost, Promise initPromise, Supplier httpCodecSupplier) {
+ this.sourcePool = sourcePool;
+ this.proxyAddress = proxyAddress;
+ this.remoteHost = remoteHost;
+ this.initPromise = initPromise;
+ this.authGenerator = authGenerator;
+ this.httpCodecSupplier = httpCodecSupplier;
+ }
+
+ public ProxyTunnelInitHandler(ChannelPool sourcePool, URI proxyAddress, ProxyAuthGenerator authGenerator,
+ URI remoteHost, Promise initPromise) {
+ this(sourcePool, proxyAddress, authGenerator, remoteHost, initPromise, HttpClientCodec::new);
+ }
+
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
ChannelPipeline pipeline = ctx.pipeline();
pipeline.addBefore(ctx.name(), null, httpCodecSupplier.get());
- HttpRequest connectRequest = connectRequest();
+
+ if (authGenerator == null) {
+ sendConnectRequest(ctx, null);
+ return;
+ }
+
+ CompletableFuture authParams;
+ try {
+ authParams = authGenerator.generateAuthParams(proxyAddress);
+ } catch (Throwable t) {
+ handleConnectRequestFailure(ctx, t);
+ return;
+ }
+
+ // Basic auth completes inline, so only pay for a thread hop when the generator is actually asynchronous. When it is,
+ // the future completes on another thread, so hop back to the event loop before touching the channel or this
+ // handler's state.
+ boolean completesInline = authParams.isDone();
+ authParams.whenComplete((params, error) -> {
+ if (completesInline) {
+ sendConnectRequestWithAuth(ctx, params, error);
+ } else {
+ ctx.executor().execute(() -> sendConnectRequestWithAuth(ctx, params, error));
+ }
+ });
+ }
+
+ private void sendConnectRequestWithAuth(ChannelHandlerContext ctx, String authParams, Throwable error) {
+ // The channel may have gone away while we were waiting; whoever completed the promise has already cleaned up.
+ if (initPromise.isDone()) {
+ return;
+ }
+
+ if (error != null) {
+ handleConnectRequestFailure(ctx, unwrap(error));
+ return;
+ }
+
+ sendConnectRequest(ctx, String.format("%s %s", authGenerator.scheme().value(), authParams));
+ }
+
+ private void sendConnectRequest(ChannelHandlerContext ctx, String proxyAuthorization) {
+ HttpRequest connectRequest;
+ try {
+ connectRequest = connectRequest(proxyAuthorization);
+ } catch (Throwable t) {
+ handleConnectRequestFailure(ctx, t);
+ return;
+ }
+
ctx.channel().writeAndFlush(connectRequest).addListener(f -> {
if (!f.isSuccess()) {
handleConnectRequestFailure(ctx, f.cause());
@@ -85,6 +153,10 @@ public void handlerAdded(ChannelHandlerContext ctx) {
});
}
+ private static Throwable unwrap(Throwable t) {
+ return t instanceof CompletionException && t.getCause() != null ? t.getCause() : t;
+ }
+
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
if (ctx.pipeline().get(HttpClientCodec.class) != null) {
@@ -145,16 +217,14 @@ private void closeAndRelease(ChannelHandlerContext ctx) {
sourcePool.release(ctx.channel());
}
- private HttpRequest connectRequest() {
+ private HttpRequest connectRequest(String proxyAuthorization) {
String uri = getUri();
HttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.CONNECT, uri,
Unpooled.EMPTY_BUFFER);
request.headers().add(HttpHeaderNames.HOST, uri);
- if (!StringUtils.isEmpty(this.username) && !StringUtils.isEmpty(this.password)) {
- String authToken = String.format("%s:%s", this.username, this.password);
- String authB64 = Base64.getEncoder().encodeToString(authToken.getBytes(CharsetUtil.UTF_8));
- request.headers().add(HttpHeaderNames.PROXY_AUTHORIZATION, String.format("Basic %s", authB64));
+ if (proxyAuthorization != null) {
+ request.headers().add(HttpHeaderNames.PROXY_AUTHORIZATION, proxyAuthorization);
}
return request;
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java
index 06d57c1aa7d2..57f9ec44483a 100644
--- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/ProxyConfigurationTest.java
@@ -16,6 +16,7 @@
package software.amazon.awssdk.http.nio.netty;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -23,9 +24,12 @@
import java.util.Random;
import java.util.Set;
import java.util.stream.Stream;
+import org.apache.logging.log4j.Level;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+import software.amazon.awssdk.testutils.LogCaptor;
/**
* Tests for {@link ProxyConfiguration}.
@@ -147,6 +151,100 @@ void setNonProxyHostsToNull_createsEmptySet() {
assertThat(cfg.nonProxyHosts()).isEmpty();
}
+ @Test
+ void build_basicAuthSchemeWithoutCredentials_throws() {
+ ProxyConfiguration.Builder builder = ProxyConfiguration.builder()
+ .host("localhost")
+ .port(8888)
+ .proxyAuthScheme(ProxyAuthScheme.BASIC);
+
+ assertThatThrownBy(builder::build)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("username and password must be configured");
+ }
+
+ @Test
+ void build_basicAuthSchemeWithoutPassword_throws() {
+ ProxyConfiguration.Builder builder = ProxyConfiguration.builder()
+ .host("localhost")
+ .port(8888)
+ .proxyAuthScheme(ProxyAuthScheme.BASIC)
+ .username("user");
+
+ assertThatThrownBy(builder::build)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("username and password must be configured");
+ }
+
+ @Test
+ void build_basicAuthSchemeWithCredentials_doesNotThrow() {
+ ProxyConfiguration cfg = ProxyConfiguration.builder()
+ .host("localhost")
+ .port(8888)
+ .proxyAuthScheme(ProxyAuthScheme.BASIC)
+ .username("user")
+ .password("pass")
+ .build();
+
+ assertThat(cfg.proxyAuthScheme()).isEqualTo(ProxyAuthScheme.BASIC);
+ }
+
+ @Test
+ void build_basicAuthSchemeWithSystemPropertyCredentials_doesNotThrow() {
+ setHttpProxyProperties();
+
+ ProxyConfiguration cfg = ProxyConfiguration.builder()
+ .proxyAuthScheme(ProxyAuthScheme.BASIC)
+ .build();
+
+ assertThat(cfg.username()).isEqualTo(TEST_USER);
+ assertThat(cfg.password()).isEqualTo(TEST_PASSWORD);
+ }
+
+ @Test
+ void build_negotiateAuthSchemeWithoutCredentials_doesNotThrow() {
+ ProxyConfiguration cfg = ProxyConfiguration.builder()
+ .host("localhost")
+ .port(8888)
+ .proxyAuthScheme(ProxyAuthScheme.NEGOTIATE)
+ .build();
+
+ assertThat(cfg.proxyAuthScheme()).isEqualTo(ProxyAuthScheme.NEGOTIATE);
+ }
+
+ @Test
+ void build_negotiateAuthSchemeWithCredentials_warnsAndKeepsBuilding() {
+ try (LogCaptor logCaptor = LogCaptor.create(Level.WARN)) {
+ ProxyConfiguration cfg = ProxyConfiguration.builder()
+ .host("localhost")
+ .port(8888)
+ .proxyAuthScheme(ProxyAuthScheme.NEGOTIATE)
+ .username(TEST_USER)
+ .password(TEST_PASSWORD)
+ .build();
+
+ assertThat(cfg.proxyAuthScheme()).isEqualTo(ProxyAuthScheme.NEGOTIATE);
+ assertThat(logCaptor.loggedEvents()).singleElement()
+ .satisfies(event -> assertThat(event.getMessage().getFormattedMessage())
+ .contains("NEGOTIATE")
+ .contains("will be ignored"));
+ }
+ }
+
+ @Test
+ void build_negotiateAuthSchemeWithSystemPropertyCredentials_doesNotWarn() {
+ setHttpProxyProperties();
+
+ try (LogCaptor logCaptor = LogCaptor.create(Level.WARN)) {
+ ProxyConfiguration.builder()
+ .proxyAuthScheme(ProxyAuthScheme.NEGOTIATE)
+ .build();
+
+ // Credentials the caller did not set here are not their mistake to fix, so warning about them would be noise.
+ assertThat(logCaptor.loggedEvents()).isEmpty();
+ }
+ }
+
@Test
void toBuilderModified_doesNotModifySource() {
ProxyConfiguration original = allPropertiesSetConfig();
@@ -185,7 +283,11 @@ private void setRandomValue(Object o, Method setter) throws InvocationTargetExce
setter.invoke(o, randomSet());
} else if (Boolean.class.equals(paramClass)) {
setter.invoke(o, RNG.nextBoolean());
- } else {
+ } else if (ProxyAuthScheme.class.equals(paramClass)) {
+ ProxyAuthScheme authScheme = ProxyAuthScheme.values()[RNG.nextInt(ProxyAuthScheme.values().length)];
+ setter.invoke(o, authScheme);
+ }
+ else {
throw new RuntimeException("Don't know how create random value for type " + paramClass);
}
}
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java
index a1d4b9781f35..8838e230329e 100644
--- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/AwaitCloseChannelPoolMapTest.java
@@ -23,45 +23,83 @@
import static software.amazon.awssdk.http.SdkHttpConfigurationOption.GLOBAL_HTTP_DEFAULTS;
import static software.amazon.awssdk.http.SdkHttpConfigurationOption.TLS_KEY_MANAGERS_PROVIDER;
-import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.github.tomakehurst.wiremock.WireMockServer;
import io.netty.channel.Channel;
import io.netty.channel.pool.ChannelPool;
import io.netty.handler.ssl.SslProvider;
-import io.netty.util.CharsetUtil;
import io.netty.util.concurrent.Future;
+import java.net.InetSocketAddress;
+import java.net.Socket;
import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
-import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
import org.apache.commons.lang3.RandomStringUtils;
-import org.junit.After;
-import org.junit.Rule;
-import org.junit.Test;
+import org.apache.kerby.kerberos.kerb.client.KrbClient;
+import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer;
+import org.apache.kerby.kerberos.kerb.type.ticket.TgtTicket;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import software.amazon.awssdk.http.Protocol;
import software.amazon.awssdk.http.ProtocolNegotiation;
import software.amazon.awssdk.http.TlsKeyManagersProvider;
+import software.amazon.awssdk.http.ProxyAuthScheme;
import software.amazon.awssdk.http.nio.netty.ProxyConfiguration;
import software.amazon.awssdk.http.nio.netty.RecordingNetworkTrafficListener;
import software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup;
import software.amazon.awssdk.utils.AttributeMap;
public class AwaitCloseChannelPoolMapTest {
+ private static final String KRB5_PROP = "java.security.krb5.conf";
+ private static final RecordingNetworkTrafficListener recorder = new RecordingNetworkTrafficListener();
- private final RecordingNetworkTrafficListener recorder = new RecordingNetworkTrafficListener();
+ private static WireMockServer mockProxy;
+
+ private static Path tempDir;
+ private static Path keytabFile;
+ private static Path ccacheFile;
+ private static int port;
+
+ private static SimpleKdcServer kdc;
+ private static String krb5PropSave;
+
+ private static Configuration negotiateAuthConfig;
private AwaitCloseChannelPoolMap channelPoolMap;
- @Rule
- public WireMockRule mockProxy = new WireMockRule(wireMockConfig()
- .dynamicPort()
- .networkTrafficListener(recorder));
+ @BeforeAll
+ public static void setup() throws Exception {
+ mockProxy = new WireMockServer(wireMockConfig().dynamicPort().networkTrafficListener(recorder));
+ mockProxy.start();
+
+ setupMockKerberos();
+ }
+
+ @AfterAll
+ public static void teardown() throws Exception {
+ if (krb5PropSave != null) {
+ System.setProperty(KRB5_PROP, krb5PropSave);
+ } else {
+ System.clearProperty(KRB5_PROP);
+ }
+ mockProxy.stop();
+ kdc.stop();
+ }
- @After
+ @AfterEach
public void methodTeardown() {
if (channelPoolMap != null) {
channelPoolMap.close();
@@ -71,6 +109,56 @@ public void methodTeardown() {
recorder.reset();
}
+ private static void setupMockKerberos() throws Exception {
+ tempDir = Files.createTempDirectory(null);
+ keytabFile = tempDir.resolve("keytab");
+ ccacheFile = tempDir.resolve("ccache");
+
+ try (Socket freePort = new Socket()) {
+ freePort.setReuseAddress(true);
+ freePort.bind(new InetSocketAddress(0));
+ port = freePort.getLocalPort();
+
+ kdc = new SimpleKdcServer();
+ kdc.setKdcRealm("EXAMPLE.COM");
+ kdc.setKdcHost("localhost");
+ kdc.setWorkDir(tempDir.toFile());
+ kdc.setKdcTcpPort(port);
+ kdc.setAllowUdp(false);
+ kdc.init();
+
+ krb5PropSave = System.getProperty(KRB5_PROP);
+
+ System.setProperty(KRB5_PROP, tempDir.resolve("krb5.conf").toAbsolutePath().toString());
+ kdc.start();
+
+ kdc.createPrincipal("alice@EXAMPLE.COM", "alicePassword");
+ kdc.createAndExportPrincipals(keytabFile.toFile(), "HTTP/localhost@EXAMPLE.COM");
+
+ // initialize the ticket cache
+ KrbClient krbClient = kdc.getKrbClient();
+ TgtTicket tgt = krbClient.requestTgt("alice@EXAMPLE.COM", "alicePassword");
+ krbClient.storeTicket(tgt, ccacheFile.toFile());
+
+ // Override config so we look at the testing cache instead of the real system cache
+ negotiateAuthConfig = new Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ Map opts = new HashMap<>();
+ opts.put("useTicketCache", "true");
+ opts.put("ticketCache", ccacheFile.toAbsolutePath().toString());
+ opts.put("refreshKrb5Config", "true");
+ opts.put("doNotPrompt", "true");
+ return new AppConfigurationEntry[] {
+ new AppConfigurationEntry(
+ "com.sun.security.auth.module.Krb5LoginModule",
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts)
+ };
+ }
+ };
+ }
+ }
+
@Test
public void close_underlyingPoolsShouldBeClosed() {
channelPoolMap = AwaitCloseChannelPoolMap.builder()
@@ -216,13 +304,16 @@ public void usingProxy_noSchemeGiven_defaultsToHttp() {
assertThat(requests).contains("CONNECT some-awesome-service:443");
}
- @Test
- public void usingProxy_withAuth() {
+ @ParameterizedTest
+ @MethodSource("proxyAuthTestParams")
+ public void usingProxy_authHeaderCorrect(ProxyAuthScheme authScheme, String username, String password,
+ String proxyAuthHeader) {
ProxyConfiguration proxyConfiguration = ProxyConfiguration.builder()
.host("localhost")
.port(mockProxy.port())
- .username("myuser")
- .password("mypassword")
+ .proxyAuthScheme(authScheme)
+ .username(username)
+ .password(password)
.build();
channelPoolMap = AwaitCloseChannelPoolMap.builder()
@@ -233,6 +324,7 @@ public void usingProxy_withAuth() {
.protocol(Protocol.HTTP1_1)
.maxStreams(100)
.sslProvider(SslProvider.OPENSSL)
+ .negotiateAuthConfig(negotiateAuthConfig)
.build();
SimpleChannelPoolAwareChannelPool simpleChannelPoolAwareChannelPool = channelPoolMap.newPool(
@@ -244,9 +336,11 @@ public void usingProxy_withAuth() {
assertThat(requests).contains("CONNECT some-awesome-service:443");
- String authB64 = Base64.getEncoder().encodeToString("myuser:mypassword".getBytes(CharsetUtil.UTF_8));
- String authHeaderValue = String.format("Basic %s", authB64);
- assertThat(requests).contains(String.format("proxy-authorization: %s", authHeaderValue));
+ if (proxyAuthHeader == null) {
+ assertThat(requests).doesNotContain("proxy-authorization:");
+ } else {
+ assertThat(requests).contains(String.format("proxy-authorization: %s", proxyAuthHeader));
+ }
}
@Test
@@ -309,4 +403,14 @@ public void releaseChannel_autoReadEnabled() {
assertThat(channel.config().isAutoRead()).isTrue();
}
+ private static Stream proxyAuthTestParams() {
+ return Stream.of(
+ Arguments.of(null, null, null, null),
+ Arguments.of(null, "user", "pass", "Basic dXNlcjpwYXNz"),
+ Arguments.of(ProxyAuthScheme.BASIC, "user", "pass", "Basic dXNlcjpwYXNz"),
+ Arguments.of(ProxyAuthScheme.NEGOTIATE, null, null, "Negotiate YII"),
+ Arguments.of(ProxyAuthScheme.NEGOTIATE, "user", "pass", "Negotiate YII")
+
+ );
+ }
}
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java
new file mode 100644
index 000000000000..99ea7397bd94
--- /dev/null
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/BasicProxyAuthGeneratorTest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.nio.netty.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.http.ProxyAuthScheme;
+
+public class BasicProxyAuthGeneratorTest {
+ private static final String USERNAME = "user";
+ private static final String PASSWORD = "pass";
+
+ private final BasicProxyAuthGenerator authGenerator = new BasicProxyAuthGenerator(USERNAME, PASSWORD);
+
+ @ParameterizedTest(name = "username = {0}, password = {1}, expected error = {2}")
+ @MethodSource("invalidCtorParams")
+ void ctor_paramsInvalid_throws(String username, String password, String errorMessage) {
+ assertThatThrownBy(() -> new BasicProxyAuthGenerator(username, password))
+ .hasMessageContaining(errorMessage);
+ }
+
+ @Test
+ void scheme_returnsCorrectValue() {
+ assertThat(authGenerator.scheme()).isEqualTo(ProxyAuthScheme.BASIC);
+ }
+
+ @Test
+ void generateAuthParams_generatedCorrectly() {
+ String expected = Base64.getEncoder()
+ .encodeToString(String.format("%s:%s", USERNAME, PASSWORD)
+ .getBytes(StandardCharsets.UTF_8));
+
+ assertThat(authGenerator.generateAuthParams(URI.create("http://amazon.com")).join()).isEqualTo(expected);
+ }
+
+ private static Stream invalidCtorParams() {
+ return Stream.of(
+ Arguments.of(null, null, "username"),
+ Arguments.of("", "", "username"),
+ Arguments.of(null, PASSWORD, "username"),
+ Arguments.of("", PASSWORD, "username"),
+ Arguments.of(USERNAME, null, "password"),
+ Arguments.of(USERNAME, "", "password")
+
+ );
+ }
+}
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java
index d43b404f3f5f..b06eee6eaadc 100644
--- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/Http1TunnelConnectionPoolTest.java
@@ -42,6 +42,8 @@
import io.netty.util.concurrent.Promise;
import java.io.IOException;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import javax.net.ssl.SSLEngine;
@@ -73,6 +75,8 @@ public class Http1TunnelConnectionPoolTest {
private static final String PROXY_PASSWORD = "mypassword";
+ private static final ProxyAuthGenerator basicAuth = new BasicProxyAuthGenerator(PROXY_USER, PROXY_PASSWORD);
+
@Mock
private ChannelPool delegatePool;
@@ -115,7 +119,7 @@ public static void teardown() {
@Test
public void tunnelAlreadyEstablished_doesNotAddInitHandler() {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration);
when(mockAttr.get()).thenReturn(true);
@@ -127,7 +131,7 @@ public void tunnelAlreadyEstablished_doesNotAddInitHandler() {
@Test(timeout = 1000)
public void tunnelNotEstablished_addsInitHandler() throws InterruptedException {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration);
when(mockAttr.get()).thenReturn(false);
@@ -149,7 +153,7 @@ public void tunnelInitFails_acquireFutureFails() {
};
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS,null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
+ HTTP_PROXY_ADDRESS,null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
Future acquireFuture = tunnelPool.acquire();
@@ -164,7 +168,7 @@ public void tunnelInitSucceeds_acquireFutureSucceeds() {
};
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
Future acquireFuture = tunnelPool.acquire();
@@ -174,7 +178,7 @@ public void tunnelInitSucceeds_acquireFutureSucceeds() {
@Test
public void acquireFromDelegatePoolFails_failsFuture() {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration);
when(delegatePool.acquire(any(Promise.class))).thenReturn(GROUP.next().newFailedFuture(new IOException("boom")));
@@ -197,7 +201,7 @@ public void sslContextProvided_andProxyUsingHttps_addsSslHandler() {
};
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, mockSslCtx,
- HTTPS_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
+ HTTPS_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
tunnelPool.acquire().awaitUninterruptibly();
@@ -218,7 +222,7 @@ public void sslContextProvided_andProxyNotUsingHttps_doesNotAddSslHandler() {
};
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, mockSslCtx,
- HTTP_PROXY_ADDRESS, null, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, supplier, configuration);
tunnelPool.acquire().awaitUninterruptibly();
@@ -231,7 +235,7 @@ public void sslContextProvided_andProxyNotUsingHttps_doesNotAddSslHandler() {
@Test
public void release_releasedToDelegatePool() {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS,null, REMOTE_ADDRESS, mockHandler, configuration);
tunnelPool.release(mockChannel);
verify(delegatePool).release(eq(mockChannel), any(Promise.class));
}
@@ -239,7 +243,7 @@ public void release_releasedToDelegatePool() {
@Test
public void release_withGivenPromise_releasedToDelegatePool() {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration);
Promise mockPromise = mock(Promise.class);
tunnelPool.release(mockChannel, mockPromise);
verify(delegatePool).release(eq(mockChannel), eq(mockPromise));
@@ -248,7 +252,7 @@ public void release_withGivenPromise_releasedToDelegatePool() {
@Test
public void close_closesDelegatePool() {
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, REMOTE_ADDRESS, mockHandler, configuration);
+ HTTP_PROXY_ADDRESS, null, REMOTE_ADDRESS, mockHandler, configuration);
tunnelPool.close();
verify(delegatePool).close();
}
@@ -257,42 +261,33 @@ public void close_closesDelegatePool() {
public void proxyAuthProvided_addInitHandler_withAuth(){
TestInitHandlerData data = new TestInitHandlerData();
- Http1TunnelConnectionPool.InitHandlerSupplier supplier = (srcPool, proxyUser, proxyPassword, remoteAddr, initFuture) -> {
+ Http1TunnelConnectionPool.InitHandlerSupplier supplier =
+ (srcPool, proxyEndpoint, proxyAuthGenerator, remoteAddr, initFuture) -> {
initFuture.setSuccess(mockChannel);
- data.proxyUser(proxyUser);
- data.proxyPassword(proxyPassword);
+ data.authHeader = proxyAuthGenerator.generateAuthParams(proxyEndpoint).join();
return mock(ChannelHandler.class);
};
Http1TunnelConnectionPool tunnelPool = new Http1TunnelConnectionPool(GROUP.next(), delegatePool, null,
- HTTP_PROXY_ADDRESS, PROXY_USER, PROXY_PASSWORD, REMOTE_ADDRESS, mockHandler, supplier, configuration);
+ HTTP_PROXY_ADDRESS, basicAuth, REMOTE_ADDRESS, mockHandler, supplier, configuration);
tunnelPool.acquire().awaitUninterruptibly();
- assertThat(data.proxyUser()).isEqualTo(PROXY_USER);
- assertThat(data.proxyPassword()).isEqualTo(PROXY_PASSWORD);
-
+ String expectedAuthHeader = Base64.getEncoder().encodeToString((PROXY_USER + ":" + PROXY_PASSWORD)
+ .getBytes(StandardCharsets.UTF_8));
+ assertThat(data.authHeader()).isEqualTo(expectedAuthHeader);
}
private static class TestInitHandlerData {
- private String proxyUser;
- private String proxyPassword;
-
- public void proxyUser(String proxyUser) {
- this.proxyUser = proxyUser;
- }
-
- public String proxyUser() {
- return this.proxyUser;
- }
+ private String authHeader;
- public void proxyPassword(String proxyPassword) {
- this.proxyPassword = proxyPassword;
+ public void authHeader(String authHeader) {
+ this.authHeader = authHeader;
}
- public String proxyPassword(){
- return this.proxyPassword;
+ public String authHeader() {
+ return authHeader;
}
}
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java
new file mode 100644
index 000000000000..97f2c3c2f4e1
--- /dev/null
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/NegotiateProxyAuthGeneratorTest.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. 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.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.http.nio.netty.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import org.apache.kerby.kerberos.kerb.KrbException;
+import org.apache.kerby.kerberos.kerb.client.KrbClient;
+import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer;
+import org.apache.kerby.kerberos.kerb.type.ticket.TgtTicket;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.testutils.FileUtils;
+
+public class NegotiateProxyAuthGeneratorTest {
+ private static final String KRB5_PROP = "java.security.krb5.conf";
+ private static final String EXECUTOR_THREAD_NAME = "test-proxy-auth";
+ private static final ExecutorService executor =
+ Executors.newSingleThreadExecutor(r -> new Thread(r, EXECUTOR_THREAD_NAME));
+ private static Path tempDir;
+ private static Path keytabFile;
+ private static Path ccacheFile;
+ private static int port;
+
+ private static SimpleKdcServer kdc;
+ private static String krb5PropSave;
+
+ private static Configuration config;
+
+ @BeforeAll
+ static void setup() throws IOException, KrbException {
+ tempDir = Files.createTempDirectory(null);
+ keytabFile = tempDir.resolve("keytab");
+ ccacheFile = tempDir.resolve("ccache");
+
+ try (Socket freePort = new Socket()) {
+ freePort.setReuseAddress(true);
+ freePort.bind(new InetSocketAddress(0));
+ port = freePort.getLocalPort();
+
+ kdc = new SimpleKdcServer();
+ kdc.setKdcRealm("EXAMPLE.COM");
+ kdc.setKdcHost("localhost");
+ kdc.setWorkDir(tempDir.toFile());
+ kdc.setKdcTcpPort(port);
+ kdc.setAllowUdp(false);
+ kdc.init();
+
+ krb5PropSave = System.getProperty(KRB5_PROP);
+
+ System.setProperty(KRB5_PROP, tempDir.resolve("krb5.conf").toAbsolutePath().toString());
+
+ kdc.start();
+
+ kdc.createPrincipal("alice@EXAMPLE.COM", "alicePassword");
+ kdc.createAndExportPrincipals(keytabFile.toFile(), "HTTP/localhost@EXAMPLE.COM");
+
+ // initialize the ticket cache
+ KrbClient krbClient = kdc.getKrbClient();
+ TgtTicket tgt = krbClient.requestTgt("alice@EXAMPLE.COM", "alicePassword");
+ krbClient.storeTicket(tgt, ccacheFile.toFile());
+
+ // Override config so we look at the testing cache instead of the real system cache
+ config = new Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ Map opts = new HashMap<>();
+ opts.put("useTicketCache", "true");
+ opts.put("ticketCache", ccacheFile.toAbsolutePath().toString());
+ opts.put("doNotPrompt", "true");
+ opts.put("refreshKrb5Config", "true");
+ return new AppConfigurationEntry[] {
+ new AppConfigurationEntry(
+ "com.sun.security.auth.module.Krb5LoginModule",
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts)
+ };
+ }
+ };
+ }
+
+ }
+
+ @AfterAll
+ static void teardown() throws KrbException {
+ executor.shutdownNow();
+ if (krb5PropSave != null) {
+ System.setProperty(KRB5_PROP, krb5PropSave);
+ } else {
+ System.clearProperty(KRB5_PROP);
+ }
+ kdc.stop();
+ FileUtils.cleanUpTestDirectory(tempDir);
+ }
+
+ @Test
+ void generateAuthParams_configValid_successfullyGeneratesToken() {
+ NegotiateProxyAuthGenerator authGenerator = new NegotiateProxyAuthGenerator(config, executor);
+
+ URI proxyEndpoint = URI.create("https://localhost:8192");
+
+ assertThat(authGenerator.generateAuthParams(proxyEndpoint).join()).startsWith("YII");
+ }
+
+ @Test
+ void generateAuthParams_ticketCacheMissing_failsWithActionableMessage() {
+ Configuration missingCacheConfig = new Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ Map opts = new HashMap<>();
+ opts.put("useTicketCache", "true");
+ opts.put("ticketCache", tempDir.resolve("nonexistent-cache").toAbsolutePath().toString());
+ opts.put("doNotPrompt", "true");
+ opts.put("refreshKrb5Config", "true");
+ return new AppConfigurationEntry[] {
+ new AppConfigurationEntry(
+ "com.sun.security.auth.module.Krb5LoginModule",
+ AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, opts)
+ };
+ }
+ };
+
+ NegotiateProxyAuthGenerator authGenerator = new NegotiateProxyAuthGenerator(missingCacheConfig, executor);
+
+ assertThatThrownBy(() -> authGenerator.generateAuthParams(URI.create("https://localhost:8192")).join())
+ .isInstanceOf(CompletionException.class)
+ .hasCauseInstanceOf(RuntimeException.class)
+ .hasMessageContaining("kinit")
+ .hasMessageContaining("ticket cache");
+ }
+
+ @Test
+ void generateAuthParams_runsOnSuppliedExecutor() {
+ AtomicReference loginThread = new AtomicReference<>();
+ Configuration recordingConfig = new Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ loginThread.set(Thread.currentThread());
+ return config.getAppConfigurationEntry(name);
+ }
+ };
+
+ new NegotiateProxyAuthGenerator(recordingConfig, executor).generateAuthParams(URI.create("https://localhost:8192"))
+ .join();
+
+ // The blocking Kerberos work must never run on the caller's thread, which in production is a Netty event loop.
+ assertThat(loginThread.get()).isNotSameAs(Thread.currentThread());
+ assertThat(loginThread.get().getName()).isEqualTo(EXECUTOR_THREAD_NAME);
+ }
+
+ @Test
+ void constructor_nullExecutor_throws() {
+ assertThatThrownBy(() -> new NegotiateProxyAuthGenerator(config, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("executor");
+ }
+
+}
diff --git a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java
index 9836a953bda9..8bc20a165b25 100644
--- a/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java
+++ b/http-clients/netty-nio-client/src/test/java/software/amazon/awssdk/http/nio/netty/internal/ProxyTunnelInitHandlerTest.java
@@ -16,6 +16,7 @@
package software.amazon.awssdk.http.nio.netty.internal;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -41,10 +42,12 @@
import io.netty.handler.ssl.SslCloseCompletionEvent;
import io.netty.handler.ssl.SslHandler;
import io.netty.util.CharsetUtil;
+import io.netty.util.concurrent.EventExecutor;
import io.netty.util.concurrent.Promise;
import java.io.IOException;
import java.net.URI;
import java.util.Base64;
+import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
import org.junit.AfterClass;
import org.junit.Before;
@@ -53,6 +56,7 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
+import software.amazon.awssdk.http.ProxyAuthScheme;
/**
* Unit tests for {@link ProxyTunnelInitHandler}.
@@ -95,7 +99,8 @@ public void addedToPipeline_addsCodec() {
Supplier codecSupplier = () -> codec;
when(mockCtx.name()).thenReturn("foo");
- ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, null, null, REMOTE_HOST, null, codecSupplier);
+ ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, null, null, null, REMOTE_HOST, null,
+ codecSupplier);
handler.handlerAdded(mockCtx);
verify(mockPipeline).addBefore(eq("foo"), eq(null), eq(codec));
@@ -202,7 +207,7 @@ public void handlerRemoved_removesCodec() {
}
@Test
- public void handledAdded_writesRequest_withoutAuth() {
+ public void handlerAdded_writesRequest_withoutAuth() {
Promise promise = GROUP.next().newPromise();
ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, REMOTE_HOST, promise);
handler.handlerAdded(mockCtx);
@@ -219,7 +224,7 @@ public void handledAdded_writesRequest_withoutAuth() {
}
@Test
- public void handledAdded_writesRequest_withAuth() {
+ public void handlerAdded_writesRequest_withAuth() {
Promise promise = GROUP.next().newPromise();
ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, PROXY_USER, PROXY_PASSWORD, REMOTE_HOST, promise);
handler.handlerAdded(mockCtx);
@@ -238,6 +243,71 @@ public void handledAdded_writesRequest_withAuth() {
assertThat(requestCaptor.getValue()).isEqualTo(expectedRequest);
}
+ @Test
+ public void handlerAdded_authParamsGeneratorThrows_failsFuture() {
+ ProxyAuthGenerator authGenerator = mock(ProxyAuthGenerator.class);
+ when(authGenerator.generateAuthParams(any(URI.class))).thenThrow(new RuntimeException("auth generator error"));
+
+ Promise promise = GROUP.next().newPromise();
+ ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, URI.create("https://amazon.com"),
+ authGenerator,
+ REMOTE_HOST,
+ promise);
+ handler.handlerAdded(mockCtx);
+
+ assertThatThrownBy(promise::get).hasMessageContaining("Unable to send CONNECT request to proxy")
+ .hasRootCauseMessage("auth generator error");
+ }
+
+ @Test
+ public void handlerAdded_authParamsCompleteAsynchronously_writesRequestOnceParamsAvailable() throws Exception {
+ CompletableFuture authParams = new CompletableFuture<>();
+ ProxyAuthGenerator authGenerator = mock(ProxyAuthGenerator.class);
+ when(authGenerator.scheme()).thenReturn(ProxyAuthScheme.NEGOTIATE);
+ when(authGenerator.generateAuthParams(any(URI.class))).thenReturn(authParams);
+
+ EventExecutor executor = GROUP.next();
+ when(mockCtx.executor()).thenReturn(executor);
+
+ Promise promise = GROUP.next().newPromise();
+ ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, URI.create("https://proxy.com"),
+ authGenerator, REMOTE_HOST, promise);
+ handler.handlerAdded(mockCtx);
+
+ // The calling thread must not have waited for the auth params, so nothing is written yet.
+ verify(mockChannel, never()).writeAndFlush(any());
+
+ authParams.complete("token");
+ // Drain the executor so the queued continuation has run.
+ executor.submit(() -> { }).get();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(mockChannel).writeAndFlush(requestCaptor.capture());
+ assertThat(requestCaptor.getValue().headers().get(HttpHeaderNames.PROXY_AUTHORIZATION)).isEqualTo("Negotiate token");
+ }
+
+ @Test
+ public void handlerAdded_authParamsFailAsynchronously_failsFuture() throws Exception {
+ CompletableFuture authParams = new CompletableFuture<>();
+ ProxyAuthGenerator authGenerator = mock(ProxyAuthGenerator.class);
+ when(authGenerator.generateAuthParams(any(URI.class))).thenReturn(authParams);
+
+ EventExecutor executor = GROUP.next();
+ when(mockCtx.executor()).thenReturn(executor);
+
+ Promise promise = GROUP.next().newPromise();
+ ProxyTunnelInitHandler handler = new ProxyTunnelInitHandler(mockChannelPool, URI.create("https://proxy.com"),
+ authGenerator, REMOTE_HOST, promise);
+ handler.handlerAdded(mockCtx);
+
+ authParams.completeExceptionally(new RuntimeException("auth generator error"));
+ executor.submit(() -> { }).get();
+
+ verify(mockChannel, never()).writeAndFlush(any());
+ assertThatThrownBy(promise::get).hasMessageContaining("Unable to send CONNECT request to proxy")
+ .hasRootCauseMessage("auth generator error");
+ }
+
private void successResponse(ProxyTunnelInitHandler handler) {
DefaultHttpResponse resp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
handler.channelRead(mockCtx, resp);
diff --git a/pom.xml b/pom.xml
index 9459c2aaa0c6..f026127f6ee3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -151,6 +151,7 @@
1.17.5
1.3.0
1.5.4
+ 2.0.3
3.1.2