CVE-2020-15240 (published 2020-10-21T18:15Z)

omniauth-auth0 (rubygems) versions >= 2.3.0 and < 2.4.1 improperly validate the JWT token signature when using the `jwt_validator.verify` method. Improper validation of the JWT token signature can allow an attacker to bypass authentication and authorization. You are affected by this vulnerability if all of the following conditions apply: 1. You are using `omniauth-auth0`. 2. You are using `JWTValidator.verify` method directly OR you are not authenticating using the SDK’s default Authorization Code Flow. The issue is patched in version 2.4.1.

Severity: MEDIUM
CVSS v2 base score: 5.8 (AV:N/AC:M/Au:N/C:P/I:P/A:N)
CVSS v3 base score: 9.1, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-347
cwe_name: Improper Verification of Cryptographic Signature
description: The product does not verify, or incorrectly verifies, the cryptographic signature for data.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/auth0/omniauth-auth0
commit_details: repo_url: https://github.com/auth0/omniauth-auth0
author: James Anderson
author_date: 2020-10-08 15:12:47-05:00
msg: Verify the JWT signature
repository: repo_url: https://github.com/auth0/omniauth-auth0
repo_name: auth0/omniauth-auth0
description: OmniAuth strategy to login with Auth0
repo_language: Ruby
owner: auth0
file_changes: filename: jwt_validator.rb
change_type: ModificationType.MODIFY
diff: @@ -28,17 +28,24 @@ module OmniAuth
         @client_secret = options.client_secret
       end
 
+      # Verify a token's signature. Only tokens signed with the RS256 or HS256 signatures are supported.
+      # @return array - The token's key and signing algorithm
       def verify_signature(jwt)
         head = token_head(jwt)
 
         # Make sure the algorithm is supported and get the decode key.
         if head[:alg] == 'RS256'
-          [rs256_decode_key(head[:kid]), head[:alg]]
+          key, alg = [rs256_decode_key(head[:kid]), head[:alg]]
         elsif head[:alg] == 'HS256'
-          [@client_secret, head[:alg]]
+          key, alg = [@client_secret, head[:alg]]
         else
           raise OmniAuth::Auth0::TokenValidationError.new("Signature algorithm of #{head[:alg]} is not supported. Expected the ID token to be signed with RS256 or HS256")
         end
+
+        # Call decode to verify the signature
+        JWT.decode(jwt, key, true, decode_opts(alg))
+
+        return key, alg
       end
 
       # Verify a JWT.
@@ -93,11 +100,27 @@ module OmniAuth
       end
 
       private
+      # Get the JWT decode options. We disable the claim checks since we perform our claim validation logic
+      # Docs: https://github.com/jwt/ruby-jwt
+      # @return hash
+      def decode_opts(alg)
+        {
+          algorithm: alg,
+          verify_expiration: false,
+          verify_iat: false,
+          verify_iss: false,
+          verify_aud: false,
+          verify_jti: false,
+          verify_subj: false,
+          verify_not_before: false
+        }
+      end
+
       def rs256_decode_key(kid)
         jwks_x5c = jwks_key(:x5c, kid)
 
         if jwks_x5c.nil?
-          raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}''")
+          raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}'")
         end
 
         jwks_public_cert(jwks_x5c.first)

programming_language: Ruby
filename: jwt_validator_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -12,17 +12,17 @@ describe OmniAuth::Auth0::JWTValidator do
   let(:domain) { 'samples.auth0.com' }
   let(:future_timecode) { 32_503_680_000 }
   let(:past_timecode) { 303_912_000 }
