omniauth-microsoft_graph 1.2.0 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6fe6e2c005ac0bf995f831460527c29ea1610daecfd48e9c83a6987a3b7ab91
4
- data.tar.gz: 569e04a9a5e50e712ea4e4a847454f6d263392a9932fcd179b6e5a1db9c77645
3
+ metadata.gz: d89d349bdaa2e7c2d75edf01ef55baa73fb647ec0ce79a6542ad946e84f6cfe4
4
+ data.tar.gz: 7d1f758e047e86b318f8d71007d3ba5735b771a1075726c49b8a2e95bc7cbdff
5
5
  SHA512:
6
- metadata.gz: b2be452a85ef752c0c0b0b30a0338cbe867dbd3e29079001ebf3ae5e7377ca0fbd3c8ef8a0fb61461c507b99c9692a8aefb9ecf4c556bcd2f7f9e4e97125a56b
7
- data.tar.gz: b77352003e95997a8993c098f894df4c3ea846c42fd0f18743cb933fe5417db3d631934ac073d2424cb7f8491407927fc83af5cfb77962b7d6a1305839bbed28
6
+ metadata.gz: afdcf7236c17dc9a213c64a44b7dc8a81e6ee46bd34696ad3889ef9207066eb46b51a8efe5cf88612975356d8d126b24714f0f0bdf8a4e3fad216eeb26b34b8c
7
+ data.tar.gz: a6f547877dacd8c7dbfcd1f8299a2fc432de9b1712b2bf74f8ae50326c360b524a790f1b63a1f9803795b51c49ff88ac9e4d2f94572454482dc4972f39334a35
@@ -8,24 +8,23 @@
8
8
  name: Ruby
9
9
 
10
10
  on:
11
- push:
12
11
  pull_request:
13
12
 
14
13
  jobs:
15
14
  test:
16
15
 
17
- runs-on: ubuntu-latest
18
16
  strategy:
19
17
  matrix:
20
- ruby-version: ['2.6', '2.7', '3.0']
18
+ os: [ubuntu-latest, macos-latest]
19
+ ruby-version: ['3.0', '3.1', '3.2', '3.3']
20
+ runs-on: ${{ matrix.os }}
21
21
 
22
22
  steps:
23
23
  - uses: actions/checkout@v2
24
24
  - name: Set up Ruby
25
25
  # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
26
26
  # change this to (see https://github.com/ruby/setup-ruby#versioning):
27
- # uses: ruby/setup-ruby@v1
28
- uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e
27
+ uses: ruby/setup-ruby@v1
29
28
  with:
30
29
  ruby-version: ${{ matrix.ruby-version }}
31
30
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
data/README.md CHANGED
@@ -22,6 +22,8 @@ Or install it yourself as:
22
22
 
23
23
  ## Usage
24
24
 
25
+ Register a new app in the [Azure Portal / App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) to get the `AZURE_APPLICATION_CLIENT_ID` and `AZURE_APPLICATION_CLIENT_SECRET` below.
26
+
25
27
  #### Configuration
26
28
  ```ruby
27
29
  Rails.application.config.middleware.use OmniAuth::Builder do
@@ -30,10 +32,43 @@ end
30
32
  ```
31
33
 
32
34
  #### Login Hint
33
- Just add {login_hint: "email@example.com"} to your url generation to form:
35
+ Just add `{login_hint: "email@example.com"}` to your url generation to form:
34
36
  ```ruby
35
37
  /auth/microsoft_graph?login_hint=email@example.com
36
38
  ```
