This is an automated email from the ASF dual-hosted git repository. kenhuuu pushed a commit to branch empty-params in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit fea79dc72c5c80b5b6dac9db81095f9cab5670d7 Author: Ken Hu <[email protected]> AuthorDate: Wed Jul 15 18:15:27 2026 -0700 Fix SigV4 request signing across GLVs to sign a minimal header set CTR The SigV4 auth interceptors signed transport-managed headers (accept-encoding, content-type, content-length) that the underlying HTTP stacks add or rewrite after signing, producing signatures that no longer matched the bytes sent and were rejected by SigV4-verifying endpoints. Java, JavaScript and .NET now sign only host and the headers the AWS SDK adds itself (x-amz-date, the payload hash, and x-amz-security-token for session credentials); Python already did this. Go additionally strips the default port (443/80) from the signed and sent Host and signs its session token. Assisted-by: Claude Code:claude-opus-4-8 --- gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs | 32 +++++---- .../test/Gremlin.Net.UnitTest/Driver/AuthTests.cs | 50 +++++++++++++ .../tinkerpop/gremlin/driver/auth/Sigv4.java | 16 ++--- .../tinkerpop/gremlin/driver/auth/Sigv4Test.java | 42 ++++++++++- gremlin-go/driver/auth/auth.go | 29 ++++++-- gremlin-go/driver/auth/auth_test.go | 62 ++++++++++++++++ gremlin-js/gremlin-javascript/lib/driver/auth.ts | 10 ++- .../gremlin-javascript/test/unit/auth-test.js | 46 ++++++++++++ .../main/python/tests/unit/driver/test_sigv4.py | 82 ++++++++++++++++++++++ 9 files changed, 339 insertions(+), 30 deletions(-) diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs index d713dc9473..69c6e1600a 100644 --- a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs +++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs @@ -62,8 +62,8 @@ namespace Gremlin.Net.Driver /// used and the resolved provider is cached on first use (the provider itself handles /// credential refresh for expiring credentials like STS). /// </summary> - /// <param name="region">The AWS region (e.g. "us-east-1").</param> - /// <param name="service">The AWS service name (e.g. "neptune-db").</param> + /// <param name="region">The region.</param> + /// <param name="service">The service name.</param> /// <param name="credentials"> /// Optional AWS credentials. When null, the default credential chain is used. /// </param> @@ -125,14 +125,12 @@ namespace Gremlin.Net.Driver OverrideSigningServiceName = clientConfig.AuthenticationServiceName, }; - // Copy headers (skip Host — signer adds it) - foreach (var header in context.Headers) - { - if (!string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase)) - { - awsRequest.Headers[header.Key] = header.Value; - } - } + // Signed header set: host (derived from the endpoint), x-amz-content-sha256 and + // x-amz-date, plus x-amz-security-token for session credentials (all set below). The + // request's own headers (accept, content-type, accept-encoding, ...) are deliberately + // NOT copied into the signature: the HTTP client may add or rewrite transport-managed + // headers after this interceptor runs, so a signature covering them would not match + // the bytes sent. // Copy query parameters var query = context.Uri.Query; @@ -154,6 +152,13 @@ namespace Gremlin.Net.Driver var payloadHash = context.GetPayloadHash(); awsRequest.Headers["x-amz-content-sha256"] = payloadHash; + // For temporary credentials, add the session token BEFORE signing so it is bound into + // the signature rather than sent as a weaker present-but-unsigned header. + if (!string.IsNullOrEmpty(credentials.Token)) + { + awsRequest.Headers["X-Amz-Security-Token"] = credentials.Token; + } + // Sign the request signer.Sign(awsRequest, clientConfig, new RequestMetrics(), credentials); @@ -171,10 +176,11 @@ namespace Gremlin.Net.Driver } context.Headers["x-amz-content-sha256"] = payloadHash; - // Add session token if temporary credentials - if (!string.IsNullOrEmpty(credentials.Token)) + // Copy back the session token that was signed above (for temporary credentials), so + // the header on the wire matches exactly what the signature covered. + if (awsRequest.Headers.ContainsKey("X-Amz-Security-Token")) { - context.Headers["X-Amz-Security-Token"] = credentials.Token; + context.Headers["X-Amz-Security-Token"] = awsRequest.Headers["X-Amz-Security-Token"]; } } diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs index 76c9910d7f..c001c17313 100644 --- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs +++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs @@ -241,5 +241,55 @@ namespace Gremlin.Net.UnitTest.Driver Assert.Contains("byte[]", ex.Message); } + + // Only host and the headers the AWS SDK adds itself are signed. Transport-managed headers + // (accept-encoding, content-type, ...) are never signed, and the session token is signed + // only when session credentials are used. + private static HttpRequestContext CreateSigv4ContextWithTransportHeaders() + { + return new HttpRequestContext("POST", new Uri("https://example.com:8182/gremlin"), + new Dictionary<string, string> + { + { "Accept", "application/vnd.graphbinary-v4.0" }, + { "Content-Type", "application/json" }, + { "Accept-Encoding", "deflate" }, + { "User-Agent", "gremlin-dotnet-test" }, + }, + Encoding.UTF8.GetBytes("{\"gremlin\":\"g.V().count()\"}")); + } + + private static string SignedHeaders(HttpRequestContext context) + { + var authorization = context.Headers["Authorization"]; + const string marker = "SignedHeaders="; + var start = authorization.IndexOf(marker, StringComparison.Ordinal) + marker.Length; + var end = authorization.IndexOf(',', start); + return end < 0 ? authorization.Substring(start) : authorization.Substring(start, end - start); + } + + [Fact] + public async Task SigV4AuthShouldSignOnlyMinimalHeaderSetForBasicCredentials() + { + var interceptor = Auth.Sigv4("region-1", "example-service", TestBasicCredentials); + var context = CreateSigv4ContextWithTransportHeaders(); + + await interceptor(context); + + // The .NET SDK signer includes x-amz-content-sha256 in the signed set; that is its + // natural behavior and is intentionally left as-is. + Assert.Equal("host;x-amz-content-sha256;x-amz-date", SignedHeaders(context)); + } + + [Fact] + public async Task SigV4AuthShouldSignSecurityTokenForSessionCredentials() + { + var interceptor = Auth.Sigv4("region-1", "example-service", TestSessionCredentials); + var context = CreateSigv4ContextWithTransportHeaders(); + + await interceptor(context); + + Assert.Equal("host;x-amz-content-sha256;x-amz-date;x-amz-security-token", SignedHeaders(context)); + Assert.Equal("MOCK_TOKEN", context.Headers["X-Amz-Security-Token"]); + } } } diff --git a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java index a19e75d749..2dd786a575 100644 --- a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java +++ b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4.java @@ -137,18 +137,14 @@ public class Sigv4 implements Auth { checkNotNull(request.getUri(), "The request URI must not be null"); checkNotNull(request.getMethod(), "The request method must not be null"); - // convert the headers to the internal API format - final Map<String, String> headers = request.headers(); + // Sign an empty header set: the signer adds (and signs) the Host, X-Amz-Date and + // X-Amz-Content-Sha256 headers itself (plus X-Amz-Security-Token for session + // credentials). The request's own headers (accept, content-type, accept-encoding, + // user-agent, ...) are deliberately NOT signed: transport-managed headers may be added or + // rewritten after this interceptor runs, so a signature covering them would no longer + // match the bytes actually sent. final Map<String, List<String>> headersInternal = new HashMap<>(); - // we don't want to add the Host header as the Signer always adds the host header. - for (Map.Entry<String, String> header : headers.entrySet()) { - // Skip adding the Host header as the signing process will add one. - if (!header.getKey().equalsIgnoreCase(HttpRequest.Headers.HOST)) { - headersInternal.put(header.getKey(), Collections.singletonList(header.getValue())); - } - } - // convert the parameters to the internal API format final URI uri = request.getUri(); final Map<String, List<String>> parametersInternal = extractParametersFromQueryString(uri.getQuery()); diff --git a/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java b/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java index a72bcd0946..1ccbe3ab4c 100644 --- a/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java +++ b/gremlin-driver/src/test/java/org/apache/tinkerpop/gremlin/driver/auth/Sigv4Test.java @@ -49,7 +49,7 @@ import static software.amazon.awssdk.http.auth.aws.internal.signer.util.SignerCo import static software.amazon.awssdk.http.auth.aws.internal.signer.util.SignerConstant.X_AMZ_SECURITY_TOKEN; public class Sigv4Test { - private static final String REGION = "us-west-2"; + private static final String REGION = "region-1"; private static final String SERVICE_NAME = "service-name"; private static final String HOST = "localhost"; private static final String URI_WITH_QUERY_PARAMS = "http://" + HOST + ":8182?a=1&b=2"; @@ -142,4 +142,44 @@ public class Sigv4Test { containsString("/" + REGION + "/service-name/aws4_request"), containsString("Signature="))); } + + @Test + public void shouldSignOnlyMinimalHeaderSetForBasicCredentials() throws Exception { + when(credentialsProvider.resolveCredentials()).thenReturn(AwsBasicCredentials.create(KEY, SECRET)); + final HttpRequest httpRequest = createRequestWithTransportHeaders(); + sigv4.intercept(httpRequest); + + // Only host and the headers the AWS SDK adds itself are signed. Transport-managed headers + // (accept-encoding, content-type, ...) are never signed, or the signature would not match + // what the server reconstructs. + assertEquals("host;x-amz-content-sha256;x-amz-date", signedHeaders(httpRequest)); + } + + @Test + public void shouldSignSecurityTokenForSessionCredentials() throws Exception { + when(credentialsProvider.resolveCredentials()) + .thenReturn(AwsSessionCredentials.create(KEY, SECRET, "session-token")); + final HttpRequest httpRequest = createRequestWithTransportHeaders(); + sigv4.intercept(httpRequest); + + assertEquals("host;x-amz-content-sha256;x-amz-date;x-amz-security-token", signedHeaders(httpRequest)); + } + + private HttpRequest createRequestWithTransportHeaders() throws Exception { + final byte[] body = "{\"gremlin\":\"g.V().count()\"}".getBytes(); + final HttpRequest httpRequest = new HttpRequest(new HashMap<>(), body, new URI(URI_WITH_QUERY_PARAMS)); + // Seed transport-managed / content headers that must NOT end up in SignedHeaders. + httpRequest.headers().put("Accept", "application/vnd.graphbinary-v4.0"); + httpRequest.headers().put("Content-Type", "application/json"); + httpRequest.headers().put("Accept-Encoding", "deflate"); + httpRequest.headers().put("User-Agent", "gremlin-java-test"); + return httpRequest; + } + + private static String signedHeaders(final HttpRequest httpRequest) { + final String authorization = httpRequest.headers().get(AUTHORIZATION); + final int start = authorization.indexOf("SignedHeaders=") + "SignedHeaders=".length(); + final int end = authorization.indexOf(',', start); + return authorization.substring(start, end < 0 ? authorization.length() : end); + } } diff --git a/gremlin-go/driver/auth/auth.go b/gremlin-go/driver/auth/auth.go index d24330179d..804037d1ae 100644 --- a/gremlin-go/driver/auth/auth.go +++ b/gremlin-go/driver/auth/auth.go @@ -26,6 +26,7 @@ import ( "context" "encoding/base64" "fmt" + "net/http" "sync" "time" @@ -98,17 +99,37 @@ func SigV4WithCredentials(region, service string, credentialsProvider aws.Creden return err } - stdReq, err := req.ToStdRequest() + // Signed header set: host, x-amz-date, x-amz-content-sha256, and (for session + // credentials) x-amz-security-token. The request's own headers (accept, content-type, + // ...) are deliberately NOT signed: the HTTP client manages transport headers such as + // Content-Length and Accept-Encoding after this interceptor runs, so a signature covering + // them would not match the bytes actually sent. + + // Strip the default port (443 for https, 80 for http) from the host so the signed + // canonical Host and the Host header sent on the wire both omit it, matching what a + // spec-compliant verifier reconstructs (a bare host). Mutating req.URL here is safe: the + // driver parses a fresh URL per request, and this also fixes the Host sent on the wire. + port := req.URL.Port() + if (req.URL.Scheme == "https" && port == "443") || (req.URL.Scheme == "http" && port == "80") { + req.URL.Host = req.URL.Hostname() + } + + payloadHash := req.PayloadHash() + stdReq, err := http.NewRequest(req.Method, req.URL.String(), nil) if err != nil { return err } - stdReq.Body = nil // Body is handled separately via payload hash + stdReq.Host = req.URL.Host + // Set the payload hash header BEFORE signing so it is part of the signed set: + // aws-sdk-go-v2's SignHTTP takes the hash as a parameter but does not add the header + // itself, and a present-but-unsigned header is rejected by the server. + stdReq.Header.Set("X-Amz-Content-Sha256", payloadHash) - if err := signer.SignHTTP(ctx, creds, stdReq, req.PayloadHash(), service, region, time.Now()); err != nil { + if err := signer.SignHTTP(ctx, creds, stdReq, payloadHash, service, region, time.Now()); err != nil { return err } - // Copy signed headers back to HttpRequest + // Copy the SigV4 output headers back onto the request. for k, v := range stdReq.Header { req.Headers[k] = v } diff --git a/gremlin-go/driver/auth/auth_test.go b/gremlin-go/driver/auth/auth_test.go index 26779d7262..5d1bcbb7ac 100644 --- a/gremlin-go/driver/auth/auth_test.go +++ b/gremlin-go/driver/auth/auth_test.go @@ -140,3 +140,65 @@ func TestSigV4(t *testing.T) { assert.Contains(t, req.Headers.Get("Authorization"), "AWS4-HMAC-SHA256") }) } + +// signedHeadersFromAuth extracts the SignedHeaders list from an Authorization header value. +func signedHeadersFromAuth(authHeader string) string { + const marker = "SignedHeaders=" + idx := strings.Index(authHeader, marker) + if idx < 0 { + return "" + } + rest := authHeader[idx+len(marker):] + if end := strings.Index(rest, ","); end >= 0 { + return rest[:end] + } + return rest +} + +// TestSigV4SignedHeaders pins the signed header set: only host and the headers the AWS SDK adds +// itself are signed, transport-managed headers such as accept-encoding are never signed even when +// present on the request, and the session token is signed only when session credentials are used. +func TestSigV4SignedHeaders(t *testing.T) { + // A default-port (443) https URL so the test also covers host:port stripping: the signed Host + // must be the bare hostname, matching what a spec-compliant verifier reconstructs. + newRequest := func() *gremlingo.HttpRequest { + req, err := gremlingo.NewHttpRequest("POST", "https://example.com:443/gremlin") + assert.NoError(t, err) + // Seed transport-managed and content headers that must NOT end up signed. + req.Headers.Set("Accept", graphBinaryMimeType) + req.Headers.Set("Content-Type", "application/json") + req.Headers.Set("Accept-Encoding", "deflate") + req.Headers.Set("User-Agent", "gremlin-go-test") + req.Body = []byte(`{"gremlin":"g.V().count()"}`) + return req + } + + t.Run("basic credentials sign only host, date, content-sha256", func(t *testing.T) { + req := newRequest() + provider := &mockCredentialsProvider{accessKey: "MOCK_ID", secretKey: "MOCK_KEY"} + err := SigV4WithCredentials("region-1", "example-service", provider)(req) + assert.NoError(t, err) + + signedHeaders := signedHeadersFromAuth(req.Headers.Get("Authorization")) + // aws-sdk-go-v2 leaves x-amz-content-sha256 in the signed set (see auth.go); that is the + // SDK's natural behavior and is intentionally left as-is. + assert.Equal(t, "host;x-amz-content-sha256;x-amz-date", signedHeaders) + assert.NotContains(t, signedHeaders, "accept-encoding") + assert.NotContains(t, signedHeaders, "content-type") + assert.NotContains(t, signedHeaders, "x-amz-security-token") + + // The signed (and sent) Host must omit the default :443 port. + assert.Equal(t, "example.com", req.URL.Host) + }) + + t.Run("session credentials also sign the security token", func(t *testing.T) { + req := newRequest() + provider := &mockCredentialsProvider{accessKey: "MOCK_ID", secretKey: "MOCK_KEY", sessionToken: "MOCK_TOKEN"} + err := SigV4WithCredentials("region-1", "example-service", provider)(req) + assert.NoError(t, err) + + signedHeaders := signedHeadersFromAuth(req.Headers.Get("Authorization")) + assert.Equal(t, "host;x-amz-content-sha256;x-amz-date;x-amz-security-token", signedHeaders) + assert.Equal(t, "MOCK_TOKEN", req.Headers.Get("X-Amz-Security-Token")) + }) +} diff --git a/gremlin-js/gremlin-javascript/lib/driver/auth.ts b/gremlin-js/gremlin-javascript/lib/driver/auth.ts index 4d14f7c4f3..f5b32739dc 100644 --- a/gremlin-js/gremlin-javascript/lib/driver/auth.ts +++ b/gremlin-js/gremlin-javascript/lib/driver/auth.ts @@ -65,6 +65,11 @@ export function sigv4(region: string, service: string, credentialsProvider?: Aws } const url = new URL(request.url); + // Sign only the Host header; the signer itself adds (and signs) x-amz-date, the payload hash, + // and x-amz-security-token for session credentials. The request's own headers (accept, + // content-type, ...) are deliberately NOT fed into the signature: the HTTP stack may add or + // rewrite transport-managed headers such as accept-encoding after this interceptor runs, so a + // signature covering them would no longer match the bytes actually sent. const signed = await signer.sign({ method: request.method, protocol: url.protocol, @@ -72,12 +77,13 @@ export function sigv4(region: string, service: string, credentialsProvider?: Aws port: url.port ? Number(url.port) : undefined, path: url.pathname + url.search, headers: { - ...request.headers, host: url.host, }, body: request.body, }); - request.headers = signed.headers; + // Merge the signed auth headers onto the request; the request's other headers (accept, + // content-type, ...) still reach the wire, they are simply not part of the signature. + request.headers = { ...request.headers, ...signed.headers }; }; } diff --git a/gremlin-js/gremlin-javascript/test/unit/auth-test.js b/gremlin-js/gremlin-javascript/test/unit/auth-test.js index e35fb1f8f4..2ad4e30510 100644 --- a/gremlin-js/gremlin-javascript/test/unit/auth-test.js +++ b/gremlin-js/gremlin-javascript/test/unit/auth-test.js @@ -115,5 +115,51 @@ describe('auth', function () { // Signing adds at least authorization and x-amz-date on top of the originals assert.ok(Object.keys(request.headers).length >= preSignKeys.length + 2); }); + + // Extracts the SignedHeaders list from an Authorization header value. + function signedHeaders(authHeader) { + const marker = 'SignedHeaders='; + const start = authHeader.indexOf(marker) + marker.length; + const end = authHeader.indexOf(',', start); + return end < 0 ? authHeader.substring(start) : authHeader.substring(start, end); + } + + // Only host and the headers the AWS SDK adds itself are signed. Transport-managed headers + // (accept-encoding, content-type, ...) are never signed, and the session token is signed only + // when session credentials are used. + function createRequestWithTransportHeaders() { + return new HttpRequest('POST', 'https://example.com:8182/gremlin', { + 'accept': 'application/vnd.graphbinary-v4.0', + 'content-type': 'application/json', + 'accept-encoding': 'deflate', + 'user-agent': 'gremlin-js-test', + }, Buffer.from('{"gremlin":"g.V().count()"}')); + } + + it('should sign only the minimal header set for basic credentials', async function () { + const request = createRequestWithTransportHeaders(); + const interceptor = sigv4('region-1', 'example-service', mockProvider); + await interceptor(request); + + // smithy's SignatureV4 includes x-amz-content-sha256 in the signed set; that is its + // natural behavior and is intentionally left as-is. + assert.strictEqual(signedHeaders(request.headers['authorization']), + 'host;x-amz-content-sha256;x-amz-date'); + }); + + it('should sign the security token for session credentials', async function () { + const providerWithToken = () => ({ + accessKeyId: 'MOCK_ACCESS_KEY', + secretAccessKey: 'MOCK_SECRET_KEY', + sessionToken: 'MOCK_SESSION_TOKEN', + }); + const request = createRequestWithTransportHeaders(); + const interceptor = sigv4('region-1', 'example-service', providerWithToken); + await interceptor(request); + + assert.strictEqual(signedHeaders(request.headers['authorization']), + 'host;x-amz-content-sha256;x-amz-date;x-amz-security-token'); + assert.strictEqual(request.headers['x-amz-security-token'], 'MOCK_SESSION_TOKEN'); + }); }); }); diff --git a/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py b/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py new file mode 100644 index 0000000000..39eddf7426 --- /dev/null +++ b/gremlin-python/src/main/python/tests/unit/driver/test_sigv4.py @@ -0,0 +1,82 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +"""SigV4 SignedHeaders invariants. + +Only host and the headers the AWS SDK adds itself are signed; transport-managed headers +(accept-encoding, content-type, ...) are never signed, or the signature would not match what +the server reconstructs. The session token is signed only when session credentials are used. + +Unlike the other GLVs, botocore's plain SigV4Auth does not add an ``x-amz-content-sha256`` +header, so Python's SignedHeaders is ``host;x-amz-date`` (the body hash is still bound into the +signature via the canonical request's mandatory payload-hash line). This is the SDK's natural +behavior and is intentionally left as-is. +""" +from botocore.credentials import Credentials + +from gremlin_python.driver.auth import sigv4 +from gremlin_python.driver.http_request import HttpRequest + +ACCESS_KEY = "foo" +SECRET_KEY = "bar" + + +def _signed_headers(request): + lower = {k.lower(): v for k, v in request.headers.items()} + authorization = lower["authorization"] + marker = "SignedHeaders=" + start = authorization.index(marker) + len(marker) + end = authorization.find(",", start) + return authorization[start:] if end < 0 else authorization[start:end] + + +def _make_request(): + # A default-port (443) https URL; seed transport-managed / content headers that must NOT be + # signed. + return HttpRequest( + method="POST", + url="https://example.com:443/gremlin", + headers={ + "accept": "application/vnd.graphbinary-v4.0", + "content-type": "application/json", + "accept-encoding": "deflate", + "user-agent": "gremlin-python-test", + }, + body=b'{"gremlin":"g.V().count()"}', + ) + + +class TestSigV4SignedHeaders: + + def test_basic_credentials_sign_only_host_and_date(self): + creds = Credentials(access_key=ACCESS_KEY, secret_key=SECRET_KEY, token=None) + request = _make_request() + + sigv4("region-1", "example-service", credentials=creds)(request) + + assert _signed_headers(request) == "host;x-amz-date" + + def test_session_credentials_also_sign_the_security_token(self): + creds = Credentials(access_key=ACCESS_KEY, secret_key=SECRET_KEY, token="MOCK_TOKEN") + request = _make_request() + + sigv4("region-1", "example-service", credentials=creds)(request) + + assert _signed_headers(request) == "host;x-amz-date;x-amz-security-token" + lower = {k.lower(): v for k, v in request.headers.items()} + assert lower["x-amz-security-token"] == "MOCK_TOKEN"
