Skip to content

Commit cafd79e

Browse files
Introduce aws sigv4a request signer (#303) (#323)
* Introduce aws sigv4a request signer * Use default provider when metadata provider is unavaliable * Move shutdown logic to application end * sbt fmt --------- (cherry picked from commit c877e09) Signed-off-by: Louis Chu <clingzhi@amazon.com> Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 5b3526f commit cafd79e

File tree

9 files changed

+505
-55
lines changed

9 files changed

+505
-55
lines changed

build.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ lazy val flintCore = (project in file("flint-core"))
6363
exclude ("com.fasterxml.jackson.core", "jackson-databind"),
6464
"com.amazonaws" % "aws-java-sdk-cloudwatch" % "1.12.593"
6565
exclude("com.fasterxml.jackson.core", "jackson-databind"),
66+
"software.amazon.awssdk" % "auth-crt" % "2.25.23",
6667
"org.scalactic" %% "scalactic" % "3.2.15" % "test",
6768
"org.scalatest" %% "scalatest" % "3.2.15" % "test",
6869
"org.scalatest" %% "scalatest-flatspec" % "3.2.15" % "test",
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.flint.core.auth;
7+
8+
import com.amazonaws.auth.AWSSessionCredentials;
9+
import com.amazonaws.auth.AWSCredentialsProvider;
10+
import com.amazonaws.services.glue.model.InvalidStateException;
11+
import org.apache.http.Header;
12+
import org.apache.http.HttpEntity;
13+
import org.apache.http.HttpEntityEnclosingRequest;
14+
import org.apache.http.HttpHost;
15+
import org.apache.http.HttpRequest;
16+
import org.apache.http.HttpRequestInterceptor;
17+
import org.apache.http.client.utils.URIBuilder;
18+
import org.apache.http.entity.BasicHttpEntity;
19+
import org.apache.http.message.BasicHeader;
20+
import org.apache.http.protocol.HttpContext;
21+
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
22+
import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute;
23+
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
24+
import software.amazon.awssdk.core.signer.Signer;
25+
import software.amazon.awssdk.http.SdkHttpFullRequest;
26+
import software.amazon.awssdk.http.SdkHttpMethod;
27+
import software.amazon.awssdk.regions.Region;
28+
29+
import java.io.IOException;
30+
import java.io.InputStream;
31+
import java.io.UncheckedIOException;
32+
import java.net.URISyntaxException;
33+
import java.util.ArrayList;
34+
import java.util.List;
35+
import java.util.Map;
36+
import java.util.TreeMap;
37+
import java.util.logging.Level;
38+
import java.util.logging.Logger;
39+
40+
import static org.apache.http.protocol.HttpCoreContext.HTTP_TARGET_HOST;
41+
import static org.opensearch.flint.core.auth.AWSRequestSigningApacheInterceptor.nvpToMapParams;
42+
import static org.opensearch.flint.core.auth.AWSRequestSigningApacheInterceptor.skipHeader;
43+
44+
/**
45+
* Interceptor for signing AWS requests according to Signature Version 4A.
46+
* This interceptor processes HTTP requests, signs them with AWS credentials,
47+
* and updates the request headers to include the signature.
48+
*/
49+
public class AWSRequestSigV4ASigningApacheInterceptor implements HttpRequestInterceptor {
50+
private static final Logger LOG = Logger.getLogger(AWSRequestSigV4ASigningApacheInterceptor.class.getName());
51+
52+
private static final String HTTPS_PROTOCOL = "https";
53+
private static final int HTTPS_PORT = 443;
54+
55+
private final String service;
56+
private final String region;
57+
private final Signer signer;
58+
private final AWSCredentialsProvider awsCredentialsProvider;
59+
60+
/**
61+
* Constructs an interceptor for AWS request signing with metadata access.
62+
*
63+
* @param service The AWS service name.
64+
* @param region The AWS region for signing.
65+
* @param signer The signer implementation.
66+
* @param awsCredentialsProvider The credentials provider for metadata access.
67+
*/
68+
public AWSRequestSigV4ASigningApacheInterceptor(String service, String region, Signer signer, AWSCredentialsProvider awsCredentialsProvider) {
69+
this.service = service;
70+
this.region = region;
71+
this.signer = signer;
72+
this.awsCredentialsProvider = awsCredentialsProvider;
73+
}
74+
75+
/**
76+
* Processes and signs an HTTP request, updating its headers with the signature.
77+
*
78+
* @param request the HTTP request to process and sign.
79+
* @param context the HTTP context associated with the request.
80+
* @throws IOException if an I/O error occurs during request processing.
81+
*/
82+
@Override
83+
public void process(HttpRequest request, HttpContext context) throws IOException {
84+
SdkHttpFullRequest requestToSign = buildSdkHttpRequest(request, context);
85+
SdkHttpFullRequest signedRequest = signRequest(requestToSign);
86+
updateRequestHeaders(request, signedRequest.headers());
87+
updateRequestEntity(request, signedRequest);
88+
}
89+
90+
/**
91+
* Builds an {@link SdkHttpFullRequest} from the Apache {@link HttpRequest}.
92+
*
93+
* @param request the HTTP request to process and sign.
94+
* @param context the HTTP context associated with the request.
95+
* @return an SDK HTTP request ready to be signed.
96+
* @throws IOException if an error occurs while building the request.
97+
*/
98+
private SdkHttpFullRequest buildSdkHttpRequest(HttpRequest request, HttpContext context) throws IOException {
99+
URIBuilder uriBuilder = parseUri(request);
100+
SdkHttpFullRequest.Builder builder = SdkHttpFullRequest.builder()
101+
.method(SdkHttpMethod.fromValue(request.getRequestLine().getMethod()))
102+
.protocol(HTTPS_PROTOCOL)
103+
.port(HTTPS_PORT)
104+
.headers(headerArrayToMap(request.getAllHeaders()))
105+
.rawQueryParameters(nvpToMapParams(uriBuilder.getQueryParams()));
106+
107+
HttpHost host = (HttpHost) context.getAttribute(HTTP_TARGET_HOST);
108+
if (host == null) {
109+
throw new InvalidStateException("Host must not be null");
110+
}
111+
builder.host(host.getHostName());
112+
try {
113+
builder.encodedPath(uriBuilder.build().getRawPath());
114+
} catch (URISyntaxException e) {
115+
throw new IOException("Invalid URI", e);
116+
}
117+
setRequestEntity(request, builder);
118+
return builder.build();
119+
}
120+
121+
/**
122+
* Sets the request entity for the {@link SdkHttpFullRequest.Builder} if the original request contains an entity.
123+
* This is used for requests that have a body, such as POST or PUT requests.
124+
*
125+
* @param request the original HTTP request.
126+
* @param builder the SDK HTTP request builder.
127+
*/
128+
private void setRequestEntity(HttpRequest request, SdkHttpFullRequest.Builder builder) {
129+
if (request instanceof HttpEntityEnclosingRequest) {
130+
HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity();
131+
if (entity != null) {
132+
builder.contentStreamProvider(() -> {
133+
try {
134+
return entity.getContent();
135+
} catch (IOException e) {
136+
throw new UncheckedIOException(e);
137+
}
138+
});
139+
}
140+
}
141+
}
142+
143+
private URIBuilder parseUri(HttpRequest request) throws IOException {
144+
try {
145+
return new URIBuilder(request.getRequestLine().getUri());
146+
} catch (URISyntaxException e) {
147+
throw new IOException("Invalid URI", e);
148+
}
149+
}
150+
151+
/**
152+
* Signs the given SDK HTTP request using the provided AWS credentials and signer.
153+
*
154+
* @param request the SDK HTTP request to sign.
155+
* @return a signed SDK HTTP request.
156+
*/
157+
private SdkHttpFullRequest signRequest(SdkHttpFullRequest request) {
158+
AWSSessionCredentials sessionCredentials = (AWSSessionCredentials) awsCredentialsProvider.getCredentials();
159+
AwsSessionCredentials awsCredentials = AwsSessionCredentials.create(
160+
sessionCredentials.getAWSAccessKeyId(),
161+
sessionCredentials.getAWSSecretKey(),
162+
sessionCredentials.getSessionToken()
163+
);
164+
165+
ExecutionAttributes executionAttributes = new ExecutionAttributes()
166+
.putAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS, awsCredentials)
167+
.putAttribute(AwsSignerExecutionAttribute.SERVICE_SIGNING_NAME, service)
168+
.putAttribute(AwsSignerExecutionAttribute.SIGNING_REGION, Region.of(region));
169+
170+
try {
171+
return signer.sign(request, executionAttributes);
172+
} catch (Exception e) {
173+
LOG.log(Level.SEVERE, "Error Sigv4a signing the request", e);
174+
throw e;
175+
}
176+
}
177+
178+
/**
179+
* Updates the HTTP request headers with the signed headers.
180+
*
181+
* @param request the original HTTP request.
182+
* @param signedHeaders the headers after signing.
183+
*/
184+
private void updateRequestHeaders(HttpRequest request, Map<String, List<String>> signedHeaders) {
185+
Header[] headers = convertHeaderMapToArray(signedHeaders);
186+
request.setHeaders(headers);
187+
}
188+
189+
/**
190+
* Updates the request entity based on the signed request. This is used to update the request body after signing.
191+
*
192+
* @param request the original HTTP request.
193+
* @param signedRequest the signed SDK HTTP request.
194+
*/
195+
private void updateRequestEntity(HttpRequest request, SdkHttpFullRequest signedRequest) {
196+
if (request instanceof HttpEntityEnclosingRequest) {
197+
HttpEntityEnclosingRequest httpEntityEnclosingRequest = (HttpEntityEnclosingRequest) request;
198+
signedRequest.contentStreamProvider().ifPresent(provider -> {
199+
InputStream contentStream = provider.newStream();
200+
BasicHttpEntity basicHttpEntity = new BasicHttpEntity();
201+
basicHttpEntity.setContent(contentStream);
202+
signedRequest.firstMatchingHeader("Content-Length").ifPresent(value ->
203+
basicHttpEntity.setContentLength(Long.parseLong(value)));
204+
signedRequest.firstMatchingHeader("Content-Type").ifPresent(basicHttpEntity::setContentType);
205+
httpEntityEnclosingRequest.setEntity(basicHttpEntity);
206+
});
207+
}
208+
}
209+
210+
/**
211+
* Converts an array of {@link Header} objects into a map, consolidating multiple values for the same header name.
212+
*
213+
* @param headers the array of {@link Header} objects to convert.
214+
* @return a map where each key is a header name and each value is a list of header values.
215+
*/
216+
private static Map<String, List<String>> headerArrayToMap(final Header[] headers) {
217+
Map<String, List<String>> headersMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
218+
for (Header header : headers) {
219+
if (!skipHeader(header)) {
220+
headersMap.computeIfAbsent(header.getName(), k -> new ArrayList<>()).add(header.getValue());
221+
}
222+
}
223+
return headersMap;
224+
}
225+
226+
/**
227+
* Converts a map of headers back into an array of {@link Header} objects.
228+
*
229+
* @param mapHeaders the map of headers to convert.
230+
* @return an array of {@link Header} objects.
231+
*/
232+
private Header[] convertHeaderMapToArray(final Map<String, List<String>> mapHeaders) {
233+
return mapHeaders.entrySet().stream()
234+
.map(entry -> new BasicHeader(entry.getKey(), String.join(",", entry.getValue())))
235+
.toArray(Header[]::new);
236+
}
237+
}

