From 4df620978cf38b40ffbacbd9d04b2870b2faab94 Mon Sep 17 00:00:00 2001 From: Daniel Azuma Date: Wed, 17 Jun 2020 08:13:42 -0700 Subject: [PATCH] feat: Support for ID token validation --- .rubocop.yml | 4 +- Gemfile | 6 +- Rakefile | 21 ++ googleauth.gemspec | 1 + integration/helper.rb | 31 ++ integration/id_tokens/key_source_test.rb | 74 +++++ lib/googleauth.rb | 1 + lib/googleauth/id_tokens.rb | 233 ++++++++++++++ lib/googleauth/id_tokens/errors.rb | 71 ++++ lib/googleauth/id_tokens/key_sources.rb | 394 +++++++++++++++++++++++ lib/googleauth/id_tokens/verifier.rb | 144 +++++++++ test/helper.rb | 33 ++ test/id_tokens/key_sources_test.rb | 240 ++++++++++++++ test/id_tokens/verifier_test.rb | 269 ++++++++++++++++ 14 files changed, 1519 insertions(+), 3 deletions(-) create mode 100644 integration/helper.rb create mode 100644 integration/id_tokens/key_source_test.rb create mode 100644 lib/googleauth/id_tokens.rb create mode 100644 lib/googleauth/id_tokens/errors.rb create mode 100644 lib/googleauth/id_tokens/key_sources.rb create mode 100644 lib/googleauth/id_tokens/verifier.rb create mode 100644 test/helper.rb create mode 100644 test/id_tokens/key_sources_test.rb create mode 100644 test/id_tokens/verifier_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 67285fc..cf2c4b7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,9 +3,11 @@ inherit_gem: AllCops: Exclude: - - "spec/**/*" - "Rakefile" + - "integration/**/*" - "rakelib/**/*" + - "spec/**/*" + - "test/**/*" Metrics/ClassLength: Max: 200 Metrics/ModuleLength: diff --git a/Gemfile b/Gemfile index 2ff988b..0d9f6c1 100755 --- a/Gemfile +++ b/Gemfile @@ -10,13 +10,15 @@ group :development do gem "fakeredis", "~> 0.5" gem "google-style", "~> 1.24.0" gem "logging", "~> 2.0" + gem "minitest", "~> 5.14" + gem "minitest-focus", "~> 1.1" gem "rack-test", "~> 0.6" - gem "rake", "~> 10.0" + gem "rake", "~> 13.0" gem "redis", "~> 3.2" gem "rspec", "~> 3.0" gem "simplecov", "~> 0.9" gem "sinatra" - gem "webmock", "~> 1.21" + gem "webmock", "~> 3.8" end platforms :jruby do diff --git a/Rakefile b/Rakefile index cf539f6..a812819 100755 --- a/Rakefile +++ b/Rakefile @@ -2,9 +2,30 @@ require "json" require "bundler/gem_tasks" +require "rubocop/rake_task" +RuboCop::RakeTask.new + +require "rake/testtask" + +desc "Run tests." +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["test/**/*_test.rb"] + t.warning = false +end + +desc "Run integration tests." +Rake::TestTask.new("integration") do |t| + t.libs << "integration" + t.test_files = FileList["integration/**/*_test.rb"] + t.warning = false +end + task :ci do header "Using Ruby - #{RUBY_VERSION}" sh "bundle exec rubocop" + Rake::Task["test"].invoke + Rake::Task["integration"].invoke sh "bundle exec rspec" end diff --git a/googleauth.gemspec b/googleauth.gemspec index 2370bd2..74fb9b6 100755 --- a/googleauth.gemspec +++ b/googleauth.gemspec @@ -33,5 +33,6 @@ Gem::Specification.new do |gem| gem.add_dependency "multi_json", "~> 1.11" gem.add_dependency "os", ">= 0.9", "< 2.0" gem.add_dependency "signet", "~> 0.14" + gem.add_development_dependency "yard", "~> 0.9" end diff --git a/integration/helper.rb b/integration/helper.rb new file mode 100644 index 0000000..abf875c --- /dev/null +++ b/integration/helper.rb @@ -0,0 +1,31 @@ +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "minitest/autorun" +require "minitest/focus" +require "googleauth" diff --git a/integration/id_tokens/key_source_test.rb b/integration/id_tokens/key_source_test.rb new file mode 100644 index 0000000..3665bd3 --- /dev/null +++ b/integration/id_tokens/key_source_test.rb @@ -0,0 +1,74 @@ +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "helper" + +describe Google::Auth::IDTokens do + describe "key source" do + let(:legacy_oidc_key_source) { + Google::Auth::IDTokens::X509CertHttpKeySource.new "https://www.googleapis.com/oauth2/v1/certs" + } + let(:oidc_key_source) { Google::Auth::IDTokens.oidc_key_source } + let(:iap_key_source) { Google::Auth::IDTokens.iap_key_source } + + it "Gets real keys from the OAuth2 V1 cert URL" do + keys = legacy_oidc_key_source.refresh_keys + refute_empty keys + keys.each do |key| + assert_kind_of OpenSSL::PKey::RSA, key.key + refute key.key.private? + assert_equal "RS256", key.algorithm + end + end + + it "Gets real keys from the OAuth2 V3 cert URL" do + keys = oidc_key_source.refresh_keys + refute_empty keys + keys.each do |key| + assert_kind_of OpenSSL::PKey::RSA, key.key + refute key.key.private? + assert_equal "RS256", key.algorithm + end + end + + it "Gets the same keys from the OAuth2 V1 and V3 cert URLs" do + keys_v1 = legacy_oidc_key_source.refresh_keys.map(&:key).map(&:export).sort + keys_v3 = oidc_key_source.refresh_keys.map(&:key).map(&:export).sort + assert_equal keys_v1, keys_v3 + end + + it "Gets real keys from the IAP public key URL" do + keys = iap_key_source.refresh_keys + refute_empty keys + keys.each do |key| + assert_kind_of OpenSSL::PKey::EC, key.key + assert_equal "ES256", key.algorithm + end + end + end +end diff --git a/lib/googleauth.rb b/lib/googleauth.rb index 6457863..2231e7e 100644 --- a/lib/googleauth.rb +++ b/lib/googleauth.rb @@ -31,5 +31,6 @@ require "googleauth/application_default" require "googleauth/client_id" require "googleauth/credentials" require "googleauth/default_credentials" +require "googleauth/id_tokens" require "googleauth/user_authorizer" require "googleauth/web_user_authorizer" diff --git a/lib/googleauth/id_tokens.rb b/lib/googleauth/id_tokens.rb new file mode 100644 index 0000000..5424bae --- /dev/null +++ b/lib/googleauth/id_tokens.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "googleauth/id_tokens/errors" +require "googleauth/id_tokens/key_sources" +require "googleauth/id_tokens/verifier" + +module Google + module Auth + ## + # ## Verifying Google ID tokens + # + # This module verifies ID tokens issued by Google. This can be used to + # authenticate signed-in users using OpenID Connect. See + # https://developers.google.com/identity/sign-in/web/backend-auth for more + # information. + # + # ### Basic usage + # + # To verify an ID token issued by Google accounts: + # + # payload = Google::Auth::IDTokens.verify_oidc the_token, + # aud: "my-app-client-id" + # + # If verification succeeds, you will receive the token's payload as a hash. + # If verification fails, an exception (normally a subclass of + # {Google::Auth::IDTokens::VerificationError}) will be raised. + # + # To verify an ID token issued by the Google identity-aware proxy (IAP): + # + # payload = Google::Auth::IDTokens.verify_iap the_token, + # aud: "my-app-client-id" + # + # These methods will automatically download and cache the Google public + # keys necessary to verify these tokens. They will also automatically + # verify the issuer (`iss`) field for their respective types of ID tokens. + # + # ### Advanced usage + # + # If you want to provide your own public keys, either by pointing at a + # custom URI or by providing the key data directly, use the Verifier class + # and pass in a key source. + # + # To point to a custom URI that returns a JWK set: + # + # source = Google::Auth::IDTokens::JwkHttpKeySource.new "https://example.com/jwk" + # verifier = Google::Auth::IDTokens::Verifier.new key_source: source + # payload = verifier.verify the_token, aud: "my-app-client-id" + # + # To provide key data directly: + # + # jwk_data = { + # keys: [ + # { + # alg: "ES256", + # crv: "P-256", + # kid: "LYyP2g", + # kty: "EC", + # use: "sig", + # x: "SlXFFkJ3JxMsXyXNrqzE3ozl_0913PmNbccLLWfeQFU", + # y: "GLSahrZfBErmMUcHP0MGaeVnJdBwquhrhQ8eP05NfCI" + # } + # ] + # } + # source = Google::Auth::IDTokens::StaticKeySource.from_jwk_set jwk_data + # verifier = Google::Auth::IDTokens::Verifier key_source: source + # payload = verifier.verify the_token, aud: "my-app-client-id" + # + module IDTokens + ## + # A list of issuers expected for Google OIDC-issued tokens. + # + # @return [Array] + # + OIDC_ISSUERS = ["accounts.google.com", "https://accounts.google.com"].freeze + + ## + # A list of issuers expected for Google IAP-issued tokens. + # + # @return [Array] + # + IAP_ISSUERS = ["https://cloud.google.com/iap"].freeze + + ## + # The URL for Google OAuth2 V3 public certs + # + # @return [String] + # + OAUTH2_V3_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs" + + ## + # The URL for Google IAP public keys + # + # @return [String] + # + IAP_JWK_URL = "https://www.gstatic.com/iap/verify/public_key-jwk" + + class << self + ## + # The key source providing public keys that can be used to verify + # ID tokens issued by Google OIDC. + # + # @return [Google::Auth::IDTokens::JwkHttpKeySource] + # + def oidc_key_source + @oidc_key_source ||= JwkHttpKeySource.new OAUTH2_V3_CERTS_URL + end + + ## + # The key source providing public keys that can be used to verify + # ID tokens issued by Google IAP. + # + # @return [Google::Auth::IDTokens::JwkHttpKeySource] + # + def iap_key_source + @iap_key_source ||= JwkHttpKeySource.new IAP_JWK_URL + end + + ## + # Reset all convenience key sources. Used for testing. + # @private + # + def forget_sources! + @oidc_key_source = @iap_key_source = nil + self + end + + ## + # A convenience method that verifies a token allegedly issued by Google + # OIDC. + # + # @param token [String] The ID token to verify + # @param aud [String,Array,nil] The expected audience. At least + # one `aud` field in the token must match at least one of the + # provided audiences, or the verification will fail with + # {Google::Auth::IDToken::AudienceMismatchError}. If `nil` (the + # default), no audience checking is performed. + # @param azp [String,Array,nil] The expected authorized party + # (azp). At least one `azp` field in the token must match at least + # one of the provided values, or the verification will fail with + # {Google::Auth::IDToken::AuthorizedPartyMismatchError}. If `nil` + # (the default), no azp checking is performed. + # @param aud [String,Array,nil] The expected audience. At least + # one `iss` field in the token must match at least one of the + # provided issuers, or the verification will fail with + # {Google::Auth::IDToken::IssuerMismatchError}. If `nil`, no issuer + # checking is performed. Default is to check against {OIDC_ISSUERS}. + # + # @return [Hash] The decoded token payload. + # @raise [KeySourceError] if the key source failed to obtain public keys + # @raise [VerificationError] if the token verification failed. + # Additional data may be available in the error subclass and message. + # + def verify_oidc token, + aud: nil, + azp: nil, + iss: OIDC_ISSUERS + + verifier = Verifier.new key_source: oidc_key_source, + aud: aud, + azp: azp, + iss: iss + verifier.verify token + end + + ## + # A convenience method that verifies a token allegedly issued by Google + # IAP. + # + # @param token [String] The ID token to verify + # @param aud [String,Array,nil] The expected audience. At least + # one `aud` field in the token must match at least one of the + # provided audiences, or the verification will fail with + # {Google::Auth::IDToken::AudienceMismatchError}. If `nil` (the + # default), no audience checking is performed. + # @param azp [String,Array,nil] The expected authorized party + # (azp). At least one `azp` field in the token must match at least + # one of the provided values, or the verification will fail with + # {Google::Auth::IDToken::AuthorizedPartyMismatchError}. If `nil` + # (the default), no azp checking is performed. + # @param aud [String,Array,nil] The expected audience. At least + # one `iss` field in the token must match at least one of the + # provided issuers, or the verification will fail with + # {Google::Auth::IDToken::IssuerMismatchError}. If `nil`, no issuer + # checking is performed. Default is to check against {IAP_ISSUERS}. + # + # @return [Hash] The decoded token payload. + # @raise [KeySourceError] if the key source failed to obtain public keys + # @raise [VerificationError] if the token verification failed. + # Additional data may be available in the error subclass and message. + # + def verify_iap token, + aud: nil, + azp: nil, + iss: IAP_ISSUERS + + verifier = Verifier.new key_source: iap_key_source, + aud: aud, + azp: azp, + iss: iss + verifier.verify token + end + end + end + end +end diff --git a/lib/googleauth/id_tokens/errors.rb b/lib/googleauth/id_tokens/errors.rb new file mode 100644 index 0000000..efd9a16 --- /dev/null +++ b/lib/googleauth/id_tokens/errors.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +module Google + module Auth + module IDTokens + ## + # Failed to obtain keys from the key source. + # + class KeySourceError < StandardError; end + + ## + # Failed to verify a token. + # + class VerificationError < StandardError; end + + ## + # Failed to verify a token because it is expired. + # + class ExpiredTokenError < VerificationError; end + + ## + # Failed to verify a token because its signature did not match. + # + class SignatureError < VerificationError; end + + ## + # Failed to verify a token because its issuer did not match. + # + class IssuerMismatchError < VerificationError; end + + ## + # Failed to verify a token because its audience did not match. + # + class AudienceMismatchError < VerificationError; end + + ## + # Failed to verify a token because its authorized party did not match. + # + class AuthorizedPartyMismatchError < VerificationError; end + end + end +end diff --git a/lib/googleauth/id_tokens/key_sources.rb b/lib/googleauth/id_tokens/key_sources.rb new file mode 100644 index 0000000..3a07341 --- /dev/null +++ b/lib/googleauth/id_tokens/key_sources.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "base64" +require "json" +require "monitor" +require "net/http" +require "openssl" + +require "jwt" + +module Google + module Auth + module IDTokens + ## + # A public key used for verifying ID tokens. + # + # This includes the public key data, ID, and the algorithm used for + # signature verification. RSA and Elliptical Curve (EC) keys are + # supported. + # + class KeyInfo + ## + # Create a public key info structure. + # + # @param id [String] The key ID. + # @param key [OpenSSL::PKey::RSA,OpenSSL::PKey::EC] The key itself. + # @param algorithm [String] The algorithm (normally `RS256` or `ES256`) + # + def initialize id: nil, key: nil, algorithm: nil + @id = id + @key = key + @algorithm = algorithm + end + + ## + # The key ID. + # @return [String] + # + attr_reader :id + + ## + # The key itself. + # @return [OpenSSL::PKey::RSA,OpenSSL::PKey::EC] + # + attr_reader :key + + ## + # The signature algorithm. (normally `RS256` or `ES256`) + # @return [String] + # + attr_reader :algorithm + + class << self + ## + # Create a KeyInfo from a single JWK, which may be given as either a + # hash or an unparsed JSON string. + # + # @param jwk [Hash,String] The JWK specification. + # @return [KeyInfo] + # @raise [KeySourceError] If the key could not be extracted from the + # JWK. + # + def from_jwk jwk + jwk = symbolize_keys ensure_json_parsed jwk + key = case jwk[:kty] + when "RSA" + extract_rsa_key jwk + when "EC" + extract_ec_key jwk + when nil + raise KeySourceError, "Key type not found" + else + raise KeySourceError, "Cannot use key type #{jwk[:kty]}" + end + new id: jwk[:kid], key: key, algorithm: jwk[:alg] + end + + ## + # Create an array of KeyInfo from a JWK Set, which may be given as + # either a hash or an unparsed JSON string. + # + # @param jwk [Hash,String] The JWK Set specification. + # @return [Array] + # @raise [KeySourceError] If a key could not be extracted from the + # JWK Set. + # + def from_jwk_set jwk_set + jwk_set = symbolize_keys ensure_json_parsed jwk_set + jwks = jwk_set[:keys] + raise KeySourceError, "No keys found in jwk set" unless jwks + jwks.map { |jwk| from_jwk jwk } + end + + private + + def ensure_json_parsed input + return input unless input.is_a? String + JSON.parse input + rescue JSON::ParserError + raise KeySourceError, "Unable to parse JSON" + end + + def symbolize_keys hash + result = {} + hash.each { |key, val| result[key.to_sym] = val } + result + end + + def extract_rsa_key jwk + begin + n_data = Base64.urlsafe_decode64 jwk[:n] + e_data = Base64.urlsafe_decode64 jwk[:e] + rescue ArgumentError + raise KeySourceError, "Badly formatted key data" + end + n_bn = OpenSSL::BN.new n_data, 2 + e_bn = OpenSSL::BN.new e_data, 2 + rsa_key = OpenSSL::PKey::RSA.new + if rsa_key.respond_to? :set_key + rsa_key.set_key n_bn, e_bn, nil + else + rsa_key.n = n_bn + rsa_key.e = e_bn + end + rsa_key.public_key + end + + # @private + CURVE_NAME_MAP = { + "P-256" => "prime256v1", + "P-384" => "secp384r1", + "P-521" => "secp521r1", + "secp256k1" => "secp256k1" + }.freeze + + def extract_ec_key jwk + begin + x_data = Base64.urlsafe_decode64 jwk[:x] + y_data = Base64.urlsafe_decode64 jwk[:y] + rescue ArgumentError + raise KeySourceError, "Badly formatted key data" + end + curve_name = CURVE_NAME_MAP[jwk[:crv]] + raise KeySourceError, "Unsupported EC curve #{jwk[:crv]}" unless curve_name + group = OpenSSL::PKey::EC::Group.new curve_name + bn = OpenSSL::BN.new ["04" + x_data.unpack1("H*") + y_data.unpack1("H*")].pack("H*"), 2 + key = OpenSSL::PKey::EC.new curve_name + key.public_key = OpenSSL::PKey::EC::Point.new group, bn + key + end + end + end + + ## + # A key source that contains a static set of keys. + # + class StaticKeySource + ## + # Create a static key source with the given keys. + # + # @param keys [Array] The keys + # + def initialize keys + @current_keys = Array(keys) + end + + ## + # Return the current keys. Does not perform any refresh. + # + # @return [Array] + # + attr_reader :current_keys + alias refresh_keys current_keys + + class << self + ## + # Create a static key source containing a single key parsed from a + # single JWK, which may be given as either a hash or an unparsed + # JSON string. + # + # @param jwk [Hash,String] The JWK specification. + # @return [StaticKeySource] + # + def from_jwk jwk + new KeyInfo.from_jwk jwk + end + + ## + # Create a static key source containing multiple keys parsed from a + # JWK Set, which may be given as either a hash or an unparsed JSON + # string. + # + # @param jwk_set [Hash,String] The JWK Set specification. + # @return [StaticKeySource] + # + def from_jwk_set jwk_set + new KeyInfo.from_jwk_set jwk_set + end + end + end + + ## + # A base key source that downloads keys from a URI. Subclasses should + # override {HttpKeySource#interpret_json} to parse the response. + # + class HttpKeySource + ## + # The default interval between retries in seconds (3600s = 1hr). + # + # @return [Integer] + # + DEFAULT_RETRY_INTERVAL = 3600 + + ## + # Create an HTTP key source. + # + # @param uri [String,URI] The URI from which to download keys. + # @param retry_interval [Integer,nil] Override the retry interval in + # seconds. This is the minimum time between retries of failed key + # downloads. + # + def initialize uri, retry_interval: nil + @uri = URI uri + @retry_interval = retry_interval || DEFAULT_RETRY_INTERVAL + @allow_refresh_at = Time.now + @current_keys = [] + @monitor = Monitor.new + end + + ## + # The URI from which to download keys. + # @return [Array] + # + attr_reader :uri + + ## + # Return the current keys, without attempting to re-download. + # + # @return [Array] + # + attr_reader :current_keys + + ## + # Attempt to re-download keys (if the retry interval has expired) and + # return the new keys. + # + # @return [Array] + # @raise [KeySourceError] if key retrieval failed. + # + def refresh_keys + @monitor.synchronize do + return @current_keys if Time.now < @allow_refresh_at + @allow_refresh_at = Time.now + @retry_interval + + response = Net::HTTP.get_response uri + raise KeySourceError, "Unable to retrieve data from #{uri}" unless response.is_a? Net::HTTPSuccess + + data = begin + JSON.parse response.body + rescue JSON::ParserError + raise KeySourceError, "Unable to parse JSON" + end + + @current_keys = Array(interpret_json(data)) + end + end + + protected + + def interpret_json _data + nil + end + end + + ## + # A key source that downloads X509 certificates. + # Used by the legacy OAuth V1 public certs endpoint. + # + class X509CertHttpKeySource < HttpKeySource + ## + # Create a key source that downloads X509 certificates. + # + # @param uri [String,URI] The URI from which to download keys. + # @param algorithm [String] The algorithm to use for signature + # verification. Defaults to "`RS256`". + # @param retry_interval [Integer,nil] Override the retry interval in + # seconds. This is the minimum time between retries of failed key + # downloads. + # + def initialize uri, algorithm: "RS256", retry_interval: nil + super uri, retry_interval: retry_interval + @algorithm = algorithm + end + + protected + + def interpret_json data + data.map do |id, cert_str| + key = OpenSSL::X509::Certificate.new(cert_str).public_key + KeyInfo.new id: id, key: key, algorithm: @algorithm + end + rescue OpenSSL::X509::CertificateError + raise KeySourceError, "Unable to parse X509 certificates" + end + end + + ## + # A key source that downloads a JWK set. + # + class JwkHttpKeySource < HttpKeySource + ## + # Create a key source that downloads a JWT Set. + # + # @param uri [String,URI] The URI from which to download keys. + # @param retry_interval [Integer,nil] Override the retry interval in + # seconds. This is the minimum time between retries of failed key + # downloads. + # + def initialize uri, retry_interval: nil + super uri, retry_interval: retry_interval + end + + protected + + def interpret_json data + KeyInfo.from_jwk_set data + end + end + + ## + # A key source that aggregates other key sources. This means it will + # aggregate the keys provided by its constituent sources. Additionally, + # when asked to refresh, it will refresh all its constituent sources. + # + class AggregateKeySource + ## + # Create a key source that aggregates other key sources. + # + # @param sources [Array] The key sources to aggregate. + # + def initialize sources + @sources = Array(sources) + end + + ## + # Return the current keys, without attempting to refresh. + # + # @return [Array] + # + def current_keys + @sources.flat_map(&:current_keys) + end + + ## + # Attempt to refresh keys and return the new keys. + # + # @return [Array] + # @raise [KeySourceError] if key retrieval failed. + # + def refresh_keys + @sources.flat_map(&:refresh_keys) + end + end + end + end +end diff --git a/lib/googleauth/id_tokens/verifier.rb b/lib/googleauth/id_tokens/verifier.rb new file mode 100644 index 0000000..4e0a810 --- /dev/null +++ b/lib/googleauth/id_tokens/verifier.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "jwt" + +module Google + module Auth + module IDTokens + ## + # An object that can verify ID tokens. + # + # A verifier maintains a set of default settings, including the key + # source and fields to verify. However, individual verification calls can + # override any of these settings. + # + class Verifier + ## + # Create a verifier. + # + # @param key_source [key source] The default key source to use. All + # verification calls must have a key source, so if no default key + # source is provided here, then calls to {#verify} _must_ provide + # a key source. + # @param aud [String,nil] The default audience (`aud`) check, or `nil` + # for no check. + # @param azp [String,nil] The default authorized party (`azp`) check, + # or `nil` for no check. + # @param iss [String,nil] The default issuer (`iss`) check, or `nil` + # for no check. + # + def initialize key_source: nil, + aud: nil, + azp: nil, + iss: nil + @key_source = key_source + @aud = aud + @azp = azp + @iss = iss + end + + ## + # Verify the given token. + # + # @param token [String] the ID token to verify. + # @param key_source [key source] If given, override the key source. + # @param aud [String,nil] If given, override the `aud` check. + # @param azp [String,nil] If given, override the `azp` check. + # @param iss [String,nil] If given, override the `iss` check. + # + # @return [Hash] the decoded payload, if verification succeeded. + # @raise [KeySourceError] if the key source failed to obtain public keys + # @raise [VerificationError] if the token verification failed. + # Additional data may be available in the error subclass and message. + # + def verify token, + key_source: :default, + aud: :default, + azp: :default, + iss: :default + key_source = @key_source if key_source == :default + aud = @aud if aud == :default + azp = @azp if azp == :default + iss = @iss if iss == :default + + raise KeySourceError, "No key sources" unless key_source + keys = key_source.current_keys + payload = decode_token token, keys, aud, azp, iss + unless payload + keys = key_source.refresh_keys + payload = decode_token token, keys, aud, azp, iss + end + raise SignatureError, "Token not verified as issued by Google" unless payload + payload + end + + private + + def decode_token token, keys, aud, azp, iss + payload = nil + keys.find do |key| + begin + options = { algorithms: key.algorithm } + decoded_token = JWT.decode token, key.key, true, options + payload = decoded_token.first + rescue JWT::ExpiredSignature + raise ExpiredTokenError, "Token signature is expired" + rescue JWT::DecodeError + nil # Try the next key + end + end + + normalize_and_verify_payload payload, aud, azp, iss + end + + def normalize_and_verify_payload payload, aud, azp, iss + return nil unless payload + + # Map the legacy "cid" claim to the canonical "azp" + payload["azp"] ||= payload["cid"] if payload.key? "cid" + + # Payload content validation + if aud && (Array(aud) & Array(payload["aud"])).empty? + raise AudienceMismatchError, "Token aud mismatch: #{payload['aud']}" + end + if azp && (Array(azp) & Array(payload["azp"])).empty? + raise AuthorizedPartyMismatchError, "Token azp mismatch: #{payload['azp']}" + end + if iss && (Array(iss) & Array(payload["iss"])).empty? + raise IssuerMismatchError, "Token iss mismatch: #{payload['iss']}" + end + + payload + end + end + end + end +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..7897e1d --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,33 @@ +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "minitest/autorun" +require "minitest/focus" +require "webmock/minitest" + +require "googleauth" diff --git a/test/id_tokens/key_sources_test.rb b/test/id_tokens/key_sources_test.rb new file mode 100644 index 0000000..307da6a --- /dev/null +++ b/test/id_tokens/key_sources_test.rb @@ -0,0 +1,240 @@ +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "helper" + +require "openssl" + +describe Google::Auth::IDTokens do + describe "StaticKeySource" do + let(:key1) { Google::Auth::IDTokens::KeyInfo.new id: "1234", key: :key1, algorithm: "RS256" } + let(:key2) { Google::Auth::IDTokens::KeyInfo.new id: "5678", key: :key2, algorithm: "ES256" } + let(:keys) { [key1, key2] } + let(:source) { Google::Auth::IDTokens::StaticKeySource.new keys } + + it "returns a static set of keys" do + assert_equal keys, source.current_keys + end + + it "does not change on refresh" do + assert_equal keys, source.refresh_keys + end + end + + describe "HttpKeySource" do + let(:certs_uri) { "https://example.com/my-certs" } + let(:certs_body) { "{}" } + + it "raises an error when failing to parse json from the site" do + source = Google::Auth::IDTokens::HttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: "whoops") + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to parse JSON", error.message + assert_requested stub + end + + it "downloads data but gets no keys" do + source = Google::Auth::IDTokens::HttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: certs_body) + keys = source.refresh_keys + assert_empty keys + assert_requested stub + end + end + + describe "X509CertHttpKeySource" do + let(:certs_uri) { "https://example.com/my-certs" } + let(:key1) { OpenSSL::PKey::RSA.new 2048 } + let(:key2) { OpenSSL::PKey::RSA.new 2048 } + let(:cert1) { generate_cert key1 } + let(:cert2) { generate_cert key2 } + let(:id1) { "1234" } + let(:id2) { "5678" } + let(:certs_body) { JSON.dump({ id1 => cert1.to_pem, id2 => cert2.to_pem }) } + + after do + WebMock.reset! + end + + def generate_cert key + cert = OpenSSL::X509::Certificate.new + cert.subject = cert.issuer = OpenSSL::X509::Name.parse "/C=BE/O=Test/OU=Test/CN=Test" + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 + cert.public_key = key.public_key + cert.serial = 0x0 + cert.version = 2 + cert.sign key, OpenSSL::Digest::SHA1.new + cert + end + + it "raises an error when failing to reach the site" do + source = Google::Auth::IDTokens::X509CertHttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: "whoops", status: 404) + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to retrieve data from #{certs_uri}", error.message + assert_requested stub + end + + it "raises an error when failing to parse json from the site" do + source = Google::Auth::IDTokens::X509CertHttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: "whoops") + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to parse JSON", error.message + assert_requested stub + end + + it "raises an error when failing to parse x509 from the site" do + source = Google::Auth::IDTokens::X509CertHttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: '{"hi": "whoops"}') + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to parse X509 certificates", error.message + assert_requested stub + end + + it "gets the right certificates" do + source = Google::Auth::IDTokens::X509CertHttpKeySource.new certs_uri + stub = stub_request(:get, certs_uri).to_return(body: certs_body) + keys = source.refresh_keys + assert_equal id1, keys[0].id + assert_equal id2, keys[1].id + assert_equal key1.public_key.to_pem, keys[0].key.to_pem + assert_equal key2.public_key.to_pem, keys[1].key.to_pem + assert_equal "RS256", keys[0].algorithm + assert_equal "RS256", keys[1].algorithm + assert_requested stub + end + end + + describe "JwkHttpKeySource" do + let(:jwk_uri) { "https://example.com/my-jwk" } + let(:id1) { "fb8ca5b7d8d9a5c6c6788071e866c6c40f3fc1f9" } + let(:id2) { "LYyP2g" } + let(:jwk1) { + { + alg: "RS256", + e: "AQAB", + kid: id1, + kty: "RSA", + n: "zK8PHf_6V3G5rU-viUOL1HvAYn7q--dxMoUkt7x1rSWX6fimla-lpoYAKhFTLU" \ + "ELkRKy_6UDzfybz0P9eItqS2UxVWYpKYmKTQ08HgUBUde4GtO_B0SkSk8iLtGh" \ + "653UBBjgXmfzdfQEz_DsaWn7BMtuAhY9hpMtJye8LQlwaS8ibQrsC0j0GZM5KX" \ + "RITHwfx06_T1qqC_MOZRA6iJs-J2HNlgeyFuoQVBTY6pRqGXa-qaVsSG3iU-vq" \ + "NIciFquIq-xydwxLqZNksRRer5VAsSHf0eD3g2DX-cf6paSy1aM40svO9EfSvG" \ + "_07MuHafEE44RFvSZZ4ubEN9U7ALSjdw", + use: "sig" + } + } + let(:jwk2) { + { + alg: "ES256", + crv: "P-256", + kid: id2, + kty: "EC", + use: "sig", + x: "SlXFFkJ3JxMsXyXNrqzE3ozl_0913PmNbccLLWfeQFU", + y: "GLSahrZfBErmMUcHP0MGaeVnJdBwquhrhQ8eP05NfCI" + } + } + let(:bad_type_jwk) { + { + alg: "RS256", + kid: "hello", + kty: "blah", + use: "sig" + } + } + let(:jwk_body) { JSON.dump({ keys: [jwk1, jwk2] }) } + let(:bad_type_body) { JSON.dump({ keys: [bad_type_jwk] }) } + + after do + WebMock.reset! + end + + it "raises an error when failing to reach the site" do + source = Google::Auth::IDTokens::JwkHttpKeySource.new jwk_uri + stub = stub_request(:get, jwk_uri).to_return(body: "whoops", status: 404) + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to retrieve data from #{jwk_uri}", error.message + assert_requested stub + end + + it "raises an error when failing to parse json from the site" do + source = Google::Auth::IDTokens::JwkHttpKeySource.new jwk_uri + stub = stub_request(:get, jwk_uri).to_return(body: "whoops") + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Unable to parse JSON", error.message + assert_requested stub + end + + it "raises an error when the json structure is malformed" do + source = Google::Auth::IDTokens::JwkHttpKeySource.new jwk_uri + stub = stub_request(:get, jwk_uri).to_return(body: '{"hi": "whoops"}') + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "No keys found in jwk set", error.message + assert_requested stub + end + + it "raises an error when an unrecognized key type is encountered" do + source = Google::Auth::IDTokens::JwkHttpKeySource.new jwk_uri + stub = stub_request(:get, jwk_uri).to_return(body: bad_type_body) + error = assert_raises Google::Auth::IDTokens::KeySourceError do + source.refresh_keys + end + assert_equal "Cannot use key type blah", error.message + assert_requested stub + end + + it "gets the right keys" do + source = Google::Auth::IDTokens::JwkHttpKeySource.new jwk_uri + stub = stub_request(:get, jwk_uri).to_return(body: jwk_body) + keys = source.refresh_keys + assert_equal id1, keys[0].id + assert_equal id2, keys[1].id + assert_kind_of OpenSSL::PKey::RSA, keys[0].key + assert_kind_of OpenSSL::PKey::EC, keys[1].key + assert_equal "RS256", keys[0].algorithm + assert_equal "ES256", keys[1].algorithm + assert_requested stub + end + end +end diff --git a/test/id_tokens/verifier_test.rb b/test/id_tokens/verifier_test.rb new file mode 100644 index 0000000..bca28e1 --- /dev/null +++ b/test/id_tokens/verifier_test.rb @@ -0,0 +1,269 @@ +# Copyright 2020 Google LLC +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require "helper" + +describe Google::Auth::IDTokens::Verifier do + describe "verify_oidc" do + let(:oidc_token) { + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjQ5MjcxMGE3ZmNkYjE1Mzk2MGNlMDFmNzYwNTIwY" \ + "TMyYzg0NTVkZmYiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwOi8vZXhhbXBsZS5jb20" \ + "iLCJhenAiOiI1NDIzMzkzNTc2MzgtY3IwZHNlcnIyZXZnN3N2MW1lZ2hxZXU3MDMyNzRm" \ + "M2hAZGV2ZWxvcGVyLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJlbWFpbCI6IjU0MjMzOTM1N" \ + "zYzOC1jcjBkc2VycjJldmc3c3YxbWVnaHFldTcwMzI3NGYzaEBkZXZlbG9wZXIuZ3Nlcn" \ + "ZpY2VhY2NvdW50LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE1OTEzNDI" \ + "3NzYsImlhdCI6MTU5MTMzOTE3NiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUu" \ + "Y29tIiwic3ViIjoiMTA0MzQxNDczMTMxODI1OTU3NjAzIn0.GGDE_5HoLacyqdufdxnAC" \ + "rXxYySKQYAzSQ5qfGjSUriuO3uLm2-rwSPFfLzzBeflEHdVX7XRFFszpxKajuZklF4dXd" \ + "0evB1u5i3QeCJ8MSZKKx6qus_ETJv4rtuPNEuyhaRcShB7BwI8RY0IZ4_EDrhYqYInrO2" \ + "wQyJGYvc41JcmoKzRoNnEVydN0Qppt9bqevq_lJg-9UjJkJ2QHjPfTgMjwhLIgNptKgtR" \ + "qdoRpJmleFlbuUqyPPJfAzv3Tc6h3kw88tEcI8R3n04xmHOSMwERFFQYJdQDMd2F9SSDe" \ + "rh40codO_GuPZ7bEUiKq9Lkx2LH5TuhythfsMzIwJpaEA" + } + let(:oidc_jwk_body) { + <<~JWK + { + "keys": [ + { + "kid": "fb8ca5b7d8d9a5c6c6788071e866c6c40f3fc1f9", + "e": "AQAB", + "alg": "RS256", + "use": "sig", + "n": "zK8PHf_6V3G5rU-viUOL1HvAYn7q--dxMoUkt7x1rSWX6fimla-lpoYAKhFTLUELkRKy_6UDzfybz0P9eItqS2UxVWYpKYmKTQ08HgUBUde4GtO_B0SkSk8iLtGh653UBBjgXmfzdfQEz_DsaWn7BMtuAhY9hpMtJye8LQlwaS8ibQrsC0j0GZM5KXRITHwfx06_T1qqC_MOZRA6iJs-J2HNlgeyFuoQVBTY6pRqGXa-qaVsSG3iU-vqNIciFquIq-xydwxLqZNksRRer5VAsSHf0eD3g2DX-cf6paSy1aM40svO9EfSvG_07MuHafEE44RFvSZZ4ubEN9U7ALSjdw", + "kty": "RSA" + }, + { + "kty": "RSA", + "kid": "492710a7fcdb153960ce01f760520a32c8455dff", + "e": "AQAB", + "alg": "RS256", + "use": "sig", + "n": "wl6TaY_3dsuLczYH_hioeQ5JjcLKLGYb--WImN9_IKMkOj49dgs25wkjsdI9XGJYhhPJLlvfjIfXH49ZGA_XKLx7fggNaBRZcj1y-I3_77tVa9N7An5JLq3HT9XVt0PNTq0mtX009z1Hva4IWZ5IhENx2rWlZOfFAXiMUqhnDc8VY3lG7vr8_VG3cw3XRKvlZQKbb6p2YIMFsUwaDGL2tVF4SkxpxIazUYfOY5lijyVugNTslOBhlEMq_43MZlkznSrbFx8ToQ2bQX4Shj-r9pLyofbo6A7K9mgWnQXGY5rQVLPYYRzUg0ThWDzwHdgxYC5MNxKyQH4RC2LPv3U0LQ" + } + ] + } + JWK + } + let(:expected_aud) { "http://example.com" } + let(:expected_azp) { "542339357638-cr0dserr2evg7sv1meghqeu703274f3h@developer.gserviceaccount.com" } + let(:unexpired_test_time) { Time.at 1591339181 } + let(:expired_test_time) { unexpired_test_time + 86400 } + + after do + WebMock.reset! + Google::Auth::IDTokens.forget_sources! + end + + it "verifies a good token with iss, aud, and azp checks" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, unexpired_test_time do + Google::Auth::IDTokens.verify_oidc oidc_token, aud: expected_aud, azp: expected_azp + end + end + + it "fails to verify a bad token" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::SignatureError do + Google::Auth::IDTokens.verify_oidc "#{oidc_token}x" + end + end + end + + it "fails to verify a token with the wrong aud" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::AudienceMismatchError do + Google::Auth::IDTokens.verify_oidc oidc_token, aud: ["hello", "world"] + end + end + end + + it "fails to verify a token with the wrong azp" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::AuthorizedPartyMismatchError do + Google::Auth::IDTokens.verify_oidc oidc_token, azp: "hello" + end + end + end + + it "fails to verify a token with the wrong issuer" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::IssuerMismatchError do + Google::Auth::IDTokens.verify_oidc oidc_token, iss: "hello" + end + end + end + + it "fails to verify an expired token" do + stub_request(:get, Google::Auth::IDTokens::OAUTH2_V3_CERTS_URL).to_return(body: oidc_jwk_body) + Time.stub :now, expired_test_time do + assert_raises Google::Auth::IDTokens::ExpiredTokenError do + Google::Auth::IDTokens.verify_oidc oidc_token + end + end + end + end + + describe "verify_iap" do + let(:iap_token) { + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjBvZUxjUSJ9.eyJhdWQiOiIvcH" \ + "JvamVjdHMvNjUyNTYyNzc2Nzk4L2FwcHMvY2xvdWQtc2FtcGxlcy10ZXN0cy1waHAtaWFwI" \ + "iwiZW1haWwiOiJkYXp1bWFAZ29vZ2xlLmNvbSIsImV4cCI6MTU5MTMzNTcyNCwiZ29vZ2xl" \ + "Ijp7ImFjY2Vzc19sZXZlbHMiOlsiYWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L2FjY2V" \ + "zc0xldmVscy9yZWNlbnRTZWN1cmVDb25uZWN0RGF0YSIsImFjY2Vzc1BvbGljaWVzLzUxOD" \ + "U1MTI4MDkyNC9hY2Nlc3NMZXZlbHMvdGVzdE5vT3AiLCJhY2Nlc3NQb2xpY2llcy81MTg1N" \ + "TEyODA5MjQvYWNjZXNzTGV2ZWxzL2V2YXBvcmF0aW9uUWFEYXRhRnVsbHlUcnVzdGVkIiwi" \ + "YWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L2FjY2Vzc0xldmVscy9jYWFfZGlzYWJsZWQ" \ + "iLCJhY2Nlc3NQb2xpY2llcy81MTg1NTEyODA5MjQvYWNjZXNzTGV2ZWxzL3JlY2VudE5vbk" \ + "1vYmlsZVNlY3VyZUNvbm5lY3REYXRhIiwiYWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L" \ + "2FjY2Vzc0xldmVscy9jb25jb3JkIiwiYWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L2Fj" \ + "Y2Vzc0xldmVscy9mdWxseVRydXN0ZWRfY2FuYXJ5RGF0YSIsImFjY2Vzc1BvbGljaWVzLzU" \ + "xODU1MTI4MDkyNC9hY2Nlc3NMZXZlbHMvZnVsbHlUcnVzdGVkX3Byb2REYXRhIl19LCJoZC" \ + "I6Imdvb2dsZS5jb20iLCJpYXQiOjE1OTEzMzUxMjQsImlzcyI6Imh0dHBzOi8vY2xvdWQuZ" \ + "29vZ2xlLmNvbS9pYXAiLCJzdWIiOiJhY2NvdW50cy5nb29nbGUuY29tOjExMzc3OTI1ODA4" \ + "MTE5ODAwNDY5NCJ9.2BlagZOoonmX35rNY-KPbONiVzFAdNXKRGkX45uGFXeHryjKgv--K6" \ + "siL8syeCFXzHvgmWpJk31sEt4YLxPKvQ" + } + let(:iap_jwk_body) { + <<~JWK + { + "keys" : [ + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "LYyP2g", + "kty" : "EC", + "use" : "sig", + "x" : "SlXFFkJ3JxMsXyXNrqzE3ozl_0913PmNbccLLWfeQFU", + "y" : "GLSahrZfBErmMUcHP0MGaeVnJdBwquhrhQ8eP05NfCI" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "mpf0DA", + "kty" : "EC", + "use" : "sig", + "x" : "fHEdeT3a6KaC1kbwov73ZwB_SiUHEyKQwUUtMCEn0aI", + "y" : "QWOjwPhInNuPlqjxLQyhveXpWqOFcQPhZ3t-koMNbZI" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "b9vTLA", + "kty" : "EC", + "use" : "sig", + "x" : "qCByTAvci-jRAD7uQSEhTdOs8iA714IbcY2L--YzynI", + "y" : "WQY0uCoQyPSozWKGQ0anmFeOH5JNXiZa9i6SNqOcm7w" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "0oeLcQ", + "kty" : "EC", + "use" : "sig", + "x" : "MdhRXGEoGJLtBjQEIjnYLPkeci9rXnca2TffkI0Kac0", + "y" : "9BoREHfX7g5OK8ELpA_4RcOnFCGSjfR4SGZpBo7juEY" + }, + { + "alg" : "ES256", + "crv" : "P-256", + "kid" : "g5X6ig", + "kty" : "EC", + "use" : "sig", + "x" : "115LSuaFVzVROJiGfdPN1kT14Hv3P4RIjthfslZ010s", + "y" : "-FAaRtO4yvrN4uJ89xwGWOEJcSwpLmFOtb0SDJxEAuc" + } + ] + } + JWK + } + let(:expected_aud) { "/projects/652562776798/apps/cloud-samples-tests-php-iap" } + let(:unexpired_test_time) { Time.at 1591335143 } + let(:expired_test_time) { unexpired_test_time + 86400 } + + after do + WebMock.reset! + Google::Auth::IDTokens.forget_sources! + end + + it "verifies a good token with iss and aud checks" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, unexpired_test_time do + Google::Auth::IDTokens.verify_iap iap_token, aud: expected_aud + end + end + + it "fails to verify a bad token" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::SignatureError do + Google::Auth::IDTokens.verify_iap "#{iap_token}x" + end + end + end + + it "fails to verify a token with the wrong aud" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::AudienceMismatchError do + Google::Auth::IDTokens.verify_iap iap_token, aud: ["hello", "world"] + end + end + end + + it "fails to verify a token with the wrong azp" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::AuthorizedPartyMismatchError do + Google::Auth::IDTokens.verify_iap iap_token, azp: "hello" + end + end + end + + it "fails to verify a token with the wrong issuer" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, unexpired_test_time do + assert_raises Google::Auth::IDTokens::IssuerMismatchError do + Google::Auth::IDTokens.verify_iap iap_token, iss: "hello" + end + end + end + + it "fails to verify an expired token" do + stub_request(:get, Google::Auth::IDTokens::IAP_JWK_URL).to_return(body: iap_jwk_body) + Time.stub :now, expired_test_time do + assert_raises Google::Auth::IDTokens::ExpiredTokenError do + Google::Auth::IDTokens.verify_iap iap_token + end + end + end + end +end