CVE-2024-21632 (Ruby, published 2024-01-02T22:15Z)
Description: value: omniauth-microsoft_graph provides an Omniauth strategy for the Microsoft Graph API. Prior to versions 2.0.0, the implementation did not validate the legitimacy of the `email` attribute of the user nor did it give/document an option to do so, making it susceptible to nOAuth misconfiguration in cases when the `email` is used as a trusted user identifier. This could lead to account takeover. Version 2.0.0 contains a fix for this issue.

Severity: nan (CVSS v3 base score: 9.8)
Weakness: CWE-287 \u2014 Improper Authentication
When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
Repository: https://github.com/synth/omniauth-microsoft_graph
Files changed:
README.md
lib/omniauth/microsoft_graph.rb
lib/omniauth/microsoft_graph/domain_verifier.rb
lib/omniauth/strategies/microsoft_graph.rb
omniauth-microsoft_graph.gemspec
spec/omniauth/microsoft_graph/domain_verifier_spec.rb
spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb
Commit message: Add email domain verification


Vulnerable code:
Just add {login_hint: "email@example.com"} to your url generation to form:
      end     
      end  
      end              


Fixed code:
Just add `{login_hint: "email@example.com"}` to your url generation to form:

#### Domain Verification
Because Microsoft allows users to set vanity emails on their accounts, the value of the user's "email" doesn't establish membership in that domain. Put another way, user malicious@hacker.biz can edit their email in Active Directory to ceo@yourcompany.com, and (depending on your auth implementation) may be able to log in automatically as that user.

To establish membership in the claimed email domain, we use two strategies:

* `email` domain matches `userPrincipalName` domain (which by definition is a verified domain)
* The user's `id_token` includes the `xms_edov` ("Email Domain Ownership Verified") claim, with a truthy value