flint-core/src/main/scala/org/opensearch/flint/core/auth/AWSRequestSigningApacheInterceptor.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public void process(final HttpRequest request, final HttpContext context)
126126
* @param params list of HTTP query params as NameValuePairs
127127
* @return a multimap of HTTP query params
128128
*/
129-
private static Map<String, List<String>> nvpToMapParams(final List<NameValuePair> params) {
129+
static Map<String, List<String>> nvpToMapParams(final List<NameValuePair> params) {
130130
Map<String, List<String>> parameterMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
131131
for (NameValuePair nvp : params) {
132132
List<String> argsList =
@@ -154,10 +154,11 @@ private static Map<String, String> headerArrayToMap(final Header[] headers) {
154154
* @param header header line to check
155155
* @return true if the given header should be excluded when signing
156156
*/
157-
private static boolean skipHeader(final Header header) {
157+
static boolean skipHeader(final Header header) {
158158
return ("content-length".equalsIgnoreCase(header.getName())
159-
&& "0".equals(header.getValue())) // Strip Content-Length: 0
160-
|| "host".equalsIgnoreCase(header.getName()); // Host comes from endpoint
159+
&& "0".equals(header.getValue())) // Strip Content-Length: 0
160+
|| "host".equalsIgnoreCase(header.getName()) // Host comes from endpoint
161+
|| "connection".equalsIgnoreCase(header.getName()); // Skip setting Connection manually
161162
}
162163

163164
/**

flint-core/src/main/scala/org/opensearch/flint/core/auth/ResourceBasedAWSRequestSigningApacheInterceptor.java

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
16
package org.opensearch.flint.core.auth;
27

8+
import com.amazonaws.auth.AWS4Signer;
39
import com.amazonaws.auth.AWSCredentialsProvider;
4-
import com.amazonaws.auth.Signer;
510
import org.apache.http.HttpException;
611
import org.apache.http.HttpRequest;
712
import org.apache.http.HttpRequestInterceptor;
813
import org.apache.http.client.utils.URIBuilder;
914
import org.apache.http.protocol.HttpContext;
15+
import org.jetbrains.annotations.TestOnly;
16+
import org.opensearch.common.Strings;
17+
import software.amazon.awssdk.authcrt.signer.AwsCrtV4aSigner;
1018

1119
import java.io.IOException;
1220
import java.net.URISyntaxException;
@@ -19,33 +27,45 @@ public class ResourceBasedAWSRequestSigningApacheInterceptor implements HttpRequ
1927

2028
private final String service;
2129
private final String metadataAccessIdentifier;
22-
final AWSRequestSigningApacheInterceptor primaryInterceptor;
23-
final AWSRequestSigningApacheInterceptor metadataAccessInterceptor;
30+
final HttpRequestInterceptor primaryInterceptor;
31+
final HttpRequestInterceptor metadataAccessInterceptor;
2432

2533
/**
2634
* Constructs an interceptor for AWS request signing with optional metadata access.
2735
*
2836
* @param service The AWS service name.
29-
* @param signer The AWS request signer.
37+
* @param region The AWS region for signing.
3038
* @param primaryCredentialsProvider The credentials provider for general access.
3139
* @param metadataAccessCredentialsProvider The credentials provider for metadata access.
3240
* @param metadataAccessIdentifier Identifier for operations requiring metadata access.
3341
*/
3442
public ResourceBasedAWSRequestSigningApacheInterceptor(final String service,
35-
final Signer signer,
43+
final String region,
3644
final AWSCredentialsProvider primaryCredentialsProvider,
3745
final AWSCredentialsProvider metadataAccessCredentialsProvider,
3846
final String metadataAccessIdentifier) {
39-
this(service,
40-
new AWSRequestSigningApacheInterceptor(service, signer, primaryCredentialsProvider),
41-
new AWSRequestSigningApacheInterceptor(service, signer, metadataAccessCredentialsProvider),
42-
metadataAccessIdentifier);
47+
if (Strings.isNullOrEmpty(service)) {
48+
throw new IllegalArgumentException("Service name must not be null or empty.");
49+
}
50+
if (Strings.isNullOrEmpty(region)) {
51+
throw new IllegalArgumentException("Region must not be null or empty.");
52+
}
53+
this.service = service;
54+
this.metadataAccessIdentifier = metadataAccessIdentifier;
55+
AWS4Signer signer = new AWS4Signer();
56+
signer.setServiceName(service);
57+
signer.setRegionName(region);
58+
this.primaryInterceptor = new AWSRequestSigningApacheInterceptor(service, signer, primaryCredentialsProvider);
59+
this.metadataAccessInterceptor = primaryCredentialsProvider.equals(metadataAccessCredentialsProvider)
60+
? this.primaryInterceptor
61+
: new AWSRequestSigV4ASigningApacheInterceptor(service, region, AwsCrtV4aSigner.builder().build(), metadataAccessCredentialsProvider);
4362
}
4463

4564
// Test constructor allowing injection of mock interceptors
65+
@TestOnly
4666
ResourceBasedAWSRequestSigningApacheInterceptor(final String service,
47-
final AWSRequestSigningApacheInterceptor primaryInterceptor,
48-
final AWSRequestSigningApacheInterceptor metadataAccessInterceptor,
67+
final HttpRequestInterceptor primaryInterceptor,
68+
final HttpRequestInterceptor metadataAccessInterceptor,
4969
final String metadataAccessIdentifier) {
5070
this.service = service == null ? "unknown" : service;
5171
this.primaryInterceptor = primaryInterceptor;
@@ -94,6 +114,6 @@ private String parseUriToPath(HttpRequest request) throws IOException {
94114
* @return true if the operation requires metadata access credentials, false otherwise.
95115
*/
96116
private boolean isMetadataAccess(String resourcePath) {
97-
return resourcePath.contains(metadataAccessIdentifier);
117+
return !Strings.isNullOrEmpty(metadataAccessIdentifier) && resourcePath.contains(metadataAccessIdentifier);
98118
}
99119
}

0 commit comments

Comments
 (0)