Skip to content

Commit 5ffd626

Browse files
authored
Merge pull request #34 from dzunk/domain_verification
Add email domain verification
2 parents d52752a + f132078 commit 5ffd626

File tree

7 files changed

+230
-5
lines changed

7 files changed

+230
-5
lines changed

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,43 @@ end
3232
```
3333

3434
#### Login Hint
35-
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:
3636
```ruby
3737
/auth/microsoft_graph?login_hint=email@example.com
3838
```
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+
3972
### Upgrading to 1.0.0
4073
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`
4174

lib/omniauth/microsoft_graph.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
require "omniauth/microsoft_graph/domain_verifier"
12
require "omniauth/microsoft_graph/version"
23
require "omniauth/strategies/microsoft_graph"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/omniauth/strategies/microsoft_graph.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class MicrosoftGraph < OmniAuth::Strategies::OAuth2
2222

2323
option :scope, DEFAULT_SCOPE
2424
option :authorized_client_ids, []
25+
option :skip_domain_verification, false
2526

2627
uid { raw_info["id"] }
2728

@@ -43,6 +44,12 @@ class MicrosoftGraph < OmniAuth::Strategies::OAuth2
4344
}
4445
end
4546

47+
def auth_hash
48+
super.tap do |ah|
49+
verify_email(ah, access_token)
50+
end
51+
end
52+
4653
def authorize_params
4754
super.tap do |params|
4855
options[:authorize_options].each do |k|
@@ -54,15 +61,15 @@ def authorize_params
5461

5562
session['omniauth.state'] = params[:state] if params[:state]
5663
end
57-
end
64+
end
5865

5966
def raw_info
6067
@raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me').parsed
6168
end
6269

6370
def callback_url
6471
options[:callback_url] || full_host + script_name + callback_path
65-
end
72+
end
6673

6774
def custom_build_access_token
6875
access_token = get_access_token(request)
@@ -119,7 +126,11 @@ def verify_token(access_token)
119126
raw_response = client.request(:get, 'https://graph.microsoft.com/v1.0/me',
120127
params: { access_token: access_token }).parsed
121128
(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
123134
end
124135
end
125136
end

omniauth-microsoft_graph.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
1818
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
1919
spec.require_paths = ["lib"]
2020

21+
spec.add_runtime_dependency 'jwt', '>= 2.0'
2122
spec.add_runtime_dependency 'omniauth', '~> 2.0'
2223
spec.add_runtime_dependency 'omniauth-oauth2', '~> 1.8.0'
2324
spec.add_development_dependency "sinatra", '~> 0'
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,18 @@
280280
end
281281
end
282282

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
283295
end
284296

285297
describe '#extra' do
@@ -445,5 +457,4 @@
445457
end.to raise_error(OAuth2::Error)
446458
end
447459
end
448-
449460
end

0 commit comments

Comments
 (0)