package Amazon::Credentials;

# - module for finding and providing AWS credentials

use strict;
use warnings;

use 5.010;

use parent qw( Exporter Class::Accessor::Fast );

__PACKAGE__->follow_best_practice;

__PACKAGE__->mk_accessors(
  qw(
    cache
    container
    debug
    error
    expiration
    imdsv2
    imdsv2_token
    logger
    order
    print_error
    profile
    raise_error
    region
    role
    session_token_required
    source
    sso_role_name
    sso_account_id
    sso_region
    timeout
    user_agent
  )
);

use Amazon::Credentials::HTTP::Response;
use Amazon::Credentials::HTTP::UserAgent;
use Carp;
use Cwd;
use Data::Dumper;
use English qw(-no_match_vars);
use Exporter;
use HTTP::Request;
use JSON::PP qw(decode_json encode_json);
use List::Util qw(pairs any);
use Scalar::Util qw(reftype);

use constant { ## no critic (ProhibitConstantPragma, Capitalization)
  AWS_AVAILABILITY_ZONE_URL        => q{latest/meta-data/placement/availability-zone},
  AWS_CONTAINER_CREDENTIALS_URL    => q{http://169.254.170.2/},
  AWS_IAM_SECURITY_CREDENTIALS_URL => q{latest/meta-data/iam/security-credentials/},
  AWS_METADATA_BASE_URL            => q{http://169.254.169.254/},
  CACHE_DIR                        => '.aws/sso/cache',
  COMMA                            => q{,},
  DEFAULT_REGION                   => 'us-east-1',
  DEFAULT_SEARCH_ORDER             => q{env,container,role,web_identity,file},
  DEFAULT_TIMEOUT                  => 3,
  DEFAULT_WINDOW_INTERVAL          => 5,
  EMPTY                            => q{},
  FALSE                            => 0,
  GET_ROLE_CREDENTIALS_HOSTNAME    => 'portal.sso.%s.amazonaws.com',
  GET_ROLE_CREDENTIALS_QUERY       => '?account_id=%s&role_name=%s',
  GET_ROLE_CREDENTIALS_URI         => 'federation/credentials',
  IMDSv2_DEFAULT_TTL               => 21_600,
  IMDSv2_HEADER                    => q{x-aws-ec2-metadata-token},
  IMDSv2_TTL_HEADER                => q{x-aws-ec2-metadata-token-ttl-seconds},
  IMDSv2_URL                       => q{latest/api/token},
  ISO8601_FORMAT                   => q{%Y-%m-%dT%H:%M:%S%z},
  LOG_FORMAT                       => qq{ %s [%s] %s\n },
  RANDOM_VALUE                     => 0xffffffff,
  SECONDS_IN_HOUR                  => 3600,
  SECONDS_IN_MINUTE                => 60,
  SUCCESS                          => 0,
  FAILURE                          => 1,
  SLASH                            => q{/},
  TRUE                             => 1,
  X_AMZ_SSO_BEARER_TOKEN           => ':x-amz-sso_bearer_token',
};

our $VERSION = '1.3.0'; ## no critic (RequireInterpolation)

our @EXPORT_OK = qw(
  get_role_credentials
  normalize_arn
  set_sso_credentials
);

# we only log at debug level, create a default logger
{
  no strict 'refs'; ## no critic (ProhibitNoStrict)

  *{'Amazon::Credentials::Logger::debug'} = sub {
    my ( $self, @message ) = @_;

    return if !$self->{debug};

    my $timestamp = scalar localtime;

    my $message = sprintf LOG_FORMAT, $timestamp, $PROCESS_ID, @message;

    print {*STDERR} $message;
  };
}

caller or __PACKAGE__->main();

########################################################################
sub new {
########################################################################
  my ( $class, @args ) = @_;

  my $options = ref $args[0] ? $args[0] : {@args};

  my $self = $class->SUPER::new($options);

  $self->_set_defaults;

  $self->_init_logger;

  $self->_init_credentials($options);

  # if the caller wants us to use his SSO credentials
  if ( $self->get_sso_role_name && $self->get_sso_account_id ) {
    my $region = $self->get_sso_region || $self->get_region;

    # this just sets the environmet
    set_sso_credentials( $self->get_sso_role_name, $self->get_sso_account_id, $region );

    # we'll set them from the environment below
    $self->set_order( ['env'] );
  }

  if ( !$options->{aws_secret_access_key}
    || !$options->{aws_access_key_id} ) {
    $self->set_credentials;

    if ( $self->get_token ) {
      $self->set_session_token_required(TRUE);
    }

    if ( !$self->get_cache ) {
      $self->reset_credentials;
    }
  }
  else {
    $self->set_credentials($options);
  }

  if ( !$self->get_region ) {
    $self->set_region( $self->get_region_from_env || $self->get_default_region );
  }

  return $self;
}

# New method replaces _init_encryption, set_aws_access_key_id,
# set_aws_secret_access_key, set_token, and all of _crypt/_fetch_passkey etc.
########################################################################
sub _init_credentials {
########################################################################
  my ( $self, $options ) = @_;
  $options //= {};

  my ( $key_id, $secret, $token ) = @{$options}{qw( aws_access_key_id aws_secret_access_key token )};

  $self->{_access_key_id}     = sub {$key_id};
  $self->{_secret_access_key} = sub {$secret};
  $self->{_session_token}     = sub {$token};

  return;
}

sub get_aws_access_key_id     { return $_[0]->{_access_key_id}->() }
sub get_aws_secret_access_key { return $_[0]->{_secret_access_key}->() }
sub get_token                 { return $_[0]->{_session_token}->() }

########################################################################
sub set_aws_access_key_id {
########################################################################
  my ( $self, $val ) = @_;

  $self->{_access_key_id} = sub {$val};
  return;
}

########################################################################
sub set_aws_secret_access_key {
########################################################################
  my ( $self, $val ) = @_;

  $self->{_secret_access_key} = sub {$val};
  return;
}

########################################################################
sub set_token {
########################################################################
  my ( $self, $val ) = @_;

  $self->{_session_token} = sub {$val};
  return;
}

########################################################################
sub set_default_logger {
########################################################################
  my ( $self, $debug ) = @_;

  $debug = $debug // $self->get_debug;

  my $logger = bless { debug => $debug }, 'Amazon::Credentials::Logger';

  $self->set_logger($logger);

  return $self;
}

########################################################################
sub reset_credentials {
########################################################################
  my ( $self, $renew ) = @_;

  if ( !$renew ) {
    $self->_init_credentials;
  }
  else {
    if ( $self->get_cache ) {
      $self->set_credentials;
    }
  }

  return $self;
}

########################################################################
sub get_region_from_env {
########################################################################
  my ($self) = @_;

  my $region = $ENV{AWS_REGION} || $ENV{AWS_DEFAULT_REGION};

  return $region;
}

########################################################################
sub get_default_region {
########################################################################
  my ($self) = @_;

  my $region = $self->get_region_from_config;

  # only do this if we are sure we are on an EC2
  if ( $self->get_source && $self->get_source eq 'IAM' ) {
    # try to get credentials from instance role, but we may not be
    # executing on an EC2 or container
    my $url;

    if ( $self->get_container ) {
      $url = "$ENV{ECS_CONTAINER_METADATA_URI_V4}/task";
    }
    else {
      $url = _create_metadata_url(AWS_AVAILABILITY_ZONE_URL);
    }

    my $ua = ref $self ? $self->get_user_agent : Amazon::Credentials::HTTP::UserAgent->new();

    my @headers;

    # add imdsv2 token to metadata request
    if ( !$self->get_container && $self->get_imdsv2_token ) {
      @headers = ( IMDSv2_HEADER, $self->get_imdsv2_token );
    }

    my $req = HTTP::Request->new( GET => $url, \@headers );

    $region = eval {
      my $rsp = $ua->request($req);

      # if not 200, then get out of Dodge
      croak "could not get availability zone\n"
        if !$rsp->is_success;

      my $content = $rsp->content;
      $content =~ s/(\d+)[[:lower:]]+$/$1/xsm;

      return $content;
    };
  }

  return $region;
}

########################################################################
sub set_credentials {
########################################################################
  my ( $self, $creds ) = @_;

  $self->set_error(EMPTY);

  $creds = eval {
    if ( !$creds ) {
      $creds = eval { return $self->find_credentials };
    }

    $self->set_error( $creds->{error} || $EVAL_ERROR );

    croak 'no credentials available'
      if !$creds->{aws_secret_access_key} || !$creds->{aws_access_key_id};

    $self->set_aws_secret_access_key( $creds->{aws_secret_access_key} );

    $self->set_aws_access_key_id( $creds->{aws_access_key_id} );

    $self->set_token( $creds->{token} || $creds->{aws_session_token} );

    $self->set_expiration( $creds->{expiration} );

    return $creds;
  };

  if ( $EVAL_ERROR && !$self->get_error ) {
    $self->set_error($EVAL_ERROR);
  }

  undef $EVAL_ERROR;

  croak $self->get_error
    if $self->get_error && $self->get_raise_error;

  carp $self->get_error
    if $self->get_error && $self->get_print_error;

  return $self;
}

########################################################################
sub get_ec2_credentials {
########################################################################
  goto &find_credentials;
}

########################################################################
sub find_credentials {
########################################################################
  my ( $self, @args ) = @_;

  my $options = ref $args[0] ? $args[0] : {@args};

  if ( $options->{profile} ) {
    $self->set_profile( $options->{profile} );
  }

  if ( $options->{order} ) {
    $self->set_order( $options->{order} );
  }

  my @search_order;

  if ( $self->get_profile ) {
    @search_order = ('file');
  }
  elsif ( ref $self->get_order && reftype( $self->get_order ) eq 'ARRAY' ) {
    @search_order = @{ $self->get_order };
  }
  elsif ( !ref $self->get_order ) {
    @search_order = split /\s*,\s*/xsm, $self->get_order;
  }

  $self->get_logger->debug( 'search order ' . join COMMA, @search_order );

  my %creds_getters = (
    env => sub {
      return $self->get_creds_from_env,;
    },
    role => sub {
      return $self->get_creds_from_role;
    },
    container => sub {
      return $self->get_creds_from_container;
    },
    web_identity => sub {
      return $self->get_creds_from_web_identity;
    },
    file => sub {
      return $self->get_creds_from_ini_file;
    },
  );

  my $creds;

  foreach my $location (@search_order) {

    $self->get_logger->debug( 'searching for credentials in: ' . $location );

    if ( $creds_getters{$location} ) {
      $creds = $creds_getters{$location}->();
    }

    last if $creds->{source};
  }

  foreach my $k ( keys %{$creds} ) {

    if ( $k !~ /^aws|token/xsm ) {
      $self->set( $k, $creds->{$k} );
    }
    elsif ( $self->can("set_$k") ) {
      $self->can("set_$k")->( $self, $creds->{$k} );
    }
  }

  $self->get_logger->debug( $self->safe_dumper($creds) );

  return $creds;
}

########################################################################
sub safe_dumper {
########################################################################
  my ( $self, $obj ) = @_;

  return if !ref $obj;

  my $safe_rsp = Dumper [$obj];

  $safe_rsp =~ s/(.*?)(access_?key[^']+)'([^']+)'([^']+)'/$1$2'$3'...'/ixsmg;
  $safe_rsp =~ s/(.*?)(secret_?access[^']+)'([^']+)'([^']+)'/$1$2'$3'...'/ixsmg;
  $safe_rsp =~ s/(.*?)(token[^']+)'([^']+)'([^']+)'/$1$2'$3'...'/ixsmg;

  return $safe_rsp;
}

# +------------------+
# | get_creds_from_* |
# +------------------+

########################################################################
sub get_creds_from_env {
########################################################################
  my ($self) = @_;

  return {}
    if !$ENV{AWS_ACCESS_KEY_ID} || !$ENV{AWS_SECRET_ACCESS_KEY};

  $self->get_logger->debug('fetching credentials from env');

  my @cred_keys = (
    aws_access_key_id     => 'AWS_ACCESS_KEY_ID',
    aws_secret_access_key => 'AWS_SECRET_ACCESS_KEY',
    aws_session_token     => 'AWS_SESSION_TOKEN',
    token                 => 'AWS_SESSION_TOKEN',
  );

  return populate_creds( 'ENV', \@cred_keys, \%ENV );
}

########################################################################
sub populate_creds {
########################################################################
  my ( $source, $cred_keys, $creds_source ) = @_;

  my $creds = { source => $source };

  foreach my $p ( pairs @{$cred_keys} ) {
    my ( $k, $v ) = @{$p};
    $creds->{$k} = $creds_source->{$v};
    next if $source =~ /env/i;

    delete $creds_source->{$v};
  }

  return $creds;
}

########################################################################
sub get_creds_from_process {
########################################################################
  my ( $self, $process ) = @_;

  $self->get_logger->debug("fetching credentials from $process");

  my $credentials = eval {
    open my $fh, q{-|}, $process
      or croak "could not open pipe to $process\n$OS_ERROR";

    local $RS = undef;

    my $credentials_str = <$fh>;

    close $fh
      or croak "could not close filehandle on $process\n";

    return decode_json($credentials_str);
  };

  if ( $EVAL_ERROR || !$credentials ) {
    croak "could not get credentials from process\n$EVAL_ERROR\n";
  }

  $self->get_logger->debug( $self->safe_dumper($credentials) );

  my @cred_keys = (
    aws_access_key_id     => 'AccessKeyId',
    aws_secret_access_key => 'SecretAccessKey',
    token                 => 'SessionToken',
    aws_session_token     => 'SessionToken',
    region                => 'Region',
    expiration            => 'Expiration',
  );

  return populate_creds( 'process', \@cred_keys, $credentials );
}

########################################################################
sub create_config_path {
########################################################################
  my ($config) = @_;

  require File::HomeDir;
  File::HomeDir->import('home');

  my $fullpath = home() . SLASH . $config;

  return -e $fullpath ? $fullpath : EMPTY;
}

########################################################################
sub get_region_from_config {
########################################################################
  my ($self) = @_;

  my $config = create_config_path('.aws/config');

  return if !$config;

  $self->get_logger->debug("config: $config");

  require Config::Tiny;

  my $ini = Config::Tiny->read($config);

  my $region;

  if ( $ini->{default} ) {
    $region = $ini->{default}->{region};
  }

  $self->get_logger->debug( 'default region: ' . $region // 'undef' );

  return $region;
}

########################################################################
sub get_creds_from_ini_file {
########################################################################
  my ( $self, $profile ) = @_;

  require Config::Tiny;

  $profile = $profile || $self->get_profile || 'default';

  my $creds = {};
  my $region;

  foreach my $config (qw( .aws/config .aws/credentials )) {
    last if $creds->{source};

    my $profile_name = $profile;

    my $fullpath = create_config_path($config);

    if ( !$fullpath ) {
      $self->get_logger->debug( 'skipping ' . $config . '...not found' );
      next;
    }

    $self->get_logger->debug( 'reading ' . $config );

    my $ini = Config::Tiny->read($fullpath);

    $self->get_logger->debug( $self->safe_dumper($ini) );

    if ( $ini->{default} ) {
      $region = $ini->{default}->{region};
    }

    my $section;

    if ( $ini->{$profile_name} ) {
      $section = $ini->{$profile_name};
    }
    elsif ( $ini->{"profile $profile_name"} ) { ## config file format
      $section = $ini->{"profile $profile_name"};
    }

    my $process = $ini->{credential_process} || $section->{credential_process};

    if ($process) {
      $creds  = $self->get_creds_from_process($process);
      $region = $section->{region} ? $section->{region} : $region;
    }
    elsif ($section) {

      # credentials in a config file shouldn't really have a session token,
      # but if it does we'll capture it as aws_session_token. For
      # historical reasons pertaining to how other CPAN classes
      # referred to the token we store it internally as token
      my @cred_keys = (
        aws_access_key_id     => 'aws_access_key_id',
        aws_secret_access_key => 'aws_secret_access_key',
        aws_session_token     => 'aws_session_token',
        token                 => 'aws_session_token',
        region                => 'region',
      );

      $creds = populate_creds( $config, \@cred_keys, $section );

      if ( !$creds->{aws_access_key_id} || !$creds->{aws_secret_access_key} ) {
        $creds = {};
      }
    }
  }

  $self->set_region( $creds->{region} || $region );

  return $creds;
}

########################################################################
sub get_creds_from_role {
########################################################################
  my ($self) = @_;

  my $aws_ec2_metadata_disabled = $ENV{AWS_EC2_METADATA_DISABLED} // EMPTY;

  if ( $aws_ec2_metadata_disabled eq 'true' ) {
    $self->get_logger->debug('AWS_EC2_METADATA_DISABLED is true, skipping...');

    return {};
  }

  # try to get credentials from instance role
  my $url = _create_metadata_url(AWS_IAM_SECURITY_CREDENTIALS_URL);

  my $ua = $self->get_user_agent;

  my $role;

  my $creds = { error => undef };

  if ( $self->get_imdsv2 ) {
    my $token_url = _create_metadata_url(IMDSv2_URL);

    my @headers = ( IMDSv2_TTL_HEADER, IMDSv2_DEFAULT_TTL );

    my $token_req = HTTP::Request->new( PUT => $token_url, \@headers );

    $self->get_logger->debug( Dumper $token_req );

    my $rsp = $ua->request($token_req);

    $self->get_logger->debug( $self->safe_dumper($rsp) );

    if ( $rsp->is_success ) {
      $self->set_imdsv2_token( $rsp->content );
    }
    else {
      croak "could not retrieve IMDSv2 token\n";
    }
  }

  my @cred_keys = (
    aws_access_key_id     => 'AccessKeyId',
    aws_secret_access_key => 'SecretAccessKey',
    token                 => 'Token',
    aws_session_token     => 'Token',
    expiration            => 'Expiration',
  );

  $creds = eval {
    # could be infinite, but I don't think so.  Either we get an
    # error ($@), or a non-200 response code
    while ( !$creds->{token} ) {

      if ($role) {
        $url .= $role;
      }

      my @headers;

      if ( $self->get_imdsv2 && $self->get_imdsv2_token ) {
        @headers = ( IMDSv2_HEADER, $self->get_imdsv2_token );
      }

      my $req = HTTP::Request->new( GET => $url, \@headers );

      $self->get_logger->debug( Dumper [ "HTTP REQUEST:\n", $req ] );

      my $rsp = $ua->request($req);

      $self->get_logger->debug( $self->dump_response($rsp) );

      # if not 200, then get out of Dodge
      last if !$rsp->is_success;

      if ($role) {
        my $creds_source = eval { decode_json( $rsp->content ); };
        my $err          = $EVAL_ERROR;
        if ($err) {
          print {*STDERR} sprintf "decode_json choked on %s\n", $rsp->content;
          print {*STDERR} $err;
          die $err;
        }

        $creds = populate_creds( 'IAM', \@cred_keys, $creds_source );
        $creds->{role} = $role;
      }
      else {
        $role = $rsp->content;
        $self->get_logger->debug( Dumper [ 'role', $role ] );

        last if !$role;
      }
    }

    return $creds;
  };

  if ( !$creds || $EVAL_ERROR ) {
    $creds->{error} = $EVAL_ERROR;
  }

  return $creds;
}

########################################################################
sub get_creds_from_container {
########################################################################
  my ( $self, $uri ) = @_;

  $uri //= $ENV{AWS_CONTAINER_CREDENTIALS_RELATIVE_URI};

  # FULL_URI is the modern form used by Lambda, EKS Pod Identity, and
  # Fargate. It takes precedence when RELATIVE_URI is not set.
  my $full_uri = $ENV{AWS_CONTAINER_CREDENTIALS_FULL_URI};

  if ( !$uri && !$full_uri ) {
    $self->get_logger->debug("not running in a container: no URI in environment\n");
    return {};
  }

  my @cred_keys = (
    aws_access_key_id     => 'AccessKeyId',
    aws_secret_access_key => 'SecretAccessKey',
    token                 => 'Token',
    aws_session_token     => 'Token',
    expiration            => 'Expiration',
  );

  # RELATIVE_URI path — classic ECS on EC2
  if ($uri) {
    return $self->_get_creds_from_relative_uri( $uri, \@cred_keys );
  }

  # FULL_URI path — Lambda, EKS Pod Identity, Fargate, and any container
  # runtime that provides a complete credential endpoint URL
  return $self->_get_creds_from_full_uri( $full_uri, \@cred_keys );
}

########################################################################
# Classic ECS path: prepend the well-known base URL to the relative URI
########################################################################
sub _get_creds_from_relative_uri {
########################################################################
  my ( $self, $uri, $cred_keys ) = @_;

  $self->get_logger->debug( Dumper( [ relative_uri => $uri ] ) );

  my $creds = eval {
    my $url = AWS_CONTAINER_CREDENTIALS_URL . $uri;
    my $ua  = $self->get_user_agent;
    my $req = HTTP::Request->new( GET => $url );
    $req->header(qw( Accept */* ));

    $self->get_logger->debug( Dumper( [ request => $req, as_string => $req->as_string ] ) );

    my $rsp = $ua->request($req);

    $self->get_logger->debug( $self->dump_response($rsp) );

    my $creds = {};

    if ( $rsp->is_success ) {
      my $creds_source = decode_json( $rsp->content );
      $creds = populate_creds( 'IAM', $cred_keys, $creds_source );
      $creds->{container} = 'ECS';
    }
    else {
      $self->get_logger->debug( 'return code: ' . $rsp->status_line );
    }

    return $creds;
  };

  if ($EVAL_ERROR) {
    $creds //= {};
    $creds->{error} = $EVAL_ERROR;
    croak $EVAL_ERROR;
  }

  return $creds // {};
}

########################################################################
# Full URI path — Lambda execution roles, EKS Pod Identity, Fargate.
#
# AWS requires the URL be either https:// or http://127.x.x.x to
# prevent SSRF. The runtime may also supply an authorization token via:
#   AWS_CONTAINER_AUTHORIZATION_TOKEN       (literal token string)
#   AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE  (path to file containing token)
########################################################################
sub _get_creds_from_full_uri {
########################################################################
  my ( $self, $url, $cred_keys ) = @_;

  $self->get_logger->debug( Dumper( [ full_uri => $url ] ) );

  # Enforce AWS security constraint: only allow known-safe URL forms
  # to prevent SSRF attacks. Mirrors the AWS SDK's allowed set:
  #   https://           — any TLS endpoint
  #   http://127.        — IPv4 loopback
  #   http://[::1]       — IPv6 loopback
  #   http://169.254.170.23 — EKS Pod Identity agent
  if (
    $url !~ m{
        \A
        (?: https://                    # any HTTPS
          | http://127\.               # IPv4 loopback
          | http://\[::1\]            # IPv6 loopback
          | http://169\.254\.170\.23 # EKS Pod Identity agent
        )
      }xsm
  ) {
    croak 'AWS_CONTAINER_CREDENTIALS_FULL_URI must be https://, '
      . 'http://127.x.x.x, http://[::1], or http://169.254.170.23 '
      . "— got: $url\n";
  }

  my $creds = eval {
    my $ua  = $self->get_user_agent;
    my $req = HTTP::Request->new( GET => $url );
    $req->header(qw( Accept */* ));

    # Add authorization token if provided by the runtime
    my $token = $self->_read_container_auth_token;

    if ( defined $token && length $token ) {
      $req->header( Authorization => $token );
    }

    $self->get_logger->debug( Dumper [ request => $req, as_string => $req->as_string ] );

    my $rsp = $ua->request($req);

    $self->get_logger->debug( $self->dump_response($rsp) );

    my $creds = {};

    if ( $rsp->is_success ) {
      my $creds_source = decode_json( $rsp->content );
      $creds = populate_creds( 'IAM', $cred_keys, $creds_source );
      $creds->{container} = 'full_uri';
    }
    else {
      $self->get_logger->debug( 'return code: ' . $rsp->status_line );
    }

    return $creds;
  };

  if ($EVAL_ERROR) {
    $creds //= {};
    $creds->{error} = $EVAL_ERROR;
    croak $EVAL_ERROR;
  }

  return $creds // {};
}

########################################################################
# Read the container authorization token from the environment.
# AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE takes precedence over the
# literal AWS_CONTAINER_AUTHORIZATION_TOKEN value.
########################################################################
sub _read_container_auth_token {
########################################################################
  my ($self) = @_;

  my $token_file = $ENV{AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE};

  if ( defined $token_file && length $token_file ) {
    my $token = eval {
      open my $fh, '<', $token_file
        or croak "cannot open token file '$token_file': $OS_ERROR\n";
      local $RS = undef;
      my $t = <$fh>;
      close $fh;
      chomp $t;
      $t;
    };

    if ($EVAL_ERROR) {
      $self->get_logger->debug("could not read authorization token file: $EVAL_ERROR");
      return undef; ## no critic
    }

    return $token;
  }

  return $ENV{AWS_CONTAINER_AUTHORIZATION_TOKEN};
}

########################################################################
# get_creds_from_web_identity
#
# Exchanges an OIDC/JWT token for temporary AWS credentials via STS
# AssumeRoleWithWebIdentity. Used by EKS IRSA (IAM Roles for Service
# Accounts) and GitHub Actions OIDC federation.
#
# Required environment variables:
#   AWS_WEB_IDENTITY_TOKEN_FILE  path to the JWT token file
#   AWS_ROLE_ARN                 ARN of the role to assume
#
# Optional:
#   AWS_ROLE_SESSION_NAME        session name (defaults to
#                                'amazon-credentials-session')
#
# AssumeRoleWithWebIdentity does NOT require AWS request signing —
# the JWT itself is the authentication. This avoids the chicken-and-egg
# problem of needing credentials to obtain credentials.
########################################################################
sub get_creds_from_web_identity {
########################################################################
  my ($self) = @_;

  my $token_file   = $ENV{AWS_WEB_IDENTITY_TOKEN_FILE};
  my $role_arn     = $ENV{AWS_ROLE_ARN};
  my $session_name = $ENV{AWS_ROLE_SESSION_NAME} // 'amazon-credentials-session';

  if ( !$token_file || !$role_arn ) {
    $self->get_logger->debug( 'web_identity: AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN ' . 'not set, skipping' );
    return {};
  }

  $self->get_logger->debug( Dumper( [ web_identity => { token_file => $token_file, role_arn => $role_arn } ] ) );

  my $creds = eval {

    # Read the JWT token from disk
    open my $fh, '<', $token_file
      or croak "cannot open web identity token file '$token_file': $OS_ERROR\n";

    local $RS = undef;
    my $token = <$fh>;
    close $fh;

    chomp $token;

    croak "web identity token file '$token_file' is empty\n"
      if !length $token;

    # Build the STS endpoint URL. Respect AWS_DEFAULT_REGION if set,
    # otherwise use the global endpoint which always works.
    my $region = $self->get_region // $ENV{AWS_DEFAULT_REGION} // $ENV{AWS_REGION} // DEFAULT_REGION;

    my $sts_host
      = $region eq DEFAULT_REGION
      ? 'https://sts.amazonaws.com'
      : "https://sts.$region.amazonaws.com";

    # Build query string — no signing required for this call
    my %params = (
      Action           => 'AssumeRoleWithWebIdentity',
      Version          => '2011-06-15',
      RoleArn          => $role_arn,
      RoleSessionName  => $session_name,
      WebIdentityToken => $token,
    );

    my $query = join q{&}, map { _uri_escape($_) . q{=} . _uri_escape( $params{$_} ) }
      sort keys %params;

    my $url = "$sts_host/?$query";

    $self->get_logger->debug("web_identity STS URL: $sts_host (token omitted)");

    my $ua  = $self->get_user_agent;
    my $req = HTTP::Request->new( GET => $url );
    $req->header( Accept => '*/*' );

    my $rsp = $ua->request($req);

    $self->get_logger->debug( $self->dump_response($rsp) );

    if ( !$rsp->is_success ) {
      # Extract error message from STS XML error response if possible
      my $detail = _extract_sts_error( $rsp->content );
      croak "AssumeRoleWithWebIdentity failed: " . $rsp->status_line . $detail . "\n";
    }

    my $xml = $rsp->content;

    my $creds = {
      aws_access_key_id     => _xml_element( $xml, 'AccessKeyId' ),
      aws_secret_access_key => _xml_element( $xml, 'SecretAccessKey' ),
      aws_session_token     => _xml_element( $xml, 'SessionToken' ),
      token                 => _xml_element( $xml, 'SessionToken' ),
      expiration            => _xml_element( $xml, 'Expiration' ),
      source                => 'web_identity',
    };

    croak "AssumeRoleWithWebIdentity returned no AccessKeyId\n"
      if !$creds->{aws_access_key_id};

    return $creds;
  };

  if ($EVAL_ERROR) {
    $self->get_logger->debug("web_identity failed: $EVAL_ERROR");
    return {};
  }

  return $creds;
}

########################################################################
# Extract a single element value from STS XML by tag name.
# STS responses are simple and predictable — no namespace issues,
# no attributes on credential elements. Avoids adding an XML dep.
########################################################################
sub _xml_element {
  my ( $xml, $tag ) = @_;

  return $1 if $xml =~ m{<$tag>([^<]+)</$tag>}xsm;
  return undef; ## no critic (Subroutines::ProhibitExplicitReturnUndef)
}

########################################################################
# Extract Code and Message from an STS XML error response for a more
# useful croak message.
########################################################################
sub _extract_sts_error {
  my ($xml) = @_;

  return q{} if !defined $xml || !length $xml;

  my $code    = _xml_element( $xml, 'Code' )    // q{};
  my $message = _xml_element( $xml, 'Message' ) // q{};

  return q{} if !$code && !$message;

  return " - $code: $message";
}

########################################################################
# Minimal URI percent-encoder for STS query string parameters.
# URI::Escape is not currently a dep of Amazon::Credentials, so we
# encode inline. Only used for the web identity token and ARN values.
########################################################################
sub _uri_escape {
  my ($str) = @_;

  $str =~ s/([^A-Za-z0-9\-_.~])/sprintf '%%%02X', ord($1)/xsmeg;

  return $str;
}

########################################################################
sub normalize_arn {
########################################################################
  my $arn = ( @_ == 2 ) ? $_[1] : $_[0];

  if ( $arn =~ m{arn:aws:sts::(\d+):assumed-role/([^/]+)/}xsm ) {
    return "arn:aws:iam::$1:role/$2";
  }

  return $arn;
}

# +---------+
# | DUMPERS |
# +---------+

########################################################################
sub dump_response {
########################################################################
  my ( $self, $rsp ) = @_;

  my $safe_rsp = {};

  foreach my $k ( keys %{$rsp} ) {
    if ( $k =~ /content/xsm ) {
      my $content = $rsp->{$k};
      $content =~ s/\"(AccessKeyId|Token|SecretAccessKey)\"\s+\:\s+\"[^\"]+\"/\"$1\" : \"...\"/gxsm;
      $safe_rsp->{$k} = $content;
    }
    else {
      $safe_rsp->{$k} = $rsp->{$k};
    }
  }

  return Dumper [$safe_rsp];
}

# +---------------+
# | TOKEN METHODS |
# +---------------+

########################################################################
sub is_token_expired {
########################################################################
  my ( $self, $window_interval ) = @_;
  $window_interval = $window_interval // DEFAULT_WINDOW_INTERVAL;

  my $expiration_date = $self->get_expiration();

  my $expired = FALSE;

  if ( defined $expiration_date ) {
    # AWS recommends getting credentials 5 minutes prior to expiration
    my $g = _iso8601_to_time($expiration_date);

    # shave 5 minutes or window interval off of the expiration time
    $g -= $window_interval * SECONDS_IN_MINUTE;

    # (expiration_time - window_interval) - current_time = # of seconds left before expiration
    my $seconds_left = $g - time;

    if ( $self->get_debug ) {
      $self->get_logger->debug("seconds left : $seconds_left");
      my $hours = int( $seconds_left / SECONDS_IN_HOUR );

      my $minutes = int( ( $seconds_left - $hours * SECONDS_IN_HOUR ) / SECONDS_IN_MINUTE );

      my $seconds = $seconds_left - ( $hours * SECONDS_IN_HOUR + $minutes * SECONDS_IN_MINUTE );

      $self->get_logger->debug("$hours hours $minutes minutes $seconds seconds until expiry");
    }

    $expired = ( $seconds_left < 0 ) ? TRUE : FALSE;

    $self->get_logger->debug(
      Dumper [
        expiration_date => $expiration_date,
        expired         => $expired
      ]
    );
  }

  return $expired;
}

########################################################################
sub refresh_credentials {
########################################################################
  goto &refresh_token;
}

########################################################################
sub refresh_token {
########################################################################
  my ($self) = @_;

  my $creds;

  if ( $self->get_container && $self->get_container eq 'ECS' ) {
    $creds = $self->get_creds_from_container;
  }
  elsif ( $self->get_role ) {
    $creds = $self->get_creds_from_role;
  }

  croak 'unable to refresh token!'
    if !ref $creds || !keys %{$creds};

  return $self->set_credentials($creds);
}

########################################################################
sub credential_keys {
########################################################################
  my ($self) = @_;

  my %credential_keys;

  if ( !$self->get_cache ) {
    my $creds = $self->find_credentials;

    %credential_keys = (
      AWS_ACCESS_KEY_ID            => $creds->{aws_access_key_id},
      AWS_SECRET_ACCESS_KEY        => $creds->{aws_secret_access_key},
      AWS_SESSION_TOKEN            => $creds->{token},
      AWS_SESSION_TOKEN_EXPIRATION => $creds->{expiration},
    );

  }
  else {
    %credential_keys = (
      AWS_ACCESS_KEY_ID            => $self->get_aws_access_key_id,
      AWS_SECRET_ACCESS_KEY        => $self->get_aws_secret_access_key,
      AWS_SESSION_TOKEN            => $self->get_token,
      AWS_SESSION_TOKEN_EXPIRATION => $self->get_expiration,
    );
  }

  if ( !defined $credential_keys{AWS_SESSION_TOKEN} ) {
    delete $credential_keys{AWS_SESSION_TOKEN};
    delete $credential_keys{AWS_SESSION_TOKEN_EXPIRATION};
  }

  return \%credential_keys;
}

########################################################################
sub as_string {
########################################################################
  my ($self) = @_;

  return JSON->new->pretty->encode( $self->credential_keys );
}

########################################################################
sub format_credentials {
########################################################################
  my ( $self, $format ) = @_;

  $format = $format || "%s %s\n";

  my $credential_keys = $self->credential_keys;

  return join EMPTY, map { sprintf $format, $_, $credential_keys->{$_} // EMPTY }
    keys %{$credential_keys};
}

########################################################################
sub set_aws_session_token { goto &set_token; }
sub get_aws_session_token { goto &get_token; }
########################################################################

########################################################################
sub set_sso_credentials {
########################################################################
  my ( $role_name, $account_id, $region ) = @_;

  croak "usage: __PACKAGE__::set_sso_credentials(role-name, account-id)\n"
    if ref $role_name;

  my $credentials = get_role_credentials(
    role_name  => $role_name,
    account_id => $account_id,
    region     => $region // EMPTY,
  );

  my @cred_keys = (
    accessKeyId     => 'AWS_ACCESS_KEY_ID',
    secretAccessKey => 'AWS_SECRET_ACCESS_KEY',
    sessionToken    => 'AWS_SESSION_TOKEN',
  );

  # stuff credentials in environment, upstream caller will set order to 'env'
  if ($credentials) {
    foreach my $p ( pairs @cred_keys ) {
      my ( $k, $v ) = @{$p};
      $ENV{$v} = $credentials->{$k}; ## no critic (RequireLocalizedPunctuationVars)
    }
  }

  return $credentials;
}

########################################################################
sub get_role_credentials {
########################################################################
  my (%args) = @_;

  my ( $account_id, $role_name, $access_token, $region ) = @args{qw(account_id role_name access_token region)};

  if ( !$access_token ) {
    $access_token = _get_access_token();
  }

  croak 'no access token'
    if !$access_token;

  $region ||= $ENV{AWS_REGION} || $ENV{AWS_DEFAULT_REGION} || DEFAULT_REGION;

  my $host = sprintf GET_ROLE_CREDENTIALS_HOSTNAME, $region;

  my $query = sprintf GET_ROLE_CREDENTIALS_QUERY, $account_id, $role_name;

  my $url = sprintf 'https://%s/%s%s', $host, GET_ROLE_CREDENTIALS_URI, $query;

  my $ua = Amazon::Credentials::HTTP::UserAgent->new;

  my $req = HTTP::Request->new( GET => $url );
  $req->header( X_AMZ_SSO_BEARER_TOKEN, $access_token );
  my $rsp = $ua->request($req);

  croak "no response from get($url)\n"
    if !$rsp;

  my $content = eval { return decode_json( $rsp->content ) if $rsp && $rsp->content_type eq 'application/json'; };

  croak sprintf "could not decode response from GetRoleCredentials\n%s", $EVAL_ERROR
    if !$content;

  return $content->{roleCredentials}
    if $rsp->is_success;

  croak sprintf "Status: %s\n%s", $rsp->status_line, Dumper( $rsp->content )
    if !ref $content;

  croak sprintf "Status: %s\n%s", $rsp->status_line, $content->{message};
}

# +-----------------+
# | PRIVATE METHODS |
# +-----------------+

########################################################################
sub _init_logger {
########################################################################
  my ($self) = @_;

  if ( !$self->get_logger || !ref $self->get_logger ) {
    $self->set_default_logger;
  }

  $self->get_logger->debug( 'using ' . ref( $self->get_logger ) . ' logger' );

  return $self;
}

########################################################################
sub _set_defaults {
########################################################################
  my ($self) = @_;

  $self->set_debug( $self->get_debug // FALSE );

  $self->set_imdsv2( $self->get_imdsv2 // TRUE );

  $self->set_cache( defined $self->get_cache ? $self->get_cache : TRUE );
  my $timeout = $self->get_timeout // DEFAULT_TIMEOUT;

  if ( !$self->get_user_agent ) {
    $self->set_user_agent( Amazon::Credentials::HTTP::UserAgent->new( timeout => $timeout ) );
  }

  my $default_search_order = [ split /\s*,\s*/xsm, DEFAULT_SEARCH_ORDER ];

  if ( !$self->get_order ) {
    $self->set_order($default_search_order);
  }

  if ( !ref $self->get_order ) {
    $self->set_order( [ split /\s*,\s*/xsm, $self->get_order ] );
  }
  elsif ( ref $self->get_order && reftype( $self->get_order ) ne 'ARRAY' ) {
    croak 'order must be a comma delimited string or array ref';
  }

  foreach my $loc ( @{ $self->get_order } ) {
    croak "invalid credential location in search order:  [$loc]"
      if !any {/^$loc$/xsm} @{$default_search_order};
  }

  if ( !$self->get_profile && any {/file/xsm} @{ $self->get_order } ) {
    $self->set_profile( $ENV{AWS_PROFILE} );
  }

  $self->set_raise_error( $self->get_raise_error // TRUE );
  $self->set_print_error( $self->get_print_error // TRUE );

  return $self;
}

########################################################################
sub _iso8601_to_time {
########################################################################
  my $iso8601 = shift;

  $iso8601 =~ s/^(.*)Z$/$1\+00:00/xsm;

  require POSIX::strptime;
  POSIX::strptime->import('strptime');

  require Time::Local;
  Time::Local->import('timegm');

  my $gmtime = eval {
    local $ENV{TZ} = 'GMT';

    timegm( strptime( $iso8601, ISO8601_FORMAT ) );
  };

  return $gmtime;
}

########################################################################
sub _create_metadata_url {
########################################################################
  my ($url) = @_;

  return AWS_METADATA_BASE_URL . $url;
}

########################################################################
sub _get_access_token {
########################################################################
  my ($home) = @_;

  require File::Find;

  File::Find->import('find');

  $home //= $ENV{HOME};
  my $cache_dir = sprintf '%s/%s', $home, CACHE_DIR;

  croak "no $cache_dir found."
    if !-d $cache_dir;

  my $access_token;

  my $cwd = getcwd;

  eval {

    find(
      sub {
        return if !/[.]json$/xsm;

        local $RS = undef;

        open my $fh, '<', $File::Find::name
          or croak 'could not open ' . $File::Find::name;

        my $json = eval { decode_json(<$fh>) };

        close $fh;

        if ( ref $json && $json->{accessToken} ) {
          $access_token = $json->{accessToken};
          croak 'found';
        }

        return;
      },
      $cache_dir
    );
  };

  CORE::chdir $cwd;

  return $access_token;
}

#######################################################################
sub help {
########################################################################
  my ($options) = @_;

  if ( !$options->{version} ) {

    print {*STDOUT} <<'END_OF_USAGE';
amazon-credentials options

Formats credentials found in env, config, SSO, role

Options
-------
--account, -a   use with --role, specify AWS account id
--container, -c get credentials from container IAM role
--ec2, -E       get credentials from EC2 IAM role
--env  -e       get credentials from environment variables
--help, -h      this
--profile, -p   get credentials from profile in credentials configuration
--role, -r      get credentials from SSO role
--version, -v   version

 $ amazon-credentials --profile=test
  export AWS_ACCESS_KEY_ID=AKI*****************
  export AWS_SECRET_ACCESS_KEY=****************************************

 $ aws sso login
 $ amazon-credentials --role my-sso-role --account 01234567890

END_OF_USAGE
  }

  print {*STDOUT} "v$VERSION\n";

  exit SUCCESS;
}

########################################################################
sub export_credentials {
########################################################################
  my ($credentials) = @_;

  $credentials //= \%ENV;

  my @cred_keys = qw(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN);

  @cred_keys = map { defined $credentials->{$_} ? $_ : () } @cred_keys;

  return join "\n", map { sprintf 'export %s=%s', $_, $credentials->{$_} } @cred_keys;
}

########################################################################
sub main {
########################################################################
  my ($self) = @_;

  require Getopt::Long;

  Getopt::Long->import(qw(:config no_ignore_case));

  my %options;

  my @option_specs = qw(
    account|a=s
    container|c
    ec2|E
    env|e
    help
    profile|p=s
    role|r=s
    version
  );

  if ( !GetOptions( \%options, @option_specs )
    || $options{help}
    || $options{version} ) {
    help( \%options );
  }

  if ( $options{role} && $options{account} ) {
    my $credentials = Amazon::Credentials::set_sso_credentials( @options{qw(role account region)} );

    print {*STDOUT} export_credentials($credentials);

    exit SUCCESS;
  }

  my @order = eval {
    return 'env'
      if $options{env};

    return 'role'
      if $options{ec2};

    return 'container'
      if $options{container};

    return 'file'
      if $options{profile};

    return split /,/xsm, DEFAULT_SEARCH_ORDER;
  };

  my $creds = $self->new(
    profile => $options{profile} // EMPTY,
    order   => [@order],
  );

  print {*STDOUT} $creds->format_credentials("export %s=%s\n");

  exit SUCCESS;
}

1;

__END__

=pod

=encoding utf8

=head1 NAME

Amazon::Credentials - fetch Amazon credentials from file, environment or role

=head1 SYNOPSIS

 my @order = qw( env file container role );
 my $creds = Amazon::Credentials->new( { order => \@order } );

CLI

 amazon-credentials --help

=head1 DESCRIPTION

=begin markdown

[![perl-Amazon-Credentials](https://github.com/rlauer6/Amazon-Credentials/actions/workflows/build.yml/badge.svg)](https://github.com/rlauer6/Amazon-Credentials/actions/workflows/build.yml)

=end markdown

C<Amazon::Credentials> finds AWS credentials from a chain of providers,
searching in a configurable order until credentials are found. The default
search order is:

 environment => container => role => web_identity => file

The following credential sources are supported:

=over 4

=item * B<Environment> - C<AWS_ACCESS_KEY_ID>, C<AWS_SECRET_ACCESS_KEY>,
and optionally C<AWS_SESSION_TOKEN>.

=item * B<Container> - ECS task roles via
C<AWS_CONTAINER_CREDENTIALS_RELATIVE_URI> (classic ECS), or any container
runtime that provides C<AWS_CONTAINER_CREDENTIALS_FULL_URI> - including
Lambda execution roles, Fargate task roles, and EKS Pod Identity.

=item * B<Instance role> - EC2 instance profile credentials via the IMDSv2
metadata endpoint (C<http://169.254.169.254>). Respects
C<AWS_EC2_METADATA_DISABLED>.

=item * B<Web Identity> - OIDC/JWT federation via STS
C<AssumeRoleWithWebIdentity>. Used by EKS IRSA (IAM Roles for Service
Accounts) and GitHub Actions. Requires C<AWS_WEB_IDENTITY_TOKEN_FILE>
and C<AWS_ROLE_ARN>.

=item * B<File> - C<~/.aws/credentials> and C<~/.aws/config> profiles,
including credential_process and SSO configurations.

=back

You can control which sources are tried, and in what order, via the
C<order> option in the constructor. See L</new>.

This class also supports SSO credentials. See L</set_sso_credentials>
and L</get_role_credentials> for details, or use the command line tool:

 amazon-credentials --role my-sso-role --account 01234567890

=head2 AWS_EC2_METADATA_DISABLED

C<Amazon::Credentials> tries hard to find credentials, searching the
environment, ECS container endpoint, EC2 instance metadata, and credential
files in turn. In some situations - particularly local development or CI
environments where no metadata endpoint is reachable - this eagerness causes
an unwanted delay while the module waits for the metadata request to time out.

You have two options for dealing with this. The first is to set
C<AWS_EC2_METADATA_DISABLED> to a true value, which disables the search for
role credentials via the EC2 instance metadata endpoint entirely. The second
is to reduce the timeout via the C<timeout> constructor option (default is 3
seconds), which limits how long the module waits for the metadata endpoint to
respond:

  my $creds = Amazon::Credentials->new( timeout => 1 );

The preferred approach when your application is designed to run in a specific
environment is to pass an explicit C<order> to the constructor, which avoids
the search entirely:

  my $creds = Amazon::Credentials->new( order => [qw(role)] );

The default credential search order is:

  environment => container => role => web_identity => file (profile)

=head1 VERSION

This document refers to version 1.3.0 of
L<Amazon::Credentials>.

=head1 METHODS AND SUBROUTINES

=head2 new

 new( options );

 my $aws_creds = Amazon::Credential->new( { profile => 'sandbox', debug => 1 });

C<options> is a hash of keys that represent various options you can
pass to the constructor to control how it will look for credentials.
Any of the options can also be retrieved using their corresponding
'get_{option} method.

=head3 options

=over 5

=item aws_access_key_id

AWS access key.

=item aws_secret_access_key

AWS secret access key.

I<Note: If you pass the access keys in the constructor then the
constructor will not look in other places for credentials.>

=item cache

Boolean that controls whether credentials are retained in the object
after being fetched. B<Caching is enabled by default.>

When caching is disabled, credentials are fetched on the first call to
a getter and the closure for that credential is reset to undef
immediately after the value is returned. Each subsequent getter call
will re-fetch credentials. Use C<credential_keys()> to retrieve the
full credential tuple in a single operation without the values ever
being split across multiple calls.

Note that disabling the cache limits the window during which credential
values are held in memory, but Perl makes no guarantees about when or
whether that memory is actually cleared by the interpreter. See
L</Caching and Memory> under L</SECURITY CONSIDERATIONS>.

=item container

If the process is running in a container, this value will contain
a string indicating the credential source. The class supports two
forms of container credentials:

B<Relative URI> (classic ECS on EC2): credentials are fetched from
the ECS container metadata endpoint using
C<AWS_CONTAINER_CREDENTIALS_RELATIVE_URI>:

 http://169.254.170.2/$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI

B<Full URI> (Lambda, Fargate, EKS Pod Identity): credentials are
fetched from the full URL provided in
C<AWS_CONTAINER_CREDENTIALS_FULL_URI>. The URL must be C<https://>,
C<http://127.x.x.x>, C<http://[::1]>, or
C<http://169.254.170.23> (EKS Pod Identity agent). An optional
authorization token may be provided via
C<AWS_CONTAINER_AUTHORIZATION_TOKEN> or
C<AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE>.

The C<container> metadata key in the returned credentials hash will
be C<'ECS'> for the relative URI form and C<'full_uri'> for the full
URI form.

=item debug

Set to true for verbose troubleshooting information. Set C<logger> to
a logger that implements a logging interface (ala
L<Log::Log4perl>.

=item env - Environment

If there exists an environment variable $AWS_PROFILE, then an attempt
will be made to retrieve credentials from the credentials file using
that profile, otherwise the class will for these environment variables
to provide credentials.

 AWS_ACCESS_KEY_ID
 AWS_SECRET_ACCESS_KEY
 AWS_SESSION_TOKEN

I<Note that when you set the environment variable AWS_PROFILE, the
order essentially is overridden and the class will look in your
credential files (F<~/.aws/config>, F<~/.aws/credentials>) to resolve
your credentials.>

=item file - Configuration Files

=over 10

=item ~/.aws/config

=item ~/.aws/credentials

=back

The class will attempt to resolve credentials by interpretting the
information in these two files. You can also specify a profile to use
for looking up the credentials by passing it into the constructor or
setting it the environment variable C<AWS_PROFILE>.  If no profile is
provided, the default credentials or the first profile found is used.

 my $aws_creds = Amazon::Credentials->new({ order => [qw/environment role file/] });

=item imdsv2

Boolean flag that causes C<Amazon::Credentials> to use the IMDSv2
protocol when retrieving instance role credentials from the EC2 metadata
service. IMDSv2 uses a session-oriented approach requiring a token to be
fetched before making metadata requests, providing stronger protection
against SSRF attacks.

Default: true

AWS recommends IMDSv2 for all EC2 workloads. IMDSv1 can be disabled at
the instance or account level as a security hardening measure, in which
case this option must be enabled for instance role credential retrieval
to succeed.

=item logger

Pass in your own logger that has a C<debug()> method.  Otherwise the
default logger will output debug messages to STDERR.
=item order

An array reference containing tokens that specifies the order in which the class will
search for credentials.

default:  env, role, container, file

Example:

  my $creds = Amazon::Credentials->new( { order => [ qw/file env role/] });

=item print_error

Whether to print the error if no credenials are found. C<raise_error>
implies C<print_error>.

default: true

=item profile

The profile name in the configuration file (F<~/.aws/config> or
F<~/.aws/credentials>).

 my $aws_creds = Amazon::Credentials->new({ profile => 'sandbox' });

The class will also look for the environment variable C<AWS_PROFILE>,
so you can invoke your script like this:

 $ AWS_PROFILE=sandbox my-script.pl

=item raise_error

Whether to raise an error if credentials are not found.

default: true

=item region

Default region. The class will attempt to find the region in either
the configuration files or the instance unless you specify the region
in the constructor.

=item role - Instance Role

The class will use the
I<http://169.254.169.254/latest/meta-data/iam/security-credential> URL
to look for an instance role and credentials.

Credentials returned by accessing the meta-data include a token that
should be passed to Amazon APIs along with the access key and secret.
That token has an expiration and should be refreshed before it
expires.

 if ( $aws_creds->is_token_expired() ) {
   $aws_creds->refresh_token()
 }

=item timeout

When looking for credentials in metadata URLs, this parameter
specifies the timeout value in seconds for HTTP metadata sevice
requests.

default: 3s

=item user_agent

Pass in your own user agent, otherwise C<Amazon::Credentials::HTTP::UserAgent>
(backed by C<HTTP::Tiny>) will be used. The object must implement a C<request>
method accepting an C<HTTP::Request> object and returning a response object
with C<content>, C<content_type>, C<is_success>, C<code>, and C<message> methods.
Probably only useful to override for testing purposes.

=back

=head2 as_string

 as_string()

Returns the credentials as a JSON encode string.

=head2 credential_keys

 my $credential_keys = $creds->credential_keys;

Return a hash reference containing the credential keys with standard
key names. Note that the session token will only be present in the
hash for temporary credentials.

=over 5

=item AWS_ACCESS_KEY_ID

=item AWS_SECRET_ACCESS_KEY

=item AWS_SESSION_TOKEN

=back

=head2 format_credentials

 format_credentials(format-string)

Returns the credentials as a formatted string.  The <format> argument
allows you to include a format string that will be used to output each
of the credential parts.

 format("export %s=%s\n");

The default format is a "%s %s\n".

=head2 find_credentials

 find_credentials( option => value, ...);

You normally don't want to use this method. It's automatically invoked
by the constructor if you don't pass in any credentials. Accepts a
hash or hash reference consisting of keys (C<order> or C<profile>) in
the same manner as the constructor.

=head2 get_creds_from_*

These methods are called internally when the C<new> constructor is
invoked. You should never need to call these methods. All of these
methods will return a hash of credential information and metadata
described below.

=over 5

=item aws_access_key_id

The AWS access key.

=item aws_secret_access_key

The AWS secret key.

=item token

Security token used with access keys.

=item expiration

Token expiration date.

=item role

IAM role if available.

=item source

The source from which the credentials were found. 

=over 3

=item * IAM - retrieved from container or instance role

=item * container - C<'ECS'> if retrieved via C<AWS_CONTAINER_CREDENTIALS_RELATIVE_URI>;
C<'full_uri'> if retrieved via C<AWS_CONTAINER_CREDENTIALS_FULL_URI>

=item * web_identity - retrieved via STS AssumeRoleWithWebIdentity

=item * file - retrieved from file

=item * process - retrieved from an external process

=item * ENV - retrieved from environment

=back

=back

=head3 get_creds_from_container

 get_creds_from_container()

Retrieves credentials from the container credential endpoint. Supports
two mechanisms, tried in this order:

B<Relative URI> - if C<AWS_CONTAINER_CREDENTIALS_RELATIVE_URI> is set,
credentials are fetched from:

 http://169.254.170.2/$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI

B<Full URI> - if C<AWS_CONTAINER_CREDENTIALS_FULL_URI> is set, that URL
is used directly. Covers Lambda execution roles, Fargate task roles, and
EKS Pod Identity. An authorization token is added automatically if
C<AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE> or
C<AWS_CONTAINER_AUTHORIZATION_TOKEN> is set in the environment.

Returns an empty hash if neither environment variable is set.

=head3 get_creds_from_web_identity

 get_creds_from_web_identity()

Retrieves temporary credentials by exchanging an OIDC/JWT web identity
token for AWS credentials via STS C<AssumeRoleWithWebIdentity>. This is
the credential mechanism used by EKS IRSA (IAM Roles for Service
Accounts) and GitHub Actions OIDC federation.

Required environment variables:

=over 5

=item C<AWS_WEB_IDENTITY_TOKEN_FILE>

Path to a file containing the OIDC/JWT token.

=item C<AWS_ROLE_ARN>

ARN of the IAM role to assume.

=back

Optional:

=over 5

=item C<AWS_ROLE_SESSION_NAME>

A name for the assumed role session. Defaults to
C<amazon-credentials-session>.

=back

The STS call is made without AWS request signing - the OIDC token
itself authenticates the request, resolving the chicken-and-egg problem
of needing credentials to obtain credentials. The regional STS endpoint
is used when C<AWS_DEFAULT_REGION> or C<AWS_REGION> is set; otherwise
the global C<sts.amazonaws.com> endpoint is used.

Returns an empty hash if the required environment variables are not set,
if the token file cannot be read, or if the STS call fails.

=head3 get_creds_from_process

 get_creds_from_process(process)

Retrieves credentials from a helper process defined in the config
file. Returns the credentials tuple.

=head3 get_creds_from_role

 get_creds_from_role()

Returns a hash, possibly containing access keys and a token.

=head2 get_default_region

Returns the region of the currently running instance or container.
The constructor will set the region to this value unless you set your
own C<region> value. Use C<get_region> to retrieve the value after
instantiation or you can call this method again and it will make a
second call to retrieve the instance metadata.

=head2 get_ec2_credentials (deprecated)

See L</find_credentials>

=head2 is_token_expired

 is_token_expired( window-interval )

Returns true if the token is about to expire (or is
expired). C<window-interval> is the time in minutes before the actual
expiration time that the method should consider the token expired.
The default is 5 minutes.  Amazon states that new credentials will be
available I<at least> 5 minutes before a token expires.

=head2 normalize_arn

 normalize_arn( arn )

 # as an exported function
 use Amazon::Credentials qw(normalize_arn);
 my $iam_arn = normalize_arn($sts_arn);

 # or as a method
 my $iam_arn = $creds->normalize_arn($sts_arn);

Converts an STS assumed-role ARN to its equivalent IAM role ARN.

 arn:aws:sts::123456789:assumed-role/my-role/session-name
   => arn:aws:iam::123456789:role/my-role

This is useful when an ARN obtained from C<GetCallerIdentity> needs to be
passed to IAM APIs such as C<SimulatePrincipalPolicy> which require an IAM
ARN and will reject STS assumed-role ARNs. Non-assumed-role ARNs (IAM users,
IAM roles) are returned unchanged.

=head2 reset_credentials

By default this method will remove credentials from the cache if you
pass a false or no value. Passing a true value will refresh your
credentials from the original source (equivalent to calling
C<set_credentials>).

=head2 refresh_token (deprecated)

use C<refresh_credentials()>

=head2 refresh_credentials()

Retrieves a fresh set of IAM credentials.

 if ( $creds->is_token_expired ) {
   $creds->refresh_token()
 }

=head2 set_credentials

Looks for your credentials according to the order specified by the
C<order> attribute passed in the constructor and stores the
credentials in the cache.

I<Note that you should never have to call
this method. If you call this method it will ignore your cache
setting!>

=head1 SSO CREDENTIALS

You can retrieve your SSO credentials after logging in using the
C<sso_set_credentials> or C<get_role_credentials> methods.

After logging in using your SSO credentials...

 aws sso login

...call one of the methods below to retrieve your credentials.

=head2 get_role_credentials

 get_role_credentials( options )

C<options> is a hash (not reference) of options

=over 5

=item role_name => role name (required)

=item account_id => AWS account id (required)

=item region => AWS region where SSO has been provisioned

default: $ENV{AWS_REGION}, $ENV{AWS_DEFAULT_REGION}, us-east-1

=back

=head2 set_sso_credentials

 set_sso_options(role-name, account-id, region)

Calls C<get_role_credentials> and set AWS credenital environment
variables. Region is optional, all other parameters are required.

 use Amazon::Credentials qw(set_sso_credentials)

 set_sso_credentials(@ENV{qw(AWS_ROLE_NAME AWS_ACCOUNT_ID)});

 my $credentials = Amazon::Credentials->new;

=head1 SETTERS/GETTERS

All of the options described in the new method can be accessed by a
I<getter> or set using a I<setter> of the same name.

Example:

 $creds->set_cache(0);

=head1 DIAGNOSTICS

Set the C<debug> option when you instantiate a L<Amazon::Credentials>
object to output debug and diagnostic messages.

=head1 CONFIGURATION AND ENVIRONMENT

The module will recognize several AWS specific environment variables
described throughout this documentation.

=over 5

=item AWS_ACCESS_KEY_ID

=item AWS_SECRET_ACCESS_KEY

=item AWS_SESSION_TOKEN

=item AWS_REGION

=item AWS_DEFAULT_REGION

=item AWS_CONTAINER_CREDENTIALS_RELATIVE_URI

=item AWS_CONTAINER_CREDENTIALS_FULL_URI

=item AWS_CONTAINER_AUTHORIZATION_TOKEN

=item AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE

=item AWS_WEB_IDENTITY_TOKEN_FILE

=item AWS_ROLE_ARN

=item AWS_ROLE_SESSION_NAME

=back

=head1 BUGS AND LIMITATIONS

L<Amazon::Credentials> will B<not> attempt to retrieve temporary
credentials for profiles that specify a role. If for example you
define a role in your credentials file thusly:

 [developer]

  role_arn = arn:aws:iam::123456789012:role/developer-access-role
  source_profile = dev

The module will not return credentials for the I<developer>
profile. While it would be theoretically possible to return those
credentials, in order to assume a role, one needs credentials (chicken
and egg problem).

Note that C<get_creds_from_web_identity> resolves this problem for
OIDC-federated environments (EKS IRSA, GitHub Actions) by calling STS
C<AssumeRoleWithWebIdentity>, which does not require AWS signing - the
OIDC token authenticates the request directly.

=head1 DEPENDENCIES

Lower versions of these modules may be acceptable.

 'Class::Accessor::Fast' => '0.31'
 'Config::Tiny'          => '2.28'
 'File::HomeDir'         => '1.00'
 'HTTP::Request'         => '6.00'
 'HTTP::Tiny'            => '0.088'
 'JSON::PP'              => '4.16'
 'List::Util'            => '1.5'
 'Net::SSLeay'           => '0'
 'IO::Socket::SSL'       => '0'
 'POSIX::strptime'       => '0.13'

...and possibly others

=head1 SECURITY CONSIDERATIONS

The security concern around your credentials is not that they can be
retrieved and viewed - any process that compromises your environment
can use the same discovery methods this module does. If your
environment is compromised, an actor can resolve your credentials the
same way this module does. The real threat is B<exfiltration>: your
credentials escaping your environment through logs, debug output,
serialized objects, or core dumps.

B<Always take precautions to prevent accidental exfiltration of your
credentials.>

=head2 How C<Amazon::Credentials> Helps Prevent Exfiltration

=head3 Closure-Based Credential Storage

Starting with version I<1.3.0>, credentials are never stored as plain
scalar attributes on the object. Instead they are captured in Perl
closures. The object holds a code reference for each credential value;
calling it returns the credential. The values themselves live only in
the closure's lexical scope and are invisible to serialization:

  use Data::Dumper;
  my $creds = Amazon::Credentials->new;
  print Dumper $creds;
  # _access_key_id     => sub { "DUMMY" },
  # _secret_access_key => sub { "DUMMY" },
  # _session_token     => sub { "DUMMY" },

C<Dumper>, C<JSON::PP::encode_json>, exception stack traces, and
similar introspection tools will show only opaque code references -
never the credential values. This replaces the previous
encryption-based approach, which was both heavier (requiring
L<Crypt::CBC> and L<Crypt::Cipher::AES>) and weaker (the passkey lived
on the same object as the ciphertext unless you explictly sourced your
passkey from an external process).

=head3 Caching and Memory

Disabling the cache with C<< cache => 0 >> means credentials are
fetched on first use and the closures are reset to undef immediately
after each getter call. This limits the window during which a live
value exists in memory.

 my $credentials = Amazon::Credentials->new( cache => 0 );

However, B<Perl makes no guarantees about when or whether memory
containing a sensitive value is actually cleared>. The interpreter
may retain a copy of the string in freed memory, in a copy-on-write
buffer, or in an arena waiting to be reclaimed. Disabling the cache
reduces the lifetime of credentials in the object, but it is not a
substitute for running in a properly secured environment.

If you need the credential tuple at once without it ever being split
across multiple getter calls, use C<credential_keys()>:

 my $keys = $creds->credential_keys;
 # { aws_access_key_id => ..., aws_secret_access_key => ..., token => ... }

=over 5

=item Option 2 - Remove them manually after use

Call C<reset_credentials()> with a false value after fetching
credentials or after they are used by downstream processes. Call it
with a true value to regenerate them.

=item Using Multiple Instances of Amazon::Credentials

You may need to assume a role using initial credentials. In this case
you can use multiple instances of L<Amazon::Credentials>.

 # 1. retrieve SSO credentials
 my $sso_credentials = Amazon::Credentials->new(
   sso_role_name  => 'developer',
   sso_account_id => '01234567890'
 );

 # 2. assume a role in another account
 my $role_arn          = 'arn:aws:iam::09876543210:role/Route53AccountAccessRole';
 my $role_session_name = "route53-role-$PID";

 my $sts = Amazon::API::STS->new( credentials => $sso_credentials );

 my $assume_role_result = $sts->AssumeRole(
   { RoleArn         => $role_arn,
     RoleSessionName => $role_session_name,
   }
 );

 my $assumed = $assume_role_result->{AssumeRoleResult}{Credentials};

 # 3. create new credentials for assumed role
 my $role_credentials = Amazon::Credentials->new(
   aws_access_key_id     => $assumed->{AccessKeyId},
   aws_secret_access_key => $assumed->{SecretAccessKey},
   expiration            => $assumed->{Expiration},
   token                 => $assumed->{SessionToken},
 );

 # 4. make a call using the assumed role
 my $rt53 = Amazon::API::Route53->new( credentials => $role_credentials );

 my $response = $rt53->ListTagsForResources(
   { ResourceType => 'hostedzone',
     ResourceIds  => \@zone_ids,
   }
 );

=back

=head2 Securing Your Logs

To troubleshoot this module you can pass a C<debug> flag that writes
diagnostic information to STDERR. Because credentials are stored as
closures, they cannot appear in debug output produced by
L<Data::Dumper> or similar tools - the object dump shows only code
references. You should still be careful with any application-level
logging that explicitly calls the getter methods and logs their return
values.

=head2 Use Temporary Credentials

Use temporary credentials with short expiration times whenever
possible. L<Amazon::Credentials> provides methods to check expiration
and refresh credentials when they have expired.

 if ( $credentials->is_token_expired ) {
   $credentials->refresh_token;
 }

=head2 Use Granular Credentials

Consider the APIs you are calling. If all you need is access to a
single S3 bucket, use credentials scoped to that bucket only. IAM
permissions can be quite specific about what resources credentials
can access and from where.

=head2 Notes on Logging and Debug Mode

Starting with version I<1.1.0>, L<Amazon::Credentials> does B<not>
use the C<DEBUG> environment variable to enable debug output. You must
explicitly pass C<< debug => 1 >> to the constructor. This prevents
upstream modules that set C<DEBUG> from inadvertently triggering debug
mode in L<Amazon::Credentials>.

=head1 INCOMPATIBILITIES

This module has not been tested on Windows OS.

=head1 CONTRIBUTING

You can find this project on GitHub at
L<https://github.com/rlauer6/perl-Amazon-Credentials>.  PRs are always
welcomed!

=head1 LICENSE AND COPYRIGHT

This module is free software. It may be used, redistributed and/or
modified under the same terms as Perl itself.

=head1 AUTHOR

Rob Lauer - <rlauer6@comcast.net>

=cut
