cloudfoundry/cloud_controller_ng

View on GitHub
spec/unit/lib/uaa/uaa_token_decoder_spec.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'spec_helper'
require 'cloud_controller/uaa/uaa_token_decoder'

module VCAP::CloudController
  RSpec.describe UaaTokenDecoder do
    subject { UaaTokenDecoder.new(uaa_config) }

    let(:uaa_config) do
      {
        resource_id: 'resource-id',
        symmetric_secret: nil,
        url: 'http://localhost:8080/uaa',
        internal_url: 'https://uaa.service.cf.internal',
        ca_file: 'spec/fixtures/certs/uaa_ca.crt'
      }
    end

    let(:uaa_info) { double(CF::UAA::Info) }
    let(:uaa_client) { instance_double(VCAP::CloudController::UaaClient) }
    let(:logger) { double(Steno::Logger) }

    before do
      allow(::CloudController::DependencyLocator.instance).to receive(:uaa_username_lookup_client).and_return(uaa_client)
      allow(uaa_client).to receive(:info).and_return(uaa_info)
      allow(Steno).to receive(:logger).with('cc.uaa_token_decoder').and_return(logger)
      # undo global stubbing in spec_helper.rb
      allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_call_original
    end

    describe '.new' do
      context 'when the decoder is created with a grace period' do
        context 'and that grace period is negative' do
          subject { UaaTokenDecoder.new(uaa_config, grace_period_in_seconds: -10) }

          it 'logs a warning that the grace period was changed to 0' do
            expect(logger).to receive(:warn).with(/negative grace period interval.*-10.*is invalid, changed to 0/i)
            subject
          end
        end

        context 'and that grace period is not an integer' do
          subject { UaaTokenDecoder.new(uaa_config, grace_period_in_seconds: 'blabla') }

          it 'raises an ArgumentError' do
            expect do
              subject
            end.to raise_error(ArgumentError, /grace period should be an integer/i)
          end
        end
      end
    end

    describe '#decode_token' do
      before do
        Timecop.freeze(Time.now.utc)
        stub_request(:get, uaa_issuer_info_url).to_return(body: { 'issuer' => uaa_issuer_string }.to_json)
      end

      after { Timecop.return }

      let(:uaa_issuer_string) { 'https://uaa.my-cf.com/uaa/stuff/here' }
      let(:uaa_issuer_info_url) { "#{VCAP::CloudController::Config.config.get(:uaa, :internal_url)}/.well-known/openid-configuration" }

      context 'when symmetric key is used' do
        before do
          uaa_config[:symmetric_secret] = 'symmetric-key'
          uaa_config[:symmetric_secret2] = 'symmetric-key2'
        end

        context 'when token is valid' do
          let(:token_content) do
            {
              'aud' => 'resource-id',
              'payload' => 123,
              'exp' => Time.now.utc.to_i + 10_000,
              'iss' => token_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549'
            }
          end

          context 'when the token issuer matches the UAA' do
            let(:token_issuer_string) { uaa_issuer_string }

            context 'when the first key decodes the token' do
              it 'decodes the token' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })

                expect(subject.decode_token("bearer #{token}")).to eq(token_content)
              end

              it 'caches the issuer info from UAA' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })
                subject.decode_token("bearer #{token}")
                subject.decode_token("bearer #{token}")

                expect(WebMock).to have_requested(:get, uaa_issuer_info_url).once
              end
            end

            context 'when the second key decodes the token' do
              it 'decodes the token' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key2' })

                expect(subject.decode_token("bearer #{token}")).to eq(token_content)
              end

              it 'caches the issuer info from UAA' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key2' })
                subject.decode_token("bearer #{token}")
                subject.decode_token("bearer #{token}")

                expect(WebMock).to have_requested(:get, uaa_issuer_info_url).once
              end
            end

            context 'when no CA certificate is configured to use with the interal UAA URL' do
              let(:uaa_config) do
                {
                  resource_id: 'resource-id',
                  symmetric_secret: nil,
                  url: 'http://localhost:8080/uaa',
                  internal_url: 'https://uaa.service.cf.internal'
                }
              end

              it 'communicates with the UAA over its internal URL without any issue' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })
                subject.decode_token("bearer #{token}")

                expect(WebMock).to have_requested(:get, uaa_issuer_info_url).once
              end

              it 'decodes the token' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })

                expect(subject.decode_token("bearer #{token}")).to eq(token_content)
              end
            end
          end

          context "when the token issuer doesn't match the UAA" do
            let(:token_issuer_string) { 'https://totally.different.issuer/uaa' }

            it 'raises an exception' do
              token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })

              expect do
                subject.decode_token("bearer #{token}")
              end.to raise_error(UaaTokenDecoder::BadToken, 'Incorrect issuer')
            end
          end

          context 'when UAA responds with a non-200 while fetching the issuer' do
            let(:token_issuer_string) { uaa_issuer_string }

            context 'when the UAA responds with a 200 within 3 attempts' do
              before do
                stub_request(:get, uaa_issuer_info_url).
                  to_return(status: 404).then.
                  to_return(status: 404).then.
                  to_return(body: { 'issuer' => uaa_issuer_string }.to_json)
              end

              it 'eventually decodes the token' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })

                expect(subject.decode_token("bearer #{token}")).to eq(token_content)
              end
            end

            context "when the UAA doesn't return a 200 within 3 attempts" do
              before do
                stub_request(:get, uaa_issuer_info_url).to_return(status: 404)
              end

              it 'raises an error' do
                token = CF::UAA::TokenCoder.encode(token_content, { skey: 'symmetric-key' })
                expect do
                  subject.decode_token("bearer #{token}")
                end.to raise_error(/Could not retrieve issuer information from UAA/)
              end
            end
          end
        end

        context 'when token is invalid' do
          let(:token_content) { 'token' }

          it 'raises BadToken exception' do
            expect(logger).to receive(:warn).with(/invalid bearer token/i)

            expect do
              subject.decode_token("bearer #{token_content}")
            end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
          end
        end
      end

      context 'when asymmetric key is used' do
        before do
          uaa_config[:symmetric_secret] = nil
          allow(uaa_info).to receive_messages(validation_keys_hash: { 'key1' => { 'value' => rsa_key.public_key.to_pem } })
        end

        let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }

        context 'when token is valid' do
          let(:token_content) do
            {
              'aud' => 'resource-id',
              'payload' => 123,
              'exp' => Time.now.utc.to_i + 10_000,
              'iss' => token_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549'
            }
          end
          let(:token_issuer_string) { 'https://uaa.my-cf.com/uaa/stuff/here' }

          context 'when the token issuer matches the UAA' do
            let(:token_issuer_string) { uaa_issuer_string }

            it 'successfully decodes token and caches key' do
              token = generate_token(rsa_key, token_content)

              expect(uaa_info).to receive(:validation_keys_hash)
              expect(subject.decode_token("bearer #{token}")).to eq(token_content)

              expect(uaa_info).not_to receive(:validation_keys_hash)
              expect(subject.decode_token("bearer #{token}")).to eq(token_content)
            end

            describe 're-fetching key' do
              let(:old_rsa_key) { OpenSSL::PKey::RSA.new(2048) }

              it 'retries to decode token with newly fetched asymmetric key' do
                allow(uaa_info).to receive(:validation_keys_hash).and_return(
                  { 'old_key' => { 'value' => old_rsa_key.public_key.to_pem } },
                  { 'new_key' => { 'value' => rsa_key.public_key.to_pem } }
                )
                expect(subject.decode_token("bearer #{generate_token(rsa_key, token_content)}")).to eq(token_content)
              end

              it 'stops retrying to decode token with newly fetched asymmetric key after 1 try' do
                allow(uaa_info).to receive(:validation_keys_hash).and_return({ 'old_key' => { 'value' => old_rsa_key.public_key.to_pem } })

                expect(logger).to receive(:warn).with(/invalid bearer token/i)
                expect do
                  subject.decode_token("bearer #{generate_token(rsa_key, token_content)}")
                end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
              end
            end
          end

          context "when the token issuer doesn't match the UAA" do
            let(:token_issuer_string) { 'https://totally.different.issuer/uaa' }

            it 'raises an exception' do
              token = generate_token(rsa_key, token_content)

              expect do
                subject.decode_token("bearer #{token}")
              end.to raise_error(UaaTokenDecoder::BadToken, 'Incorrect issuer')
            end
          end

          context 'when the token issuer is out of date' do
            let(:token_issuer_string) { 'https://totally.different.issuer/uaa' }
            let(:invalid_issuer) { 'oops' }

            it 'calls uaa_issuer twice' do
              token = generate_token(rsa_key, token_content)
              allow_any_instance_of(UaaTokenDecoder).to receive(:fetch_uaa_issuer).and_return(invalid_issuer, token_issuer_string)

              expect(subject.decode_token("bearer #{token}")).to eq(token_content)
            end
          end

          context 'when UAA responds with a non-200 while fetching the issuer' do
            let(:token_issuer_string) { uaa_issuer_string }

            context 'when the UAA responds with a 200 within 3 attempts' do
              before do
                stub_request(:get, uaa_issuer_info_url).
                  to_return(status: 404).then.
                  to_return(status: 404).then.
                  to_return(body: { 'issuer' => uaa_issuer_string }.to_json)
              end

              it 'eventually decodes the token' do
                token = generate_token(rsa_key, token_content)

                expect(subject.decode_token("bearer #{token}")).to eq(token_content)
              end
            end

            context "when the UAA doesn't return a 200 within 3 attempts" do
              before do
                stub_request(:get, uaa_issuer_info_url).to_return(status: 404)
              end

              it 'raises an error' do
                token = generate_token(rsa_key, token_content)

                expect do
                  subject.decode_token("bearer #{token}")
                end.to raise_error(/Could not retrieve issuer information from UAA/)
              end
            end
          end
        end

        context 'when token has invalid audience' do
          let(:token_content) do
            {
              'aud' => 'invalid-audience',
              'payload' => 123,
              'exp' => Time.now.utc.to_i + 10_000,
              'iss' => uaa_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549'
            }
          end

          it 'raises an BadToken error' do
            expect(logger).to receive(:warn).with(/invalid bearer token/i)
            expect do
              subject.decode_token("bearer #{generate_token(rsa_key, token_content)}")
            end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
          end
        end

        context 'when token has expired' do
          let(:token_content) do
            {
              'aud' => 'resource-id',
              'payload' => 123, 'exp' => Time.now.utc.to_i,
              'jti' => 'adb2aa5535d04a3180ec56927c859549'
            }
          end

          it 'raises a BadToken error' do
            expect(logger).to receive(:warn).with(/token expired/i)
            expect do
              subject.decode_token("bearer #{generate_token(rsa_key, token_content)}")
            end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
          end
        end

        context 'when token is invalid' do
          it 'raises BadToken error' do
            expect(logger).to receive(:warn).with(/invalid bearer token/i)
            expect do
              subject.decode_token('bearer invalid-token')
            end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
          end

          context 'when token is not an access token' do
            let(:token_content) do
              {
                'aud' => 'resource-id',
                'payload' => 123,
                'exp' => Time.now.utc.to_i + 10_000,
                'iss' => token_issuer_string,
                'jti' => 'adb2aa5535d04a3180ec56927c859549-r'
              }
            end
            let(:token_issuer_string) { uaa_issuer_string }

            it 'raises BadToken error' do
              token = generate_token(rsa_key, token_content)

              expect do
                subject.decode_token("bearer #{token}")
              end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
            end
          end
        end

        context 'when multiple asymmetric keys are used' do
          let(:bad_rsa_key) { OpenSSL::PKey::RSA.new(2048) }
          let(:token_content) do
            {
              'aud' => 'resource-id',
              'payload' => 123,
              'exp' => Time.now.utc.to_i + 10_000,
              'iss' => uaa_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549'
            }
          end

          it 'succeeds when it has first key that is valid' do
            allow(uaa_info).to receive(:validation_keys_hash).and_return({
                                                                           'new_key' => { 'value' => rsa_key.public_key.to_pem },
                                                                           'bad_key' => { 'value' => bad_rsa_key.public_key.to_pem }
                                                                         })
            token = generate_token(rsa_key, token_content)

            expect(uaa_info).to receive(:validation_keys_hash)
            expect(subject.decode_token("bearer #{token}")).to eq(token_content)
          end

          it 'succeeds when subsequent key is valid' do
            allow(uaa_info).to receive(:validation_keys_hash).and_return({
                                                                           'bad_key' => { 'value' => bad_rsa_key.public_key.to_pem },
                                                                           'new_key' => { 'value' => rsa_key.public_key.to_pem }
                                                                         })
            token = generate_token(rsa_key, token_content)

            expect(uaa_info).to receive(:validation_keys_hash)
            expect(subject.decode_token("bearer #{token}")).to eq(token_content)
          end

          it 're-fetches keys when none of the keys are valid' do
            other_bad_key = OpenSSL::PKey::RSA.new(2048)
            allow(uaa_info).to receive(:validation_keys_hash).and_return(
              {
                'bad_key' => { 'value' => bad_rsa_key.public_key.to_pem },
                'other_bad_key' => { 'value' => other_bad_key.public_key.to_pem }
              },
              {
                're-fetched_key' => { 'value' => rsa_key.public_key.to_pem }
              }
            )
            token = generate_token(rsa_key, token_content)

            expect(uaa_info).to receive(:validation_keys_hash).twice
            expect(subject.decode_token("bearer #{token}")).to eq(token_content)
          end

          it 'fails when re-fetched keys are also not valid' do
            other_bad_key = OpenSSL::PKey::RSA.new(2048)
            final_bad_key = OpenSSL::PKey::RSA.new(2048)
            allow(uaa_info).to receive(:validation_keys_hash).and_return(
              {
                'bad_key' => { 'value' => bad_rsa_key.public_key.to_pem },
                'other_bad_key' => { 'value' => other_bad_key.public_key.to_pem }
              },
              {
                'final_bad_key' => { 'value' => final_bad_key.public_key.to_pem }
              }
            )
            token = generate_token(rsa_key, token_content)

            expect(uaa_info).to receive(:validation_keys_hash).twice
            expect(logger).to receive(:warn).with(/invalid bearer token/i)
            expect do
              subject.decode_token("bearer #{token}")
            end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
          end
        end

        context 'when the decoder has an alternate reference time specified' do
          subject { UaaTokenDecoder.new(uaa_config, alternate_reference_time: Time.now.utc.to_i - 100) }
          let(:token_content) do
            { 'aud' => 'resource-id',
              'payload' => 123,
              'exp' => Time.now.utc.to_i,
              'iss' => uaa_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549' }
          end

          let(:token) { generate_token(rsa_key, token_content) }

          context 'and the token is currently expired but had not expired at the alternate reference time' do
            it 'decodes the token' do
              token_content['exp'] = Time.now.utc.to_i - 50
              expect(logger).to receive(:info).with(/using alternate reference time/i)
              expect(subject.decode_token("bearer #{token}")).to eq token_content
            end
          end

          context 'and the token was expired at the alternate reference time' do
            it 'raises and logs a warning about the expired token' do
              token_content['exp'] = Time.now.utc.to_i - 150
              expect(logger).to receive(:info).with(/using alternate reference time/i)
              expect(logger).to receive(:warn).with(/token expired/i)
              expect do
                subject.decode_token("bearer #{token}")
              end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
            end
          end
        end

        context 'when the decoder has both a grace period and an alternate reference time specified' do
          subject { UaaTokenDecoder.new(uaa_config, grace_period_in_seconds: 100, alternate_reference_time: '123') }
          it 'raises an ArgumentError' do
            expect do
              subject
            end.to raise_error(ArgumentError, /grace period and alternate reference time cannot be used together/i)
          end
        end

        context 'when the decoder has a grace period specified' do
          subject { UaaTokenDecoder.new(uaa_config, grace_period_in_seconds: 100) }
          let(:token_content) do
            { 'aud' => 'resource-id',
              'payload' => 123,
              'exp' => Time.now.utc.to_i,
              'iss' => uaa_issuer_string,
              'jti' => 'adb2aa5535d04a3180ec56927c859549' }
          end

          let(:token) { generate_token(rsa_key, token_content) }

          context 'and the token is currently expired but had not expired within the grace period' do
            it 'decodes the token and logs a warning about expiration within the grace period' do
              token_content['exp'] = Time.now.utc.to_i - 50
              expect(logger).to receive(:warn).with(/token currently expired but accepted within grace period of 100 seconds/i)
              expect(subject.decode_token("bearer #{token}")).to eq token_content
            end
          end

          context 'and the token expired outside of the grace period' do
            it 'raises and logs a warning about the expired token' do
              token_content['exp'] = Time.now.utc.to_i - 150
              expect(logger).to receive(:warn).with(/token expired/i)
              expect do
                subject.decode_token("bearer #{token}")
              end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)
            end
          end

          context 'and that grace period interval is negative' do
            subject { UaaTokenDecoder.new(uaa_config, grace_period_in_seconds: -10) }

            it 'sets the grace period to be 0 instead' do
              token_content['exp'] = Time.now.utc.to_i
              expired_token        = generate_token(rsa_key, token_content)
              allow(logger).to receive(:warn)
              expect do
                subject.decode_token("bearer #{expired_token}")
              end.to raise_error(VCAP::CloudController::UaaTokenDecoder::BadToken)

              token_content['exp'] = Time.now.utc.to_i + 1
              valid_token          = generate_token(rsa_key, token_content)
              expect(subject.decode_token("bearer #{valid_token}")).to eq token_content
            end
          end
        end

        def generate_token(rsa_key, content)
          CF::UAA::TokenCoder.encode(content, pkey: rsa_key, algorithm: 'RS256')
        end
      end
    end
  end
end