omniauth-microsoft_graph 1.2.0 → 2.0.0

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: 340d76dc549fc5e3710217599a247901f775a1ae26a4ea62eb3916928bc6afac
4
+ data.tar.gz: 6319931d11bb4ff224c573a2fc16633b771cbd407115c1cca644d0226c05baae
5
5
  SHA512:
6
- metadata.gz: b2be452a85ef752c0c0b0b30a0338cbe867dbd3e29079001ebf3ae5e7377ca0fbd3c8ef8a0fb61461c507b99c9692a8aefb9ecf4c556bcd2f7f9e4e97125a56b
7
- data.tar.gz: b77352003e95997a8993c098f894df4c3ea846c42fd0f18743cb933fe5417db3d631934ac073d2424cb7f8491407927fc83af5cfb77962b7d6a1305839bbed28
6
+ metadata.gz: 8bc6c22cf81b3996abe32d83f0e13ca88a156e69f53465e2421fe93f96193499743d37ffe9147ded38b877964aec637314ffc178839d06c5b52330ef46010984
7
+ data.tar.gz: 62acd37d43f7b2e79171c328898a696c0f4c4d0583dfd817a0be54544e2b3adfd7239f9128a919b742b7131ba68ca74670059ece7e3c57424f834b6817173e70
@@ -17,7 +17,7 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
  strategy:
19
19
  matrix:
20
- ruby-version: ['2.6', '2.7', '3.0']
20
+ ruby-version: ['3.0']
21
21
 
22
22
  steps:
23
23
  - uses: actions/checkout@v2
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,86 @@
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
+
13
+ class DomainVerificationError < OmniAuth::Error; end
14
+
15
+ class DomainVerifier
16
+ def self.verify!(auth_hash, access_token, options)
17
+ new(auth_hash, access_token, options).verify!
18
+ end
19
+
20
+ def initialize(auth_hash, access_token, options)
21
+ @email_domain = auth_hash['info']['email']&.split('@')&.last
22
+ @upn_domain = auth_hash['extra']['raw_info']['userPrincipalName']&.split('@')&.last
23
+ @access_token = access_token
24
+ @id_token = access_token.params['id_token']
25
+ @skip_verification = options[:skip_domain_verification]
26
+ end
27
+
28
+ def verify!
29
+ # The userPrincipalName property is mutable, but must always contain a
30
+ # verified domain:
31
+ #
32
+ # "The general format is alias@domain, where domain must be present in
33
+ # the tenant's collection of verified domains."
34
+ # https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0
35
+ #
36
+ # This means while it's not suitable for consistently identifying a user
37
+ # (the domain might change), it is suitable for verifying membership in
38
+ # a given domain.
39
+ return true if email_domain == upn_domain ||
40
+ skip_verification == true ||
41
+ (skip_verification.is_a?(Array) && skip_verification.include?(email_domain)) ||
42
+ domain_verified_jwt_claim
43
+ raise DomainVerificationError, verification_error_message
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :access_token,
49
+ :email_domain,
50
+ :id_token,
51
+ :permitted_domains,
52
+ :skip_verification,
53
+ :upn_domain
54
+
55
+ # https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference
56
+ # Microsoft offers an optional claim `xms_edov` that will indicate whether the
57
+ # user's email domain is part of the organization's verified domains. This has to be
58
+ # explicitly configured in the app registration.
59
+ #
60
+ # To get to it, we need to decode the ID token with the key material from Microsoft's
61
+ # OIDC configuration endpoint, and inspect it for the claim in question.
62
+ def domain_verified_jwt_claim
63
+ oidc_config = access_token.get(OIDC_CONFIG_URL).parsed
64
+ algorithms = oidc_config['id_token_signing_alg_values_supported']
65
+ keys = JWT::JWK::Set.new(access_token.get(oidc_config['jwks_uri']).parsed)
66
+ decoded_token = JWT.decode(id_token, nil, true, algorithms: algorithms, jwks: keys)
67
+ # https://github.com/MicrosoftDocs/azure-docs/issues/111425#issuecomment-1761043378
68
+ # Comments seemed to indicate the value is not consistent
69
+ ['1', 1, 'true', true].include?(decoded_token.first['xms_edov'])
70
+ rescue JWT::VerificationError, ::OAuth2::Error
71
+ false
72
+ end
73
+
74
+ def verification_error_message
75
+ <<~MSG
76
+ The email domain '#{email_domain}' is not a verified domain for this Azure AD account.
77
+ You can either:
78
+ * Update the user's email to match the principal domain '#{upn_domain}'
79
+ * Skip verification on the '#{email_domain}' domain (not recommended)
80
+ * Disable verification with `skip_domain_verification: true` (NOT RECOMMENDED!)
81
+ Refer to the README for more details.
82
+ MSG
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module MicrosoftGraph
3
- VERSION = "1.2.0"
3
+ VERSION = "2.0.0"
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,6 +18,7 @@ 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
24
  spec.add_development_dependency "sinatra", '~> 0'
@@ -0,0 +1,82 @@
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
+ # Sign a fake ID token with our own local key
45
+ let(:mock_key) do
46
+ optional_parameters = { kid: 'mock-kid', use: 'sig', alg: 'RS256' }
47
+ JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
48
+ end
49
+ let(:id_token) do
50
+ payload = { email: email, xms_edov: true }
51
+ JWT.encode(payload, mock_key.signing_key, mock_key[:alg], kid: mock_key[:kid])
52
+ end
53
+
54
+ # Mock the API responses to return the local key
55
+ before do
56
+ allow(access_token).to receive(:get)
57
+ .with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL)
58
+ .and_return(
59
+ double('OAuth2::Response', parsed: {
60
+ 'id_token_signing_alg_values_supported' => ['RS256'],
61
+ 'jwks_uri' => 'https://example.com/jwks-keys'
62
+ })
63
+ )
64
+ allow(access_token).to receive(:get)
65
+ .with('https://example.com/jwks-keys')
66
+ .and_return(
67
+ double('OAuth2::Response', parsed: JWT::JWK::Set.new(mock_key).export)
68
+ )
69
+ end
70
+
71
+ it { is_expected.to be_truthy }
72
+ end
73
+
74
+ context 'when all verification strategies fail' do
75
+ before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) }
76
+
77
+ it 'raises a DomainVerificationError' do
78
+ expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError
79
+ end
80
+ end
81
+ end
82
+ 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.0
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: 2023-12-30 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
@@ -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.4.22
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