From 303333c741da7e4e29c5475b2c9b86b0a4cc5fec Mon Sep 17 00:00:00 2001 From: Boku Kihara Date: Thu, 23 May 2024 13:52:42 +0900 Subject: [PATCH 1/4] [experimental] Added a provider with web identity token --- src/aws_credentials_web_identity.erl | 188 +++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/aws_credentials_web_identity.erl diff --git a/src/aws_credentials_web_identity.erl b/src/aws_credentials_web_identity.erl new file mode 100644 index 0000000..db57a98 --- /dev/null +++ b/src/aws_credentials_web_identity.erl @@ -0,0 +1,188 @@ +%% @doc This provider fetches the credentials with +%% AssumeRoleWithWebIdentity +%% API. +%% By default, this module uses the Amazon CLI tools to call the API. +%% This behavior can be changed by providing `assume_role_with_web_identity' callback by options. +%% For example, some module may resolve web identity token by aws-erlang. +%% (aws-erlang is much larger library than aws_credentials, so this module does not use it by default.) +%% +%% Environment parameters: +%% +%% @end +-module(aws_credentials_web_identity). +-behaviour(aws_credentials_provider). + +-export([fetch/1, assume_role_with_web_identity/5]). + +-type region() :: binary(). +-type role_arn() :: binary(). +-type role_session_name() :: binary(). +-type web_identity_token_file() :: binary() | string(). +-type web_identity_token() :: binary(). + +-callback assume_role_with_web_identity(region(), role_arn(), role_session_name(), web_identity_token(), map()) -> + {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()} | {error, any()}. + +-define(COMMAND_MAX_OUTPUT, + 1048576). +-define(COMMAND_TIMEOUT, + 5000). +-define(AWS_CLI_COMMAND, + <<"aws">>). + +-spec fetch(aws_credentials_provider:options()) -> + {error, any()} | {ok, aws_credentials:credentials(), aws_credentials:expiration()}. +fetch(Options) -> + try + {ok, Region} = get_region(Options), + {ok, RoleArn} = get_role_arn(Options), + {ok, RoleSessionName} = get_role_session_name(Options), + {ok, TokenFile} = get_token_file(Options), + {ok, Token} = load_token_file(TokenFile), + Module = maps:get(web_identity_token_module, Options, ?MODULE), + ModuleOptions = maps:get(web_identity_token_module_options, Options, #{}), + Module:assume_role_with_web_identity(Region, RoleArn, RoleSessionName, Token, ModuleOptions) + catch + error:{badmatch, {error, Reason}} -> {error, Reason} + end. + +-spec get_region(aws_credentials_provider:options()) -> {error, any()} | {ok, region()}. +get_region(Options) -> + case {os:getenv("AWS_DEFAULT_REGION"), os:getenv("AWS_REGION"), maps:get(region, Options, undefined)} of + {_, _, Region} when is_binary(Region) -> + {ok, Region}; + {_, AwsRegion, _} when is_list(AwsRegion) -> + {ok, list_to_binary(AwsRegion)}; + {AwsDefaultRegion, _, _} when is_list(AwsDefaultRegion) -> + {ok, list_to_binary(AwsDefaultRegion)}; + _ -> + {error, no_region} + end. + +-spec get_role_arn(aws_credentials_provider:options()) -> {error, any()} | {ok, role_arn()}. +get_role_arn(Options) -> + case {os:getenv("AWS_ROLE_ARN"), maps:get(role_arn, Options, undefined)} of + {_, RoleArn} when is_binary(RoleArn) -> + {ok, RoleArn}; + {AwsRoleArn, _} when is_list(AwsRoleArn) -> + {ok, list_to_binary(AwsRoleArn)}; + _ -> + {error, no_role_arn} + end. + +-spec get_role_session_name(aws_credentials_provider:options()) -> {ok, role_session_name()}. +get_role_session_name(Options) -> + case {os:getenv("AWS_ROLE_SESSION_NAME"), maps:get(role_session_name, Options, undefined)} of + {_, RoleSessionName} when is_binary(RoleSessionName) -> + {ok, RoleSessionName}; + {AwsRoleSessionName, _} when is_list(AwsRoleSessionName) -> + {ok, list_to_binary(AwsRoleSessionName)}; + _ -> + %% session name is used to uniquely identify a session. + %% So simply use unix time in nanoseconds. + {ok, integer_to_binary(erlang:system_time(nanosecond))} + end. + +-spec get_token_file(aws_credentials_provider:options()) -> {error, any()} | {ok, web_identity_token_file()}. +get_token_file(Options) -> + case {os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), maps:get(web_identity_token_file, Options, undefined)} of + {_, File} when is_binary(File) -> + {ok, File}; + {AwsFile, _} when is_list(AwsFile) -> + {ok, AwsFile}; + _ -> + {error, no_web_identity_token_file} + end. + +-spec load_token_file(web_identity_token_file()) -> {error, any()} | {ok, web_identity_token()}. +load_token_file(TokenFile) -> + case file:read_file(TokenFile) of + {ok, Data} -> + [Token | _] = binary:split(Data, <<"\n">>), + {ok, Token}; + {error, Reason} -> + {error, {failed_to_read_web_identity_token_file, Reason}} + end. + +%% default implementation of assume_role_with_web_identity callback +-spec assume_role_with_web_identity(region(), role_arn(), role_session_name(), web_identity_token(), map()) -> + {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()} | {error, any()}. +assume_role_with_web_identity(Region, RoleArn, RoleSessionName, WebIdentityToken, Options) -> + Result = do_aws_cli([<<"sts assume-role-with-web-identity">>, + <<" --region ">>, Region, + <<" --role-arn ">>, RoleArn, + <<" --role-session-name ">>, RoleSessionName, + <<" --web-identity-token ">>, WebIdentityToken + ], Options), + case Result of + {ok, 0, Output} -> + OutputMap = jsx:decode(Output), + CredentialsMap = maps:get(<<"Credentials">>, OutputMap), + AccessKeyId = maps:get(<<"AccessKeyId">>, CredentialsMap), + SecretAccessKey = maps:get(<<"SecretAccessKey">>, CredentialsMap), + Token = maps:get(<<"SessionToken">>, CredentialsMap), + Credentials = aws_credentials:make_map(?MODULE, AccessKeyId, SecretAccessKey, Token, Region), + Expiration = maps:get(<<"Expiration">>, CredentialsMap), + {ok, Credentials, Expiration}; + {ok, StatusCode, Output} -> + {error, {aws_cli_failed, StatusCode, Output}}; + Error -> + Error + end. + +-spec aws_cli_command(map()) -> binary(). +aws_cli_command(Options) -> + case {os:getenv("AWS_CLI_COMMAND"), maps:get(aws_cli_command, Options, undefined)} of + {false, undefined} -> ?AWS_CLI_COMMAND; + {false, Command} -> Command; + {Command, undefined} -> list_to_binary(Command); + {_, Command} -> Command + end. + +-spec do_aws_cli(iodata(), map()) -> + {error, any()} + | {ok, non_neg_integer(), binary()}. +do_aws_cli(Subcommand, Options) -> + AwsCliCommand = aws_cli_command(Options), + CommandLine = iolist_to_binary([AwsCliCommand, <<" ">>, Subcommand]), + Port = open_port({spawn, CommandLine}, [stream, use_stdio, binary, exit_status]), + do_aws_cli_loop(Port, []). + +-spec do_aws_cli_loop(port(), [binary()]) -> {error, any()} | {ok, non_neg_integer(), binary()}. +do_aws_cli_loop(Port, Data) -> + receive + {Port, {data, NewData}} -> + ConcatData = [Data, NewData], + case erlang:external_size(ConcatData) > ?COMMAND_MAX_OUTPUT of + true -> + do_aws_cli_close(Port), + {error, output_size_exceeded}; + false -> + do_aws_cli_loop(Port, NewData) + end; + {Port, {exit_status, Status}} -> + do_aws_cli_close(Port), + {ok, Status, iolist_to_binary(Data)} + after ?COMMAND_TIMEOUT-> + do_aws_cli_close(Port), + {error, timeout} + end. + +-spec do_aws_cli_close(port()) -> ok. +do_aws_cli_close(Port) -> + catch port_close(Port), + ok. From 5f76e376afa27010a8d73fb8ae51977a7fd1ee04 Mon Sep 17 00:00:00 2001 From: Boku Kihara Date: Thu, 23 May 2024 16:45:31 +0900 Subject: [PATCH 2/4] brush up to make lint happy --- src/aws_credentials_web_identity.erl | 80 ++++++++++++++-------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/src/aws_credentials_web_identity.erl b/src/aws_credentials_web_identity.erl index db57a98..6ccc9b2 100644 --- a/src/aws_credentials_web_identity.erl +++ b/src/aws_credentials_web_identity.erl @@ -33,8 +33,10 @@ -type role_session_name() :: binary(). -type web_identity_token_file() :: binary() | string(). -type web_identity_token() :: binary(). +-export_type([region/0, role_arn/0, role_session_name/0, web_identity_token/0]). --callback assume_role_with_web_identity(region(), role_arn(), role_session_name(), web_identity_token(), map()) -> +-callback assume_role_with_web_identity( + region(), role_arn(), role_session_name(), web_identity_token(), map()) -> {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()} | {error, any()}. -define(COMMAND_MAX_OUTPUT, @@ -62,50 +64,48 @@ fetch(Options) -> -spec get_region(aws_credentials_provider:options()) -> {error, any()} | {ok, region()}. get_region(Options) -> - case {os:getenv("AWS_DEFAULT_REGION"), os:getenv("AWS_REGION"), maps:get(region, Options, undefined)} of - {_, _, Region} when is_binary(Region) -> - {ok, Region}; - {_, AwsRegion, _} when is_list(AwsRegion) -> - {ok, list_to_binary(AwsRegion)}; - {AwsDefaultRegion, _, _} when is_list(AwsDefaultRegion) -> - {ok, list_to_binary(AwsDefaultRegion)}; - _ -> - {error, no_region} + RegionByOptions = maps:get(region, Options, undefined), + RegionByEnv = os:getenv("AWS_REGION"), + DefaultRegionByEnv = os:getenv("AWS_DEFAULT_REGION"), + case {RegionByOptions, RegionByEnv, DefaultRegionByEnv} of + _ when is_binary(RegionByOptions) -> {ok, RegionByOptions}; + _ when is_list(RegionByEnv) -> {ok, list_to_binary(RegionByEnv)}; + _ when is_list(DefaultRegionByEnv) -> {ok, list_to_binary(DefaultRegionByEnv)}; + _ -> {error, no_region} end. -spec get_role_arn(aws_credentials_provider:options()) -> {error, any()} | {ok, role_arn()}. get_role_arn(Options) -> - case {os:getenv("AWS_ROLE_ARN"), maps:get(role_arn, Options, undefined)} of - {_, RoleArn} when is_binary(RoleArn) -> - {ok, RoleArn}; - {AwsRoleArn, _} when is_list(AwsRoleArn) -> - {ok, list_to_binary(AwsRoleArn)}; - _ -> - {error, no_role_arn} + RoleArnByOptions = maps:get(role_arn, Options, undefined), + RoleArnByEnv = os:getenv("AWS_ROLE_ARN"), + case {RoleArnByOptions, RoleArnByEnv} of + _ when is_binary(RoleArnByOptions) -> {ok, RoleArnByOptions}; + _ when is_list(RoleArnByEnv) -> {ok, list_to_binary(RoleArnByEnv)}; + _ -> {error, no_role_arn} end. -spec get_role_session_name(aws_credentials_provider:options()) -> {ok, role_session_name()}. get_role_session_name(Options) -> - case {os:getenv("AWS_ROLE_SESSION_NAME"), maps:get(role_session_name, Options, undefined)} of - {_, RoleSessionName} when is_binary(RoleSessionName) -> - {ok, RoleSessionName}; - {AwsRoleSessionName, _} when is_list(AwsRoleSessionName) -> - {ok, list_to_binary(AwsRoleSessionName)}; + RoleSessionNameByOptions = maps:get(role_session_name, Options, undefined), + RoleSessionNameByEnv = os:getenv("AWS_ROLE_SESSION_NAME"), + case {RoleSessionNameByOptions, RoleSessionNameByEnv} of + _ when is_binary(RoleSessionNameByOptions) -> {ok, RoleSessionNameByOptions}; + _ when is_list(RoleSessionNameByEnv) -> {ok, list_to_binary(RoleSessionNameByEnv)}; _ -> %% session name is used to uniquely identify a session. %% So simply use unix time in nanoseconds. {ok, integer_to_binary(erlang:system_time(nanosecond))} end. --spec get_token_file(aws_credentials_provider:options()) -> {error, any()} | {ok, web_identity_token_file()}. +-spec get_token_file(aws_credentials_provider:options()) -> + {error, any()} | {ok, web_identity_token_file()}. get_token_file(Options) -> - case {os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), maps:get(web_identity_token_file, Options, undefined)} of - {_, File} when is_binary(File) -> - {ok, File}; - {AwsFile, _} when is_list(AwsFile) -> - {ok, AwsFile}; - _ -> - {error, no_web_identity_token_file} + TokenFileByOptions = maps:get(web_identity_token_file, Options, undefined), + TokenFileByEnv = os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), + case {TokenFileByOptions, TokenFileByEnv} of + _ when is_binary(TokenFileByOptions) -> {ok, TokenFileByOptions}; + _ when is_list(TokenFileByEnv) -> {ok, TokenFileByEnv}; + _ -> {error, no_web_identity_token_file} end. -spec load_token_file(web_identity_token_file()) -> {error, any()} | {ok, web_identity_token()}. @@ -119,7 +119,8 @@ load_token_file(TokenFile) -> end. %% default implementation of assume_role_with_web_identity callback --spec assume_role_with_web_identity(region(), role_arn(), role_session_name(), web_identity_token(), map()) -> +-spec assume_role_with_web_identity( + region(), role_arn(), role_session_name(), web_identity_token(), map()) -> {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()} | {error, any()}. assume_role_with_web_identity(Region, RoleArn, RoleSessionName, WebIdentityToken, Options) -> Result = do_aws_cli([<<"sts assume-role-with-web-identity">>, @@ -135,9 +136,9 @@ assume_role_with_web_identity(Region, RoleArn, RoleSessionName, WebIdentityToken AccessKeyId = maps:get(<<"AccessKeyId">>, CredentialsMap), SecretAccessKey = maps:get(<<"SecretAccessKey">>, CredentialsMap), Token = maps:get(<<"SessionToken">>, CredentialsMap), - Credentials = aws_credentials:make_map(?MODULE, AccessKeyId, SecretAccessKey, Token, Region), Expiration = maps:get(<<"Expiration">>, CredentialsMap), - {ok, Credentials, Expiration}; + C = aws_credentials:make_map(?MODULE, AccessKeyId, SecretAccessKey, Token, Region), + {ok, C, Expiration}; {ok, StatusCode, Output} -> {error, {aws_cli_failed, StatusCode, Output}}; Error -> @@ -146,11 +147,12 @@ assume_role_with_web_identity(Region, RoleArn, RoleSessionName, WebIdentityToken -spec aws_cli_command(map()) -> binary(). aws_cli_command(Options) -> - case {os:getenv("AWS_CLI_COMMAND"), maps:get(aws_cli_command, Options, undefined)} of - {false, undefined} -> ?AWS_CLI_COMMAND; - {false, Command} -> Command; - {Command, undefined} -> list_to_binary(Command); - {_, Command} -> Command + CommandByOptions = maps:get(aws_cli_command, Options, undefined), + CommandByEnv = os:getenv("AWS_CLI_COMMAND"), + case {CommandByOptions, CommandByEnv} of + _ when is_binary(CommandByOptions) -> CommandByOptions; + _ when is_list(CommandByEnv) -> list_to_binary(CommandByEnv); + _ -> ?AWS_CLI_COMMAND end. -spec do_aws_cli(iodata(), map()) -> @@ -177,7 +179,7 @@ do_aws_cli_loop(Port, Data) -> {Port, {exit_status, Status}} -> do_aws_cli_close(Port), {ok, Status, iolist_to_binary(Data)} - after ?COMMAND_TIMEOUT-> + after ?COMMAND_TIMEOUT -> do_aws_cli_close(Port), {error, timeout} end. From f1169863e6958093476383d8345483045a763fb7 Mon Sep 17 00:00:00 2001 From: Boku Kihara Date: Thu, 23 May 2024 17:01:02 +0900 Subject: [PATCH 3/4] fixed type mistakes --- src/aws_credentials_provider.erl | 1 + src/aws_credentials_web_identity.erl | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/aws_credentials_provider.erl b/src/aws_credentials_provider.erl index 9ea7430..9a10960 100644 --- a/src/aws_credentials_provider.erl +++ b/src/aws_credentials_provider.erl @@ -36,6 +36,7 @@ | aws_credentials_file | aws_credentials_ecs | aws_credentials_ec2 + | aws_credentials_web_identity | module(). -type error_log() :: [{provider(), term()}]. -export_type([ options/0, expiration/0, provider/0 ]). diff --git a/src/aws_credentials_web_identity.erl b/src/aws_credentials_web_identity.erl index 6ccc9b2..33baead 100644 --- a/src/aws_credentials_web_identity.erl +++ b/src/aws_credentials_web_identity.erl @@ -47,7 +47,7 @@ <<"aws">>). -spec fetch(aws_credentials_provider:options()) -> - {error, any()} | {ok, aws_credentials:credentials(), aws_credentials:expiration()}. + {error, any()} | {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()}. fetch(Options) -> try {ok, Region} = get_region(Options), From 1393d0bc9da8bcdcef7cd1b7729021015afe1d22 Mon Sep 17 00:00:00 2001 From: Boku Kihara Date: Thu, 23 May 2024 18:36:34 +0900 Subject: [PATCH 4/4] fixed data concatnation --- src/aws_credentials_web_identity.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aws_credentials_web_identity.erl b/src/aws_credentials_web_identity.erl index 33baead..ea65baf 100644 --- a/src/aws_credentials_web_identity.erl +++ b/src/aws_credentials_web_identity.erl @@ -174,7 +174,7 @@ do_aws_cli_loop(Port, Data) -> do_aws_cli_close(Port), {error, output_size_exceeded}; false -> - do_aws_cli_loop(Port, NewData) + do_aws_cli_loop(Port, ConcatData) end; {Port, {exit_status, Status}} -> do_aws_cli_close(Port),