omniauth-microsoft_graph 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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