-  let(:jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
+  let(:valid_jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
 
   let(:rsa_private_key) do
     OpenSSL::PKey::RSA.generate 2048
   end
 
-  let(:rsa_token_jwks) do
+  let(:valid_jwks) do
     {
       keys: [
         {
-          kid: jwks_kid,
+          kid: valid_jwks_kid,
           x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)]
         }
       ]
@@ -91,29 +91,29 @@ describe OmniAuth::Auth0::JWTValidator do
     end
   end
 
-  describe 'JWT verifier jwks_key' do
+  describe 'JWT verifier jwks key parsing' do
     let(:jwt_validator) do
       make_jwt_validator
     end
 
     before do
-      stub_jwks
+      stub_complete_jwks
     end
 
     it 'should return a key' do
-      expect(jwt_validator.jwks_key(:alg, jwks_kid)).to eq('RS256')
+      expect(jwt_validator.jwks_key(:alg, valid_jwks_kid)).to eq('RS256')
     end
 
     it 'should return an x5c key' do
-      expect(jwt_validator.jwks_key(:x5c, jwks_kid).length).to eq(1)
+      expect(jwt_validator.jwks_key(:x5c, valid_jwks_kid).length).to eq(1)
     end
 
     it 'should return nil if there is not key' do
-      expect(jwt_validator.jwks_key(:auth0, jwks_kid)).to eq(nil)
+      expect(jwt_validator.jwks_key(:auth0, valid_jwks_kid)).to eq(nil)
     end
 
     it 'should return nil if the key ID is invalid' do
-      expect(jwt_validator.jwks_key(:alg, "#{jwks_kid}_invalid")).to eq(nil)
+      expect(jwt_validator.jwks_key(:alg, "#{valid_jwks_kid}_invalid")).to eq(nil)
     end
   end
 
@@ -153,8 +153,24 @@ describe OmniAuth::Auth0::JWTValidator do
     end
 
     before do
-      stub_jwks
-      stub_dummy_jwks
+      stub_complete_jwks
+      stub_expected_jwks
+    end
+
+    it 'should fail when JWT is nil' do
+      expect do
+        jwt_validator.verify(nil)
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "ID token is required but missing"
+      }))
+    end
+
+    it 'should fail when JWT is not well-formed' do
+      expect do
+        jwt_validator.verify('abc.123')
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "ID token could not be decoded"
+      }))
     end
 
     it 'should fail with missing issuer' do
@@ -248,6 +264,39 @@ describe OmniAuth::Auth0::JWTValidator do
       }))
     end
 
+    it 'should pass when past expiration but within default leeway' do
+      exp = Time.now.to_i - 59
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'sub',
+        aud: client_id,
+        exp: exp,
+        iat: past_timecode
+      }
+
+      token = make_hs256_token(payload)
+      id_token = jwt_validator.verify(token)
+      expect(id_token['exp']).to eq(exp)
+    end
+
+    it 'should fail when past expiration and outside default leeway' do
+      exp = Time.now.to_i - 61
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'sub',
+        aud: client_id,
+        exp: exp,
+        iat: past_timecode
+      }
+
+      token = make_hs256_token(payload)
+      expect do
+        jwt_validator.verify(token)
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "Expiration time (exp) claim error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(exp + 60)})"
+      }))
+    end
+
     it 'should fail when missing iat' do
       payload = {
         iss: "https://#{domain}/",
@@ -377,6 +426,114 @@ describe OmniAuth::Auth0::JWTValidator do
       }))
     end
 
+    it 'should fail when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
+      now = Time.now.to_i
+      auth_time = now - 121
+      max_age = 60
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'sub',
+        aud: client_id,
+        exp: future_timecode,
+        iat: past_timecode,
+        auth_time: auth_time
+      }
+
+      token = make_hs256_token(payload)
+      expect do
+        jwt_validator.verify(token, { max_age: max_age })
+        # Time.at(auth_time + max_age + leeway
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (#{Time.now}) is after last auth time (#{Time.at(auth_time + max_age + 60)})"
+      }))
+    end
+
+    it 'should verify when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
+      now = Time.now.to_i
+      auth_time = now - 119
+      max_age = 60
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'sub',
+        aud: client_id,
+        exp: future_timecode,
+        iat: past_timecode,
+        auth_time: auth_time
+      }
+
+      token = make_hs256_token(payload)
+      id_token = jwt_validator.verify(token, { max_age: max_age })
+      expect(id_token['auth_time']).to eq(auth_time)
+    end
+
+    it 'should fail for RS256 token when kid is incorrect' do
+      domain = 'example.org'
+      sub = 'abc123'
+      payload = {
+        sub: sub,
+        exp: future_timecode,
+        iss: "https://#{domain}/",
+        iat: past_timecode,
+        aud: client_id
+      }
+      invalid_kid = 'invalid-kid'
+      token = make_rs256_token(payload, invalid_kid)
+      expect do
+        verified_token = make_jwt_validator(opt_domain: domain).verify(token)
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "Could not find a public key for Key ID (kid) 'invalid-kid'"
+      }))
+    end
+
+    it 'should fail when RS256 token has invalid signature' do
+      domain = 'example.org'
+      sub = 'abc123'
+      payload = {
+        sub: sub,
+        exp: future_timecode,
+        iss: "https://#{domain}/",
+        iat: past_timecode,
+        aud: client_id
+      }
+      token = make_rs256_token(payload) + 'bad'
+      expect do
+        verified_token = make_jwt_validator(opt_domain: domain).verify(token)
+      end.to raise_error(an_instance_of(JWT::VerificationError).and having_attributes({
+        message: "Signature verification raised"
+      }))
+    end
+
+    it 'should fail when algorithm is not RS256 or HS256' do
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'abc123',
+        aud: client_id,
+        exp: future_timecode,
+        iat: past_timecode
+      }
+      token = JWT.encode payload, 'secret', 'HS384'
+      expect do
+        jwt_validator.verify(token)
+      end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
+        message: "Signature algorithm of HS384 is not supported. Expected the ID token to be signed with RS256 or HS256"
+      }))
+    end
+
+    it 'should fail when HS256 token has invalid signature' do
+      payload = {
+        iss: "https://#{domain}/",
+        sub: 'abc123',
+        aud: client_id,
+        exp: future_timecode,
+        iat: past_timecode
+      }
+      token = make_hs256_token(payload, 'bad_secret')
+      expect do
+        # validator is configured to use "CLIENT_SECRET" by default
+        jwt_validator.verify(token)
+      end.to raise_error(an_instance_of(JWT::VerificationError))
+    end
+
     it 'should verify a valid HS256 token with multiple audiences' do
       audience = [
         client_id,
@@ -417,13 +574,44 @@ describe OmniAuth::Auth0::JWTValidator do
         exp: future_timecode,
         iss: "https://#{domain}/",
         iat: past_timecode,
-        aud: client_id,
-        kid: jwks_kid
+        aud: client_id
       }
       token = make_rs256_token(payload)
       verified_token = make_jwt_validator(opt_domain: domain).verify(token)
       expect(verified_token['sub']).to eq(sub)
     end
