Last active
September 28, 2025 19:09
-
-
Save s1037989/479087c752d7461d3499c12971636678 to your computer and use it in GitHub Desktop.
Use Mojo::UserAgent::Role::AWSSignature4 to interface with AWS REST API, such as controlling EC2 or working uploading and downloading S3 content. Works with S3-compatible APIs, of course, such as Digital Ocean Spaces and Wasabi.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use Mojo::Base -strict; | |
| use Test::More; | |
| plan skip_all => 'set AWS_ACCESS_KEY and AWS_SECRET_KEY to enable this test' | |
| unless $ENV{AWS_ACCESS_KEY} && $ENV{AWS_SECRET_KEY}; | |
| use Mojo::UserAgent; | |
| my $ua = Mojo::UserAgent->with_roles('+AWSSignature4')->new; | |
| my $tx = $ua->build_tx(GET => 'https://ec2.amazonaws.com?Action=DescribeVolumes&Version=2016-11-15' => awssig4 => { | |
| debug => 1, | |
| expires => undef, | |
| region => 'us-east-2', | |
| service => 'ec2', | |
| }); | |
| like $tx->req->headers->authorization, qr($ENV{AWS_ACCESS_KEY}), 'authorization contains access_key'; | |
| # diag explain $tx->req->headers->to_hash; | |
| my $res = $ua->start($tx)->res; | |
| ok $res->dom->find('requestId'), 'has a requestId'; | |
| # diag $res->body; | |
| done_testing; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package Mojo::UserAgent::Role::AWSSignature4; | |
| use Mojo::Base -role, -signatures; | |
| use Digest::SHA; | |
| use Mojo::Collection; | |
| use Time::Piece; | |
| has access_key => sub { $ENV{AWS_ACCESS_KEY} or die 'missing "access_key"' }; | |
| has aws_algorithm => 'AWS4-HMAC-SHA256'; | |
| has content => undef; | |
| has debug => 0; | |
| has expires => 86_400; | |
| has region => 'us-east-1'; | |
| has secret_key => sub { $ENV{AWS_SECRET_KEY} or die 'missing "secret_key"' }; | |
| has service => sub { die 'missing "service"' }; | |
| has session_token => sub { $ENV{AWS_SESSION_TOKEN} || undef }; | |
| has unsigned_payload => 0; | |
| has _tx => sub { die }; | |
| around build_tx => sub ($orig, $self, @args) { | |
| $self->transactor->add_generator(awssig4 => sub { | |
| my ($transactor, $tx, $config) = @_; | |
| my $aws = $self->new({%$config, _tx => $tx}); | |
| $tx->req->content->asset(Mojo::Asset::File->new(path => $aws->content)) if $aws->content; | |
| $tx->req->headers->host($tx->req->url->host || 'localhost'); | |
| $tx->req->headers->header('X-Amz-Date' => $aws->date_timestamp); | |
| $tx->req->headers->header('X-Amz-Content-Sha256' => $aws->hashed_payload); | |
| $tx->req->headers->header('X-Amz-Expires' => $aws->expires) if $aws->expires; | |
| $tx->req->headers->authorization($aws->authorization); | |
| }); | |
| $orig->($self, @args); | |
| }; | |
| sub authorization ($self) { | |
| sprintf | |
| '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', | |
| $self->aws_algorithm, | |
| $self->access_key, | |
| $self->credential_scope, | |
| $self->signed_header_list, | |
| $self->signature | |
| } | |
| sub canonical_headers ($self) { | |
| join '', map { lc($_) . ":" . $self->_tx->req->headers->to_hash->{$_} . "\n" } @{$self->header_list}; | |
| } | |
| sub canonical_qstring { shift->_tx->req->url->query->to_string } | |
| sub canonical_request ($self) { | |
| Mojo::Collection->new( | |
| $self->_tx->req->method, | |
| $self->_tx->req->url->path->to_abs_string, | |
| $self->canonical_qstring, | |
| $self->canonical_headers, | |
| $self->signed_header_list, | |
| $self->hashed_payload | |
| )->tap(sub{ warn $_->map(sub { "CR:$_" })->join("\n") if $self->debug })->join("\n"); | |
| } | |
| sub credential_scope ($self) { | |
| join '/', $self->date, $self->region, $self->service, 'aws4_request'; | |
| } | |
| sub date { shift->time->ymd('') } | |
| sub date_timestamp { $_[0]->time->ymd('').'T'.$_[0]->time->hms('').'Z' } | |
| sub hashed_payload ($self) { | |
| $self->unsigned_payload ? 'UNSIGNED-PAYLOAD' : Digest::SHA::sha256_hex($self->_tx->req->body); | |
| } | |
| sub header_list { [sort keys %{shift->_tx->req->headers->to_hash}] } | |
| sub signature ($self) { | |
| Digest::SHA::hmac_sha256_hex($self->string_to_sign, $self->signing_key); | |
| } | |
| sub signed_header_list { join ';', map { lc($_) } @{shift->header_list} } | |
| sub signed_qstring ($self) { | |
| $self->_tx->req->url->query(['X-Amz-Signature' => $self->signature]); | |
| } | |
| sub signing_key ($self) { | |
| my $kSecret = "AWS4" . $self->secret_key; | |
| my $kDate = Digest::SHA::hmac_sha256($self->date, $kSecret); | |
| my $kRegion = Digest::SHA::hmac_sha256($self->region, $kDate); | |
| my $kService = Digest::SHA::hmac_sha256($self->service, $kRegion); | |
| Digest::SHA::hmac_sha256("aws4_request", $kService); | |
| } | |
| sub string_to_sign ($self) { | |
| Mojo::Collection->new( | |
| $self->aws_algorithm, | |
| $self->date_timestamp, | |
| $self->credential_scope, | |
| Digest::SHA::sha256_hex($self->canonical_request) | |
| )->tap(sub{ warn $_->map(sub { "STS:$_" })->join("\n") if $self->debug })->join("\n"); | |
| } | |
| sub time { gmtime } | |
| 1; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use Mojo::Base -strict, -signatures; | |
| use Test::More; | |
| plan skip_all => 'set AWS_ACCESS_KEY and AWS_SECRET_KEY to enable this test' | |
| unless $ENV{AWS_ACCESS_KEY} && $ENV{AWS_SECRET_KEY}; | |
| use Mojo::UserAgent; | |
| my $ua = Mojo::UserAgent->with_roles('+AWSSignature4')->new; | |
| $ua->transactor->add_generator(stream => sub ($transactor, $tx, $path) { | |
| $tx->req->content->asset(Mojo::Asset::File->new(path => $path)); | |
| }); | |
| subtest 'Describe Volumes : S3 API' => sub { | |
| my $tx = $ua->build_tx(GET => 'https://ec2.amazonaws.com?Action=DescribeVolumes&Version=2016-11-15' => awssig4 => { | |
| debug => 1, | |
| expires => undef, | |
| service => 's3', | |
| }); | |
| like $tx->req->headers->authorization, qr($ENV{AWS_ACCESS_KEY}), 'authorization contains access_key'; | |
| # diag explain $tx->req->headers->to_hash; | |
| my $res = $ua->start($tx)->res; | |
| ok $res->dom->find('requestId'), 'has a requestId'; | |
| # diag $res->body; | |
| }; | |
| subtest 'Upload Object : S3 API' => sub { | |
| my $tx = $ua->build_tx(PUT => sprintf('ec2.amazonaws.com/%s', __FILE__) => awssig4 => { | |
| debug => 1, | |
| expires => undef, | |
| service => 's3', | |
| } => stream => __FILE__); # instead of using "stream" generator, could also do "path(__FILE__)->slurp" | |
| like $tx->req->headers->authorization, qr($ENV{AWS_ACCESS_KEY}), 'authorization contains access_key'; | |
| # diag explain $tx->req->headers->to_hash; | |
| my $res = $ua->start($tx)->res; | |
| ok $res->dom->find('requestId'), 'has a requestId'; | |
| # diag $res->body; | |
| }; | |
| done_testing; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment