Skip to content

Commit 2e48bf7

Browse files
bors[bot]omus
andauthored
Merge #620
620: Support `credential_process` config setting r=omus a=omus Adds support for the AWS config setting [`credential_process`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html). I want to add support for this feature as I want I'm using [`aws-vault`'s integration](https://github.com/99designs/aws-vault/blob/master/USAGE.md#use-case-2-aws-vault-is-a-master-credentials-vault-for-aws-sdk) which means I can store my AWS credentials in one place. As part of this I encountered that if you have an `~/.aws/credentials` file that the `credential_process` setting was ignored. This made me do some testing into if the credential preference order mirrors what the AWS CLI does. The results of these tests found the following: - config file `sso_*` used over `~/.aws/credentials` - `~/.aws/credentials` used over config file `credential_process` - config file `credential_process` used over config file `aws_access_key_id`/`aws_secret_access_key` This was using the AWS CLI version `aws-cli/2.11.13 Python/3.11.3 Darwin/22.4.0 source/arm64 prompt/off`. In this PR the `credential_process` is preferred over config file `aws_access_key_id`/`aws_secret_access_key` but the `sso_*` credentials have the wrong preference ordering. I'll try to address the credential preference ordering issues in another PR. Co-authored-by: Curtis Vogt <[email protected]>
2 parents c16fb18 + d4ec678 commit 2e48bf7

File tree

5 files changed

+184
-8
lines changed

5 files changed

+184
-8
lines changed

Project.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "AWS"
22
uuid = "fbe9abb3-538b-5e4e-ba9e-bc94f4f92ebc"
33
license = "MIT"
4-
version = "1.84.1"
4+
version = "1.85.0"
55

66
[deps]
77
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
@@ -23,7 +23,7 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
2323
XMLDict = "228000da-037f-5747-90a9-8195ccbf91a5"
2424

2525
[compat]
26-
Compat = "3.29, 4"
26+
Compat = "3.32, 4"
2727
GitHub = "5"
2828
HTTP = "1"
2929
IniFile = "0.5"

src/AWS.jl

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module AWS
22

3-
using Compat: Compat, @something
3+
using Compat: Compat, @compat, @something
44
using Base64
55
using Dates
66
using Downloads: Downloads, Downloader, Curl

src/AWSCredentials.jl

+30-5
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,22 @@ using ..AWSExceptions
88

99
export AWSCredentials,
1010
aws_account_number,
11-
aws_get_region,
1211
aws_get_profile_settings,
12+
aws_get_region,
1313
aws_user_arn,
1414
check_credentials,
15+
credentials_from_webtoken,
1516
dot_aws_config,
17+
dot_aws_config_file,
1618
dot_aws_credentials,
1719
dot_aws_credentials_file,
18-
dot_aws_config_file,
1920
ec2_instance_credentials,
2021
ecs_instance_credentials,
2122
env_var_credentials,
23+
external_process_credentials,
2224
localhost_is_ec2,
23-
localhost_maybe_ec2,
2425
localhost_is_lambda,
25-
credentials_from_webtoken
26+
localhost_maybe_ec2
2627

2728
function localhost_maybe_ec2()
2829
return localhost_is_ec2() || isfile("/sys/devices/virtual/dmi/id/product_uuid")
@@ -424,10 +425,14 @@ function dot_aws_config(profile=nothing)
424425
settings = _aws_profile_config(ini, p)
425426
isempty(settings) && return nothing
426427

428+
credential_process = get(settings, "credential_process", nothing)
427429
access_key = get(settings, "aws_access_key_id", nothing)
428430
sso_start_url = get(settings, "sso_start_url", nothing)
429431

430-
if !isnothing(access_key)
432+
if !isnothing(credential_process)
433+
cmd = Cmd(Base.shell_split(credential_process))
434+
return external_process_credentials(cmd)
435+
elseif !isnothing(access_key)
431436
access_key, secret_key, token = _aws_get_credential_details(p, ini)
432437
return AWSCredentials(access_key, secret_key, token)
433438
elseif !isnothing(sso_start_url)
@@ -559,6 +564,26 @@ function credentials_from_webtoken()
559564
)
560565
end
561566