39
+
40
+ #### Domain Verification
41
+ 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.
42
+
43
+ To establish membership in the claimed email domain, we use two strategies:
44
+
45
+ * `email` domain matches `userPrincipalName` domain (which by definition is a verified domain)
46
+ * The user's `id_token` includes the `xms_edov` ("Email Domain Ownership Verified") claim, with a truthy value
47
+
48
+ 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.
49
+
50
+ If you're not able or don't need to support domain verification, you can bypass for an individual domain:
51
+ ```ruby
52
+ Rails.application.config.middleware.use OmniAuth::Builder do
53
+ provider :microsoft_graph,
54
+ ENV['AZURE_APPLICATION_CLIENT_ID'],
55
+ ENV['AZURE_APPLICATION_CLIENT_SECRET'],
56
+ skip_domain_verification: %w[contoso.com]
57
+ end
58
+ ```
59
+
60
+ Or, you can disable domain verification entirely. We *strongly recommend* that you do *not* disable domain verification if at all possible.
61
+ ```ruby
62
+ Rails.application.config.middleware.use OmniAuth::Builder do
63
+ provider :microsoft_graph,
64
+ ENV['AZURE_APPLICATION_CLIENT_ID'],
65
+ ENV['AZURE_APPLICATION_CLIENT_SECRET'],
66
+ skip_domain_verification: true
67
+ end
68
+ ```
69
+
70
+ [nOAuth: How Microsoft OAuth Misconfiguration Can Lead to Full Account Takeover](https://www.descope.com/blog/post/noauth) from [Descope](https://www.descope.com/)
71
+
37
72
  ### Upgrading to 1.0.0
38
73
  This version requires OmniAuth v2. If you are using Rails, you will need to include or upgrade `omniauth-rails_csrf_protection`. If you upgrade and get an error in your logs complaining about "authenticity error" or similiar, make sure to do `bundle update omniauth-rails_csrf_protection`
39
74
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ require 'jwt' # for token signature validation
3
+ require 'omniauth' # to inherit from OmniAuth::Error
4
+ require 'oauth2' # to rescue OAuth2::Error
5
+
6
+ module OmniAuth
7
+ module MicrosoftGraph
8
+ # Verify user email domains to mitigate the nOAuth vulnerability
9
+ # https://www.descope.com/blog/post/noauth
10
+ # https://clerk.com/docs/authentication/social-connections/microsoft#stay-secure-against-the-n-o-auth-vulnerability
11
+ OIDC_CONFIG_URL = 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration'
12
+ COMMON_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
13
+
14
+ class DomainVerificationError < OmniAuth::Error; end
15
+
16
+ class DomainVerifier
17
+ def self.verify!(auth_hash, access_token, options)
18
+ new(auth_hash, access_token, options).verify!
19
+ end
20
+
21
+ def initialize(auth_hash, access_token, options)
22
+ @email_domain = auth_hash['info']['email']&.split('@')&.last
23
+ @upn_domain = auth_hash['extra']['raw_info']['userPrincipalName']&.split('@')&.last
24
+ @access_token = access_token
25
+ @id_token = access_token.params['id_token']
26
+ @skip_verification = options[:skip_domain_verification]
27
+ end
28
+
29
+ def verify!
30
+ # The userPrincipalName property is mutable, but must always contain a
31
+ # verified domain:
32
+ #
33
+ # "The general format is alias@domain, where domain must be present in
34
+ # the tenant's collection of verified domains."
35
+ # https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0
36
+ #
37
+ # This means while it's not suitable for consistently identifying a user
38
+ # (the domain might change), it is suitable for verifying membership in
39
+ # a given domain.
40
+ return true if email_domain == upn_domain ||
41
+ skip_verification == true ||
42
+ (skip_verification.is_a?(Array) && skip_verification.include?(email_domain)) ||
43
+ domain_verified_jwt_claim
44
+ raise DomainVerificationError, verification_error_message
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :access_token,
50
+ :email_domain,
51
+ :id_token,
52
+ :permitted_domains,
53
+ :skip_verification,
54
+ :upn_domain
55
+
56
+ # https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference
57
+ # Microsoft offers an optional claim `xms_edov` that will indicate whether the
58
+ # user's email domain is part of the organization's verified domains. This has to be
59
+ # explicitly configured in the app registration.
60
+ #
61
+ # To get to it, we need to decode the ID token with the key material from Microsoft's
62
+ # OIDC configuration endpoint, and inspect it for the claim in question.
63
+ def domain_verified_jwt_claim
64
+ oidc_config = access_token.get(OIDC_CONFIG_URL).parsed
65
+ algorithms = oidc_config['id_token_signing_alg_values_supported']
66
+ jwks = get_jwks(oidc_config)
67
+ decoded_token = JWT.decode(id_token, nil, true, algorithms: algorithms, jwks: jwks)
68
+ xms_edov_valid?(decoded_token)
69
+ rescue JWT::VerificationError, ::OAuth2::Error
70
+ false
71
+ end
72
+
73
+ def xms_edov_valid?(decoded_token)
74
+ # https://github.com/MicrosoftDocs/azure-docs/issues/111425#issuecomment-1761043378
75
+ # Comments seemed to indicate the value is not consistent
76
+ ['1', 1, 'true', true].include?(decoded_token.first['xms_edov'])
77
+ end
78
+
79
+ def get_jwks(oidc_config)
80
+ # Depending on the tenant, the JWKS endpoint might be different. We need to
81
+ # consider both the JWKS from the OIDC configuration and the common JWKS endpoint.
82
+ oidc_config_jwk_keys = access_token.get(oidc_config['jwks_uri']).parsed[:keys]
83
+ common_jwk_keys = access_token.get(COMMON_JWKS_URL).parsed[:keys]
84
+ JWT::JWK::Set.new(oidc_config_jwk_keys + common_jwk_keys)
85
+ end
86
+
87
+ def verification_error_message
88
+ <<~MSG
89
+ The email domain '#{email_domain}' is not a verified domain for this Azure AD account.
90
+ You can either:
91
+ * Update the user's email to match the principal domain '#{upn_domain}'
92
+ * Skip verification on the '#{email_domain}' domain (not recommended)
93
+ * Disable verification with `skip_domain_verification: true` (NOT RECOMMENDED!)
94
+ Refer to the README for more details.
95
+ MSG
96
+ end
97
+ end
98
+ end
99
+ end
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module MicrosoftGraph
3
- VERSION = "1.2.0"
3
+ VERSION = "2.0.1"
4
4
  end
5
5
  end
@@ -1,2 +1,3 @@
1
+ require "omniauth/microsoft_graph/domain_verifier"
1
2
  require "omniauth/microsoft_graph/version"
2
3
  require "omniauth/strategies/microsoft_graph"
@@ -22,6 +22,7 @@ module OmniAuth
22
22
 
23
23
  option :scope, DEFAULT_SCOPE
24
24
  option :authorized_client_ids, []
25
+ option :skip_domain_verification, false
25
26
 
26
27
  uid { raw_info["id"] }
27
28
 
@@ -43,6 +44,12 @@ module OmniAuth
43
44
  }
