diff --git a/Gemfile.lock b/Gemfile.lock index 485a703..4501d3e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cognito_token_verifier (0.5.0) + cognito_token_verifier (0.6.0) activesupport (>= 5.2) json-jwt (~> 1.11) rest-client (~> 2.0) @@ -39,7 +39,7 @@ GEM unf (>= 0.0.5, < 1.0.0) erubi (1.10.0) http-accept (1.7.0) - http-cookie (1.0.3) + http-cookie (1.0.5) domain_name (~> 0.5) i18n (1.9.1) concurrent-ruby (~> 1.0) @@ -51,9 +51,9 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.5.9) method_source (1.0.0) - mime-types (3.3.1) + mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2020.0512) + mime-types-data (3.2022.0105) mini_portile2 (2.8.0) minitest (5.15.0) netrc (0.11.0) @@ -103,7 +103,7 @@ GEM concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.7) + unf_ext (0.0.8.2) zeitwerk (2.5.4) PLATFORMS diff --git a/lib/cognito_token_verifier/config.rb b/lib/cognito_token_verifier/config.rb index 8f250dd..450f479 100644 --- a/lib/cognito_token_verifier/config.rb +++ b/lib/cognito_token_verifier/config.rb @@ -15,27 +15,41 @@ def any_token_use? ['all', 'any', ['id', 'access']].any?{|usage| usage == token_use } end - def allow_expired_tokens? - allow_expired_tokens + def validate! + raise ConfigSetupError.new(self) unless aws_region.present? and user_pool_id.present? end - def jwks - begin - raise ConfigSetupError.new(self) unless aws_region.present? and user_pool_id.present? - @jwks ||= JSON.parse(RestClient.get(jwk_url)) - rescue RestClient::Exception, JSON::JSONError => e - raise JWKFetchError - end + def allow_expired_tokens? + allow_expired_tokens end - def iss - "https://cognito-idp.#{aws_region}.amazonaws.com/#{user_pool_id}" - end + # def jwks + # begin + # raise ConfigSetupError.new(self) unless aws_region.present? and user_pool_id.present? + # @jwks ||= JSON.parse(RestClient.get(jwk_url)) + # rescue RestClient::Exception, JSON::JSONError => e + # raise JWKFetchError + # end + # end + + # TODO Because the load balancer does not encrypt the user claims, we + # recommend that you configure the target group to use HTTPS. If you + # configure your target group to use HTTP, be sure to restrict the traffic + # to your load balancer using security groups. We also recommend that you + # verify the signature before doing any authorization based on the claims. + # To get the public key, get the key ID from the JWT header and use it to + # look up the public key from the endpoint. The endpoint for each AWS Region + # is as follows: + # https://public-keys.auth.elb.region.amazonaws.com/key-id + # See: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html + # def iss + # "https://cognito-idp.#{aws_region}.amazonaws.com/#{user_pool_id}" + # end private - def jwk_url - "#{iss}/.well-known/jwks.json" - end + # def jwk_url + # "#{iss}/.well-known/jwks.json" + # end end end diff --git a/lib/cognito_token_verifier/controller_macros.rb b/lib/cognito_token_verifier/controller_macros.rb index fe87153..d0704f8 100644 --- a/lib/cognito_token_verifier/controller_macros.rb +++ b/lib/cognito_token_verifier/controller_macros.rb @@ -13,8 +13,13 @@ module ControllerMacros def cognito_token return @cognito_token if @cognito_token.present? # Caching here, so gem user can access token themselves for additional checks - raise TokenMissing unless request.headers['authorization'].present? - @cognito_token = CognitoTokenVerifier::Token.new(request.headers['authorization']) + @cognito_token = CognitoTokenVerifier::Token.new(find_jwt_claims!) + end + + # Support cognito authentication off loaded by ALB + # https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html + def find_jwt_claims! + request.headers['x-amzn-oidc-data'].presence || request.headers['authorization'].presence || raise(TokenMissing) end def verify_cognito_token diff --git a/lib/cognito_token_verifier/errors.rb b/lib/cognito_token_verifier/errors.rb index 68c1a03..0590e54 100644 --- a/lib/cognito_token_verifier/errors.rb +++ b/lib/cognito_token_verifier/errors.rb @@ -48,11 +48,12 @@ def message class InvalidIss < CognitoTokenVerifier::Error def initialize(token) - @iss = token.decoded_token['iss'] + @iss = token.iss + @decoded_iss = token.decoded_token['iss'] end def message - "Invalid token ISS reference. Received #{@iss} while expecting #{CognitoTokenVerifier.config.iss}." + "Invalid token ISS reference. Received #{@decoded_iss} while expecting #{@iss}." end end end diff --git a/lib/cognito_token_verifier/token.rb b/lib/cognito_token_verifier/token.rb index a381b98..1267258 100644 --- a/lib/cognito_token_verifier/token.rb +++ b/lib/cognito_token_verifier/token.rb @@ -2,14 +2,21 @@ module CognitoTokenVerifier class Token - attr_reader :header, :decoded_token + attr_reader :encoded_jwt, :header, :idp_type def initialize(jwt) - begin - @header= JSON.parse(Base64.decode64(jwt.split('.')[0])) - @jwk = JSON::JWK.new(CognitoTokenVerifier.config.jwks["keys"].detect{|jwk| jwk['kid'] == header['kid']}) - @decoded_token = JSON::JWT.decode(jwt, @jwk) - rescue JSON::JWS::VerificationFailed, JSON::JSONError => e + @encoded_jwt = jwt + @header = JSON.parse(Base64.decode64(jwt.split('.')[0])) + @idp_type = header["signer"]&.include?("arn:aws:elasticloadbalancing") ? :alb : :cognito + rescue JSON::JSONError + raise TokenDecodingError + end + + def decoded_token + @decoded_token ||= begin + @jwk = JSON::JWK.new(jwks["keys"].detect{|jwk| jwk['kid'] == header['kid']}) + JSON::JWT.decode(encoded_jwt, @jwk) + rescue JSON::JWS::VerificationFailed raise TokenDecodingError end end @@ -18,12 +25,41 @@ def expired? decoded_token['exp'] < Time.now.to_i and not CognitoTokenVerifier.config.allow_expired_tokens? end + def jwks + CognitoTokenVerifier.config.validate! + @jwks ||= JSON.parse(RestClient.get(jwk_url)) + rescue RestClient::Exception, JSON::JSONError => e + raise JWKFetchError + end + + def jwk_url + case idp_type + when :alb + "#{iss}/#{header['kid']}" + else + "#{iss}/.well-known/jwks.json" + end + end + + def iss + case idp_type + when :alb + "https://public-keys.auth.elb.#{CognitoTokenVerifier.config.aws_region}.amazonaws.com" + else + "https://cognito-idp.#{CognitoTokenVerifier.config.aws_region}.amazonaws.com/#{CognitoTokenVerifier.config.user_pool_id}" + end + end + def valid_token_use? CognitoTokenVerifier.config.any_token_use? || [CognitoTokenVerifier.config.token_use].flatten.include?(decoded_token['token_use']) end + def alb_claim? + idp_type == :alb + end + def valid_iss? - decoded_token['iss'] == CognitoTokenVerifier.config.iss + decoded_token['iss'] == iss end end end diff --git a/spec/cognito_token_verifier/token_spec.rb b/spec/cognito_token_verifier/token_spec.rb index 112fc7f..93439f8 100644 --- a/spec/cognito_token_verifier/token_spec.rb +++ b/spec/cognito_token_verifier/token_spec.rb @@ -11,6 +11,10 @@ expect(subject.decoded_token).to_not be_nil end + it "sets the correct idp type" do + expect(subject.idp_type).to be :cognito + end + describe "#expired?" do it "returns true when the token is expired" do expect(subject.expired?).to be true @@ -37,4 +41,21 @@ expect(subject.valid_token_use?).to be false end end + + + context "ALB terminated cognito" do + subject { CognitoTokenVerifier::Token.new(COGNITO_ALB_TEST_TOKEN) } + + before :each do + allow_any_instance_of(RestClient).to receive(:get).and_return({}) + end + + it "provides access to the decoded token header" do + expect(subject.header).to_not be_nil + end + + it "sets the correct idp type" do + expect(subject.idp_type).to be :alb + end + end end diff --git a/spec/fixtures/constants.rb b/spec/fixtures/constants.rb index 644d346..2b2cb95 100644 --- a/spec/fixtures/constants.rb +++ b/spec/fixtures/constants.rb @@ -1 +1,11 @@ COGNITO_TEST_TOKEN = "eyJraWQiOiJhYXN4VGgycHF0YkFJVDErb0xvZVZoY0dWWVY4UVVhZ0JtOG9CZHN5WWlrPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI1MWFhNjIzNS0xNGU5LTRlNjctODI4Yy1hNmQ0NGEyMTJmN2EiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiY3VzdG9tOkVtcGxveWVlIjoiMCIsImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC51cy13ZXN0LTIuYW1hem9uYXdzLmNvbVwvdXMtd2VzdC0yXzk0S1hWMjdyciIsImNvZ25pdG86dXNlcm5hbWUiOiI1MWFhNjIzNS0xNGU5LTRlNjctODI4Yy1hNmQ0NGEyMTJmN2EiLCJhdWQiOiIyZXNjNTRvbGFmaXJrYmNzMmZkYTJjYjEzMSIsImV2ZW50X2lkIjoiMzU2ZWZlNzMtMDQ3Mi0xMWU5LWE0YmUtODk5MDg1NjkzYTljIiwidG9rZW5fdXNlIjoiaWQiLCJjdXN0b206UGFyZW50IjoiMCIsImF1dGhfdGltZSI6MTU0NTMyMjQxOCwiY3VzdG9tOkFkbWluIjoiMSIsImV4cCI6MTU0NTMyNjAxOCwiaWF0IjoxNTQ1MzIyNDE4LCJlbWFpbCI6ImJpbGxAa2FuZ2Fyb290aW1lLmNvbSJ9.I-bFL_d0A0yyxslqpxrHED8iNLBm_DrYCKubgxzmAKb3D_MyKfYUw6AjjznLzb4Q1rYoe-JwAWH1Ms0Dt2H8hQeApCdODvl9NZOgsr7Vh9JxprXehT3IcC1zXCLvlsWlX81kCvvg_moUg9rBd5LuBtLvXAs6YIjG304stmCGczhq6Da29AzJYZd8elGR24DyRLWBuDCI_8v3mKsPd3h2NcVqhr-KiM74X5nNqZTB5Rj2sFZJLdgqpZbRlkDeidukzFJTWpW9ZVc_9J6Qls34TNYL4myS6UALS9RtbAlfopphg2GMTwjsAfakj9HnUglxFuCOrp_pwptyHGdYy7QcUQ".freeze +COGNITO_ALB_TEST_TOKEN = "ewogICAiYWxnIjogImFsZ29yaXRobSIsCiAgICJraWQiOiAiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwKICAgInNpZ25lciI6ICJhcm46YXdzOmVsYXN0aWNsb2FkYmFsYW5jaW5nOnJlZ2lvbi1jb2RlOmFjY291bnQtaWQ6bG9hZGJhbGFuY2VyL2FwcC9sb2FkLWJhbGFuY2VyLW5hbWUvbG9hZC1iYWxhbmNlci1pZCIsIAogICAiaXNzIjogInVybCIsCiAgICJjbGllbnQiOiAiY2xpZW50LWlkIiwKICAgImV4cCI6ICJleHBpcmF0aW9uIgp9Cg".freeze + +# { +# "alg": "algorithm", +# "kid": "12345678-1234-1234-1234-123456789012", +# "signer": "arn:aws:elasticloadbalancing:region-code:account-id:loadbalancer/app/load-balancer-name/load-balancer-id", +# "iss": "url", +# "client": "client-id", +# "exp": "expiration" +# } \ No newline at end of file