diff --git a/Project.toml b/Project.toml index 4855fee..32b370b 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "AWSCore" uuid = "4f1ea46c-232b-54a6-9b17-cc2d0f3e6598" -version = "0.6.2" +version = "0.7.0" [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" @@ -11,6 +11,7 @@ IniFile = "83e8ac13-25f8-5344-8a64-a9f2b223428f" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" LazyJSON = "fc18253b-5e1b-504c-a4a2-9ece4944c004" MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d" +Mocking = "78c3b35d-d492-501b-9361-3d52fe80e533" Retry = "20febd7b-183b-5ae2-ac4a-720e7ce64774" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" SymDict = "2da68c74-98d7-5633-99d6-8493888d7b1e" diff --git a/src/AWSCore.jl b/src/AWSCore.jl index b09ed55..ad2a2e7 100644 --- a/src/AWSCore.jl +++ b/src/AWSCore.jl @@ -8,8 +8,7 @@ module AWSCore -export AWSException, AWSConfig, AWSRequest, - aws_config, default_aws_config +export AWSException, AWSConfig, AWSRequest, aws_config, default_aws_config, SignatureV4 using Base64 using Dates @@ -55,7 +54,9 @@ include("AWSException.jl") include("AWSCredentials.jl") include("names.jl") include("mime.jl") - +include("signaturev4.jl") +include("sign.jl") +include("Services.jl") #------------------------------------------------------------------------------# @@ -557,11 +558,6 @@ global debug_level = 0 function set_debug_level(n) global debug_level = n end - - -include("Services.jl") - - end # module AWSCore diff --git a/src/AWSCredentials.jl b/src/AWSCredentials.jl index 46e81fb..89a4df5 100644 --- a/src/AWSCredentials.jl +++ b/src/AWSCredentials.jl @@ -1,37 +1,38 @@ -#==============================================================================# -# AWSCredentials.jl -# -# Load AWS Credentials from: -# - EC2 Instance Profile, -# - Environment variables, or -# - ~/.aws/credentials file. -# -# Copyright OC Technology Pty Ltd 2014 - All rights reserved -#==============================================================================# +import Base: copy! +using IniFile +using HTTP +using Dates +using Mocking +using JSON export AWSCredentials, localhost_is_lambda, localhost_is_ec2, localhost_maybe_ec2, + check_credentials, aws_user_arn, - aws_account_number, - check_credentials + aws_get_role_details, + aws_get_region, + get_awscreds_ec2, + get_awscreds_env_vars, + get_awscreds_ecs, + dot_aws_config, + dot_aws_credentials """ When you interact with AWS, you specify your [AWS Security Credentials](http://docs.aws.amazon.com/general/latest/gr/aws-security-credentials.html) to verify who you are and whether you have permission to access the resources that you are requesting. AWS uses the security credentials to authenticate and authorize your requests. - The fields `access_key_id` and `secret_key` hold the access keys used to authenticate API requests (see [Creating, Modifying, and Viewing Access Keys](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey)). - [Temporary Security Credentials](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html) require the extra session `token` field. - The `user_arn` and `account_number` fields are used to cache the result of the [`aws_user_arn`](@ref) and [`aws_account_number`](@ref) functions. + AWSCore searches for credentials in a series of possible locations and stop as soon as it finds credentials. The order of precedence for this search is as follows: + 1. Passing credentials directly to the `AWSCredentials` constructor 2. [Environment variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) 3. Shared credential file [(~/.aws/credentials)](http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html) @@ -43,10 +44,8 @@ Once the credentials are found, the method by which they were accessed is stored and the DateTime at which they will expire is stored in the `expiry` field. This allows the credentials to be refreshed as needed using [`check_credentials`](@ref). If `renew` is set to `nothing`, no attempt will be made to refresh the credentials. - Any renewal function is expected to return `nothing` on failure or a populated `AWSCredentials` object on success. The `renew` field of the returned `AWSCredentials` will be discarded and does not need to be set. - To specify the profile to use from `~/.aws/credentials`, do, for example, `AWSCredentials(profile="profile-name")`. """ mutable struct AWSCredentials @@ -56,117 +55,123 @@ mutable struct AWSCredentials user_arn::String account_number::String expiry::DateTime - renew::Union{Function, Nothing} + renew::Union{Function, Nothing} # Method which credentials were obtained + + function AWSCredentials( + access_key_id, + secret_key, + token="", + user_arn="", + account_number=""; + expiry=typemax(DateTime), + renew=nothing + ) + return new(access_key_id, secret_key, token, user_arn, account_number, expiry, renew) + end +end - function AWSCredentials(access_key_id,secret_key, - token="", user_arn="", account_number=""; - expiry=typemax(DateTime), - renew=nothing) - new(access_key_id, secret_key, token, user_arn, account_number, expiry, renew) +function Base.show(io::IO,c::AWSCredentials) + println(io, string(c.user_arn, + c.user_arn == "" ? "" : " ", + "(", + c.account_number, + c.account_number == "" ? "" : ", ", + c.access_key_id, + c.secret_key == "" ? "" : ", $(c.secret_key[1:3])...", + c.token == "" ? "" : ", $(c.token[1:3])..."), + c.expiry, + ")") +end + +function Base.copyto!(dest::AWSCredentials, src::AWSCredentials) + for f in fieldnames(typeof(dest)) + setfield!(dest, f, getfield(src, f)) end end +Base.@deprecate copy!(dest::AWSCredentials, src::AWSCredentials) copyto!(dest, src) +dot_aws_config_file() = get(ENV, "AWS_CONFIG_FILE", joinpath(homedir(), ".aws", "config")) +dot_aws_credentials_file() = get(ENV, "AWS_SHARED_CREDENTIALS_FILE", joinpath(homedir(), ".aws", "credentials")) +localhost_maybe_ec2() = localhost_is_ec2() || isfile("/sys/devices/virtual/dmi/id/product_uuid") +localhost_is_lambda() = haskey(ENV, "LAMBDA_TASK_ROOT") +_aws_get_profile() = get(ENV, "AWS_DEFAULT_PROFILE", get(ENV, "AWS_PROFILE", "default")) + + +""" + AWSCredentials(profile) + +# Arguments + +- `profile::AbstractString`: Specific profile used to search for AWSCredentials + +Create an AWSCredentials object, given a provided profile (if not provided 'default' will be used). + +Checks credential locations in the order: + 1. Environment Variables + 2. ~/.aws/credentials + 3. ~/.aws/config + 4. EC2 or ECS metadata +""" function AWSCredentials(;profile=nothing) creds = nothing - renew = Nothing + credential_method = nothing # Define our search options functions = [ - env_instance_credentials, + get_awscreds_env_vars, () -> dot_aws_credentials(profile), () -> dot_aws_config(profile), - instance_credentials, + _get_awscreds_instance, ] # Loop through our search locations until we get credentials back for f in functions - renew = f - creds = renew() + credential_method = f + creds = credential_method() creds === nothing || break end creds === nothing && error("Can't find AWS credentials!") - creds.renew = renew - - if debug_level > 0 - display(creds) - println() - end + creds.renew = credential_method return creds end -will_expire(cr::AWSCredentials) = now(UTC) >= cr.expiry - Minute(5) - """ - check_credentials(cr::AWSCredentials; force_refresh::Bool=false) + localhost_is_ec2() -Checks current AWSCredentials, refreshing them if they are soon to expire. -If force_refresh is `true` the credentials will be renewed immediately. -""" -function check_credentials(cr::AWSCredentials; force_refresh::Bool=false) - if force_refresh || will_expire(cr) - if debug_level > 0 - println("Renewing credentials... ") - end - renew = cr.renew +Checks to see if the localhost is on EC2. - if renew !== nothing - new_creds = renew() +Note: This assumes that you are running on a Linux instance and will not work for Windows instances. - new_creds === nothing && error("Can't find AWS credentials!") - copyto!(cr, new_creds) +Checking to see if you are running on an EC2 instance is a complicated problem due to a large amount +of caveats. Below is a list of methods to implement to work through most of these: - # Ensure renewal function is not overwritten by the new credentials - cr.renew = renew - else - if debug_level > 0 - println("Credentials cannot be renewed...") - end - end - end +1. Check the `hostname -d`; this will not work if using non-Amazon DNS +2. Check metadata with EC2 internal domain name `curl -s http://instance-data.ec2.internal`; this +will not work with a VPC (legacy EC2 only) +3. Check `sudo dmidecode -s bios-version`; this requires `dmidecode` on the instance +4. Check `/sys/devices/virtual/dmi/id/bios_version`; this may not work depending on the instance, +Amazon does not document this file however so it's quite unreliable +5. Check `http://169.254.169.254`; This is a link-local address for metadata, apparently other cloud +providers make this metadata URL available now as well so it's not guaranteed that you're on an EC2 +instance - return cr -end - -function Base.show(io::IO,c::AWSCredentials) - println(io, string(c.user_arn, - c.user_arn == "" ? "" : " ", - "(", - c.account_number, - c.account_number == "" ? "" : ", ", - c.access_key_id, - c.secret_key == "" ? "" : ", $(c.secret_key[1:3])...", - c.token == "" ? "" : ", $(c.token[1:3])..."), - ")") -end - -function Base.copyto!(dest::AWSCredentials, src::AWSCredentials) - for f in fieldnames(typeof(dest)) - setfield!(dest, f, getfield(src, f)) - end -end -import Base: copy! -Base.@deprecate copy!(dest::AWSCredentials, src::AWSCredentials) copyto!(dest, src) - - -""" -Is Julia running in an AWS Lambda sandbox? -""" -localhost_is_lambda() = haskey(ENV, "LAMBDA_TASK_ROOT") +To check if you are on a Windows EC2 instance we need to use + `wmic path win32_computersystemproduct get uuid`, this will return the instance UUID. However, this + is still not a guarantee that you're on an EC2 instance as RNG could have just made the first three + characters `EC2`. + Expected output from the command above: + > EC2AE145-D1DC-13B2-94ED-01234ABCDEF -""" -Is Julia running on an EC2 virtual machine? + Instances using SMBIOS 2.4 may have the UUID represented in little-endian: + > 45E12AEC-DCD1-B213-94ED-01234ABCDEF """ function localhost_is_ec2() - - if localhost_is_lambda() - return false - end - - if isfile("/sys/hypervisor/uuid") && - String(read("/sys/hypervisor/uuid",3)) == "ec2" + # Note: This will not work on new m5 and c5 instances because they use a new hypervisor stack + # and the kernel does not create files in sysfs + if isfile("/sys/hypervisor/uuid") && String(read("/sys/hypervisor/uuid", 3)) == "ec2" return true end @@ -175,7 +180,7 @@ function localhost_is_ec2() # product_uuid is not world readable! # https://patchwork.kernel.org/patch/6461521/ # https://github.com/JuliaCloud/AWSCore.jl/issues/24 - if String(read("/sys/devices/virtual/dmi/id/product_uuid")) == "EC2" + if read("/sys/devices/virtual/dmi/id/product_uuid", String) == "EC2" return true end catch @@ -185,263 +190,311 @@ function localhost_is_ec2() return false end -localhost_maybe_ec2() = localhost_is_ec2() || - isfile("/sys/devices/virtual/dmi/id/product_uuid") - """ - aws_user_arn(::AWSConfig) + check_credentials(aws_creds::AWSCredentials, force_refresh::Bool) -Unique -[Amazon Resource Name] -(http://docs.aws.amazon.com/IAM/latest/UserGuide/id_users.html) -for configrued user. +# Arguments +- `aws_creds::AWSCredentials`: AWSCredentials to be checked / refreshed +- `force_refresh::Bool`: `true` to refresh the credentials -e.g. `"arn:aws:iam::account-ID-without-hyphens:user/Bob"` +Checks current AWSCredentials, refreshing them if they are soon to expire. If `force_refresh` is +`true` the credentials will be renewed immediately """ -function aws_user_arn(aws::AWSConfig) - creds = aws[:creds] - if creds.user_arn == "" - r = Services.sts(aws, "GetCallerIdentity", []) - creds.user_arn = r["Arn"] - creds.account_number = r["Account"] - end - return creds.user_arn -end +function check_credentials(aws_creds::AWSCredentials; force_refresh::Bool=false) + if force_refresh || _will_expire(aws_creds) + credential_method = aws_creds.renew + if credential_method !== nothing + new_aws_creds = credential_method() -""" - aws_account_number(::AWSConfig) + new_aws_creds === nothing && error("Can't find AWS credentials!") + copyto!(aws_creds, new_aws_creds) -12-digit [AWS Account Number](http://docs.aws.amazon.com/general/latest/gr/acct-identifiers.html). -""" -function aws_account_number(aws::AWSConfig) - creds = aws[:creds] - if creds.account_number == "" - aws_user_arn(aws) + # Ensure credential_method is not overwritten by the new credentials + aws_creds.renew = credential_method + end end - return creds.account_number + + return aws_creds end -""" - ec2_metadata(key) +function _will_expire(aws_creds::AWSCredentials) + return now(UTC) >= aws_creds.expiry - Minute(5) +end + -Fetch [EC2 meta-data] -(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) -for `key`. """ -function ec2_metadata(key) + _ec2_metadata(metadata_endpoint::String) - @assert localhost_maybe_ec2() +# Arguments +- `metadata_endpoint::String`: AWS internal meta data endpoint to hit - String(http_get("http://169.254.169.254/latest/meta-data/$key").body) +Retrieve the EC2 meta data from the local AWS endpoint. +""" +function _ec2_metadata(metadata_endpoint::String) + try + request = @mock HTTP.request( + "GET", + "http://169.254.169.254/latest/meta-data/$metadata_endpoint", + retry=false, + retries=0, + readtimeout=5 + ) + + return String(request.body) + catch + return nothing + end end -function instance_credentials() + +function _get_awscreds_instance() if haskey(ENV, "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") - return ecs_instance_credentials() + return get_awscreds_ecs() elseif localhost_maybe_ec2() - return ec2_instance_credentials() - else - return nothing + return get_awscreds_ec2() end -end -""" -Load [Instance Profile Credentials] -(http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials) -for EC2 virtual machine. -""" -function ec2_instance_credentials() + return nothing +end - @assert localhost_maybe_ec2() - info = ec2_metadata("iam/info") - info = LazyJSON.value(info) +""" + get_awscreds_ec2() - name = ec2_metadata("iam/security-credentials/") - creds = ec2_metadata("iam/security-credentials/$name") - new_creds = LazyJSON.value(creds) +Parse the EC2 metadata to retrieve AWSCredentials. +""" +function get_awscreds_ec2() + info = _ec2_metadata("iam/info") + info = JSON.parse(info) - if debug_level > 0 - print("Loading AWSCredentials from EC2 metadata... ") - end + name = _ec2_metadata("iam/security-credentials/") + creds = _ec2_metadata("iam/security-credentials/$name") + new_creds = JSON.parse(creds) expiry = DateTime(strip(new_creds["Expiration"], 'Z')) - AWSCredentials(new_creds["AccessKeyId"], - new_creds["SecretAccessKey"], - new_creds["Token"], - info["InstanceProfileArn"]; - expiry = expiry) + return AWSCredentials( + new_creds["AccessKeyId"], + new_creds["SecretAccessKey"], + new_creds["Token"], + info["InstanceProfileArn"]; + expiry=expiry, + renew=get_awscreds_ec2 + ) end - -""" -Load [ECS Task Credentials] -(http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html) """ -function ecs_instance_credentials() + get_awscreds_ecs() - @assert haskey(ENV, "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") +Retrieve credentials from the local endpoint. More information can be found at: +https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html +""" +function get_awscreds_ecs() + uri = get(ENV, "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "") - uri = ENV["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] + if uri == "" + return nothing + end - new_creds = String(http_get("http://169.254.170.2$uri").body) - new_creds = LazyJSON.value(new_creds) + new_creds = nothing - if debug_level > 0 - print("Loading AWSCredentials from ECS metadata... ") + try + result = @mock HTTP.request( + "GET", + "http://169.254.170.2$uri", + retry=false, + retries=0, + readtimeout=5 + ) + new_creds = String(result.body) + catch e + rethrow(e) end + new_creds = JSON.parse(new_creds) expiry = DateTime(strip(new_creds["Expiration"], 'Z')) - AWSCredentials(new_creds["AccessKeyId"], - new_creds["SecretAccessKey"], - new_creds["Token"], - new_creds["RoleArn"]; - expiry = expiry) + return AWSCredentials( + new_creds["AccessKeyId"], + new_creds["SecretAccessKey"], + new_creds["Token"], + new_creds["RoleArn"]; + expiry=expiry, + renew=get_awscreds_ecs + ) end - -""" -Load Credentials from [environment variables] -(http://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html) -`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` etc. -(e.g. in Lambda sandbox). """ -function env_instance_credentials() - - if haskey(ENV, "AWS_ACCESS_KEY_ID") - if debug_level > 0 - print("Loading AWSCredentials from ENV[\"AWS_ACCESS_KEY_ID\"]... ") - end + get_awscreds_env_vars() +Retrieve the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` from their environment variables. +""" +function get_awscreds_env_vars() + if haskey(ENV, "AWS_ACCESS_KEY_ID") && haskey(ENV, "AWS_SECRET_ACCESS_KEY") return AWSCredentials( ENV["AWS_ACCESS_KEY_ID"], ENV["AWS_SECRET_ACCESS_KEY"], get(ENV, "AWS_SESSION_TOKEN", ""), get(ENV, "AWS_USER_ARN", ""); - renew = env_instance_credentials + renew=get_awscreds_env_vars ) - else - return nothing end -end - -using IniFile - -function dot_aws_credentials_file() - get(ENV, "AWS_SHARED_CREDENTIALS_FILE", joinpath(homedir(), ".aws", "credentials")) + return nothing end """ -Try to load Credentials from [AWS CLI ~/.aws/credentials file] -(http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html) + dot_aws_credentials(profile=nothing) + +# Arguments +- `profile`: Specific profile used to get AWSCredentials, default is `nothing` + +Retrieve AWSCredentials from the `~/.aws/credentials` file """ -function dot_aws_credentials(profile = nothing) - creds = nothing +function dot_aws_credentials(profile=nothing) credential_file = dot_aws_credentials_file() - ini = nothing if isfile(credential_file) ini = read(Inifile(), credential_file) - key, key_id, token = aws_get_credential_details( - profile === nothing ? aws_get_profile() : profile, + access_key, secret_key, token = _aws_get_credential_details( + profile === nothing ? _aws_get_profile() : profile, ini, false ) - if key !== :notfound - creds = AWSCredentials(key_id, key, token) + if access_key !== :notfound + return AWSCredentials(access_key, secret_key, token) end end - return creds + return nothing end -dot_aws_config_file() = get(ENV, "AWS_CONFIG_FILE", joinpath(homedir(), ".aws", "config")) - """ -Try to load Credentials or assume a role via the [AWS CLI ~/.aws/config file] -(http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html) + dot_aws_config(profile=nothing) + +# Arguments +- `profile`: Specific profile used to get AWSCredentials, default is `nothing` + +Retrieve AWSCredentials for the default or specified profile from the `~/.aws/config` file. If this +fails try to retrieve credentials from `_aws_get_role()`. """ -function dot_aws_config(profile = nothing) - creds = nothing +function dot_aws_config(profile=nothing) config_file = dot_aws_config_file() - ini = nothing if isfile(config_file) ini = read(Inifile(), config_file) - p = profile === nothing ? aws_get_profile() : profile - key, key_id, token = aws_get_credential_details(p, ini, true) + p = profile === nothing ? _aws_get_profile() : profile + println(typeof(profile)) + access_key, secret_key, token = _aws_get_credential_details(p, ini, true) - if key !== :notfound - creds = AWSCredentials(key_id, key, token) + if access_key !== :notfound + return AWSCredentials(access_key, secret_key, token) else - creds = aws_get_role(p, ini) + return _aws_get_role(p, ini) end end - return creds + return nothing end -function aws_get_role_details(profile::AbstractString, ini::Inifile) - if debug_level > 0 - println("Loading \"$profile\" Profile from " * - dot_aws_config_file() * "... ") - end - - role_arn = get(ini, profile, "role_arn") - source_profile = get(ini, profile, "source_profile") - - profile = "profile $profile" - role_arn = get(ini, profile, "role_arn", role_arn) - source_profile = get(ini, profile, "source_profile", source_profile) - (source_profile, role_arn) -end +""" + _aws_get_credential_details(profile::AbstractString, ini::Inifile, config::Bool) -function aws_get_credential_details(profile::AbstractString, ini::Inifile, config::Bool) - if debug_level > 0 - filename = config ? dot_aws_config_file() : dot_aws_credentials_file() - println("Loading \"$profile\" AWSCredentials from " * filename - * "... ") - end +# Arguments +- `profile::AbstractString`: Specific profile used to get AWSCredentials +- `ini::Inifile`: Inifile to look into for the `profile` credentials +- `config::Bool`: True if using `~/.aws/config` - key_id = get(ini, profile, "aws_access_key_id") - key = get(ini, profile, "aws_secret_access_key") +Get `AWSCredentials` for the specified `profile` from the `inifile`. If targeting the `~/.aws/config` +file, with a non-default `profile`, you must specify `config=true` otherwise the default credentials +will be returned. +""" +function _aws_get_credential_details(profile::AbstractString, ini::Inifile, config::Bool) + access_key = get(ini, profile, "aws_access_key_id") + secret_key = get(ini, profile, "aws_secret_access_key") token = get(ini, profile, "aws_session_token", "") if config profile = "profile $profile" - key_id = get(ini, profile, "aws_access_key_id", key_id) - key = get(ini, profile, "aws_secret_access_key", key) + access_key = get(ini, profile, "aws_access_key_id", access_key) + secret_key = get(ini, profile, "aws_secret_access_key", secret_key) token = get(ini, profile, "aws_session_token", token) end - (key, key_id, token) + return (access_key, secret_key, token) end -function aws_get_profile() - get(ENV, "AWS_DEFAULT_PROFILE", get(ENV, "AWS_PROFILE", "default")) -end +""" + aws_get_region(profile::AbstractString, ini::Inifile) + +# Arguments +- `profile::AbstractString`: Specific profile used to get the region +- `ini::Inifile`: Inifile to look in for the region + +Retrieve the AWS Region for a given profile, returns `us-east-1` as a default. +""" function aws_get_region(profile::AbstractString, ini::Inifile) region = get(ENV, "AWS_DEFAULT_REGION", "us-east-1") - region = get(ini, profile, "region", region) region = get(ini, "profile $profile", "region", region) + + return region end -function aws_get_role(role::AbstractString, ini::Inifile) - source_profile, role_arn = aws_get_role_details(role, ini) - source_profile === :notfound && return nothing +""" + aws_user_arn(aws::AWSConfig) + +# Arguments +- `aws::AWSConfig`: SymbolDict used to retrieve the user arn + +Retrieve the user ARN from the `AWSConfig`, if not present query Security Token Services (STS). +""" +function aws_user_arn(aws::AWSConfig) + creds = aws[:creds] + if creds.user_arn == "" + r = Services.sts(aws, "GetCallerIdentity", []) + creds.user_arn = r["Arn"] + creds.account_number = r["Account"] + end + return creds.user_arn +end + +""" + aws_account_number(aws::AWSConfig) - if debug_level > 0 - println("Assuming \"$source_profile\"... ") +# Arguments +- `aws::AWSConfig`: SymbolDict used to retrieve the AWS account number + +Retrieve the `AWS account number` from the `AWSConfig` object. + +BUG: https://github.com/JuliaCloud/AWSCore.jl/issues/87 +""" +function aws_account_number(aws::AWSConfig) + creds = aws[:creds] + if creds.account_number == "" + aws_user_arn(aws) end + return creds.account_number +end + + +""" + _aws_get_role(role::AbstractString, ini::Inifile) + +# Arguments +- `role::AbstractString`: Name of the `role` +- `ini::Inifile`: Inifile to look into to find the `role` + +Retrieve the `AWSCredentials` from Security Token Services (STS). +""" +function _aws_get_role(role::AbstractString, ini::Inifile) + source_profile, role_arn = aws_get_role_details(role, ini) + source_profile === :notfound && return nothing credentials = nothing for f in [dot_aws_credentials, dot_aws_config] @@ -450,23 +503,41 @@ function aws_get_role(role::AbstractString, ini::Inifile) end credentials === nothing && return nothing - config = AWSConfig(:creds=>credentials, :region=>aws_get_region(source_profile, ini)) role = Services.sts( config, "AssumeRole", RoleArn=role_arn, - RoleSessionName=replace(role, r"[^\w+=,.@-]" => s"-"), + RoleSessionName=replace(role, r"[^\w+=,.@-]" => s"-") ) + role_creds = role["Credentials"] - AWSCredentials(role_creds["AccessKeyId"], + return AWSCredentials( + role_creds["AccessKeyId"], role_creds["SecretAccessKey"], role_creds["SessionToken"]; - expiry = unix2datetime(role_creds["Expiration"])) + expiry=unix2datetime(role_creds["Expiration"]) + ) end -#==============================================================================# -# End of file. -#==============================================================================# + +""" + aws_get_role_details(profile::AbstractString, ini::Inifile) + +# Arguments +- `profile::AbstractString`: Specific profile to get role details about +- `ini::Inifile`: Inifile to look into to find the role details + +Return a tuple of `profile` details and the `role arn`. +""" +function aws_get_role_details(profile::AbstractString, ini::Inifile) + role_arn = get(ini, profile, "role_arn") + source_profile = get(ini, profile, "source_profile") + profile = "profile $profile" + role_arn = get(ini, profile, "role_arn", role_arn) + source_profile = get(ini, profile, "source_profile", source_profile) + + return (source_profile, role_arn) +end \ No newline at end of file diff --git a/src/names.jl b/src/names.jl index 232baa7..343f41f 100644 --- a/src/names.jl +++ b/src/names.jl @@ -112,7 +112,7 @@ arn_match(s, n, p) = occursin(p, s) || (debug_level == 0 || @warn("Bad ARN $n: \"$s\""); false) is_arn_prefix(s) = arn_match(s, "prefix", r"^arn$") -is_partition(s) = arn_match(s, "partiton", r"^aws[a-z-]*$") +is_partition(s) = arn_match(s, "partition", r"^aws[a-z-]*$") is_service(s) = arn_match(s, "service", r"^[a-zA-Z0-9\-]+$") is_region(s) = arn_match(s, "region", r"^([a-z]{2}-[a-z]+-\d)?$") is_account(s) = arn_match(s, "account", r"^(\d{12})?$") diff --git a/src/signaturev4.jl b/src/signaturev4.jl new file mode 100644 index 0000000..6c11a60 --- /dev/null +++ b/src/signaturev4.jl @@ -0,0 +1,227 @@ +module SignatureV4 + +using Base64 +using Dates +using HTTP +using HTTP.Pairs +using HTTP.URIs +using HTTP: Headers, Layer, Request +using IniFile +using MbedTLS + +export AWS4AuthLayer, sign! + +""" + AWS4AuthLayer{Next} <: HTTP.Layer + +Abstract type used by [`HTTP.request`](@ref) to add an +[AWS Signature v4](http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) +authentication layer to the request. + +Historically this layer has been placed in-between the `MessageLayer` and `RetryLayer` in the HTTP +stack. The request stack can be found +[here](https://github.com/JuliaWeb/HTTP.jl/blob/v0.8.6/src/HTTP.jl#L534). + +An example of how to layer insert can be found below: +```julia +using AWSCore +using HTTP + +stack = HTTP.stack() + +# This will insert the AWS4AuthLayer before the RetryLayer +insert(stack, RetryLayer, AWS4AuthLayer) +``` +""" +abstract type AWS4AuthLayer{Next<:Layer} <: Layer{Next} end + +credentials = NamedTuple() + +""" + HTTP.request(::Type{AWS4AuthLayer}, url::HTTP.URI, req::HTTP.Request, body) -> HTTP.Response + +Perform the given request, adding a layer of AWS authentication using +[AWS Signature v4](http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). +An "Authorization" header to the request. +""" +function HTTP.request(::Type{AWS4AuthLayer{Next}}, url::URI, req::Request, body; kw...) where Next + if !haskey(kw, :aws_access_key_id) && !haskey(ENV, "AWS_ACCESS_KEY_ID") + kw = merge(dot_aws_credentials(), kw) + end + sign!(req.method, url, req.headers, req.body; kw...) + return HTTP.request(Next, url, req, body; kw...) +end + +# Normalize whitespace to the form required in the canonical headers. +# Note that the expected format for multiline headers seems not to be explicitly +# documented, but Amazon provides a test case for it, so we'll match that behavior. +# We replace each `\n` with a `,` and remove all whitespace around the newlines, +# then any remaining contiguous whitespace is replaced with a single space. +function _normalize_ws(s::AbstractString) + if any(isequal('\n'), s) + return join(map(_normalize_ws, split(s, '\n')), ',') + end + + return replace(strip(s), r"\s+" => " ") +end + +""" + sign!(method::String, url::HTTP.URI, headers::HTTP.Headers, body; kwargs...) + +Add an "Authorization" header to `headers`, modifying it in place. +The header contains a computed signature based on the given credentials as well as +metadata about what was used to compute the signature. +For more information, see the AWS documentation on the +[Signature v4](http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html) +process. + +# Keyword arguments + +All keyword arguments to this function are optional, as they have default values. + +* `body_sha256`: A precomputed SHA-256 sum of `body` +* `body_md5`: A precomputed MD5 sum of `body` +* `timestamp`: The timestamp used in request signing (defaults to now in UTC) +* `aws_service`: The AWS service for the request (determined from the URL) +* `aws_region`: The AWS region for the request (determined from the URL) +* `aws_access_key_id`: AWS access key (read from the environment) +* `aws_secret_access_key`: AWS secret access key (read from the environment) +* `aws_session_token`: AWS session token (read from the environment, or empty) +* `token_in_signature`: Use `aws_session_token` when computing the signature (`true`) +* `include_md5`: Add the "Content-MD5" header to `headers` (`true`) +* `include_sha256`: Add the "x-amz-content-sha256" header to `headers` (`true`) +""" +function sign!(method::String, + url::URI, + headers::Headers, + body::Vector{UInt8}; + body_sha256::Vector{UInt8}=digest(MD_SHA256, body), + body_md5::Vector{UInt8}=digest(MD_MD5, body), + t::Union{DateTime,Nothing}=nothing, + timestamp::DateTime=now(Dates.UTC), + aws_service::String=String(split(url.host, ".")[1]), + aws_region::String=String(split(url.host, ".")[2]), + aws_access_key_id::String=ENV["AWS_ACCESS_KEY_ID"], + aws_secret_access_key::String=ENV["AWS_SECRET_ACCESS_KEY"], + aws_session_token::String=get(ENV, "AWS_SESSION_TOKEN", ""), + token_in_signature=true, + include_md5=true, + include_sha256=true, + kw...) + if t !== nothing + Base.depwarn("The `t` keyword argument to `sign!` is deprecated; use " * + "`timestamp` instead.", :sign!) + timestamp = t + end + + # ISO8601 date/time strings for time of request... + date = Dates.format(timestamp, dateformat"yyyymmdd") + datetime = Dates.format(timestamp, dateformat"yyyymmddTHHMMSS\Z") + + # Authentication scope... + scope = [date, aws_region, aws_service, "aws4_request"] + + # Signing key generated from today's scope string... + signing_key = string("AWS4", aws_secret_access_key) + for element in scope + signing_key = digest(MD_SHA256, element, signing_key) + end + + # Authentication scope string... + scope = join(scope, "/") + + # SHA256 hash of content... + content_hash = bytes2hex(body_sha256) + + # HTTP headers... + rmkv(headers, "Authorization") + setkv(headers, "host", url.host) + setkv(headers, "x-amz-date", datetime) + include_md5 && setkv(headers, "Content-MD5", base64encode(body_md5)) + if (aws_service == "s3" && method == "PUT") || include_sha256 + # This header is required for S3 PUT requests. See the documentation at + # https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html + setkv(headers, "x-amz-content-sha256", content_hash) + end + if aws_session_token != "" + setkv(headers, "x-amz-security-token", aws_session_token) + end + + # Sort and lowercase() Headers to produce canonical form... + unique_header_keys = Vector{String}() + normalized_headers = Dict{String,Vector{String}}() + for (k, v) in sort!([lowercase(k) => v for (k, v) in headers], by=first) + # Some services want the token included as part of the signature + if k == "x-amz-security-token" && !token_in_signature + continue + end + # In Amazon's examples, they exclude Content-Length from signing. This does not + # appear to be addressed in the documentation, so we'll just mimic the example. + if k == "content-length" + continue + end + if !haskey(normalized_headers, k) + normalized_headers[k] = Vector{String}() + push!(unique_header_keys, k) + end + push!(normalized_headers[k], _normalize_ws(v)) + end + canonical_headers = map(unique_header_keys) do k + string(k, ':', join(normalized_headers[k], ',')) + end + signed_headers = join(unique_header_keys, ';') + + # Sort Query String... + query = sort!(collect(queryparams(url.query)), by=first) + + # Paths for requests to S3 should be escaped but not normalized. See + # http://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#canonical-request + # Note that escapepath escapes ~ per RFC 1738, but Amazon includes an example in their + # signature v4 test suite where ~ remains unescaped. We follow the spec here and thus + # deviate from Amazon's example in this case. + path = escapepath(aws_service == "s3" ? url.path : URIs.normpath(url.path)) + + # Create hash of canonical request... + canonical_form = join([method, + path, + escapeuri(query), + join(canonical_headers, "\n"), + "", + signed_headers, + content_hash], "\n") + canonical_hash = bytes2hex(digest(MD_SHA256, canonical_form)) + + # Create and sign "String to Sign"... + string_to_sign = "AWS4-HMAC-SHA256\n$datetime\n$scope\n$canonical_hash" + signature = bytes2hex(digest(MD_SHA256, string_to_sign, signing_key)) + + # Append Authorization header... + setkv(headers, "Authorization", string( + "AWS4-HMAC-SHA256 ", + "Credential=$aws_access_key_id/$scope, ", + "SignedHeaders=$signed_headers, ", + "Signature=$signature" + )) + + return headers +end + +""" +Load Credentials from [AWS CLI ~/.aws/credentials file] +(http://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html). +""" +function dot_aws_credentials()::NamedTuple + global credentials + isempty(credentials) || return credentials + f = get(ENV, "AWS_CONFIG_FILE", joinpath(homedir(), ".aws", "credentials")) + isfile(f) || return NamedTuple() + p = get(ENV, "AWS_DEFAULT_PROFILE", get(ENV, "AWS_PROFILE", "default")) + ini = read(Inifile(), f) + credentials = ( + aws_access_key_id=String(get(ini, p, "aws_access_key_id")), + aws_secret_access_key=String(get(ini, p, "aws_secret_access_key")) + ) + + return credentials +end +end # module AWS4AuthRequest diff --git a/test/arn.jl b/test/arn.jl new file mode 100644 index 0000000..b123f59 --- /dev/null +++ b/test/arn.jl @@ -0,0 +1,8 @@ +@testset "ARN" begin + @test arn(aws,"s3","foo/bar") == "arn:aws:s3:::foo/bar" + @test arn(aws,"s3","foo") == "arn:aws:s3:::foo" + @test arn(aws,"sqs", "au-test-queue", "ap-southeast-2", "1234") == + "arn:aws:sqs:ap-southeast-2:1234:au-test-queue" + @test arn(aws,"sns","*","*",1234) == "arn:aws:sns:*:1234:*" + @test arn(aws,"iam","role/foo-role", "", 1234) == "arn:aws:iam::1234:role/foo-role" +end \ No newline at end of file diff --git a/test/aws4.jl b/test/aws4.jl new file mode 100644 index 0000000..8afac8f --- /dev/null +++ b/test/aws4.jl @@ -0,0 +1,201 @@ +# Based on https://docs.aws.amazon.com/general/latest/gr/signature-v4-test-suite.html +# and https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html + +header_keys(headers) = sort!(map(first, headers)) +const required_headers = ["Authorization", "host", "x-amz-date"] + +function test_sign!(method, headers, params, body=""; opts...) + SignatureV4.sign!(method, + URI("https://example.amazonaws.com/" * params), + headers, + Vector{UInt8}(body); + timestamp=DateTime(2015, 8, 30, 12, 36), + aws_service="service", + aws_region="us-east-1", + # NOTE: These are the example credentials as specified in the AWS docs, + # they are not real + aws_access_key_id="AKIDEXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + include_md5=false, + include_sha256=false, + opts...) + return headers +end + +function test_auth_string(headers, sig, key="AKIDEXAMPLE", date="20150830", service="service") + d = [ + "AWS4-HMAC-SHA256 Credential" => "$key/$date/us-east-1/$service/aws4_request", + "SignedHeaders" => headers, + "Signature" => sig, + ] + join(map(p->join(p, '='), d), ", ") +end + +@testset "AWS Signature Version 4" begin + # The signature for requests with no headers where the path ends up as simply / + slash_only_sig = "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31" + noheaders = [ + ("get-vanilla", "", slash_only_sig), + ("get-vanilla-empty-query-key", "?Param1=value1", "a67d582fa61cc504c4bae71f336f98b97f1ea3c7a6bfe1b6e45aec72011b9aeb"), + ("get-utf8", "ሴ", "8318018e0b0f223aa2bbf98705b62bb787dc9c0e678f255a891fd03141be5d85"), + ("get-relative", "example/..", slash_only_sig), + ("get-relative-relative", "example1/example2/../..", slash_only_sig), + ("get-slash", "/", slash_only_sig), + ("get-slash-dot-slash", "./", slash_only_sig), + ("get-slashes", "example/", "9a624bd73a37c9a373b5312afbebe7a714a789de108f0bdfe846570885f57e84"), + ("get-slash-pointless-dot", "./example", "ef75d96142cf21edca26f06005da7988e4f8dc83a165a80865db7089db637ec5"), + ("get-space", "example space/", "652487583200325589f1fba4c7e578f72c47cb61beeca81406b39ddec1366741"), + ("post-vanilla", "", "5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b"), + ("post-vanilla-empty-query-value", "?Param1=value1", "28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11"), + ] + + @testset "$name" for (name, p, sig) in noheaders + m = startswith(name, "get") ? "GET" : "POST" + headers = test_sign!(m, Headers([]), p) + @test header_keys(headers) == required_headers + d = Dict(headers) + @test d["x-amz-date"] == "20150830T123600Z" + @test d["host"] == "example.amazonaws.com" + @test d["Authorization"] == test_auth_string("host;x-amz-date", sig) + end + + yesheaders = [ + ("get-header-key-duplicate", "", "", + Headers(["My-Header1" => "value2", + "My-Header1" => "value2", + "My-Header1" => "value1"]), + "host;my-header1;x-amz-date", + "c9d5ea9f3f72853aea855b47ea873832890dbdd183b4468f858259531a5138ea"), + ("get-header-value-multiline", "", "", + Headers(["My-Header1" => "value1\n value2\n value3"]), + "host;my-header1;x-amz-date", + "ba17b383a53190154eb5fa66a1b836cc297cc0a3d70a5d00705980573d8ff790"), + ("get-header-value-order", "", "", + Headers(["My-Header1" => "value4", + "My-Header1" => "value1", + "My-Header1" => "value3", + "My-Header1" => "value2"]), + "host;my-header1;x-amz-date", + "08c7e5a9acfcfeb3ab6b2185e75ce8b1deb5e634ec47601a50643f830c755c01"), + ("get-header-value-trim", "", "", + Headers(["My-Header1" => " value1", + "My-Header2" => " \"a b c\""]), + "host;my-header1;my-header2;x-amz-date", + "acc3ed3afb60bb290fc8d2dd0098b9911fcaa05412b367055dee359757a9c736"), + ("post-header-key-sort", "", "", + Headers(["My-Header1" => "value1"]), + "host;my-header1;x-amz-date", + "c5410059b04c1ee005303aed430f6e6645f61f4dc9e1461ec8f8916fdf18852c"), + ("post-header-value-case", "", "", + Headers(["My-Header1" => "VALUE1"]), + "host;my-header1;x-amz-date", + "cdbc9802e29d2942e5e10b5bccfdd67c5f22c7c4e8ae67b53629efa58b974b7d"), + ("post-x-www-form-urlencoded", "", "Param1=value1", + Headers(["Content-Type" => "application/x-www-form-urlencoded", + "Content-Length" => "13"]), + "content-type;host;x-amz-date", + "ff11897932ad3f4e8b18135d722051e5ac45fc38421b1da7b9d196a0fe09473a"), + ("post-x-www-form-urlencoded-parameters", "", "Param1=value1", + Headers(["Content-Type" => "application/x-www-form-urlencoded; charset=utf8", + "Content-Length" => "13"]), + "content-type;host;x-amz-date", + "1a72ec8f64bd914b0e42e42607c7fbce7fb2c7465f63e3092b3b0d39fa77a6fe"), + ] + + @testset "$name" for (name, p, body, h, sh, sig) in yesheaders + hh = sort(map(first, h)) + m = startswith(name, "get") ? "GET" : "POST" + test_sign!(m, h, p, body) + @test header_keys(h) == sort(vcat(required_headers, hh)) + d = Dict(h) # collapses duplicates but we don't care here + @test d["x-amz-date"] == "20150830T123600Z" + @test d["host"] == "example.amazonaws.com" + @test d["Authorization"] == test_auth_string(sh, sig) + end + + @testset "AWS Security Token Service" begin + # Not a real security token, provided by AWS as an example + token = string("AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwd", + "QWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/k", + "McGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXD", + "vp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64", + "lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2I", + "CCR/oLxBA==") + + @testset "Token included in signature" begin + sh = "host;x-amz-date;x-amz-security-token" + sig = "85d96828115b5dc0cfc3bd16ad9e210dd772bbebba041836c64533a82be05ead" + h = test_sign!("POST", Headers([]), "", aws_session_token=token) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig) + @test haskey(d, "x-amz-security-token") + end + + @testset "Token not included in signature" begin + sh = "host;x-amz-date" + sig = "5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b" + h = test_sign!("POST", Headers([]), "", aws_session_token=token, token_in_signature=false) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig) + @test haskey(d, "x-amz-security-token") + end + end + + @testset "AWS Simple Storage Service" begin + s3url = "https://examplebucket.s3.amazonaws.com" + opts = (timestamp=DateTime(2013, 5, 24), + aws_service="s3", + aws_region="us-east-1", + # NOTE: These are the example credentials as specified in the AWS docs, + # they are not real + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + include_md5=false) + + @testset "GET Object" begin + sh = "host;range;x-amz-content-sha256;x-amz-date" + sig = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" + h = Headers(["Range" => "bytes=0-9"]) + SignatureV4.sign!("GET", URI(s3url * "/test.txt"), h, UInt8[]; opts...) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig, opts.aws_access_key_id, "20130524", "s3") + @test haskey(d, "x-amz-content-sha256") # required for S3 requests + end + + @testset "PUT Object" begin + sh = "date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class" + sig = "98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd" + h = Headers(["Date" => "Fri, 24 May 2013 00:00:00 GMT", + "x-amz-storage-class" => "REDUCED_REDUNDANCY"]) + SignatureV4.sign!("PUT", URI(s3url * "/test\$file.text"), h, UInt8[]; + # Override the SHA-256 of the request body, since the actual body is not provided + # for this example in the documentation, only the SHA + body_sha256=hex2bytes("44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072"), + opts...) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig, opts.aws_access_key_id, "20130524", "s3") + @test haskey(d, "x-amz-content-sha256") + end + + @testset "GET Bucket Lifecycle" begin + sh = "host;x-amz-content-sha256;x-amz-date" + sig = "fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543" + h = Headers([]) + SignatureV4.sign!("GET", URI(s3url * "/?lifecycle"), h, UInt8[]; opts...) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig, opts.aws_access_key_id, "20130524", "s3") + @test haskey(d, "x-amz-content-sha256") + end + + @testset "GET Bucket (List Objects)" begin + sh = "host;x-amz-content-sha256;x-amz-date" + sig = "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7" + h = Headers([]) + SignatureV4.sign!("GET", URI(s3url * "/?max-keys=2&prefix=J"), h, UInt8[]; opts...) + d = Dict(h) + @test d["Authorization"] == test_auth_string(sh, sig, opts.aws_access_key_id, "20130524", "s3") + @test haskey(d, "x-amz-content-sha256") + end + end +end + diff --git a/test/credentials.jl b/test/credentials.jl new file mode 100644 index 0000000..e63b6ae --- /dev/null +++ b/test/credentials.jl @@ -0,0 +1,495 @@ +@testset "Load Credentials" begin + user = aws_user_arn(aws) + @test occursin(r"^arn:aws:iam::[0-9]+:[^:]+$", user) + aws[:region] = "us-east-1" + + try + AWSCore.Services.iam("GetFoo", Dict("ContentType" => "JSON")) + @test false + catch e + @test ecode(e) == "InvalidAction" + end + + try + AWSCore.Services.iam("GetUser", Dict("UserName" => "notauser", "ContentType" => "JSON")) + @test false + catch e + @test ecode(e) in ["AccessDenied", "NoSuchEntity"] + end + + try + AWSCore.Services.iam("GetUser", Dict("UserName" => "@#!%%!", "ContentType" => "JSON")) + @test false + catch e + @test ecode(e) == "ValidationError" + end + + try + AWSCore.Services.iam("CreateUser", Dict("UserName" => "root", "ContentType" => "JSON")) + @test false + catch e + @test ecode(e) in ["AccessDenied", "EntityAlreadyExists"] + end +end + +@testset "NoAuth" begin + pub_request1 = Dict{Symbol, Any}( + :service => "s3", + :headers => Dict{String, String}("Range" => "bytes=0-0"), + :content => "", + :resource => "/invenia-static-website-content/invenia_ca/index.html", + :url => "https://s3.us-east-1.amazonaws.com/invenia-static-website-content/invenia_ca/index.html", + :verb => "GET", + :region => "us-east-1", + :creds => nothing, + ) + pub_request2 = Dict{Symbol, Any}( + :service => "s3", + :headers => Dict{String, String}("Range" => "bytes=0-0"), + :content => "", + :resource => "ryft-public-sample-data/AWS-x86-AMI-queries.json", + :url => "https://s3.amazonaws.com/ryft-public-sample-data/AWS-x86-AMI-queries.json", + :verb => "GET", + :region => "us-east-1", + :creds => nothing, + ) + response = nothing + try + response = AWSCore.do_request(pub_request1) + catch e + @test ecode(e) in ["AccessDenied", "NoSuchEntity"] + try + response = AWSCore.do_request(pub_request2) + catch e + @test ecode(e) in ["AccessDenied", "NoSuchEntity"] + end + end + @test response == "<" || response == UInt8['['] +end + +@testset "AWSCredentials" begin + @testset "Defaults" begin + creds = AWSCredentials("access_key_id" ,"secret_key") + @test creds.token == "" + @test creds.user_arn == "" + @test creds.account_number == "" + @test creds.expiry == typemax(DateTime) + @test creds.renew == nothing + end + + @testset "Renewal" begin + # Credentials shouldn't throw an error if no renew function is supplied + creds = AWSCredentials("access_key_id", "secret_key", renew=nothing) + newcreds = check_credentials(creds, force_refresh = true) + + # Creds should remain unchanged if no renew function exists + @test creds === newcreds + @test creds.access_key_id == "access_key_id" + @test creds.secret_key == "secret_key" + @test creds.renew == nothing + + # Creds should error if the renew function returns nothing + creds = AWSCredentials("access_key_id", "secret_key", renew = () -> nothing) + @test_throws ErrorException check_credentials(creds, force_refresh=true) + + # Creds should remain unchanged + @test creds.access_key_id == "access_key_id" + @test creds.secret_key == "secret_key" + + # Creds should take on value of a returned AWSCredentials except renew function + function gen_credentials() + i = 0 + () -> (i += 1; AWSCredentials("NEW_ID_$i", "NEW_KEY_$i")) + end + + creds = AWSCredentials( + "access_key_id", + "secret_key", + renew=gen_credentials(), + expiry=now(UTC), + ) + + @test creds.renew !== nothing + renewed = creds.renew() + + @test creds.access_key_id == "access_key_id" + @test creds.secret_key == "secret_key" + @test creds.expiry <= now(UTC) + @test AWSCore._will_expire(creds) + + @test renewed.access_key_id === "NEW_ID_1" + @test renewed.secret_key == "NEW_KEY_1" + @test renewed.renew === nothing + @test renewed.expiry == typemax(DateTime) + @test !AWSCore._will_expire(renewed) + renew = creds.renew + + # Check renewal on time out + newcreds = check_credentials(creds, force_refresh=false) + @test creds === newcreds + @test creds.access_key_id == "NEW_ID_2" + @test creds.secret_key == "NEW_KEY_2" + @test creds.renew !== nothing + @test creds.renew === renew + @test creds.expiry == typemax(DateTime) + @test !AWSCore._will_expire(creds) + + # Check renewal doesn't happen if not forced or timed out + newcreds = check_credentials(creds, force_refresh=false) + @test creds === newcreds + @test creds.access_key_id == "NEW_ID_2" + @test creds.secret_key == "NEW_KEY_2" + @test creds.renew !== nothing + @test creds.renew === renew + @test creds.expiry == typemax(DateTime) + + # Check forced renewal works + newcreds = check_credentials(creds, force_refresh=true) + @test creds === newcreds + @test creds.access_key_id == "NEW_ID_3" + @test creds.secret_key == "NEW_KEY_3" + @test creds.renew !== nothing + @test creds.renew === renew + @test creds.expiry == typemax(DateTime) + end + + mktemp() do config_file, config_io + write(config_io, """[profile test] + output = json + region = us-east-1 + + [profile test:dev] + source_profile = test + role_arn = arn:aws:iam::123456789000:role/Dev + + [profile test:sub-dev] + source_profile = test:dev + role_arn = arn:aws:iam::123456789000:role/SubDev + + [profile test2] + aws_access_key_id = WRONG_ACCESS_ID + aws_secret_access_key = WRONG_ACCESS_KEY + output = json + region = us-east-1 + + [profile test3] + source_profile = test:dev + role_arn = arn:aws:iam::123456789000:role/test3 + + [profile test4] + source_profile = test:dev + role_arn = arn:aws:iam::123456789000:role/test3 + aws_access_key_id = RIGHT_ACCESS_ID4 + aws_secret_access_key = RIGHT_ACCESS_KEY4 + """) + close(config_io) + + mktemp() do creds_file, creds_io + write(creds_io, """[test] + aws_access_key_id = TEST_ACCESS_ID + aws_secret_access_key = TEST_ACCESS_KEY + + [test2] + aws_access_key_id = RIGHT_ACCESS_ID2 + aws_secret_access_key = RIGHT_ACCESS_KEY2 + + [test3] + aws_access_key_id = RIGHT_ACCESS_ID3 + aws_secret_access_key = RIGHT_ACCESS_KEY3 + """) + close(creds_io) + + withenv( + "AWS_SHARED_CREDENTIALS_FILE" => creds_file, + "AWS_CONFIG_FILE" => config_file, + "AWS_DEFAULT_PROFILE" => "test", + "AWS_ACCESS_KEY_ID" => nothing + ) do + + @testset "Loading" begin + # Check credentials load + config = aws_config() + creds = config[:creds] + @test creds isa AWSCredentials + + @test creds.access_key_id == "TEST_ACCESS_ID" + @test creds.secret_key == "TEST_ACCESS_KEY" + @test creds.renew !== nothing + + # Check credential file takes precedence over config + ENV["AWS_DEFAULT_PROFILE"] = "test2" + config = aws_config() + creds = config[:creds] + + @test creds.access_key_id == "RIGHT_ACCESS_ID2" + @test creds.secret_key == "RIGHT_ACCESS_KEY2" + + # Check credentials take precedence over role + ENV["AWS_DEFAULT_PROFILE"] = "test3" + config = aws_config() + creds = config[:creds] + + @test creds.access_key_id == "RIGHT_ACCESS_ID3" + @test creds.secret_key == "RIGHT_ACCESS_KEY3" + + ENV["AWS_DEFAULT_PROFILE"] = "test4" + config = aws_config() + creds = config[:creds] + + @test creds.access_key_id == "RIGHT_ACCESS_ID4" + @test creds.secret_key == "RIGHT_ACCESS_KEY4" + end + + @testset "Refresh" begin + ENV["AWS_DEFAULT_PROFILE"] = "test" + # Check credentials refresh on timeout + config = aws_config() + creds = config[:creds] + creds.access_key_id = "EXPIRED_ACCESS_ID" + creds.secret_key = "EXPIRED_ACCESS_KEY" + creds.expiry = now(UTC) + + @test creds.renew !== nothing + renew = creds.renew + @test renew() isa AWSCredentials + + creds = check_credentials(config[:creds]) + + @test creds.access_key_id == "TEST_ACCESS_ID" + @test creds.secret_key == "TEST_ACCESS_KEY" + @test creds.expiry > now(UTC) + + # Check renew function remains unchanged + @test creds.renew !== nothing + @test creds.renew === renew + + # Check force_refresh + creds.access_key_id = "WRONG_ACCESS_KEY" + creds = check_credentials(creds, force_refresh = true) + @test creds.access_key_id == "TEST_ACCESS_ID" + end + + @testset "Profile" begin + # Check profile kwarg + ENV["AWS_DEFAULT_PROFILE"] = "test" + creds = AWSCredentials(profile="test2") + @test creds.access_key_id == "RIGHT_ACCESS_ID2" + @test creds.secret_key == "RIGHT_ACCESS_KEY2" + + config = aws_config(profile="test2") + creds = config[:creds] + @test creds.access_key_id == "RIGHT_ACCESS_ID2" + @test creds.secret_key == "RIGHT_ACCESS_KEY2" + + # Check profile persists on renewal + creds.access_key_id = "WRONG_ACCESS_ID2" + creds.secret_key = "WRONG_ACCESS_KEY2" + creds = check_credentials(creds, force_refresh=true) + + @test creds.access_key_id == "RIGHT_ACCESS_ID2" + @test creds.secret_key == "RIGHT_ACCESS_KEY2" + end + + @testset "Assume Role" begin + # Check we try to assume a role + ENV["AWS_DEFAULT_PROFILE"] = "test:dev" + + try + conf = aws_config() + @test false + catch e + @test e isa AWSException + @test ecode(e) == "InvalidClientTokenId" + end + + # Check we try to assume a role + ENV["AWS_DEFAULT_PROFILE"] = "test:sub-dev" + let oldout = stdout + r,w = redirect_stdout() + try + aws_config() + @test false + catch e + @test e isa AWSException + @test ecode(e) == "InvalidClientTokenId" + end + redirect_stdout(oldout) + close(w) + output = String(read(r)) + occursin("Assuming \"test:dev\"", output) + occursin("Assuming \"test\"", output) + close(r) + end + end + end + end + end +end + +@testset "Retrieving AWS Credentials" begin + test_values = Dict{String, Any}( + "Default-Profile" => "default", + "Test-Profile" => "test", + "Test-Config-Profile" => "profile test", + + # Default profile values, needs to match due to AWSCredentials.jl:239 + "AccessKeyId" => "Default-Key", + "SecretAccessKey" => "Default-Secret", + + "Test-AccessKeyId" => "Test-Key", + "Test-SecretAccessKey" => "Test-Secret", + + "Token" => "Test-Token", + "InstanceProfileArn" => "Test-Arn", + "RoleArn" => "Test-Arn", + "Expiration" => now(), + + "URI" => "/Test-URI/", + "Security-Credentials" => "Test-Security-Credentials" + ) + + http_request_patch = @patch function HTTP.request(method::String, url; kwargs...) + security_credentials = test_values["Security-Credentials"] + uri = test_values["URI"] + + if url == "http://169.254.169.254/latest/meta-data/iam/info" + instance_profile_arn = test_values["InstanceProfileArn"] + return HTTP.Response("{\"InstanceProfileArn\": \"$instance_profile_arn\"}") + elseif url == "http://169.254.169.254/latest/meta-data/iam/security-credentials/" + return HTTP.Response(test_values["Security-Credentials"]) + elseif url == "http://169.254.169.254/latest/meta-data/iam/security-credentials/$security_credentials" || url == "http://169.254.170.2$uri" + my_dict = JSON.json(test_values) + response = HTTP.Response(my_dict) + return response + else + return nothing + end + end + + @testset "~/.aws/config - Default Profile" begin + mktemp() do config_file, config_io + write(config_io, """ + [$(test_values["Default-Profile"])] + aws_access_key_id=$(test_values["AccessKeyId"]) + aws_secret_access_key=$(test_values["SecretAccessKey"]) + """) + close(config_io) + + withenv("AWS_CONFIG_FILE" => config_file) do + default_profile = dot_aws_config() + + @test default_profile.access_key_id == test_values["AccessKeyId"] + @test default_profile.secret_key == test_values["SecretAccessKey"] + end + end + end + + @testset "~/.aws/config - Specified Profile" begin + mktemp() do config_file, config_io + write(config_io, """ + [$(test_values["Test-Config-Profile"])] + aws_access_key_id=$(test_values["Test-AccessKeyId"]) + aws_secret_access_key=$(test_values["Test-SecretAccessKey"]) + """) + close(config_io) + + withenv("AWS_CONFIG_FILE" => config_file) do + specified_result = dot_aws_config(test_values["Test-Profile"]) + + @test specified_result.access_key_id == test_values["Test-AccessKeyId"] + @test specified_result.secret_key == test_values["Test-SecretAccessKey"] + end + end + end + + @testset "~/.aws/creds - Default Profile" begin + mktemp() do creds_file, creds_io + write(creds_io, """ + [$(test_values["Default-Profile"])] + aws_access_key_id=$(test_values["AccessKeyId"]) + aws_secret_access_key=$(test_values["SecretAccessKey"]) + """) + close(creds_io) + + withenv("AWS_SHARED_CREDENTIALS_FILE" => creds_file) do + specified_result = dot_aws_credentials() + + @test specified_result.access_key_id == test_values["AccessKeyId"] + @test specified_result.secret_key == test_values["SecretAccessKey"] + end + end + end + + @testset "~/.aws/creds - Specified Profile" begin + mktemp() do creds_file, creds_io + write(creds_io, """ + [$(test_values["Test-Profile"])] + aws_access_key_id=$(test_values["Test-AccessKeyId"]) + aws_secret_access_key=$(test_values["Test-SecretAccessKey"]) + """) + close(creds_io) + + withenv("AWS_SHARED_CREDENTIALS_FILE" => creds_file) do + specified_result = dot_aws_credentials(test_values["Test-Profile"]) + + @test specified_result.access_key_id == test_values["Test-AccessKeyId"] + @test specified_result.secret_key == test_values["Test-SecretAccessKey"] + end + end + end + + @testset "Environment Variables" begin + withenv("AWS_ACCESS_KEY_ID" => test_values["AccessKeyId"], + "AWS_SECRET_ACCESS_KEY" => test_values["SecretAccessKey"]) do + aws_creds = get_awscreds_env_vars() + @test aws_creds.access_key_id == test_values["AccessKeyId"] + @test aws_creds.secret_key == test_values["SecretAccessKey"] + end + end + + @testset "Instance - EC2" begin + apply([http_request_patch]) do + result = get_awscreds_ec2() + @test result.access_key_id == test_values["AccessKeyId"] + @test result.secret_key == test_values["SecretAccessKey"] + @test result.token == test_values["Token"] + @test result.user_arn == test_values["InstanceProfileArn"] + @test result.expiry == test_values["Expiration"] + @test result.renew == get_awscreds_ec2 + end + end + + @testset "Instance - ECS" begin + withenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" => test_values["URI"]) do + apply([http_request_patch]) do + result = get_awscreds_ecs() + @test result.access_key_id == test_values["AccessKeyId"] + @test result.secret_key == test_values["SecretAccessKey"] + @test result.token == test_values["Token"] + @test result.user_arn == test_values["RoleArn"] + @test result.expiry == test_values["Expiration"] + @test result.renew == get_awscreds_ecs + end + end + end + + @testset "Helper functions" begin + @testset "Check Credentials - EnvVars" begin + withenv("AWS_ACCESS_KEY_ID" => test_values["AccessKeyId"], + "AWS_SECRET_ACCESS_KEY" => test_values["SecretAccessKey"]) do + testAWSCredentials = AWSCredentials( + test_values["AccessKeyId"], + test_values["SecretAccessKey"], + expiry=Dates.now() - Minute(10), + renew=get_awscreds_env_vars + ) + + result = check_credentials(testAWSCredentials, force_refresh=true) + @test result.access_key_id == testAWSCredentials.access_key_id + @test result.secret_key == testAWSCredentials.secret_key + @test result.expiry == typemax(DateTime) + @test result.renew == testAWSCredentials.renew + end + end + end +end \ No newline at end of file diff --git a/test/exceptions.jl b/test/exceptions.jl new file mode 100644 index 0000000..bc51897 --- /dev/null +++ b/test/exceptions.jl @@ -0,0 +1,20 @@ +@testset "AWSException" begin + code = "InvalidSignatureException" + message = "Signature expired: ..." + body = """ + { + "__type": "$code", + "message": "$message" + } + """ + headers = ["Content-Type" => "application/x-amz-json-1.1"] + status_code = 400 + + # This does not actually send a request, just creates the object to test with + req = HTTP.Request("GET", "https://amazon.ca", headers, body) + resp = HTTP.Response(status_code, headers; body=body, request=req) + ex = AWSException(HTTP.StatusError(status_code, resp)) + + @test ex.code == code + @test ex.message == message +end \ No newline at end of file diff --git a/test/jltest.aws.example b/test/jltest.aws.example deleted file mode 100644 index 47f0948..0000000 --- a/test/jltest.aws.example +++ /dev/null @@ -1,4 +0,0 @@ -arn:aws:iam::[account number here]:user/ocaws.jl.test -[Access Key here] -[Secred Key here] -ap-southeast-2 diff --git a/test/runtests.jl b/test/runtests.jl index 97cf7dd..195a98d 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,601 +4,24 @@ # Copyright OC Technology Pty Ltd 2014 - All rights reserved #==============================================================================# -using Test -using Dates using AWSCore -using SymDict +using Dates +using HTTP +using HTTP: Headers, URI +using IniFile +using JSON +using Mocking using Retry +using SymDict +using Test using XMLDict -using HTTP - -AWSCore.set_debug_level(1) - -@testset "AWSCore" begin +Mocking.activate() aws = aws_config() - -@testset "NoAuth" begin - pub_request1 = Dict{Symbol, Any}( - :service => "s3", - :headers => Dict{String, String}("Range" => "bytes=0-0"), - :content => "", - :resource => "/invenia-static-website-content/invenia_ca/index.html", - :url => "https://s3.us-east-1.amazonaws.com/invenia-static-website-content/invenia_ca/index.html", - :verb => "GET", - :region => "us-east-1", - :creds => nothing, - ) - pub_request2 = Dict{Symbol, Any}( - :service => "s3", - :headers => Dict{String, String}("Range" => "bytes=0-0"), - :content => "", - :resource => "ryft-public-sample-data/AWS-x86-AMI-queries.json", - :url => "https://s3.amazonaws.com/ryft-public-sample-data/AWS-x86-AMI-queries.json", - :verb => "GET", - :region => "us-east-1", - :creds => nothing, - ) - response = nothing - try - response = AWSCore.do_request(pub_request1) - catch e - println(e) - @test ecode(e) in ["AccessDenied", "NoSuchEntity"] - try - response = AWSCore.do_request(pub_request2) - catch e - println(e) - @test ecode(e) in ["AccessDenied", "NoSuchEntity"] - end - end - @test response == "<" || response == UInt8['['] -end -@testset "Load Credentials" begin - user = aws_user_arn(aws) - - @test occursin(r"^arn:aws:iam::[0-9]+:[^:]+$", user) - - println("Authenticated as: $user") - - aws[:region] = "us-east-1" - - println("Testing exceptions...") - try - AWSCore.Services.iam("GetFoo", Dict("ContentType" => "JSON")) - @test false - catch e - println(e) - @test ecode(e) == "InvalidAction" - end - - try - AWSCore.Services.iam("GetUser", Dict("UserName" => "notauser", - "ContentType" => "JSON")) - @test false - catch e - println(e) - @test ecode(e) in ["AccessDenied", "NoSuchEntity"] - end - - try - AWSCore.Services.iam("GetUser", Dict("UserName" => "@#!%%!", - "ContentType" => "JSON")) - @test false - catch e - println(e) - @test ecode(e) == "ValidationError" - end - - try - AWSCore.Services.iam("CreateUser", Dict("UserName" => "root", - "ContentType" => "JSON")) - @test false - catch e - println(e) - @test ecode(e) in ["AccessDenied", "EntityAlreadyExists"] - end -end - -@testset "AWSCredentials" begin - @testset "Defaults" begin - creds = AWSCredentials("access_key_id" ,"secret_key") - @test creds.token == "" - @test creds.user_arn == "" - @test creds.account_number == "" - @test creds.expiry == typemax(DateTime) - @test creds.renew == nothing - end - - @testset "Renewal" begin - # Credentials shouldn't throw an error if no renew function is supplied - creds = AWSCredentials("access_key_id", "secret_key", renew = nothing) - newcreds = check_credentials(creds, force_refresh = true) - # Creds should remain unchanged if no renew function exists - @test creds === newcreds - @test creds.access_key_id == "access_key_id" - @test creds.secret_key == "secret_key" - @test creds.renew == nothing - - # Creds should error if the renew function returns nothing - creds = AWSCredentials("access_key_id", "secret_key", renew = () -> nothing) - @test_throws ErrorException check_credentials(creds, force_refresh = true) - # Creds should remain unchanged - @test creds.access_key_id == "access_key_id" - @test creds.secret_key == "secret_key" - - # Creds should take on value of a returned AWSCredentials except renew function - function gen_credentials() - i = 0 - () -> (i += 1; AWSCredentials("NEW_ID_$i", "NEW_KEY_$i")) - end - - creds = AWSCredentials( - "access_key_id", - "secret_key", - renew = gen_credentials(), - expiry = now(UTC), - ) - - @test creds.renew !== nothing - renewed = creds.renew() - - @test creds.access_key_id == "access_key_id" - @test creds.secret_key == "secret_key" - @test creds.expiry <= now(UTC) - @test AWSCore.will_expire(creds) - - @test renewed.access_key_id === "NEW_ID_1" - @test renewed.secret_key == "NEW_KEY_1" - @test renewed.renew === nothing - @test renewed.expiry == typemax(DateTime) - @test !AWSCore.will_expire(renewed) - renew = creds.renew - - # Check renewal on time out - newcreds = check_credentials(creds, force_refresh = false) - @test creds === newcreds - @test creds.access_key_id == "NEW_ID_2" - @test creds.secret_key == "NEW_KEY_2" - @test creds.renew !== nothing - @test creds.renew === renew - @test creds.expiry == typemax(DateTime) - @test !AWSCore.will_expire(creds) - - # Check renewal doesn't happen if not forced or timed out - newcreds = check_credentials(creds, force_refresh = false) - @test creds === newcreds - @test creds.access_key_id == "NEW_ID_2" - @test creds.secret_key == "NEW_KEY_2" - @test creds.renew !== nothing - @test creds.renew === renew - @test creds.expiry == typemax(DateTime) - - # Check forced renewal works - newcreds = check_credentials(creds, force_refresh = true) - @test creds === newcreds - @test creds.access_key_id == "NEW_ID_3" - @test creds.secret_key == "NEW_KEY_3" - @test creds.renew !== nothing - @test creds.renew === renew - @test creds.expiry == typemax(DateTime) - end - - mktemp() do config_file, config_io - write(config_io, """[profile test] - output = json - region = us-east-1 - - [profile test:dev] - source_profile = test - role_arn = arn:aws:iam::123456789000:role/Dev - - [profile test:sub-dev] - source_profile = test:dev - role_arn = arn:aws:iam::123456789000:role/SubDev - - [profile test2] - aws_access_key_id = WRONG_ACCESS_ID - aws_secret_access_key = WRONG_ACCESS_KEY - output = json - region = us-east-1 - - [profile test3] - source_profile = test:dev - role_arn = arn:aws:iam::123456789000:role/test3 - - [profile test4] - source_profile = test:dev - role_arn = arn:aws:iam::123456789000:role/test3 - aws_access_key_id = RIGHT_ACCESS_ID4 - aws_secret_access_key = RIGHT_ACCESS_KEY4 - """) - close(config_io) - - mktemp() do creds_file, creds_io - write(creds_io, """[test] - aws_access_key_id = TEST_ACCESS_ID - aws_secret_access_key = TEST_ACCESS_KEY - - [test2] - aws_access_key_id = RIGHT_ACCESS_ID2 - aws_secret_access_key = RIGHT_ACCESS_KEY2 - - [test3] - aws_access_key_id = RIGHT_ACCESS_ID3 - aws_secret_access_key = RIGHT_ACCESS_KEY3 - """) - close(creds_io) - - withenv( - "AWS_SHARED_CREDENTIALS_FILE" => creds_file, - "AWS_CONFIG_FILE" => config_file, - "AWS_DEFAULT_PROFILE" => "test", - "AWS_ACCESS_KEY_ID" => nothing - ) do - - @testset "Loading" begin - # Check credentials load - config = aws_config() - creds = config[:creds] - @test creds isa AWSCredentials - - @test creds.access_key_id == "TEST_ACCESS_ID" - @test creds.secret_key == "TEST_ACCESS_KEY" - @test creds.renew !== nothing - - # Check credential file takes precedence over config - ENV["AWS_DEFAULT_PROFILE"] = "test2" - config = aws_config() - creds = config[:creds] - - @test creds.access_key_id == "RIGHT_ACCESS_ID2" - @test creds.secret_key == "RIGHT_ACCESS_KEY2" - - # Check credentials take precedence over role - ENV["AWS_DEFAULT_PROFILE"] = "test3" - config = aws_config() - creds = config[:creds] - - @test creds.access_key_id == "RIGHT_ACCESS_ID3" - @test creds.secret_key == "RIGHT_ACCESS_KEY3" - - ENV["AWS_DEFAULT_PROFILE"] = "test4" - config = aws_config() - creds = config[:creds] - - @test creds.access_key_id == "RIGHT_ACCESS_ID4" - @test creds.secret_key == "RIGHT_ACCESS_KEY4" - end - - @testset "Refresh" begin - ENV["AWS_DEFAULT_PROFILE"] = "test" - # Check credentials refresh on timeout - config = aws_config() - creds = config[:creds] - creds.access_key_id = "EXPIRED_ACCESS_ID" - creds.secret_key = "EXPIRED_ACCESS_KEY" - creds.expiry = now(UTC) - - @test creds.renew !== nothing - renew = creds.renew - @test renew() isa AWSCredentials - - creds = check_credentials(config[:creds]) - - @test creds.access_key_id == "TEST_ACCESS_ID" - @test creds.secret_key == "TEST_ACCESS_KEY" - @test creds.expiry > now(UTC) - - # Check renew function remains unchanged - @test creds.renew !== nothing - @test creds.renew === renew - - # Check force_refresh - creds.access_key_id = "WRONG_ACCESS_KEY" - creds = check_credentials(creds, force_refresh = true) - @test creds.access_key_id == "TEST_ACCESS_ID" - end - - @testset "Profile" begin - # Check profile kwarg - ENV["AWS_DEFAULT_PROFILE"] = "test" - creds = AWSCredentials(profile="test2") - @test creds.access_key_id == "RIGHT_ACCESS_ID2" - @test creds.secret_key == "RIGHT_ACCESS_KEY2" - - config = aws_config(profile="test2") - creds = config[:creds] - @test creds.access_key_id == "RIGHT_ACCESS_ID2" - @test creds.secret_key == "RIGHT_ACCESS_KEY2" - - # Check profile persists on renewal - creds.access_key_id = "WRONG_ACCESS_ID2" - creds.secret_key = "WRONG_ACCESS_KEY2" - creds = check_credentials(creds, force_refresh=true) - - @test creds.access_key_id == "RIGHT_ACCESS_ID2" - @test creds.secret_key == "RIGHT_ACCESS_KEY2" - end - - @testset "Assume Role" begin - # Check we try to assume a role - ENV["AWS_DEFAULT_PROFILE"] = "test:dev" - - try - aws_config() - @test false - catch e - @test e isa AWSException - @test ecode(e) == "InvalidClientTokenId" - end - - # Check we try to assume a role - ENV["AWS_DEFAULT_PROFILE"] = "test:sub-dev" - let oldout = stdout - r,w = redirect_stdout() - try - aws_config() - @test false - catch e - @test e isa AWSException - @test ecode(e) == "InvalidClientTokenId" - end - redirect_stdout(oldout) - close(w) - output = String(read(r)) - occursin("Assuming \"test:dev\"", output) - occursin("Assuming \"test\"", output) - close(r) - end - end - end - end - end -end - -@testset "XML Parsing" begin - XML(x)=parse_xml(x) - - xml = """ - - - - http://queue.amazonaws.com/123456789012/testQueue - - - - - 7a62c49f-347e-4fc4-9331-6e8e7a96aa73 - - - - """ - - @assert XML(xml)["CreateQueueResult"]["QueueUrl"] == - "http://queue.amazonaws.com/123456789012/testQueue" - - xml = """ - - - - 2015-12-23T22:45:36Z - arn:aws:iam::012541411202:root - 012541411202 - 2015-09-15T01:07:23Z - - - - 837446c9-abaf-11e5-9f63-65ae4344bd73 - - - """ - - @test XML(xml)["GetUserResult"]["User"]["Arn"] == "arn:aws:iam::012541411202:root" - - - xml = """ - - - - ReceiveMessageWaitTimeSeconds - 2 - - - VisibilityTimeout - 30 - - - ApproximateNumberOfMessages - 0 - - - ApproximateNumberOfMessagesNotVisible - 0 - - - CreatedTimestamp - 1286771522 - - - LastModifiedTimestamp - 1286771522 - - - QueueArn - arn:aws:sqs:us-east-1:123456789012:qfoo - - - MaximumMessageSize - 8192 - - - MessageRetentionPeriod - 345600 - - - - 1ea71be5-b5a2-4f9d-b85a-945d8d08cd0b - - - """ - - d = Dict(a["Name"] => a["Value"] for a in XML(xml)["GetQueueAttributesResult"]["Attribute"]) - - @test d["MessageRetentionPeriod"] == "345600" - @test d["CreatedTimestamp"] == "1286771522" - - - xml = """ - - - - bcaf1ffd86f461ca5fb16fd081034f - webfile - - - - quotes - 2006-02-03T16:45:09.000Z - - - samples - 2006-02-03T16:41:58.000Z - - - - """ - - @test map(b->b["Name"], XML(xml)["Buckets"]["Bucket"]) == ["quotes", "samples"] - - - xml = """ - - - Domain1 - Domain2 - TWV0ZXJpbmdUZXN0RG9tYWluMS0yMDA3MDYwMTE2NTY= - - - eb13162f-1b95-4511-8b12-489b86acfd28 - 0.0000219907 - - - """ - - @test XML(xml)["ListDomainsResult"]["DomainName"] == ["Domain1", "Domain2"] -end - -@testset "AWS Signature Version 4" begin - function aws4_request_headers_test() - - r = @SymDict( - creds = AWSCredentials( - "AKIDEXAMPLE", - "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" - ), - region = "us-east-1", - verb = "POST", - service = "iam", - url = "http://iam.amazonaws.com/", - content = "Action=ListUsers&Version=2010-05-08", - headers = Dict( - "Content-Type" => - "application/x-www-form-urlencoded; charset=utf-8", - "Host" => "iam.amazonaws.com" - ) - ) - - AWSCore.sign!(r, DateTime("2011-09-09T23:36:00")) - - h = r[:headers] - out = join(["$k: $(h[k])\n" for k in sort(collect(keys(h)))]) - - expected = ( - "Authorization: AWS4-HMAC-SHA256 " * - "Credential=AKIDEXAMPLE/20110909/us-east-1/iam/aws4_request, " * - "SignedHeaders=content-md5;content-type;host;" * - "x-amz-content-sha256;x-amz-date, " * - "Signature=1a6db936024345449ef4507f890c5161" * - "bbfa2ff2490866653bb8b58b7ba1554a\n" * - "Content-MD5: r2d9jRneykOuUqFWSFXKCg==\n" * - "Content-Type: application/x-www-form-urlencoded; " * - "charset=utf-8\n" * - "Host: iam.amazonaws.com\n" * - "x-amz-content-sha256: b6359072c78d70ebee1e81adcbab4f01" * - "bf2c23245fa365ef83fe8f1f955085e2\n" * - "x-amz-date: 20110909T233600Z\n") - - @test out == expected - end - - aws4_request_headers_test() -end - -@testset "ARN" begin - @test arn(aws,"s3","foo/bar") == "arn:aws:s3:::foo/bar" - @test arn(aws,"s3","foo") == "arn:aws:s3:::foo" - @test arn(aws,"sqs", "au-test-queue", "ap-southeast-2", "1234") == - "arn:aws:sqs:ap-southeast-2:1234:au-test-queue" - - @test arn(aws,"sns","*","*",1234) == "arn:aws:sns:*:1234:*" - @test arn(aws,"iam","role/foo-role", "", 1234) == - "arn:aws:iam::1234:role/foo-role" -end - -@testset "Misc" begin - @test HTTP.escapepath("invocations/function:f:PROD") == - "invocations/function%3Af%3APROD" -end - -@testset "Exception" begin - code = "InvalidSignatureException" - message = "Signature expired: ..." - body = """ - { - "__type": "$code", - "message": "$message" - } - """ - headers = ["Content-Type" => "application/x-amz-json-1.1"] - status_code = 400 - - # This does not actually send a request, just creates the object to test with - req = HTTP.Request("GET", "https://amazon.ca", headers, body) - resp = HTTP.Response(status_code, headers; body=body, request=req) - ex = AWSException(HTTP.StatusError(status_code, resp)) - - @test ex.code == code - @test ex.message == message -end - -instance_type = get(ENV, "AWSCORE_INSTANCE_TYPE", "") -if instance_type == "EC2" - @testset "EC2" begin - @test_nowarn AWSCore.ec2_metadata("instance-id") - @test startswith(AWSCore.ec2_metadata("instance-id"), "i-") - - @test AWSCore.localhost_maybe_ec2() - @test AWSCore.localhost_is_ec2() - @test_nowarn AWSCore.ec2_instance_credentials() - ec2_creds = AWSCore.ec2_instance_credentials() - @test ec2_creds !== nothing - - default_creds = AWSCredentials() - @test default_creds.access_key_id == ec2_creds.access_key_id - @test default_creds.secret_key == ec2_creds.secret_key - end -elseif instance_type == "ECS" - @testset "ECS" begin - @test_nowarn AWSCore.ecs_instance_credentials() - ecs_creds = AWSCore.ecs_instance_credentials() - @test ecs_creds !== nothing - - default_creds = AWSCredentials() - @test default_creds.access_key_id == ecs_creds.access_key_id - @test default_creds.secret_key == ecs_creds.secret_key - end -end - -end # testset "AWSCore" +include("aws4.jl") +include("arn.jl") +include("credentials.jl") +include("exceptions.jl") +include("signaturev4.jl") +include("xml.jl") diff --git a/test/signaturev4.jl b/test/signaturev4.jl new file mode 100644 index 0000000..b2821db --- /dev/null +++ b/test/signaturev4.jl @@ -0,0 +1,34 @@ +@testset "AWS Signature Version 4" begin + r = @SymDict( + creds = AWSCredentials("AKIDEXAMPLE","wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"), + region = "us-east-1", + verb = "POST", + service = "iam", + url = "http://iam.amazonaws.com/", + content = "Action=ListUsers&Version=2010-05-08", + headers = Dict( + "Content-Type" => "application/x-www-form-urlencoded; charset=utf-8", + "Host" => "iam.amazonaws.com" + ) + ) + + AWSCore.sign!(r, DateTime("2011-09-09T23:36:00")) + + h = r[:headers] + out = join(["$k: $(h[k])\n" for k in sort(collect(keys(h)))]) + + expected = ( + "Authorization: AWS4-HMAC-SHA256 " * + "Credential=AKIDEXAMPLE/20110909/us-east-1/iam/aws4_request, " * + "SignedHeaders=content-md5;content-type;host;" * + "x-amz-content-sha256;x-amz-date, " * + "Signature=1a6db936024345449ef4507f890c5161bbfa2ff2490866653bb8b58b7ba1554a\n" * + "Content-MD5: r2d9jRneykOuUqFWSFXKCg==\n" * + "Content-Type: application/x-www-form-urlencoded; charset=utf-8\n" * + "Host: iam.amazonaws.com\n" * + "x-amz-content-sha256: b6359072c78d70ebee1e81adcbab4f01bf2c23245fa365ef83fe8f1f955085e2\n" * + "x-amz-date: 20110909T233600Z\n" + ) + + @test out == expected +end \ No newline at end of file diff --git a/test/xml.jl b/test/xml.jl new file mode 100644 index 0000000..131ca8c --- /dev/null +++ b/test/xml.jl @@ -0,0 +1,144 @@ +@testset "QueueURL" begin + expected = "http://queue.amazonaws.com/123456789012/testQueue" + + xml = """ + + + + http://queue.amazonaws.com/123456789012/testQueue + + + + + 7a62c49f-347e-4fc4-9331-6e8e7a96aa73 + + + + """ + + @assert parse_xml(xml)["CreateQueueResult"]["QueueUrl"] == expected +end + +@testset "User ARN" begin + expected = "arn:aws:iam::012541411202:root" + + xml = """ + + + + 2015-12-23T22:45:36Z + arn:aws:iam::012541411202:root + 012541411202 + 2015-09-15T01:07:23Z + + + + 837446c9-abaf-11e5-9f63-65ae4344bd73 + + + """ + + @test parse_xml(xml)["GetUserResult"]["User"]["Arn"] == expected +end + +@testset "Domain Names" begin + expected = ["Domain1", "Domain2"] + + xml = """ + + + Domain1 + Domain2 + TWV0ZXJpbmdUZXN0RG9tYWluMS0yMDA3MDYwMTE2NTY= + + + eb13162f-1b95-4511-8b12-489b86acfd28 + 0.0000219907 + + + """ + + @test parse_xml(xml)["ListDomainsResult"]["DomainName"] == expected +end + +@testset "Bucket Names" begin + expected = ["quotes", "samples"] + + xml = """ + + + + bcaf1ffd86f461ca5fb16fd081034f + webfile + + + + quotes + 2006-02-03T16:45:09.000Z + + + samples + 2006-02-03T16:41:58.000Z + + + + """ + + @test map(b->b["Name"], parse_xml(xml)["Buckets"]["Bucket"]) == expected +end + +@testset "Attributes" begin + expected_retention_period = "345600" + expected_timestamp = "1286771522" + + xml = """ + + + + ReceiveMessageWaitTimeSeconds + 2 + + + VisibilityTimeout + 30 + + + ApproximateNumberOfMessages + 0 + + + ApproximateNumberOfMessagesNotVisible + 0 + + + CreatedTimestamp + 1286771522 + + + LastModifiedTimestamp + 1286771522 + + + QueueArn + arn:aws:sqs:us-east-1:123456789012:qfoo + + + MaximumMessageSize + 8192 + + + MessageRetentionPeriod + 345600 + + + + 1ea71be5-b5a2-4f9d-b85a-945d8d08cd0b + + + """ + + d = Dict(a["Name"] => a["Value"] for a in parse_xml(xml)["GetQueueAttributesResult"]["Attribute"]) + + @test d["MessageRetentionPeriod"] == expected_retention_period + @test d["CreatedTimestamp"] == expected_timestamp +end \ No newline at end of file