44
45
  end
45
46
 
47
+ def auth_hash
48
+ super.tap do |ah|
49
+ verify_email(ah, access_token)
50
+ end
51
+ end
52
+
46
53
  def authorize_params
47
54
  super.tap do |params|
48
55
  options[:authorize_options].each do |k|
@@ -54,7 +61,7 @@ module OmniAuth
54
61
 
55
62
  session['omniauth.state'] = params[:state] if params[:state]
56
63
  end
57
- end
64
+ end
58
65
 
59
66
  def raw_info
60
67
  @raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me').parsed
@@ -62,7 +69,7 @@ module OmniAuth
62
69
 
63
70
  def callback_url
64
71
  options[:callback_url] || full_host + script_name + callback_path
65
- end
72
+ end
66
73
 
67
74
  def custom_build_access_token
68
75
  access_token = get_access_token(request)
@@ -119,7 +126,11 @@ module OmniAuth
119
126
  raw_response = client.request(:get, 'https://graph.microsoft.com/v1.0/me',
120
127
  params: { access_token: access_token }).parsed
121
128
  (raw_response['aud'] == options.client_id) || options.authorized_client_ids.include?(raw_response['aud'])
122
- end
129
+ end
130
+
131
+ def verify_email(auth_hash, access_token)
132
+ OmniAuth::MicrosoftGraph::DomainVerifier.verify!(auth_hash, access_token, options)
133
+ end
123
134
  end
124
135
  end
