diff --git a/lib/vault/api/auth.rb b/lib/vault/api/auth.rb index 4a4c343d..c4d82a40 100644 --- a/lib/vault/api/auth.rb +++ b/lib/vault/api/auth.rb @@ -186,6 +186,60 @@ def aws_ec2(role, pkcs7, nonce = nil) return secret end + # Authenticate via AWS IAM auth method by providing a AWS CredentialProvider (either ECS, AssumeRole, etc.) + # If authentication is successful, the resulting token will be stored on the client and used + # for future requests. + # + # @example + # Vault.auth.aws_iam("dev-role-iam", Aws::AssumeRoleCredentials.new, "vault.example.com", "https://sts.us-east-2.amazonaws.com") #=> # + # + # @param [String] role + # @param [CredentialProvider] credentials_provider + # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/CredentialProvider.html + # @param [String] iam_auth_header_value optional + # As of Jan 2018, Vault will accept ANY or NO header if none is configured by the Vault server admin + # @param [String] sts_endpoint optional + # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_enable-regions.html + # @return [Secret] + def aws_iam(role, credentials_provider, iam_auth_header_value = nil, sts_endpoint = 'https://sts.amazonaws.com') + require "aws-sigv4" + require "base64" + + request_body = 'Action=GetCallerIdentity&Version=2011-06-15' + request_method = 'POST' + + vault_headers = { + 'User-Agent' => Vault::Client::USER_AGENT, + 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8' + } + + vault_headers['X-Vault-AWS-IAM-Server-ID'] = iam_auth_header_value if iam_auth_header_value + + sig4_headers = Aws::Sigv4::Signer.new( + service: 'sts', + region: region_from_sts_endpoint(sts_endpoint), + credentials_provider: credentials_provider + ).sign_request( + http_method: request_method, + url: sts_endpoint, + headers: vault_headers, + body: request_body + ).headers + + payload = { + role: role, + iam_http_request_method: request_method, + iam_request_url: Base64.strict_encode64(sts_endpoint), + iam_request_headers: Base64.strict_encode64(vault_headers.merge(sig4_headers).to_json), + iam_request_body: Base64.strict_encode64(request_body) + } + + json = client.post('/v1/auth/aws/login', JSON.fast_generate(payload)) + secret = Secret.decode(json) + client.token = secret.auth.client_token + return secret + end + # Authenticate via a TLS authentication method. If authentication is # successful, the resulting token will be stored on the client and used # for future requests. @@ -215,5 +269,21 @@ def tls(pem = nil, path = 'cert') client.token = secret.auth.client_token return secret end + + private + + # Parse an AWS region from a STS endpoint + # STS in the China (Beijing) region (cn-north-1) is sts.cn-north-1.amazonaws.com.cn + # Take care changing below regex with that edge case in mind + # + # @param [String] sts_endpoint + # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_enable-regions.html + # + # @return [String] aws region + def region_from_sts_endpoint(sts_endpoint) + valid_sts_endpoint = %r{https:\/\/sts\.?(.*).amazonaws.com}.match(sts_endpoint) + raise "Unable to parse STS endpoint #{sts_endpoint}" unless valid_sts_endpoint + valid_sts_endpoint[1].empty? ? 'us-east-1' : valid_sts_endpoint[1] + end end end diff --git a/spec/integration/api/auth_spec.rb b/spec/integration/api/auth_spec.rb index 06f88267..c08c23e2 100644 --- a/spec/integration/api/auth_spec.rb +++ b/spec/integration/api/auth_spec.rb @@ -1,4 +1,5 @@ require "spec_helper" +require "aws-sigv4" module Vault describe Auth do @@ -211,5 +212,51 @@ module Vault }.to_not change { subject.token } end end + + describe "#aws_iam", vault: "> 0.7.3" do + before(:context) do + vault_test_client.sys.enable_auth("aws", "aws", nil) + vault_test_client.post("/v1/auth/aws/config/client", JSON.fast_generate("iam_server_id_header_value" => "iam_header_canary")) + end + + after(:context) do + vault_test_client.sys.disable_auth("aws") + end + + let!(:old_token) { subject.token } + let(:credentials_provider) do + double( + credentials: + double(access_key_id: 'very', secret_access_key: 'secure', session_token: 'thing') + ) + end + let(:secret) { double(auth: double(client_token: 'a great token')) } + + after do + subject.token = old_token + end + + it "does not authenticate if iam_server_id_header_value does not match" do + expect(::Aws::Sigv4::Signer).to( + receive(:new).with( + service: 'sts', region: 'cn-north-1', credentials_provider: credentials_provider + ).and_call_original + ) + expect do + subject.auth.aws_iam('a_rolename', credentials_provider, 'mismatched_iam_header', 'https://sts.cn-north-1.amazonaws.com.cn') + end.to raise_error(Vault::HTTPClientError, /expected iam_header_canary but got mismatched_iam_header/) + end + + it "authenticates and saves the token on the client" do + expect(subject).to receive(:post).and_return 'huzzah!' + expect(Secret).to receive(:decode).and_return secret + expect(::Aws::Sigv4::Signer).to( + receive(:new).with( + service: 'sts', region: 'cn-north-1', credentials_provider: credentials_provider + ).and_call_original + ) + subject.auth.aws_iam('a_rolename', credentials_provider, 'iam_header_canary', 'https://sts.cn-north-1.amazonaws.com.cn') + end + end end end diff --git a/spec/unit/auth_spec.rb b/spec/unit/auth_spec.rb new file mode 100644 index 00000000..74b0f63f --- /dev/null +++ b/spec/unit/auth_spec.rb @@ -0,0 +1,30 @@ +require "spec_helper" + +module Vault + describe Authenticate do + let(:auth) { Authenticate.new(client: nil) } + describe "#region_from_sts_endpoint" do + subject { auth.send(:region_from_sts_endpoint, sts_endpoint) } + + context 'with a china endpoint' do + let(:sts_endpoint) { "https://sts.cn-north-1.amazonaws.com.cn" } + it { is_expected.to eq 'cn-north-1' } + end + + context 'with a GovCloud endpoint' do + let(:sts_endpoint) { "https://sts.us-gov-west-1.amazonaws.com" } + it { is_expected.to eq 'us-gov-west-1' } + end + + context 'with no regional endpoint' do + let(:sts_endpoint) { "https://sts.amazonaws.com" } + it { is_expected.to eq 'us-east-1' } + end + + context 'with a malformed url' do + let(:sts_endpoint) { "https:sts.amazonaws.com" } + it { expect { subject }.to raise_exception(StandardError, "Unable to parse STS endpoint https:sts.amazonaws.com") } + end + end + end +end diff --git a/vault.gemspec b/vault.gemspec index b0027fb2..5729956a 100644 --- a/vault.gemspec +++ b/vault.gemspec @@ -19,6 +19,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_runtime_dependency "aws-sigv4" + spec.add_development_dependency "bundler" spec.add_development_dependency "pry" spec.add_development_dependency "rake", "~> 12.0"