+
+    it 'should verify a HS256 JWT signature when calling verify signature directly' do
+      sub = 'abc123'
+        payload = {
+          iss: "https://#{domain}/",
+          sub: sub,
+          aud: client_id,
+          exp: future_timecode,
+          iat: past_timecode
+        }
+        token = make_hs256_token(payload)
+        verified_token_signature = jwt_validator.verify_signature(token)
+        expect(verified_token_signature[0]).to eq('CLIENT_SECRET')
+        expect(verified_token_signature[1]).to eq('HS256')
+    end
+
+    it 'should verify a RS256 JWT signature verify signature directly' do
+      domain = 'example.org'
+      sub = 'abc123'
+      payload = {
+        sub: sub,
+        exp: future_timecode,
+        iss: "https://#{domain}/",
+        iat: past_timecode,
+        aud: client_id
+      }
+      token = make_rs256_token(payload)
+      verified_token_signature = make_jwt_validator(opt_domain: domain).verify_signature(token)
+      expect(verified_token_signature.length).to be(2)
+      expect(verified_token_signature[0]).to be_a(OpenSSL::PKey::RSA)
+      expect(verified_token_signature[1]).to eq('RS256')
+    end
   end
 
   private
@@ -439,14 +627,16 @@ describe OmniAuth::Auth0::JWTValidator do
     OmniAuth::Auth0::JWTValidator.new(opts)
   end
 
-  def make_hs256_token(payload = nil)
+  def make_hs256_token(payload = nil, secret = nil)
     payload = { sub: 'abc123' } if payload.nil?
-    JWT.encode payload, client_secret, 'HS256'
+    secret = client_secret if secret.nil?
+    JWT.encode payload, secret, 'HS256'
   end
 
-  def make_rs256_token(payload = nil)
+  def make_rs256_token(payload = nil, kid = nil)
     payload = { sub: 'abc123' } if payload.nil?
-    JWT.encode payload, rsa_private_key, 'RS256', kid: jwks_kid
+    kid = valid_jwks_kid if kid.nil?
+    JWT.encode payload, rsa_private_key, 'RS256', kid: kid
   end
 
   def make_cert(private_key)
@@ -474,7 +664,7 @@ describe OmniAuth::Auth0::JWTValidator do
     cert.sign private_key, OpenSSL::Digest::SHA1.new
   end
 
-  def stub_jwks
+  def stub_complete_jwks
     stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json')
       .to_return(
         headers: { 'Content-Type' => 'application/json' },
@@ -483,18 +673,11 @@ describe OmniAuth::Auth0::JWTValidator do
       )
   end
 
-  def stub_bad_jwks
-    stub_request(:get, 'https://samples.auth0.com/.well-known/jwks-bad.json')
-      .to_return(
-        status: 404
-      )
-  end
-
-  def stub_dummy_jwks
+  def stub_expected_jwks
     stub_request(:get, 'https://example.org/.well-known/jwks.json')
       .to_return(
         headers: { 'Content-Type' => 'application/json' },
-        body: rsa_token_jwks,
+        body: valid_jwks,
         status: 200
       )
   end

programming_language: Ruby