The `xms_edov` claim is [optional](https://github.com/MicrosoftDocs/azure-docs/issues/111425), and must be configured in the Azure console before it's available in the token. Refer to [Clerk's guide](https://clerk.com/docs/authentication/social-connections/microsoft#stay-secure-against-the-n-o-auth-vulnerability) for instructions on configuring the claim.

If you're not able or don't need to support domain verification, you can bypass for an individual domain:
```ruby
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :microsoft_graph,
           ENV['AZURE_APPLICATION_CLIENT_ID'],
           ENV['AZURE_APPLICATION_CLIENT_SECRET'],
           skip_domain_verification: %w[contoso.com]
end
```

Or, you can disable domain verification entirely. We *strongly recommend* that you do *not* disable domain verification if at all possible.
```ruby
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :microsoft_graph,
           ENV['AZURE_APPLICATION_CLIENT_ID'],
           ENV['AZURE_APPLICATION_CLIENT_SECRET'],
           skip_domain_verification: true
end
```

[nOAuth: How Microsoft OAuth Misconfiguration Can Lead to Full Account Takeover](https://www.descope.com/blog/post/noauth) from [Descope](https://www.descope.com/)

require "omniauth/microsoft_graph/domain_verifier"
# frozen_string_literal: true
require 'jwt' # for token signature validation
require 'omniauth' # to inherit from OmniAuth::Error
require 'oauth2' # to rescue OAuth2::Error

module OmniAuth
  module MicrosoftGraph
    # Verify user email domains to mitigate the nOAuth vulnerability
    # https://www.descope.com/blog/post/noauth
    # https://clerk.com/docs/authentication/social-connections/microsoft#stay-secure-against-the-n-o-auth-vulnerability
    OIDC_CONFIG_URL = 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration'

    class DomainVerificationError < OmniAuth::Error; end

    class DomainVerifier
      def self.verify!(auth_hash, access_token, options)
        new(auth_hash, access_token, options).verify!
      end

      def initialize(auth_hash, access_token, options)
        @email_domain = auth_hash['info']['email']&.split('@')&.last
        @upn_domain = auth_hash['extra']['raw_info']['userPrincipalName']&.split('@')&.last
        @access_token = access_token
        @id_token = access_token.params['id_token']
        @skip_verification = options[:skip_domain_verification]
      end

      def verify!
        # The userPrincipalName property is mutable, but must always contain a
        # verified domain:
        #
        #  "The general format is alias@domain, where domain must be present in
        #  the tenant's collection of verified domains."
        #  https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0
        #
        # This means while it's not suitable for consistently identifying a user
        # (the domain might change), it is suitable for verifying membership in
        # a given domain.
        return true if email_domain == upn_domain ||
          skip_verification == true ||
          (skip_verification.is_a?(Array) && skip_verification.include?(email_domain)) ||
          domain_verified_jwt_claim
        raise DomainVerificationError, verification_error_message
      end

      private

      attr_reader :access_token,
                  :email_domain,
                  :id_token,
                  :permitted_domains,
                  :skip_verification,
                  :upn_domain

      # https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference
      # Microsoft offers an optional claim `xms_edov` that will indicate whether the
      # user's email domain is part of the organization's verified domains. This has to be
      # explicitly configured in the app registration.
      #
      # To get to it, we need to decode the ID token with the key material from Microsoft's
      # OIDC configuration endpoint, and inspect it for the claim in question.
      def domain_verified_jwt_claim
        oidc_config = access_token.get(OIDC_CONFIG_URL).parsed
        algorithms = oidc_config['id_token_signing_alg_values_supported']
        keys = JWT::JWK::Set.new(access_token.get(oidc_config['jwks_uri']).parsed)
        decoded_token = JWT.decode(id_token, nil, true, algorithms: algorithms, jwks: keys)
        # https://github.com/MicrosoftDocs/azure-docs/issues/111425#issuecomment-1761043378
        # Comments seemed to indicate the value is not consistent
        ['1', 1, 'true', true].include?(decoded_token.first['xms_edov'])
      rescue JWT::VerificationError, ::OAuth2::Error
        false
      end

      def verification_error_message
        <<~MSG
          The email domain '#{email_domain}' is not a verified domain for this Azure AD account.
          You can either:
            * Update the user's email to match the principal domain '#{upn_domain}'
            * Skip verification on the '#{email_domain}' domain (not recommended)
            * Disable verification with `skip_domain_verification: true` (NOT RECOMMENDED!)
          Refer to the README for more details.
        MSG
      end
    end
  end
end
      option :skip_domain_verification, false
      def auth_hash
        super.tap do |ah|
          verify_email(ah, access_token)
        end
      end

      end
      end
      end

      def verify_email(auth_hash, access_token)
        OmniAuth::MicrosoftGraph::DomainVerifier.verify!(auth_hash, access_token, options)
      end
  spec.add_runtime_dependency 'jwt', '>= 2.0'
# frozen_string_literal: true

require 'spec_helper'
require 'omniauth/microsoft_graph/domain_verifier'

RSpec.describe OmniAuth::MicrosoftGraph::DomainVerifier do
  subject(:verifier) { described_class.new(auth_hash, access_token, options) }

  let(:auth_hash) do
    {
      'info' => { 'email' => email },
      'extra' => { 'raw_info' => { 'userPrincipalName' => upn } }
    }
  end
  let(:email) { 'foo@example.com' }
  let(:upn) { 'bar@hackerman.biz' }
  let(:options) { { skip_domain_verification: false } }
  let(:access_token) { double('OAuth2::AccessToken', params: { 'id_token' => id_token }) }
  let(:id_token) { nil }

  describe '#verify!' do
    subject(:result) { verifier.verify! }

    context 'when email domain and userPrincipalName domain match' do
      let(:email) { 'foo@example.com' }
      let(:upn) { 'bar@example.com' }

      it { is_expected.to be_truthy }
    end

    context 'when domain validation is disabled' do
      let(:options) { super().merge(skip_domain_verification: true) }

      it { is_expected.to be_truthy }
    end

    context 'when the email domain is explicitly permitted' do
      let(:options) { super().merge(skip_domain_verification: ['example.com']) }

      it { is_expected.to be_truthy }
    end

    context 'when the ID token indicates domain verification' do
      # Sign a fake ID token with our own local key
      let(:mock_key) do
        optional_parameters = { kid: 'mock-kid', use: 'sig', alg: 'RS256' }
        JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
      end
      let(:id_token) do
        payload = { email: email, xms_edov: true }
        JWT.encode(payload, mock_key.signing_key, mock_key[:alg], kid: mock_key[:kid])
      end

      # Mock the API responses to return the local key
      before do
        allow(access_token).to receive(:get)
          .with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL)
          .and_return(
            double('OAuth2::Response', parsed: {
              'id_token_signing_alg_values_supported' => ['RS256'],
              'jwks_uri' => 'https://example.com/jwks-keys'
            })
          )
        allow(access_token).to receive(:get)
          .with('https://example.com/jwks-keys')
          .and_return(
            double('OAuth2::Response', parsed: JWT::JWK::Set.new(mock_key).export)
          )
      end

      it { is_expected.to be_truthy }
    end

    context 'when all verification strategies fail' do
      before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) }

      it 'raises a DomainVerificationError' do
        expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError
      end
    end
  end
end
    context 'when email verification fails' do
      let(:response_hash) { { mail: 'something@domain.invalid' } }
      let(:error) { OmniAuth::MicrosoftGraph::DomainVerificationError.new }

      before do
        allow(OmniAuth::MicrosoftGraph::DomainVerifier).to receive(:verify!).and_raise(error)
      end

      it 'raises an error' do
        expect { subject.auth_hash }.to raise_error error
      end
    end