diff --git a/README.md b/README.md index 55e50a7..cfd616a 100644 --- a/README.md +++ b/README.md @@ -683,7 +683,9 @@ with httpx.Client() as client: Note that the following changes were made compared to `requests-aws4auth`: - Each request now has its own signing key and `x-amz-date`. Meaning **you can use the same auth instance for more than one request**. - `session_token` was renamed into `security_token` for consistency with the underlying name at Amazon. - - `include_hdrs` parameter was renamed into `include_headers` and provided values will not be stripped, [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG). + - `include_hdrs` parameter was renamed into `include_headers`. When using this parameter: + - Provided values will not be stripped, [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG). + - If multiple values are provided for a same header, the computation will be based on the value order you provided and value separated by `, `. Instead of ordered values separated by comma for `requests-aws4auth`. - `amz_date` attribute has been removed. - It is not possible to provide a `date`. It will default to now. - It is not possible to provide an `AWSSigningKey` instance, use explicit parameters instead. diff --git a/httpx_auth/aws.py b/httpx_auth/aws.py index c0eed73..9194e92 100644 --- a/httpx_auth/aws.py +++ b/httpx_auth/aws.py @@ -131,11 +131,6 @@ def _get_canonical_headers(self, req: httpx.Request) -> Tuple[str, str]: Return the Canonical Headers and the Signed Headers strs as a tuple (canonical_headers, signed_headers). """ - # Aggregate for upper/lowercase header name collisions in header names, - # AMZ requires values of colliding headers be concatenated into a - # single header with lowercase name. Although this is not possible with - # Requests, since it uses a case-insensitive dict to hold headers, this - # is here just in case you duck type with a regular dict included_headers = {} for header, header_value in req.headers.items(): if (header or "*") in self.include_headers or ( @@ -144,17 +139,13 @@ def _get_canonical_headers(self, req: httpx.Request) -> Tuple[str, str]: # x-amz-client-context break mobile analytics auth if included and not header == "x-amz-client-context" ): - header_values = included_headers.setdefault(header, []) - header_values.append(_amz_norm_whitespace(header_value)) + included_headers[header] = _amz_norm_whitespace(header_value) canonical_headers = "" signed_headers = [] for header in sorted(included_headers): signed_headers.append(header) - - header_values = included_headers[header] - header_values = ",".join(sorted(header_values)) - canonical_headers += f"{header}:{header_values}\n" + canonical_headers += f"{header}:{included_headers[header]}\n" signed_headers = ";".join(signed_headers) diff --git a/tests/aws_signature_v4/test_aws4auth_async.py b/tests/aws_signature_v4/test_aws4auth_async.py index cb0333d..071f04d 100644 --- a/tests/aws_signature_v4/test_aws4auth_async.py +++ b/tests/aws_signature_v4/test_aws4auth_async.py @@ -265,6 +265,47 @@ async def test_aws_auth_does_not_strips_header_names( ) +@time_machine.travel("2018-10-11T15:05:05.663979+00:00", tick=False) +@pytest.mark.asyncio +async def test_aws_auth_header_with_multiple_values( + httpx_mock: HTTPXMock, +): + auth = httpx_auth.AWS4Auth( + access_id="access_id", + secret_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + region="us-east-1", + service="iam", + include_headers=[ + "Host", + "content-type", + "date", + "cusTom", + "x-amz-*", + ], + ) + + httpx_mock.add_response( + url="https://authorized_only", + method="POST", + match_headers={ + "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Authorization": "AWS4-HMAC-SHA256 Credential=access_id/20181011/us-east-1/iam/aws4_request, SignedHeaders=custom;host;x-amz-content-sha256;x-amz-date, Signature=77fcee19291cb9334678ca7221729baab12848ce49225561477ce95c44222dfb", + "x-amz-date": "20181011T150505Z", + "Custom": "value2, value1", + "custoM": "value3", + }, + ) + + async with httpx.AsyncClient() as client: + await client.post( + "https://authorized_only", + headers=httpx.Headers( + [("Custom", "value2"), ("Custom", "value1"), ("custoM", "value3")] + ), + auth=auth, + ) + + @time_machine.travel("2018-10-11T15:05:05.663979+00:00", tick=False) @pytest.mark.asyncio async def test_aws_auth_with_security_token_and_content_in_request( diff --git a/tests/aws_signature_v4/test_aws4auth_sync.py b/tests/aws_signature_v4/test_aws4auth_sync.py index 2073c89..a5fcaa4 100644 --- a/tests/aws_signature_v4/test_aws4auth_sync.py +++ b/tests/aws_signature_v4/test_aws4auth_sync.py @@ -256,6 +256,46 @@ def test_aws_auth_does_not_strips_header_names( ) +@time_machine.travel("2018-10-11T15:05:05.663979+00:00", tick=False) +def test_aws_auth_header_with_multiple_values( + httpx_mock: HTTPXMock, +): + auth = httpx_auth.AWS4Auth( + access_id="access_id", + secret_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + region="us-east-1", + service="iam", + include_headers=[ + "Host", + "content-type", + "date", + "cusTom", + "x-amz-*", + ], + ) + + httpx_mock.add_response( + url="https://authorized_only", + method="POST", + match_headers={ + "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Authorization": "AWS4-HMAC-SHA256 Credential=access_id/20181011/us-east-1/iam/aws4_request, SignedHeaders=custom;host;x-amz-content-sha256;x-amz-date, Signature=77fcee19291cb9334678ca7221729baab12848ce49225561477ce95c44222dfb", + "x-amz-date": "20181011T150505Z", + "Custom": "value2, value1", + "custoM": "value3", + }, + ) + + with httpx.Client() as client: + client.post( + "https://authorized_only", + headers=httpx.Headers( + [("Custom", "value2"), ("Custom", "value1"), ("custoM", "value3")] + ), + auth=auth, + ) + + @time_machine.travel("2018-10-11T15:05:05.663979+00:00", tick=False) def test_aws_auth_with_security_token_and_content_in_request(httpx_mock: HTTPXMock): auth = httpx_auth.AWS4Auth(