125
136
  end
@@ -18,9 +18,10 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
+ spec.add_runtime_dependency 'jwt', '~> 2.0'
21
22
  spec.add_runtime_dependency 'omniauth', '~> 2.0'
22
23
  spec.add_runtime_dependency 'omniauth-oauth2', '~> 1.8.0'
23
- spec.add_development_dependency "sinatra", '~> 0'
24
+ spec.add_development_dependency "sinatra", '~> 2.2'
24
25
  spec.add_development_dependency "rake", '~> 12.3.3', '>= 12.3.3'
25
26
  spec.add_development_dependency 'rspec', '~> 3.6'
26
27
  spec.add_development_dependency "mocha", '~> 0'
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'omniauth/microsoft_graph/domain_verifier'
5
+
6
+ RSpec.describe OmniAuth::MicrosoftGraph::DomainVerifier do
7
+ subject(:verifier) { described_class.new(auth_hash, access_token, options) }
8
+
9
+ let(:auth_hash) do
10
+ {
11
+ 'info' => { 'email' => email },
12
+ 'extra' => { 'raw_info' => { 'userPrincipalName' => upn } }
13
+ }
14
+ end
15
+ let(:email) { 'foo@example.com' }
16
+ let(:upn) { 'bar@hackerman.biz' }
17
+ let(:options) { { skip_domain_verification: false } }
18
+ let(:access_token) { double('OAuth2::AccessToken', params: { 'id_token' => id_token }) }
19
+ let(:id_token) { nil }
20
+
21
+ describe '#verify!' do
22
+ subject(:result) { verifier.verify! }
23
+
24
+ context 'when email domain and userPrincipalName domain match' do
25
+ let(:email) { 'foo@example.com' }
26
+ let(:upn) { 'bar@example.com' }
27
+
28
+ it { is_expected.to be_truthy }
29
+ end
30
+
31
+ context 'when domain validation is disabled' do
32
+ let(:options) { super().merge(skip_domain_verification: true) }
33
+
34
+ it { is_expected.to be_truthy }
35
+ end
36
+
37
+ context 'when the email domain is explicitly permitted' do
38
+ let(:options) { super().merge(skip_domain_verification: ['example.com']) }
39
+
40
+ it { is_expected.to be_truthy }
41
+ end
42
+
43
+ context 'when the ID token indicates domain verification' do
44
+ let(:mock_oidc_key) do
45
+ optional_parameters = { kid: 'mock_oidc_key', use: 'sig', alg: 'RS256' }
46
+ JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
47
+ end
48
+
49
+ let(:mock_common_key) do
50
+ optional_parameters = { kid: 'mock_common_key', use: 'sig', alg: 'RS256' }
51
+ JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
52
+ end
53
+
54
+ # Mock the API responses to return the mock keys
55
+ before do
56
+ allow(access_token).to receive(:get)
57
+ .with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL)
58
+ .and_return(
59
+ double(
60
+ 'OAuth2::Response',
61
+ parsed: {
62
+ 'id_token_signing_alg_values_supported' => ['RS256'],
63
+ 'jwks_uri' => 'https://example.com/jwks-keys',
64
+ }
65
+ )
66
+ )
67
+ allow(access_token).to receive(:get)
68
+ .with('https://example.com/jwks-keys')
69
+ .and_return(
70
+ double(
71
+ 'OAuth2::Response',
72
+ parsed: JWT::JWK::Set.new(mock_oidc_key).export
73
+ )
74
+ )
75
+ allow(access_token).to receive(:get)
76
+ .with(OmniAuth::MicrosoftGraph::COMMON_JWKS_URL)
77
+ .and_return(
78
+ double(
79
+ 'OAuth2::Response',
80
+ parsed: JWT::JWK::Set.new(mock_common_key).export,
81
+ body: JWT::JWK::Set.new(mock_common_key).export.to_json
82
+ )
83
+ )
84
+ end
85
+
86
+ context 'when the kid exists in the oidc key' do
87
+ let(:id_token) do
88
+ payload = { email: email, xms_edov: true }
89
+ JWT.encode(payload, mock_oidc_key.signing_key, mock_oidc_key[:alg], kid: mock_oidc_key[:kid])
90
+ end
91
+
92
+ it { is_expected.to be_truthy }
93
+ end
94
+
95
+ context "when the kid exists in the common key" do
96
+ let(:id_token) do
97
+ payload = { email: email, xms_edov: true }
98
+ JWT.encode(payload, mock_common_key.signing_key, mock_common_key[:alg], kid: mock_common_key[:kid])
99
+ end
100
+
101
+ it { is_expected.to be_truthy }
102
+ end
103
+ end
104
+
105
+ context 'when all verification strategies fail' do
106
+ before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) }
107
+
108
+ it 'raises a DomainVerificationError' do
109
+ expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError
110
+ end
111
+ end
112
+ end
113
+ end
@@ -280,6 +280,18 @@ describe OmniAuth::Strategies::MicrosoftGraph do
280
280
  end
