Skip to content

Commit

Permalink
put_aws_sigv4: Add (hidden for now) option :into
Browse files Browse the repository at this point in the history
  • Loading branch information
wojtekmach committed Mar 26, 2024
1 parent 7b7868a commit 7616d39
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 25 deletions.
68 changes: 43 additions & 25 deletions lib/req/steps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1479,41 +1479,59 @@ defmodule Req.Steps do
:secret_access_key,
:service,
:region,
:datetime
:datetime,
:into
])

{body, options} =
case request.body do
nil ->
{"", []}
{into, aws_options} = Keyword.pop(aws_options, :into, :headers)

iodata when is_binary(iodata) or is_list(iodata) ->
{iodata, []}
case into do
:headers ->
{body, options} =
case request.body do
nil ->
{"", []}

_enumerable ->
if Req.Request.get_header(request, "content-length") == [] do
raise "content-length header must be explicitly set when streaming request body"
iodata when is_binary(iodata) or is_list(iodata) ->
{iodata, []}

_enumerable ->
if Req.Request.get_header(request, "content-length") == [] do
raise "content-length header must be explicitly set when streaming request body"
end

{"", [body_digest: "UNSIGNED-PAYLOAD"]}
end

{"", [body_digest: "UNSIGNED-PAYLOAD"]}
end
request = Req.Request.put_new_header(request, "host", request.url.host)

request = Req.Request.put_new_header(request, "host", request.url.host)
headers = for {name, values} <- request.headers, value <- values, do: {name, value}

headers = for {name, values} <- request.headers, value <- values, do: {name, value}
headers =
Req.Utils.aws_sigv4(
aws_options ++
[
method: request.method,
url: to_string(request.url),
headers: headers,
body: body
] ++ options
)

headers =
Req.Utils.aws_sigv4(
aws_options ++
[
method: request.method,
url: to_string(request.url),
headers: headers,
body: body
] ++ options
)
Req.merge(request, headers: headers)

Req.merge(request, headers: headers)
:url ->
url =
Req.Utils.aws_sigv4_url(
aws_options ++
[
method: request.method,
url: to_string(request.url)
]
)

put_in(request.url, url)
end
else
request
end
Expand Down
77 changes: 77 additions & 0 deletions lib/req/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,83 @@ defmodule Req.Utils do
] ++ headers
end

@doc """
Create AWS Signature v4 URL.
https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
"""
def aws_sigv4_url(options) do
{access_key_id, options} = Keyword.pop!(options, :access_key_id)
{secret_access_key, options} = Keyword.pop!(options, :secret_access_key)
{region, options} = Keyword.pop!(options, :region)
{service, options} = Keyword.pop!(options, :service)
{datetime, options} = Keyword.pop!(options, :datetime)
{method, options} = Keyword.pop!(options, :method)
{url, options} = Keyword.pop!(options, :url)
[] = options

datetime = DateTime.truncate(datetime, :second)
datetime_string = DateTime.to_iso8601(datetime, :basic)
date_string = Date.to_iso8601(datetime, :basic)
method = method |> Atom.to_string() |> String.upcase()
url = URI.parse(url)
service = to_string(service)

canonical_query_string =
URI.encode_query([
{"X-Amz-Algorithm", "AWS4-HMAC-SHA256"},
{"X-Amz-Credential", "#{access_key_id}/#{date_string}/#{region}/#{service}/aws4_request"},
{"X-Amz-Date", datetime_string},
{"X-Amz-Expires", 86400},
{"X-Amz-SignedHeaders", "host"}
])

canonical_headers = [{"host", URI.parse(url).host}]

signed_headers =
Enum.map_intersperse(
Enum.sort(canonical_headers),
";",
&String.downcase(elem(&1, 0), :ascii)
)

true = url.query in [nil, ""]

canonical_request =
iodata("""
#{String.upcase(method)}
#{url.path || "/"}
#{canonical_query_string}
#{Enum.map_intersperse(canonical_headers, "\n", fn {name, value} -> [name, ":", value] end)}
#{signed_headers}
UNSIGNED-PAYLOAD\
""")

string_to_sign =
iodata("""
AWS4-HMAC-SHA256
#{datetime_string}
#{date_string}/#{region}/#{service}/aws4_request
#{hex(sha256(canonical_request))}\
""")

signature =
["AWS4", secret_access_key]
|> hmac(date_string)
|> hmac(region)
|> hmac(service)
|> hmac("aws4_request")
|> hmac(string_to_sign)
|> hex()

put_in(
url.query,
canonical_query_string <>
"&X-Amz-Signature=#{signature}"
)
end

defp hex(data) do
Base.encode16(data, case: :lower)
end
Expand Down
6 changes: 6 additions & 0 deletions test/req/integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,11 @@ defmodule Req.IntegrationTest do
)

assert Req.get!(req, url: "/key1").body == String.duplicate(now, 2)

req = Req.merge(req, url: "/key1")
req = put_in(req.options[:aws_sigv4][:into], :url)
url = Req.Request.prepare(req).url

assert Req.get!(url).body == String.duplicate(now, 2)
end
end
23 changes: 23 additions & 0 deletions test/req/utils_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,27 @@ defmodule Req.UtilsTest do
Enum.map(signature2, fn {name, value} -> {String.downcase(name), value} end)
end
end

describe "aws_sigv4_url" do
test "GET" do
options = [
access_key_id: "dummy-access-key-id",
secret_access_key: "dummy-secret-access-key",
region: "dummy-region",
service: "s3",
datetime: ~U[2024-01-01 09:00:00Z],
method: :get,
url: "https://s3"
]

assert to_string(Req.Utils.aws_sigv4_url(options)) ==
"https://s3?" <>
"X-Amz-Algorithm=AWS4-HMAC-SHA256" <>
"&X-Amz-Credential=dummy-access-key-id%2F20240101%2Fdummy-region%2Fs3%2Faws4_request" <>
"&X-Amz-Date=20240101T090000Z" <>
"&X-Amz-Expires=86400" <>
"&X-Amz-SignedHeaders=host" <>
"&X-Amz-Signature=684b112675beaf7f858dbf650cc12c5aa3d0eeb15fa4038ea809149f3c6476e3"
end
end
end

0 comments on commit 7616d39

Please sign in to comment.