567+
"""
568+
external_process_credentials(cmd::Base.AbstractCmd) -> AWSCredentials
569+
570+
Sources AWS credentials from an external process as defined in the AWS CLI config file.
571+
See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
572+
for details.
573+
"""
574+
function external_process_credentials(cmd::Base.AbstractCmd)
575+
nt = open(cmd, "r") do io
576+
_read_credential_process(io)
577+
end
578+
return AWSCredentials(
579+
nt.access_key_id,
580+
nt.secret_access_key,
581+
@something(nt.session_token, "");
582+
expiry=@something(nt.expiration, typemax(DateTime)),
583+
renew=() -> external_process_credentials(cmd),
584+
)
585+
end
586+
562587
"""
563588
aws_get_region(; profile=nothing, config=nothing, default="$DEFAULT_REGION")
564589

src/utilities/credentials.jl

+35
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,38 @@ function _aws_get_sso_credential_details(profile::AbstractString, ini::Inifile)
212212

213213
return (access_key, secret_key, token, expiry)
214214
end
215+
216+
"""
217+
_read_credential_process(io::IO) -> NamedTuple
218+
219+
Parse the AWS CLI external process output out as defined in:
220+
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html
221+
"""
222+
function _read_credential_process(io::IO)
223+
# `JSON.parse` chokes on `Base.Process` I/O streams.
224+
json = JSON.parse(read(io, String))
225+
226+
version = json["Version"]
227+
if version != 1
228+
error(
229+
"Credential process returned unhandled version $version:\n",
230+
sprint(JSON.print, json, 2),
231+
)
232+
end
233+
234+
access_key_id = json["AccessKeyId"]
235+
secret_access_key = json["SecretAccessKey"]
236+
237+
# The presence of the "Expiration" key determines if the provided credentials are
238+
# long-term credentials or temporary credentials. Temporary credentials must include a
239+
# session token (https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html)
240+
if haskey(json, "Expiration") || haskey(json, "SessionToken")
241+
expiration = parse(DateTime, json["Expiration"], dateformat"yyyy-mm-dd\THH:MM:SS\Z")
242+
session_token = json["SessionToken"]
243+
else
244+
expiration = nothing
245+
session_token = nothing
246+
end
247+
248+
return @compat (; access_key_id, secret_access_key, session_token, expiration)
249+
end

test/AWSCredentials.jl

+116
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,69 @@ end
511511
end
512512
end
513513

514+
@testset "~/.aws/config - Credential Process" begin
515+
mktempdir() do dir
516+
config_file = joinpath(dir, "config")
517+
credential_process_file = joinpath(dir, "cred_process")
518+
open(credential_process_file, "w") do io
519+
println(io, "#!/bin/sh")
520+
println(io, "cat <<EOF")
521+
json = Dict(
522+
"Version" => 1,
523+
"AccessKeyId" => test_values["Test-AccessKeyId"],
524+
"SecretAccessKey" => test_values["Test-SecretAccessKey"],
525+
)
526+
JSON.print(io, json)
527+
println(io, "\nEOF")
528+
end
529+
chmod(credential_process_file, 0o700)
530+
531+
withenv("AWS_CONFIG_FILE" => config_file) do
532+
@testset "support" begin
533+
open(config_file, "w") do io
534+
write(
535+
io,
536+
"""
537+
[profile $(test_values["Test-Config-Profile"])]
538+
credential_process = $(abspath(credential_process_file))
539+
""",
540+
)
541+
end
542+
543+
result = dot_aws_config(test_values["Test-Config-Profile"])
544+
545+
@test result.access_key_id == test_values["Test-AccessKeyId"]
546+
@test result.secret_key == test_values["Test-SecretAccessKey"]
547+
@test isempty(result.token)
548+
@test result.expiry == typemax(DateTime)
549+
end
550+
551+
# The AWS CLI uses the config file `credential_process` setting over
552+
# specifying the config file `aws_access_key_id`/`aws_secret_access_key`.
553+
@testset "precedence" begin
554+
open(config_file, "w") do io
555+
write(
556+
io,
557+
"""
558+
[profile $(test_values["Test-Config-Profile"])]
559+
aws_access_key_id = invalid
560+
aws_secret_access_key = invalid
561+
credential_process = $(abspath(credential_process_file))
562+
""",
563+
)
564+
end
565+
566+
result = dot_aws_config(test_values["Test-Config-Profile"])
567+
568+
@test result.access_key_id == test_values["Test-AccessKeyId"]
569+
@test result.secret_key == test_values["Test-SecretAccessKey"]
570+
@test isempty(result.token)
571+
@test result.expiry == typemax(DateTime)
572+
end
573+
end
574+
end
575+
end
576+
514577
@testset "~/.aws/creds - Default Profile" begin
515578
mktemp() do creds_file, creds_io
516579
write(
@@ -696,6 +759,59 @@ end
696759
end
697760
end
698761

762+
@testset "Credential Process" begin
763+
gen_process(json) = Cmd(["echo", JSON.json(json)])
764+
765+
long_term_resp = Dict(
766+
"Version" => 1,
767+
"AccessKeyId" => "access-key",
768+
"SecretAccessKey" => "secret-key",
769+
# format trick: using this comment to force use of multiple lines
770+
)
771+
creds = external_process_credentials(gen_process(long_term_resp))
772+
@test creds.access_key_id == long_term_resp["AccessKeyId"]
773+
@test creds.secret_key == long_term_resp["SecretAccessKey"]
774+
@test isempty(creds.token)
775+
@test creds.expiry == typemax(DateTime)
776+
777+
expiration = floor(now(UTC), Second)
778+
temporary_resp = Dict(
779+
"Version" => 1,
780+
"AccessKeyId" => "access-key",
781+
"SecretAccessKey" => "secret-key",
782+
"SessionToken" => "session-token",
783+
"Expiration" => Dates.format(expiration, dateformat"yyyy-mm-dd\THH:MM:SS\Z"),
784+
)
785+
creds = external_process_credentials(gen_process(temporary_resp))
786+
@test creds.access_key_id == temporary_resp["AccessKeyId"]
787+
@test creds.secret_key == temporary_resp["SecretAccessKey"]
788+
@test creds.token == temporary_resp["SessionToken"]
789+
@test creds.expiry == expiration
790+
791+
unhandled_version_resp = Dict("Version" => 2)
792+
json = sprint(JSON.print, unhandled_version_resp, 2)
793+
ex = ErrorException("Credential process returned unhandled version 2:\n$json")
794+
@test_throws ex external_process_credentials(gen_process(unhandled_version_resp))
795+
796+
missing_token_resp = Dict(
797+
"Version" => 1,
798+
"AccessKeyId" => "access-key",
799+
"SecretAccessKey" => "secret-key",
800+
"Expiration" => Dates.format(expiration, dateformat"yyyy-mm-dd\THH:MM:SS\Z"),
801+
)
802+
ex = KeyError("SessionToken")
803+
@test_throws ex external_process_credentials(gen_process(missing_token_resp))
804+
805+
missing_expiration_resp = Dict(
806+
"Version" => 1,
807+
"AccessKeyId" => "access-key",
808+
"SecretAccessKey" => "secret-key",
809+
"SessionToken" => "session-token",
810+
)
811+
ex = KeyError("Expiration")
812+
@test_throws ex external_process_credentials(gen_process(missing_expiration_resp))
813+
end
814+
699815
@testset "Credentials Not Found" begin
700816
patches = [
701817
@patch HTTP.request(method::String, url; kwargs...) = nothing

0 commit comments

Comments
 (0)