281
281
  end
282
282
 
283
+ context 'when email verification fails' do
284
+ let(:response_hash) { { mail: 'something@domain.invalid' } }
285
+ let(:error) { OmniAuth::MicrosoftGraph::DomainVerificationError.new }
286
+
287
+ before do
288
+ allow(OmniAuth::MicrosoftGraph::DomainVerifier).to receive(:verify!).and_raise(error)
289
+ end
290
+
291
+ it 'raises an error' do
292
+ expect { subject.auth_hash }.to raise_error error
293
+ end
294
+ end
283
295
  end
284
296
 
285
297
  describe '#extra' do
@@ -445,5 +457,4 @@ describe OmniAuth::Strategies::MicrosoftGraph do
445
457
  end.to raise_error(OAuth2::Error)
446
458
  end
447
459
  end
448
-
449
460
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth-microsoft_graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Philips
@@ -9,8 +9,22 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-09-10 00:00:00.000000000 Z
12
+ date: 2024-06-02 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: jwt
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '2.0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '2.0'
14
28
  - !ruby/object:Gem::Dependency
15
29
  name: omniauth
16
30
  requirement: !ruby/object:Gem::Requirement
@@ -45,14 +59,14 @@ dependencies:
45
59
  requirements:
46
60
  - - "~>"
47
61
  - !ruby/object:Gem::Version
48
- version: '0'
62
+ version: '2.2'
49
63
  type: :development
50
64
  prerelease: false
51
65
  version_requirements: !ruby/object:Gem::Requirement
52
66
  requirements:
53
67
  - - "~>"
54
68
  - !ruby/object:Gem::Version
55
- version: '0'
69
+ version: '2.2'
56
70
  - !ruby/object:Gem::Dependency
57
71
  name: rake
58
72
  requirement: !ruby/object:Gem::Requirement
@@ -119,10 +133,12 @@ files:
119
133
  - Rakefile
120
134
  - example/example.rb
121
135
  - lib/omniauth/microsoft_graph.rb
136
+ - lib/omniauth/microsoft_graph/domain_verifier.rb
122
137
  - lib/omniauth/microsoft_graph/version.rb
123
138
  - lib/omniauth/strategies/microsoft_graph.rb
124
139
  - lib/omniauth_microsoft_graph.rb
125
140
  - omniauth-microsoft_graph.gemspec
141
+ - spec/omniauth/microsoft_graph/domain_verifier_spec.rb
126
142
  - spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb
127
143
  - spec/spec_helper.rb
128
144
  homepage: https://github.com/synth/omniauth-microsoft_graph
@@ -144,10 +160,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
160
  - !ruby/object:Gem::Version
145
161
  version: '0'
146
162
  requirements: []
147
- rubygems_version: 3.1.6
163
+ rubygems_version: 3.3.26
148
164
  signing_key:
149
165
  specification_version: 4
150
166
  summary: omniauth provider for Microsoft Graph
151
167
  test_files:
168
+ - spec/omniauth/microsoft_graph/domain_verifier_spec.rb
152
169
  - spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb
153
170
  - spec/spec_helper.rb