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 +4 -4
- data/.github/workflows/ruby.yml +1 -1
- data/README.md +36 -1
- data/lib/omniauth/microsoft_graph/domain_verifier.rb +86 -0
- data/lib/omniauth/microsoft_graph/version.rb +1 -1
- data/lib/omniauth/microsoft_graph.rb +1 -0
- data/lib/omniauth/strategies/microsoft_graph.rb +14 -3
- data/omniauth-microsoft_graph.gemspec +1 -0
- data/spec/omniauth/microsoft_graph/domain_verifier_spec.rb +82 -0
- data/spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb +12 -1
- metadata +20 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 340d76dc549fc5e3710217599a247901f775a1ae26a4ea62eb3916928bc6afac
|
4
|
+
data.tar.gz: 6319931d11bb4ff224c573a2fc16633b771cbd407115c1cca644d0226c05baae
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8bc6c22cf81b3996abe32d83f0e13ca88a156e69f53465e2421fe93f96193499743d37ffe9147ded38b877964aec637314ffc178839d06c5b52330ef46010984
|
7
|
+
data.tar.gz: 62acd37d43f7b2e79171c328898a696c0f4c4d0583dfd817a0be54544e2b3adfd7239f9128a919b742b7131ba68ca74670059ece7e3c57424f834b6817173e70
|
data/.github/workflows/ruby.yml
CHANGED
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
|
@@ -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:
|
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:
|
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.
|
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
|