-
-
Notifications
You must be signed in to change notification settings - Fork 62
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #34 from dzunk/domain_verification
Add email domain verification
- Loading branch information
Showing
7 changed files
with
230 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,10 +32,43 @@ end | |
``` | ||
|
||
#### Login Hint | ||
Just add {login_hint: "[email protected]"} to your url generation to form: | ||
Just add `{login_hint: "[email protected]"}` to your url generation to form: | ||
```ruby | ||
/auth/microsoft_graph?login_hint=email@example.com | ||
``` | ||
|
||
#### Domain Verification | ||
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 [email protected] can edit their email in Active Directory to [email protected], and (depending on your auth implementation) may be able to log in automatically as that user. | ||
|
||
To establish membership in the claimed email domain, we use two strategies: | ||
|
||
* `email` domain matches `userPrincipalName` domain (which by definition is a verified domain) | ||
* The user's `id_token` includes the `xms_edov` ("Email Domain Ownership Verified") claim, with a truthy value | ||
|
||
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. | ||
|
||
If you're not able or don't need to support domain verification, you can bypass for an individual domain: | ||
```ruby | ||
Rails.application.config.middleware.use OmniAuth::Builder do | ||
provider :microsoft_graph, | ||
ENV['AZURE_APPLICATION_CLIENT_ID'], | ||
ENV['AZURE_APPLICATION_CLIENT_SECRET'], | ||
skip_domain_verification: %w[contoso.com] | ||
end | ||
``` | ||
|
||
Or, you can disable domain verification entirely. We *strongly recommend* that you do *not* disable domain verification if at all possible. | ||
```ruby | ||
Rails.application.config.middleware.use OmniAuth::Builder do | ||
provider :microsoft_graph, | ||
ENV['AZURE_APPLICATION_CLIENT_ID'], | ||
ENV['AZURE_APPLICATION_CLIENT_SECRET'], | ||
skip_domain_verification: true | ||
end | ||
``` | ||
|
||
[nOAuth: How Microsoft OAuth Misconfiguration Can Lead to Full Account Takeover](https://www.descope.com/blog/post/noauth) from [Descope](https://www.descope.com/) | ||
|
||
### Upgrading to 1.0.0 | ||
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` | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
require "omniauth/microsoft_graph/domain_verifier" | ||
require "omniauth/microsoft_graph/version" | ||
require "omniauth/strategies/microsoft_graph" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
# frozen_string_literal: true | ||
require 'jwt' # for token signature validation | ||
require 'omniauth' # to inherit from OmniAuth::Error | ||
require 'oauth2' # to rescue OAuth2::Error | ||
|
||
module OmniAuth | ||
module MicrosoftGraph | ||
# Verify user email domains to mitigate the nOAuth vulnerability | ||
# https://www.descope.com/blog/post/noauth | ||
# https://clerk.com/docs/authentication/social-connections/microsoft#stay-secure-against-the-n-o-auth-vulnerability | ||
OIDC_CONFIG_URL = 'https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration' | ||
|
||
class DomainVerificationError < OmniAuth::Error; end | ||
|
||
class DomainVerifier | ||
def self.verify!(auth_hash, access_token, options) | ||
new(auth_hash, access_token, options).verify! | ||
end | ||
|
||
def initialize(auth_hash, access_token, options) | ||
@email_domain = auth_hash['info']['email']&.split('@')&.last | ||
@upn_domain = auth_hash['extra']['raw_info']['userPrincipalName']&.split('@')&.last | ||
@access_token = access_token | ||
@id_token = access_token.params['id_token'] | ||
@skip_verification = options[:skip_domain_verification] | ||
end | ||
|
||
def verify! | ||
# The userPrincipalName property is mutable, but must always contain a | ||
# verified domain: | ||
# | ||
# "The general format is alias@domain, where domain must be present in | ||
# the tenant's collection of verified domains." | ||
# https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0 | ||
# | ||
# This means while it's not suitable for consistently identifying a user | ||
# (the domain might change), it is suitable for verifying membership in | ||
# a given domain. | ||
return true if email_domain == upn_domain || | ||
skip_verification == true || | ||
(skip_verification.is_a?(Array) && skip_verification.include?(email_domain)) || | ||
domain_verified_jwt_claim | ||
raise DomainVerificationError, verification_error_message | ||
end | ||
|
||
private | ||
|
||
attr_reader :access_token, | ||
:email_domain, | ||
:id_token, | ||
:permitted_domains, | ||
:skip_verification, | ||
:upn_domain | ||
|
||
# https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference | ||
# Microsoft offers an optional claim `xms_edov` that will indicate whether the | ||
# user's email domain is part of the organization's verified domains. This has to be | ||
# explicitly configured in the app registration. | ||
# | ||
# To get to it, we need to decode the ID token with the key material from Microsoft's | ||
# OIDC configuration endpoint, and inspect it for the claim in question. | ||
def domain_verified_jwt_claim | ||
oidc_config = access_token.get(OIDC_CONFIG_URL).parsed | ||
algorithms = oidc_config['id_token_signing_alg_values_supported'] | ||
keys = JWT::JWK::Set.new(access_token.get(oidc_config['jwks_uri']).parsed) | ||
decoded_token = JWT.decode(id_token, nil, true, algorithms: algorithms, jwks: keys) | ||
# https://github.com/MicrosoftDocs/azure-docs/issues/111425#issuecomment-1761043378 | ||
# Comments seemed to indicate the value is not consistent | ||
['1', 1, 'true', true].include?(decoded_token.first['xms_edov']) | ||
rescue JWT::VerificationError, ::OAuth2::Error | ||
false | ||
end | ||
|
||
def verification_error_message | ||
<<~MSG | ||
The email domain '#{email_domain}' is not a verified domain for this Azure AD account. | ||
You can either: | ||
* Update the user's email to match the principal domain '#{upn_domain}' | ||
* Skip verification on the '#{email_domain}' domain (not recommended) | ||
* Disable verification with `skip_domain_verification: true` (NOT RECOMMENDED!) | ||
Refer to the README for more details. | ||
MSG | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'spec_helper' | ||
require 'omniauth/microsoft_graph/domain_verifier' | ||
|
||
RSpec.describe OmniAuth::MicrosoftGraph::DomainVerifier do | ||
subject(:verifier) { described_class.new(auth_hash, access_token, options) } | ||
|
||
let(:auth_hash) do | ||
{ | ||
'info' => { 'email' => email }, | ||
'extra' => { 'raw_info' => { 'userPrincipalName' => upn } } | ||
} | ||
end | ||
let(:email) { '[email protected]' } | ||
let(:upn) { '[email protected]' } | ||
let(:options) { { skip_domain_verification: false } } | ||
let(:access_token) { double('OAuth2::AccessToken', params: { 'id_token' => id_token }) } | ||
let(:id_token) { nil } | ||
|
||
describe '#verify!' do | ||
subject(:result) { verifier.verify! } | ||
|
||
context 'when email domain and userPrincipalName domain match' do | ||
let(:email) { '[email protected]' } | ||
let(:upn) { '[email protected]' } | ||
|
||
it { is_expected.to be_truthy } | ||
end | ||
|
||
context 'when domain validation is disabled' do | ||
let(:options) { super().merge(skip_domain_verification: true) } | ||
|
||
it { is_expected.to be_truthy } | ||
end | ||
|
||
context 'when the email domain is explicitly permitted' do | ||
let(:options) { super().merge(skip_domain_verification: ['example.com']) } | ||
|
||
it { is_expected.to be_truthy } | ||
end | ||
|
||
context 'when the ID token indicates domain verification' do | ||
# Sign a fake ID token with our own local key | ||
let(:mock_key) do | ||
optional_parameters = { kid: 'mock-kid', use: 'sig', alg: 'RS256' } | ||
JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters) | ||
end | ||
let(:id_token) do | ||
payload = { email: email, xms_edov: true } | ||
JWT.encode(payload, mock_key.signing_key, mock_key[:alg], kid: mock_key[:kid]) | ||
end | ||
|
||
# Mock the API responses to return the local key | ||
before do | ||
allow(access_token).to receive(:get) | ||
.with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL) | ||
.and_return( | ||
double('OAuth2::Response', parsed: { | ||
'id_token_signing_alg_values_supported' => ['RS256'], | ||
'jwks_uri' => 'https://example.com/jwks-keys' | ||
}) | ||
) | ||
allow(access_token).to receive(:get) | ||
.with('https://example.com/jwks-keys') | ||
.and_return( | ||
double('OAuth2::Response', parsed: JWT::JWK::Set.new(mock_key).export) | ||
) | ||
end | ||
|
||
it { is_expected.to be_truthy } | ||
end | ||
|
||
context 'when all verification strategies fail' do | ||
before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) } | ||
|
||
it 'raises a DomainVerificationError' do | ||
expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -280,6 +280,18 @@ | |
end | ||
end | ||
|
||
context 'when email verification fails' do | ||
let(:response_hash) { { mail: '[email protected]' } } | ||
let(:error) { OmniAuth::MicrosoftGraph::DomainVerificationError.new } | ||
|
||
before do | ||
allow(OmniAuth::MicrosoftGraph::DomainVerifier).to receive(:verify!).and_raise(error) | ||
end | ||
|
||
it 'raises an error' do | ||
expect { subject.auth_hash }.to raise_error error | ||
end | ||
end | ||
end | ||
|
||
describe '#extra' do | ||
|
@@ -445,5 +457,4 @@ | |
end.to raise_error(OAuth2::Error) | ||
end | ||
end | ||
|
||
end |