diff --git a/README.md b/README.md index 604bcef..c771e18 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ sources in the following default order: 3. An AWS [credentials file][1] 4. ECS task credentials 5. EC2 metadata +6. EKS Pod Identity +7. Web Identity Usage ----- diff --git a/rebar.config b/rebar.config index 522db88..d0ee324 100644 --- a/rebar.config +++ b/rebar.config @@ -40,4 +40,6 @@ , deprecated_functions ]}. +{dialyzer, [{plt_extra_apps, [xmerl]}]}. + {plugins, [ {rebar3_lint, "3.0.1"} ]}. diff --git a/src/aws_credentials_provider.erl b/src/aws_credentials_provider.erl index cf6f062..91ab327 100644 --- a/src/aws_credentials_provider.erl +++ b/src/aws_credentials_provider.erl @@ -37,6 +37,7 @@ | aws_credentials_ecs | aws_credentials_ec2 | aws_credentials_eks + | aws_credentials_web_identity | module(). -type error_log() :: [{provider(), term()}]. -export_type([ options/0, expiration/0, provider/0, error_log/0 ]). @@ -50,7 +51,8 @@ aws_credentials_file, aws_credentials_ecs, aws_credentials_ec2, - aws_credentials_eks]). + aws_credentials_eks, + aws_credentials_web_identity]). -spec fetch() -> {ok, aws_credentials:credentials(), expiration()} | diff --git a/src/aws_credentials_web_identity.erl b/src/aws_credentials_web_identity.erl new file mode 100644 index 0000000..bc5fdde --- /dev/null +++ b/src/aws_credentials_web_identity.erl @@ -0,0 +1,66 @@ +%% @doc This provider looks up credential information from web identity token +%% Environment parameters: +%% +%% @end +-module(aws_credentials_web_identity). +-behaviour(aws_credentials_provider). + +-include_lib("xmerl/include/xmerl.hrl"). + +-define(ASSUME_ROLE_URL, + "https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity&Version=2011-06-15" ++ + "&RoleArn=~s&WebIdentityToken=~s&RoleSessionName=~s"). +-define(DEFAULT_SESSION_NAME, "erlang_aws_credentials"). + +-export([fetch/1]). + +-spec fetch(aws_credentials_provider:options()) -> + {error, _} + | {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()}. +fetch(Options) -> + RoleArn = os:getenv("AWS_ROLE_ARN"), + TokenFile = os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), + AuthToken = read_token(TokenFile), + SessionName = maps:get(role_session_name, Options, ?DEFAULT_SESSION_NAME), + Response = fetch_assume_role_token(RoleArn, AuthToken, SessionName), + make_map(Response). + +-spec read_token(false | string()) -> {error, _} | {ok, binary()}. +read_token(false) -> {error, no_credentials}; +read_token(Path) -> file:read_file(Path). + +-spec fetch_assume_role_token(false | string(), {error, _} | {ok, binary()}, binary()) -> + {error, _} + | {ok, aws_credentials_httpc:status_code(), + aws_credentials_httpc:body(), + aws_credentials_httpc:headers()}. +fetch_assume_role_token(false, _AuthToken, _SessionName) -> {error, no_credentials}; +fetch_assume_role_token(_RoleArn, {error, _Error} = Error, _SessionName) -> Error; +fetch_assume_role_token(RoleArn, {ok, AuthToken}, SessionName) -> + Url = lists:flatten(io_lib:format(?ASSUME_ROLE_URL, [RoleArn, AuthToken, SessionName])), + aws_credentials_httpc:request(get, Url). + +-spec make_map({error, _} +| {ok, aws_credentials_httpc:status_code(), + aws_credentials_httpc:body(), + aws_credentials_httpc:headers()}) -> + {error, _} + | {ok, aws_credentials:credentials(), aws_credentials_provider:expiration()}. +make_map({error, _Error} = Error) -> Error; +make_map({ok, _Status, Body, _Headers}) -> + {Doc, []} = xmerl_scan:string(binary_to_list(Body)), + [#xmlText{value = AccessKeyId}] = xmerl_xpath:string("//Credentials/AccessKeyId/text()", Doc), + [#xmlText{value = SecretAccessKey}] = + xmerl_xpath:string("//Credentials/SecretAccessKey/text()", Doc), + [#xmlText{value = Token}] = xmerl_xpath:string("//Credentials/SessionToken/text()", Doc), + [#xmlText{value = Expiration}] = xmerl_xpath:string("//Credentials/Expiration/text()", Doc), + Creds = aws_credentials:make_map(?MODULE, + list_to_binary(AccessKeyId), + list_to_binary(SecretAccessKey), + list_to_binary(Token)), + {ok, Creds, list_to_binary(Expiration)}. diff --git a/test/aws_credentials_providers_SUITE.erl b/test/aws_credentials_providers_SUITE.erl index 25715c7..fbd1d04 100644 --- a/test/aws_credentials_providers_SUITE.erl +++ b/test/aws_credentials_providers_SUITE.erl @@ -36,6 +36,8 @@ all() -> , {group, application_env} , {group, ecs} , {group, eks} + , {group, web_identity} + , {group, web_identity_default_session_name} , {group, credential_process} ]. @@ -50,6 +52,8 @@ groups() -> , {application_env, [], all_testcases()} , {ecs, [], all_testcases()} , {eks, [], all_testcases()} + , {web_identity, [], all_testcases()} + , {web_identity_default_session_name, [], all_testcases()} , {credential_process, [], all_testcases()} ]. @@ -75,6 +79,8 @@ init_per_group(GroupName, Config) -> application_env -> init_group(application_env, provider(env), application_env, Config); credential_process -> init_group(credential_process, provider(file), credential_process, Config); + web_identity_default_session_name = GroupName -> + init_group(GroupName, provider(web_identity), GroupName, Config); GroupName -> init_group(GroupName, Config) end. @@ -123,6 +129,12 @@ assert_test(credential_process) -> assert_test(eks) -> Provider = provider(eks), assert_values(?DUMMY_ACCESS_KEY, ?DUMMY_SECRET_ACCESS_KEY, Provider); +assert_test(WebIdentity) when WebIdentity =:= web_identity; + WebIdentity =:= web_identity_default_session_name -> + Provider = provider(web_identity), + assert_values(?DUMMY_ACCESS_KEY, ?DUMMY_SECRET_ACCESS_KEY, Provider), + #{token := Token} = aws_credentials:get_credentials(), + ?assertEqual(<<"unused">>, Token); assert_test(GroupName) -> Provider = provider(GroupName), assert_values(?DUMMY_ACCESS_KEY, ?DUMMY_SECRET_ACCESS_KEY, Provider). @@ -159,6 +171,8 @@ provider_opts(credential_env, _Config) -> #{credential_path => os:getenv("HOME")}; provider_opts(credential_process, Config) -> #{credential_path => ?config(data_dir, Config) ++ "credential_process/"}; +provider_opts(web_identity, _Config) -> + #{role_session_name => "overridden"}; provider_opts(_GroupName, _Config) -> #{}. @@ -213,6 +227,28 @@ setup_provider(eks, Config) -> , {"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", OldTokenFile} ] }; +setup_provider(web_identity_default_session_name, Config) -> + OldRoleArn = os:getenv("AWS_ROLE_ARN"), + OldWebIdentityTokenFile = os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), + os:putenv("AWS_ROLE_ARN", "arg:aws:iam::123123123"), + os:putenv("AWS_WEB_IDENTITY_TOKEN_FILE", ?config(data_dir, Config) ++ "web_identity/token"), + meck:new(httpc, [no_link, passthrough]), + meck:expect(httpc, request, fun mock_httpc_request_web_identity_default_session_name/5), + #{ mocks => [httpc] + , env => [ {"AWS_ROLE_ARN", OldRoleArn} + , {"AWS_WEB_IDENTITY_TOKEN_FILE", OldWebIdentityTokenFile} + ]}; +setup_provider(web_identity, Config) -> + OldRoleArn = os:getenv("AWS_ROLE_ARN"), + OldWebIdentityTokenFile = os:getenv("AWS_WEB_IDENTITY_TOKEN_FILE"), + os:putenv("AWS_ROLE_ARN", "arg:aws:iam::123123123"), + os:putenv("AWS_WEB_IDENTITY_TOKEN_FILE", ?config(data_dir, Config) ++ "web_identity/token"), + meck:new(httpc, [no_link, passthrough]), + meck:expect(httpc, request, fun mock_httpc_request_web_identity/5), + #{ mocks => [httpc] + , env => [ {"AWS_ROLE_ARN", OldRoleArn} + , {"AWS_WEB_IDENTITY_TOKEN_FILE", OldWebIdentityTokenFile} + ]}; setup_provider(config_env, Config) -> Old = os:getenv("AWS_CONFIG_FILE"), os:putenv("AWS_CONFIG_FILE", ?config(data_dir, Config) ++ "env/config"), @@ -283,6 +319,31 @@ mock_httpc_request_eks(Method, Request, HTTPOptions, Options, Profile) -> meck:passthrough([Method, Request, HTTPOptions, Options, Profile]) end. +mock_httpc_request_web_identity_default_session_name( + Method, Request, HTTPOptions, Options, Profile) -> + case Request of + {"https://sts.amazonaws.com/" ++ + "?Action=AssumeRoleWithWebIdentity&Version=2011-06-15" ++ + "&RoleArn=arg:aws:iam::123123123" ++ + "&WebIdentityToken=dummy-web-identity-token" ++ + "&RoleSessionName=erlang_aws_credentials", []} -> + {ok, response('web-identity-credentials')}; + _ -> + meck:passthrough([Method, Request, HTTPOptions, Options, Profile]) + end. + +mock_httpc_request_web_identity(Method, Request, HTTPOptions, Options, Profile) -> + case Request of + {"https://sts.amazonaws.com/" ++ + "?Action=AssumeRoleWithWebIdentity&Version=2011-06-15" ++ + "&RoleArn=arg:aws:iam::123123123" ++ + "&WebIdentityToken=dummy-web-identity-token" ++ + "&RoleSessionName=overridden", []} -> + {ok, response('web-identity-credentials')}; + _ -> + meck:passthrough([Method, Request, HTTPOptions, Options, Profile]) + end. + response(BodyTag) -> StatusLine = {unused, 200, unused}, Headers = [], @@ -296,7 +357,7 @@ body('security-credentials') -> body('dummy-role') -> jsx:encode(#{ 'AccessKeyId' => ?DUMMY_ACCESS_KEY , 'SecretAccessKey' => ?DUMMY_SECRET_ACCESS_KEY - , 'Expiration' => <<"2025-09-25T23:43:56Z">> + , 'Expiration' => <<"2026-09-25T23:43:56Z">> , 'Token' => unused }); body('document') -> @@ -304,15 +365,26 @@ body('document') -> body('dummy-uri') -> jsx:encode(#{ 'AccessKeyId' => ?DUMMY_ACCESS_KEY , 'SecretAccessKey' => ?DUMMY_SECRET_ACCESS_KEY - , 'Expiration' => <<"2025-09-25T23:43:56Z">> + , 'Expiration' => <<"2026-09-25T23:43:56Z">> , 'Token' => unused }); body('eks-credentials') -> jsx:encode(#{ 'AccessKeyId' => ?DUMMY_ACCESS_KEY , 'SecretAccessKey' => ?DUMMY_SECRET_ACCESS_KEY - , 'Expiration' => <<"2025-09-25T23:43:56Z">> + , 'Expiration' => <<"2026-09-25T23:43:56Z">> , 'Token' => unused - }). + }); +body('web-identity-credentials') -> + <<" + + + ", ?DUMMY_ACCESS_KEY/binary, " + ", ?DUMMY_SECRET_ACCESS_KEY/binary, " + unused + 2026-09-25T23:43:56Z + + + ">>. maybe_put_env(Key, false) -> os:unsetenv(Key); diff --git a/test/aws_credentials_providers_SUITE_data/web_identity/token b/test/aws_credentials_providers_SUITE_data/web_identity/token new file mode 100644 index 0000000..4d83eee --- /dev/null +++ b/test/aws_credentials_providers_SUITE_data/web_identity/token @@ -0,0 +1 @@ +dummy-web-identity-token \ No newline at end of file