omniauth-microsoft_graph 0.2.1 → 2.2.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 +5 -5
- data/.github/FUNDING.yml +3 -0
- data/.github/workflows/ruby.yml +32 -0
- data/.travis.yml +2 -1
- data/README.md +46 -1
- data/Rakefile +5 -7
- data/lib/omniauth/microsoft_graph/domain_verifier.rb +99 -0
- data/lib/omniauth/microsoft_graph/version.rb +2 -2
- data/lib/omniauth/microsoft_graph.rb +1 -0
- data/lib/omniauth/strategies/microsoft_graph.rb +114 -26
- data/omniauth-microsoft_graph.gemspec +8 -7
- data/spec/omniauth/microsoft_graph/domain_verifier_spec.rb +120 -0
- data/spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb +538 -0
- data/spec/spec_helper.rb +4 -0
- metadata +71 -27
- data/test/strategy_test.rb +0 -26
- data/test/test_helper.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e1ace889e4805d8c7a520ec058b237e48ce6fc69653daa869afb3b2871cff7fe
|
|
4
|
+
data.tar.gz: b515974d6ecf60e4fd6450b89b6de6046db45c7e0523992b76d06165dc489b47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eeb19611338cc3034db9cd094e7ca179f6b45da19c5b84991cbce2deb2a5ef2feb71b0445fc051af6c32e0eaa5e1b62b72a05ced3ee57b0cf4880dce6731e6fb
|
|
7
|
+
data.tar.gz: 8903764632bf5de5064d2652f32f6f802b8ab0cdc4c56284f3faf6ba3b2865ed538c3665b3218d9a3be7af7fb50a51a6560f174380905cfdde84e1bac57c2845
|
data/.github/FUNDING.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# This workflow uses actions that are not certified by GitHub.
|
|
2
|
+
# They are provided by a third-party and are governed by
|
|
3
|
+
# separate terms of service, privacy policy, and support
|
|
4
|
+
# documentation.
|
|
5
|
+
# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
|
|
6
|
+
# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
|
|
7
|
+
|
|
8
|
+
name: Ruby
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
pull_request:
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
|
|
16
|
+
strategy:
|
|
17
|
+
matrix:
|
|
18
|
+
os: [ubuntu-latest, macos-latest]
|
|
19
|
+
ruby-version: ['3.0', '3.1', '3.2', '3.3']
|
|
20
|
+
runs-on: ${{ matrix.os }}
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v2
|
|
24
|
+
- name: Set up Ruby
|
|
25
|
+
# To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
|
|
26
|
+
# change this to (see https://github.com/ruby/setup-ruby#versioning):
|
|
27
|
+
uses: ruby/setup-ruby@v1
|
|
28
|
+
with:
|
|
29
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
30
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
31
|
+
- name: Run tests
|
|
32
|
+
run: bundle exec rake
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# Omniauth::MicrosoftGraph
|
|
1
|
+
# Omniauth::MicrosoftGraph 
|
|
2
|
+
|
|
2
3
|
|
|
3
4
|
Microsoft Graph OAuth2 Strategy for OmniAuth.
|
|
4
5
|
Can be used to authenticate with Office365 or other MS services, and get a token for the Microsoft Graph Api, formerly the Office365 Unified Api.
|
|
@@ -21,12 +22,56 @@ Or install it yourself as:
|
|
|
21
22
|
|
|
22
23
|
## Usage
|
|
23
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
|
+
|
|
27
|
+
#### Configuration
|
|
24
28
|
```ruby
|
|
25
29
|
Rails.application.config.middleware.use OmniAuth::Builder do
|
|
26
30
|
provider :microsoft_graph, ENV['AZURE_APPLICATION_CLIENT_ID'], ENV['AZURE_APPLICATION_CLIENT_SECRET']
|
|
27
31
|
end
|
|
28
32
|
```
|
|
29
33
|
|
|
34
|
+
#### Login Hint
|
|
35
|
+
Just add `{login_hint: "email@example.com"}` to your url generation to form:
|
|
36
|
+
```ruby
|
|
37
|
+
/auth/microsoft_graph?login_hint=email@example.com
|
|
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
|
+
|
|
72
|
+
### Upgrading to 1.0.0
|
|
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`
|
|
74
|
+
|
|
30
75
|
## Contributing
|
|
31
76
|
|
|
32
77
|
1. Fork it ( https://github.com/synth/omniauth-microsoft_graph/fork )
|
data/Rakefile
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
require "rake/testtask"
|
|
1
|
+
# frozen_string_literal: true
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
t.test_files = FileList['test/*_test.rb']
|
|
7
|
-
end
|
|
3
|
+
require File.join('bundler', 'gem_tasks')
|
|
4
|
+
require File.join('rspec', 'core', 'rake_task')
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
10
7
|
|
|
8
|
+
task default: :spec
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
COMMON_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
|
|
13
|
+
|
|
14
|
+
class DomainVerificationError < OmniAuth::Error; end
|
|
15
|
+
|
|
16
|
+
class DomainVerifier
|
|
17
|
+
def self.verify!(auth_hash, access_token, options)
|
|
18
|
+
new(auth_hash, access_token, options).verify!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(auth_hash, access_token, options)
|
|
22
|
+
@email_domain = auth_hash['info']['email']&.split('@')&.last
|
|
23
|
+
@upn_domain = auth_hash['extra']['raw_info']['userPrincipalName']&.split('@')&.last
|
|
24
|
+
@access_token = access_token
|
|
25
|
+
@id_token = access_token.params['id_token']
|
|
26
|
+
@skip_verification = options[:skip_domain_verification]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def verify!
|
|
30
|
+
# The userPrincipalName property is mutable, but must always contain a
|
|
31
|
+
# verified domain:
|
|
32
|
+
#
|
|
33
|
+
# "The general format is alias@domain, where domain must be present in
|
|
34
|
+
# the tenant's collection of verified domains."
|
|
35
|
+
# https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0
|
|
36
|
+
#
|
|
37
|
+
# This means while it's not suitable for consistently identifying a user
|
|
38
|
+
# (the domain might change), it is suitable for verifying membership in
|
|
39
|
+
# a given domain.
|
|
40
|
+
return true if email_domain.casecmp?(upn_domain) ||
|
|
41
|
+
skip_verification == true ||
|
|
42
|
+
(skip_verification.is_a?(Array) && skip_verification.include?(email_domain)) ||
|
|
43
|
+
domain_verified_jwt_claim
|
|
44
|
+
raise DomainVerificationError, verification_error_message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :access_token,
|
|
50
|
+
:email_domain,
|
|
51
|
+
:id_token,
|
|
52
|
+
:permitted_domains,
|
|
53
|
+
:skip_verification,
|
|
54
|
+
:upn_domain
|
|
55
|
+
|
|
56
|
+
# https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference
|
|
57
|
+
# Microsoft offers an optional claim `xms_edov` that will indicate whether the
|
|
58
|
+
# user's email domain is part of the organization's verified domains. This has to be
|
|
59
|
+
# explicitly configured in the app registration.
|
|
60
|
+
#
|
|
61
|
+
# To get to it, we need to decode the ID token with the key material from Microsoft's
|
|
62
|
+
# OIDC configuration endpoint, and inspect it for the claim in question.
|
|
63
|
+
def domain_verified_jwt_claim
|
|
64
|
+
oidc_config = access_token.get(OIDC_CONFIG_URL).parsed
|
|
65
|
+
algorithms = oidc_config['id_token_signing_alg_values_supported']
|
|
66
|
+
jwks = get_jwks(oidc_config)
|
|
67
|
+
decoded_token = JWT.decode(id_token, nil, true, algorithms: algorithms, jwks: jwks)
|
|
68
|
+
xms_edov_valid?(decoded_token)
|
|
69
|
+
rescue JWT::VerificationError, ::OAuth2::Error
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def xms_edov_valid?(decoded_token)
|
|
74
|
+
# https://github.com/MicrosoftDocs/azure-docs/issues/111425#issuecomment-1761043378
|
|
75
|
+
# Comments seemed to indicate the value is not consistent
|
|
76
|
+
['1', 1, 'true', true].include?(decoded_token.first['xms_edov'])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get_jwks(oidc_config)
|
|
80
|
+
# Depending on the tenant, the JWKS endpoint might be different. We need to
|
|
81
|
+
# consider both the JWKS from the OIDC configuration and the common JWKS endpoint.
|
|
82
|
+
oidc_config_jwk_keys = access_token.get(oidc_config['jwks_uri']).parsed[:keys]
|
|
83
|
+
common_jwk_keys = access_token.get(COMMON_JWKS_URL).parsed[:keys]
|
|
84
|
+
JWT::JWK::Set.new(oidc_config_jwk_keys + common_jwk_keys)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def verification_error_message
|
|
88
|
+
<<~MSG
|
|
89
|
+
The email domain '#{email_domain}' is not a verified domain for this Azure AD account.
|
|
90
|
+
You can either:
|
|
91
|
+
* Update the user's email to match the principal domain '#{upn_domain}'
|
|
92
|
+
* Skip verification on the '#{email_domain}' domain (not recommended)
|
|
93
|
+
* Disable verification with `skip_domain_verification: true` (NOT RECOMMENDED!)
|
|
94
|
+
Refer to the README for more details.
|
|
95
|
+
MSG
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -3,64 +3,152 @@ require 'omniauth-oauth2'
|
|
|
3
3
|
module OmniAuth
|
|
4
4
|
module Strategies
|
|
5
5
|
class MicrosoftGraph < OmniAuth::Strategies::OAuth2
|
|
6
|
+
BASE_SCOPE_URL = 'https://graph.microsoft.com/'
|
|
7
|
+
BASE_SCOPES = %w[offline_access openid email profile].freeze
|
|
8
|
+
DEFAULT_SCOPE = 'offline_access openid email profile User.Read'.freeze
|
|
9
|
+
YAMMER_PROFILE_URL = 'https://www.yammer.com/api/v1/users/current.json'
|
|
10
|
+
MICROSOFT_GRAPH_PROFILE_URL = 'https://graph.microsoft.com/v1.0/me'
|
|
11
|
+
|
|
6
12
|
option :name, :microsoft_graph
|
|
7
13
|
|
|
8
14
|
option :client_options, {
|
|
9
|
-
site: 'https://login.microsoftonline.com',
|
|
10
|
-
token_url: '
|
|
11
|
-
authorize_url: '
|
|
15
|
+
site: 'https://login.microsoftonline.com/',
|
|
16
|
+
token_url: 'common/oauth2/v2.0/token',
|
|
17
|
+
authorize_url: 'common/oauth2/v2.0/authorize'
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
option :authorize_options, %i[display score auth_type scope prompt login_hint domain_hint response_mode]
|
|
20
|
+
option :authorize_options, %i[state callback_url access_type display score auth_type scope prompt login_hint domain_hint response_mode]
|
|
21
|
+
|
|
22
|
+
option :token_params, {
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
option :scope, DEFAULT_SCOPE
|
|
26
|
+
option :authorized_client_ids, []
|
|
27
|
+
option :skip_domain_verification, false
|
|
15
28
|
|
|
16
29
|
uid { raw_info["id"] }
|
|
17
30
|
|
|
18
31
|
info do
|
|
19
32
|
{
|
|
20
|
-
email
|
|
21
|
-
first_name
|
|
22
|
-
last_name
|
|
23
|
-
name
|
|
24
|
-
nickname
|
|
33
|
+
'email' => raw_info["mail"],
|
|
34
|
+
'first_name' => raw_info["givenName"],
|
|
35
|
+
'last_name' => raw_info["surname"],
|
|
36
|
+
'name' => [raw_info["givenName"], raw_info["surname"]].join(' '),
|
|
37
|
+
'nickname' => raw_info["displayName"],
|
|
25
38
|
}
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
extra do
|
|
29
42
|
{
|
|
30
43
|
'raw_info' => raw_info,
|
|
31
|
-
'params' => access_token.params
|
|
44
|
+
'params' => access_token.params,
|
|
45
|
+
'aud' => options.client_id
|
|
32
46
|
}
|
|
33
47
|
end
|
|
34
|
-
|
|
35
|
-
def callback_url
|
|
36
|
-
options[:redirect_uri] || (full_host + script_name + callback_path)
|
|
37
|
-
end
|
|
38
48
|
|
|
39
|
-
def
|
|
40
|
-
|
|
49
|
+
def auth_hash
|
|
50
|
+
super.tap do |ah|
|
|
51
|
+
verify_email(ah, access_token)
|
|
52
|
+
end
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def authorize_params
|
|
44
56
|
super.tap do |params|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
params[v.to_sym] = request.params[v]
|
|
48
|
-
end
|
|
57
|
+
options[:authorize_options].each do |k|
|
|
58
|
+
params[k] = request.params[k.to_s] unless [nil, ''].include?(request.params[k.to_s])
|
|
49
59
|
end
|
|
60
|
+
|
|
61
|
+
params[:scope] = get_scope(params)
|
|
62
|
+
params[:access_type] = 'offline' if params[:access_type].nil?
|
|
63
|
+
|
|
64
|
+
session['omniauth.state'] = params[:state] if params[:state]
|
|
50
65
|
end
|
|
51
66
|
end
|
|
52
67
|
|
|
53
|
-
def
|
|
54
|
-
raw_info
|
|
68
|
+
def raw_info
|
|
69
|
+
@raw_info ||= access_token.get(profile_endpoint).parsed
|
|
55
70
|
end
|
|
56
71
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
def callback_url
|
|
73
|
+
options[:callback_url] || full_host + script_name + callback_path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def custom_build_access_token
|
|
77
|
+
access_token = get_access_token(request)
|
|
78
|
+
# Get the profile(microsoft graph / yammer) endpoint choice based on returned bearer token
|
|
79
|
+
@profile_endpoint = determine_profile_endpoint(request)
|
|
80
|
+
access_token
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
alias build_access_token custom_build_access_token
|
|
84
|
+
|
|
85
|
+
def profile_endpoint
|
|
86
|
+
@profile_endpoint ||= MICROSOFT_GRAPH_PROFILE_URL
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def determine_profile_endpoint(request)
|
|
90
|
+
scope = request&.env&.dig('omniauth.params', 'scope')
|
|
91
|
+
|
|
92
|
+
if scope&.include?('yammer')
|
|
93
|
+
YAMMER_PROFILE_URL
|
|
60
94
|
else
|
|
61
|
-
|
|
95
|
+
MICROSOFT_GRAPH_PROFILE_URL
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def get_access_token(request)
|
|
102
|
+
verifier = request.params['code']
|
|
103
|
+
redirect_uri = request.params['redirect_uri'] || request.params['callback_url']
|
|
104
|
+
if verifier && request.xhr?
|
|
105
|
+
client_get_token(verifier, redirect_uri || '/auth/microsoft_graph/callback')
|
|
106
|
+
elsif verifier
|
|
107
|
+
client_get_token(verifier, redirect_uri || callback_url)
|
|
108
|
+
elsif verify_token(request.params['access_token'])
|
|
109
|
+
::OAuth2::AccessToken.from_hash(client, request.params.dup)
|
|
110
|
+
elsif request.content_type =~ /json/i
|
|
111
|
+
begin
|
|
112
|
+
body = JSON.parse(request.body.read)
|
|
113
|
+
request.body.rewind # rewind request body for downstream middlewares
|
|
114
|
+
verifier = body && body['code']
|
|
115
|
+
client_get_token(verifier, '/auth/microsoft_graph/callback') if verifier
|
|
116
|
+
rescue JSON::ParserError => e
|
|
117
|
+
warn "[omniauth microsoft_graph] JSON parse error=#{e}"
|
|
118
|
+
end
|
|
62
119
|
end
|
|
63
120
|
end
|
|
121
|
+
|
|
122
|
+
def client_get_token(verifier, redirect_uri)
|
|
123
|
+
client.auth_code.get_token(verifier, get_token_options(redirect_uri), get_token_params)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def get_token_params
|
|
127
|
+
deep_symbolize(options.auth_token_params || {})
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def get_token_options(redirect_uri = '')
|
|
131
|
+
{ redirect_uri: redirect_uri }.merge(token_params.to_hash(symbolize_keys: true))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get_scope(params)
|
|
135
|
+
raw_scope = params[:scope] || DEFAULT_SCOPE
|
|
136
|
+
scope_list = raw_scope.split(' ').map { |item| item.split(',') }.flatten
|
|
137
|
+
scope_list.map! { |s| s =~ %r{^https?://} || BASE_SCOPES.include?(s) ? s : "#{BASE_SCOPE_URL}#{s}" }
|
|
138
|
+
scope_list.join(' ')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def verify_token(access_token)
|
|
142
|
+
return false unless access_token
|
|
143
|
+
# access_token.get('https://graph.microsoft.com/v1.0/me').parsed
|
|
144
|
+
raw_response = client.request(:get, 'https://graph.microsoft.com/v1.0/me',
|
|
145
|
+
params: { access_token: access_token }).parsed
|
|
146
|
+
(raw_response['aud'] == options.client_id) || options.authorized_client_ids.include?(raw_response['aud'])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def verify_email(auth_hash, access_token)
|
|
150
|
+
OmniAuth::MicrosoftGraph::DomainVerifier.verify!(auth_hash, access_token, options)
|
|
151
|
+
end
|
|
64
152
|
end
|
|
65
153
|
end
|
|
66
154
|
end
|
|
@@ -5,7 +5,7 @@ require 'omniauth/microsoft_graph/version'
|
|
|
5
5
|
|
|
6
6
|
Gem::Specification.new do |spec|
|
|
7
7
|
spec.name = "omniauth-microsoft_graph"
|
|
8
|
-
spec.version =
|
|
8
|
+
spec.version = OmniAuth::MicrosoftGraph::VERSION
|
|
9
9
|
spec.authors = ["Peter Philips", "Joel Van Horn"]
|
|
10
10
|
spec.email = ["pete@p373.net", "joel@joelvanhorn.com"]
|
|
11
11
|
spec.summary = %q{omniauth provider for Microsoft Graph}
|
|
@@ -18,10 +18,11 @@ 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
|
|
22
|
-
|
|
23
|
-
spec.
|
|
24
|
-
spec.add_development_dependency "
|
|
25
|
-
spec.add_development_dependency "
|
|
26
|
-
spec.add_development_dependency
|
|
21
|
+
spec.add_runtime_dependency 'jwt', '>= 2.0', '< 4.0'
|
|
22
|
+
spec.add_runtime_dependency 'omniauth', '~> 2.0'
|
|
23
|
+
spec.add_runtime_dependency 'omniauth-oauth2', '~> 1.8.0'
|
|
24
|
+
spec.add_development_dependency "sinatra", '~> 4.1'
|
|
25
|
+
spec.add_development_dependency "rake", '~> 12.3.3', '>= 12.3.3'
|
|
26
|
+
spec.add_development_dependency 'rspec', '~> 3.6'
|
|
27
|
+
spec.add_development_dependency "mocha", '~> 0'
|
|
27
28
|
end
|
|
@@ -0,0 +1,120 @@
|
|
|
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 email domain and userPrincipalName domain match but have different casing' do
|
|
32
|
+
let(:email) { 'foo@example.com' }
|
|
33
|
+
let(:upn) { 'bar@EXAMPLE.COM' }
|
|
34
|
+
|
|
35
|
+
it { is_expected.to be_truthy }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
context 'when domain validation is disabled' do
|
|
39
|
+
let(:options) { super().merge(skip_domain_verification: true) }
|
|
40
|
+
|
|
41
|
+
it { is_expected.to be_truthy }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
context 'when the email domain is explicitly permitted' do
|
|
45
|
+
let(:options) { super().merge(skip_domain_verification: ['example.com']) }
|
|
46
|
+
|
|
47
|
+
it { is_expected.to be_truthy }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'when the ID token indicates domain verification' do
|
|
51
|
+
let(:mock_oidc_key) do
|
|
52
|
+
optional_parameters = { kid: 'mock_oidc_key', use: 'sig', alg: 'RS256' }
|
|
53
|
+
JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
let(:mock_common_key) do
|
|
57
|
+
optional_parameters = { kid: 'mock_common_key', use: 'sig', alg: 'RS256' }
|
|
58
|
+
JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Mock the API responses to return the mock keys
|
|
62
|
+
before do
|
|
63
|
+
allow(access_token).to receive(:get)
|
|
64
|
+
.with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL)
|
|
65
|
+
.and_return(
|
|
66
|
+
double(
|
|
67
|
+
'OAuth2::Response',
|
|
68
|
+
parsed: {
|
|
69
|
+
'id_token_signing_alg_values_supported' => ['RS256'],
|
|
70
|
+
'jwks_uri' => 'https://example.com/jwks-keys',
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
allow(access_token).to receive(:get)
|
|
75
|
+
.with('https://example.com/jwks-keys')
|
|
76
|
+
.and_return(
|
|
77
|
+
double(
|
|
78
|
+
'OAuth2::Response',
|
|
79
|
+
parsed: JWT::JWK::Set.new(mock_oidc_key).export
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
allow(access_token).to receive(:get)
|
|
83
|
+
.with(OmniAuth::MicrosoftGraph::COMMON_JWKS_URL)
|
|
84
|
+
.and_return(
|
|
85
|
+
double(
|
|
86
|
+
'OAuth2::Response',
|
|
87
|
+
parsed: JWT::JWK::Set.new(mock_common_key).export,
|
|
88
|
+
body: JWT::JWK::Set.new(mock_common_key).export.to_json
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context 'when the kid exists in the oidc key' do
|
|
94
|
+
let(:id_token) do
|
|
95
|
+
payload = { email: email, xms_edov: true }
|
|
96
|
+
JWT.encode(payload, mock_oidc_key.signing_key, mock_oidc_key[:alg], kid: mock_oidc_key[:kid])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it { is_expected.to be_truthy }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
context "when the kid exists in the common key" do
|
|
103
|
+
let(:id_token) do
|
|
104
|
+
payload = { email: email, xms_edov: true }
|
|
105
|
+
JWT.encode(payload, mock_common_key.signing_key, mock_common_key[:alg], kid: mock_common_key[:kid])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it { is_expected.to be_truthy }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
context 'when all verification strategies fail' do
|
|
113
|
+
before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) }
|
|
114
|
+
|
|
115
|
+
it 'raises a DomainVerificationError' do
|
|
116
|
+
expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|