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 +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
|