├── lib ├── omniauth_microsoft_graph.rb └── omniauth │ ├── microsoft_graph │ ├── version.rb │ └── domain_verifier.rb │ ├── microsoft_graph.rb │ └── strategies │ └── microsoft_graph.rb ├── .travis.yml ├── spec ├── spec_helper.rb └── omniauth │ ├── microsoft_graph │ └── domain_verifier_spec.rb │ └── strategies │ └── microsoft_graph_oauth2_spec.rb ├── Gemfile ├── .github ├── FUNDING.yml └── workflows │ └── ruby.yml ├── Rakefile ├── CHANGELOG.md ├── example └── example.rb ├── LICENSE.txt ├── omniauth-microsoft_graph.gemspec └── README.md /lib/omniauth_microsoft_graph.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth/microsoft_graph' 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3 4 | - 2.6 5 | - jruby 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.join('bundler', 'setup') 4 | require 'rspec' 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in omniauth-office365.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/omniauth/microsoft_graph/version.rb: -------------------------------------------------------------------------------- 1 | module OmniAuth 2 | module MicrosoftGraph 3 | VERSION = "2.0.2" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [synth] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | -------------------------------------------------------------------------------- /lib/omniauth/microsoft_graph.rb: -------------------------------------------------------------------------------- 1 | require "omniauth/microsoft_graph/domain_verifier" 2 | require "omniauth/microsoft_graph/version" 3 | require "omniauth/strategies/microsoft_graph" 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.join('bundler', 'gem_tasks') 4 | require File.join('rspec', 'core', 'rake_task') 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### 0.1.0 - 2016-06-18 4 | 5 | * Converted omniauth-office365 to omniauth-microsoft_graph - big ups to [@simi](https://github.com/simi) 6 | 7 | ### 0.0.1 - 2015-05-25 8 | 9 | * initial version (big props to [@dayash](https://github.com/dayash)) 10 | 11 | -------------------------------------------------------------------------------- /example/example.rb: -------------------------------------------------------------------------------- 1 | $:.push File.dirname(__FILE__) + '/../lib' 2 | 3 | require 'microsoft_graph' 4 | require 'sinatra' 5 | require 'json' 6 | 7 | set :port, 4200 8 | 9 | client_id = ENV['AZURE_APPLICATION_CLIENT_ID'] 10 | secret = ENV['AZURE_APPLICATION_CLIENT_SECRET'] 11 | 12 | use Rack::Session::Cookie 13 | use OmniAuth::Builder do 14 | provider :microsoft_graph, client_id, secret 15 | end 16 | 17 | get '/' do 18 | "Log in with Microsoft" 19 | end 20 | 21 | get '/auth/microsoft_graph/callback' do 22 | content_type 'text/plain' 23 | request.env['omniauth.auth'].to_json 24 | end 25 | 26 | get '/auth/failure' do 27 | content_type 'text/plain' 28 | params.to_json 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Josef Šimánek 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 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 33 | -------------------------------------------------------------------------------- /omniauth-microsoft_graph.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'omniauth/microsoft_graph/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "omniauth-microsoft_graph" 8 | spec.version = OmniAuth::MicrosoftGraph::VERSION 9 | spec.authors = ["Peter Philips", "Joel Van Horn"] 10 | spec.email = ["pete@p373.net", "joel@joelvanhorn.com"] 11 | spec.summary = %q{omniauth provider for Microsoft Graph} 12 | spec.description = %q{omniauth provider for new Microsoft Graph API} 13 | spec.homepage = "https://github.com/synth/omniauth-microsoft_graph" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency 'jwt', '~> 2.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", '~> 2.2' 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' 28 | end 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omniauth::MicrosoftGraph ![ruby workflow](https://github.com/synth/omniauth-microsoft_graph/actions/workflows/ruby.yml/badge.svg) 2 | 3 | 4 | Microsoft Graph OAuth2 Strategy for OmniAuth. 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. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'omniauth-microsoft_graph' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install omniauth-microsoft_graph 22 | 23 | ## Usage 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 28 | ```ruby 29 | Rails.application.config.middleware.use OmniAuth::Builder do 30 | provider :microsoft_graph, ENV['AZURE_APPLICATION_CLIENT_ID'], ENV['AZURE_APPLICATION_CLIENT_SECRET'] 31 | end 32 | ``` 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 | 75 | ## Contributing 76 | 77 | 1. Fork it ( https://github.com/synth/omniauth-microsoft_graph/fork ) 78 | 2. Create your feature branch (`git checkout -b my-new-feature`) 79 | 3. Commit your changes (`git commit -am 'Add some feature'`) 80 | 4. Push to the branch (`git push origin my-new-feature`) 81 | 5. Create a new Pull Request 82 | -------------------------------------------------------------------------------- /spec/omniauth/microsoft_graph/domain_verifier_spec.rb: -------------------------------------------------------------------------------- 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 | let(:mock_oidc_key) do 45 | optional_parameters = { kid: 'mock_oidc_key', use: 'sig', alg: 'RS256' } 46 | JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters) 47 | end 48 | 49 | let(:mock_common_key) do 50 | optional_parameters = { kid: 'mock_common_key', use: 'sig', alg: 'RS256' } 51 | JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters) 52 | end 53 | 54 | # Mock the API responses to return the mock keys 55 | before do 56 | allow(access_token).to receive(:get) 57 | .with(OmniAuth::MicrosoftGraph::OIDC_CONFIG_URL) 58 | .and_return( 59 | double( 60 | 'OAuth2::Response', 61 | parsed: { 62 | 'id_token_signing_alg_values_supported' => ['RS256'], 63 | 'jwks_uri' => 'https://example.com/jwks-keys', 64 | } 65 | ) 66 | ) 67 | allow(access_token).to receive(:get) 68 | .with('https://example.com/jwks-keys') 69 | .and_return( 70 | double( 71 | 'OAuth2::Response', 72 | parsed: JWT::JWK::Set.new(mock_oidc_key).export 73 | ) 74 | ) 75 | allow(access_token).to receive(:get) 76 | .with(OmniAuth::MicrosoftGraph::COMMON_JWKS_URL) 77 | .and_return( 78 | double( 79 | 'OAuth2::Response', 80 | parsed: JWT::JWK::Set.new(mock_common_key).export, 81 | body: JWT::JWK::Set.new(mock_common_key).export.to_json 82 | ) 83 | ) 84 | end 85 | 86 | context 'when the kid exists in the oidc key' do 87 | let(:id_token) do 88 | payload = { email: email, xms_edov: true } 89 | JWT.encode(payload, mock_oidc_key.signing_key, mock_oidc_key[:alg], kid: mock_oidc_key[:kid]) 90 | end 91 | 92 | it { is_expected.to be_truthy } 93 | end 94 | 95 | context "when the kid exists in the common key" do 96 | let(:id_token) do 97 | payload = { email: email, xms_edov: true } 98 | JWT.encode(payload, mock_common_key.signing_key, mock_common_key[:alg], kid: mock_common_key[:kid]) 99 | end 100 | 101 | it { is_expected.to be_truthy } 102 | end 103 | end 104 | 105 | context 'when all verification strategies fail' do 106 | before { allow(access_token).to receive(:get).and_raise(::OAuth2::Error.new('whoops')) } 107 | 108 | it 'raises a DomainVerificationError' do 109 | expect { result }.to raise_error OmniAuth::MicrosoftGraph::DomainVerificationError 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/omniauth/microsoft_graph/domain_verifier.rb: -------------------------------------------------------------------------------- 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 == 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 100 | -------------------------------------------------------------------------------- /lib/omniauth/strategies/microsoft_graph.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth-oauth2' 2 | 3 | module OmniAuth 4 | module Strategies 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 | 12 | option :name, :microsoft_graph 13 | 14 | option :client_options, { 15 | site: 'https://login.microsoftonline.com/', 16 | token_url: 'common/oauth2/v2.0/token', 17 | authorize_url: 'common/oauth2/v2.0/authorize' 18 | } 19 | 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 28 | 29 | uid { raw_info["id"] } 30 | 31 | info do 32 | { 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"], 38 | } 39 | end 40 | 41 | extra do 42 | { 43 | 'raw_info' => raw_info, 44 | 'params' => access_token.params, 45 | 'aud' => options.client_id 46 | } 47 | end 48 | 49 | def auth_hash 50 | super.tap do |ah| 51 | verify_email(ah, access_token) 52 | end 53 | end 54 | 55 | def authorize_params 56 | super.tap do |params| 57 | options[:authorize_options].each do |k| 58 | params[k] = request.params[k.to_s] unless [nil, ''].include?(request.params[k.to_s]) 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] 65 | end 66 | end 67 | 68 | def raw_info 69 | @raw_info ||= access_token.get(profile_endpoint).parsed 70 | end 71 | 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 94 | else 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 119 | end 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 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/omniauth/strategies/microsoft_graph_oauth2_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'json' 5 | require 'omniauth_microsoft_graph' 6 | require 'stringio' 7 | 8 | describe OmniAuth::Strategies::MicrosoftGraph do 9 | let(:request) { double('Request', params: {}, cookies: {}, env: {}) } 10 | let(:app) do 11 | lambda do 12 | [200, {}, ['Hello.']] 13 | end 14 | end 15 | 16 | subject do 17 | OmniAuth::Strategies::MicrosoftGraph.new(app, 'appid', 'secret', @options || {}).tap do |strategy| 18 | allow(strategy).to receive(:request) do 19 | request 20 | end 21 | end 22 | end 23 | 24 | before do 25 | OmniAuth.config.test_mode = true 26 | end 27 | 28 | after do 29 | OmniAuth.config.test_mode = false 30 | end 31 | 32 | describe '#client_options' do 33 | it 'has correct site' do 34 | expect(subject.client.site).to eq('https://login.microsoftonline.com/') 35 | end 36 | 37 | it 'has correct authorize_url' do 38 | expect(subject.client.options[:authorize_url]).to eq('common/oauth2/v2.0/authorize') 39 | end 40 | 41 | it 'has correct token_url' do 42 | expect(subject.client.options[:token_url]).to eq('common/oauth2/v2.0/token') 43 | end 44 | 45 | describe 'overrides' do 46 | context 'as strings' do 47 | it 'should allow overriding the site' do 48 | @options = { client_options: { 'site' => 'https://example.com' } } 49 | expect(subject.client.site).to eq('https://example.com') 50 | end 51 | 52 | it 'should allow overriding the authorize_url' do 53 | @options = { client_options: { 'authorize_url' => 'https://example.com' } } 54 | expect(subject.client.options[:authorize_url]).to eq('https://example.com') 55 | end 56 | 57 | it 'should allow overriding the token_url' do 58 | @options = { client_options: { 'token_url' => 'https://example.com' } } 59 | expect(subject.client.options[:token_url]).to eq('https://example.com') 60 | end 61 | end 62 | 63 | context 'as symbols' do 64 | it 'should allow overriding the site' do 65 | @options = { client_options: { site: 'https://example.com' } } 66 | expect(subject.client.site).to eq('https://example.com') 67 | end 68 | 69 | it 'should allow overriding the authorize_url' do 70 | @options = { client_options: { authorize_url: 'https://example.com' } } 71 | expect(subject.client.options[:authorize_url]).to eq('https://example.com') 72 | end 73 | 74 | it 'should allow overriding the token_url' do 75 | @options = { client_options: { token_url: 'https://example.com' } } 76 | expect(subject.client.options[:token_url]).to eq('https://example.com') 77 | end 78 | end 79 | end 80 | end 81 | 82 | describe '#authorize_options' do 83 | %i[display score auth_type scope prompt login_hint domain_hint response_mode].each do |k| 84 | it "should support #{k}" do 85 | @options = { k => 'http://someval' } 86 | expect(subject.authorize_params[k.to_s]).to eq('http://someval') 87 | end 88 | end 89 | 90 | describe 'callback_url' do 91 | it 'should default to nil' do 92 | @options = {} 93 | expect(subject.authorize_params['callback_url']).to eq(nil) 94 | end 95 | 96 | it 'should set the callback_url parameter if present' do 97 | @options = { callback_url: 'https://example.com' } 98 | expect(subject.authorize_params['callback_url']).to eq('https://example.com') 99 | end 100 | end 101 | 102 | describe 'access_type' do 103 | it 'should default to "offline"' do 104 | @options = {} 105 | expect(subject.authorize_params['access_type']).to eq('offline') 106 | end 107 | 108 | it 'should set the access_type parameter if present' do 109 | @options = { access_type: 'online' } 110 | expect(subject.authorize_params['access_type']).to eq('online') 111 | end 112 | end 113 | 114 | describe 'login_hint' do 115 | it 'should default to nil' do 116 | expect(subject.authorize_params['login_hint']).to eq(nil) 117 | end 118 | 119 | it 'should set the login_hint parameter if present' do 120 | @options = { login_hint: 'john@example.com' } 121 | expect(subject.authorize_params['login_hint']).to eq('john@example.com') 122 | end 123 | end 124 | 125 | describe 'prompt' do 126 | it 'should default to nil' do 127 | expect(subject.authorize_params['prompt']).to eq(nil) 128 | end 129 | 130 | it 'should set the prompt parameter if present' do 131 | @options = { prompt: 'consent select_account' } 132 | expect(subject.authorize_params['prompt']).to eq('consent select_account') 133 | end 134 | end 135 | 136 | describe 'scope' do 137 | 138 | it 'should leave base scopes as is' do 139 | @options = { scope: 'profile' } 140 | expect(subject.authorize_params['scope']).to eq('profile') 141 | end 142 | 143 | it 'should join scopes' do 144 | @options = { scope: 'profile,email' } 145 | expect(subject.authorize_params['scope']).to eq('profile email') 146 | end 147 | 148 | it 'should deal with whitespace when joining scopes' do 149 | @options = { scope: 'profile, email' } 150 | expect(subject.authorize_params['scope']).to eq('profile email') 151 | end 152 | 153 | it 'should set default scope to email,profile' do 154 | expect(subject.authorize_params['scope']).to eq('offline_access openid email profile https://graph.microsoft.com/User.Read') 155 | end 156 | 157 | it 'should support space delimited scopes' do 158 | @options = { scope: 'profile email' } 159 | expect(subject.authorize_params['scope']).to eq('profile email') 160 | end 161 | 162 | it 'should support extremely badly formed scopes' do 163 | @options = { scope: 'profile email,foo,steve yeah http://example.com' } 164 | expect(subject.authorize_params['scope']).to eq('profile email https://graph.microsoft.com/foo https://graph.microsoft.com/steve https://graph.microsoft.com/yeah http://example.com') 165 | end 166 | end 167 | 168 | describe 'state' do 169 | it 'should set the state parameter' do 170 | @options = { state: 'some_state' } 171 | expect(subject.authorize_params['state']).to eq('some_state') 172 | expect(subject.authorize_params[:state]).to eq('some_state') 173 | expect(subject.session['omniauth.state']).to eq('some_state') 174 | end 175 | 176 | it 'should set the omniauth.state dynamically' do 177 | allow(subject).to receive(:request) { double('Request', params: { 'state' => 'some_state' }, env: {}) } 178 | expect(subject.authorize_params['state']).to eq('some_state') 179 | expect(subject.authorize_params[:state]).to eq('some_state') 180 | expect(subject.session['omniauth.state']).to eq('some_state') 181 | end 182 | end 183 | 184 | describe 'overrides' do 185 | it 'should include top-level options that are marked as :authorize_options' do 186 | @options = { authorize_options: %i[scope foo request_visible_actions], scope: 'http://bar', foo: 'baz', hd: 'wow', request_visible_actions: 'something' } 187 | expect(subject.authorize_params['scope']).to eq('http://bar') 188 | expect(subject.authorize_params['foo']).to eq('baz') 189 | expect(subject.authorize_params['hd']).to eq(nil) 190 | expect(subject.authorize_params['request_visible_actions']).to eq('something') 191 | end 192 | 193 | describe 'request overrides' do 194 | %i[access_type login_hint prompt scope state].each do |k| 195 | context "authorize option #{k}" do 196 | let(:request) { double('Request', params: { k.to_s => 'http://example.com' }, cookies: {}, env: {}) } 197 | 198 | it "should set the #{k} authorize option dynamically in the request" do 199 | @options = { k: '' } 200 | expect(subject.authorize_params[k.to_s]).to eq('http://example.com') 201 | end 202 | end 203 | end 204 | 205 | describe 'custom authorize_options' do 206 | let(:request) { double('Request', params: { 'foo' => 'something' }, cookies: {}, env: {}) } 207 | 208 | it 'should support request overrides from custom authorize_options' do 209 | @options = { authorize_options: [:foo], foo: '' } 210 | expect(subject.authorize_params['foo']).to eq('something') 211 | end 212 | end 213 | end 214 | end 215 | end 216 | 217 | describe '#authorize_params' do 218 | it 'should include any authorize params passed in the :authorize_params option' do 219 | @options = { authorize_params: { request_visible_actions: 'something', foo: 'bar', baz: 'zip' }, hd: 'wow', bad: 'not_included' } 220 | expect(subject.authorize_params['request_visible_actions']).to eq('something') 221 | expect(subject.authorize_params['foo']).to eq('bar') 222 | expect(subject.authorize_params['baz']).to eq('zip') 223 | expect(subject.authorize_params['bad']).to eq(nil) 224 | end 225 | end 226 | 227 | describe '#token_params' do 228 | it 'should include any token params passed in the :token_params option' do 229 | @options = { token_params: { foo: 'bar', baz: 'zip' } } 230 | expect(subject.token_params['foo']).to eq('bar') 231 | expect(subject.token_params['baz']).to eq('zip') 232 | end 233 | end 234 | 235 | describe '#token_options' do 236 | it 'should include top-level options that are marked as :token_options' do 237 | @options = { token_options: %i[scope foo], scope: 'bar', foo: 'baz', bad: 'not_included' } 238 | expect(subject.token_params['scope']).to eq('bar') 239 | expect(subject.token_params['foo']).to eq('baz') 240 | expect(subject.token_params['bad']).to eq(nil) 241 | end 242 | end 243 | 244 | describe '#callback_path' do 245 | it 'has the correct default callback path' do 246 | allow(subject).to receive(:script_name).and_return('') 247 | expect(subject.callback_path).to eq('/auth/microsoft_graph/callback') 248 | end 249 | 250 | it 'should set the callback_path parameter if present' do 251 | @options = { callback_path: '/auth/foo/callback' } 252 | expect(subject.callback_path).to eq('/auth/foo/callback') 253 | end 254 | 255 | it 'should set the callback_path with script_name if present' do 256 | allow(subject).to receive(:script_name).and_return('/api/v1') 257 | expect(subject.callback_path).to eq('/api/v1/auth/microsoft_graph/callback') 258 | end 259 | end 260 | 261 | describe '#info' do 262 | let(:client) do 263 | OAuth2::Client.new('abc', 'def') do |builder| 264 | builder.request :url_encoded 265 | builder.adapter :test do |stub| 266 | stub.get('/v1.0/me') { [200, { 'content-type' => 'application/json' }, response_hash.to_json] } 267 | end 268 | end 269 | end 270 | let(:access_token) { OAuth2::AccessToken.from_hash(client, { 'access_token' => 'a' }) } 271 | before { allow(subject).to receive(:access_token).and_return(access_token) } 272 | 273 | context 'with verified email' do 274 | let(:response_hash) do 275 | { mail: 'something@domain.invalid' } 276 | end 277 | 278 | it 'should return equal email ' do 279 | expect(subject.info['email']).to eq('something@domain.invalid') 280 | end 281 | end 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 295 | end 296 | 297 | describe '#extra' do 298 | let(:client) do 299 | OAuth2::Client.new('abc', 'def') do |builder| 300 | builder.request :url_encoded 301 | builder.adapter :test do |stub| 302 | stub.get('/v1.0/me') { [200, { 'content-type' => 'application/json' }, '{"id": "12345"}'] } 303 | end 304 | end 305 | end 306 | let(:access_token) { OAuth2::AccessToken.from_hash(client, { 'access_token' => 'a' }) } 307 | 308 | before { allow(subject).to receive(:access_token).and_return(access_token) } 309 | 310 | describe 'raw_info' do 311 | it 'should include raw_info' do 312 | expect(subject.extra['raw_info']).to eq('id' => '12345') 313 | end 314 | end 315 | end 316 | 317 | describe 'build_access_token' do 318 | it 'should use a hybrid authorization request_uri if this is an AJAX request with a code parameter' do 319 | allow(request).to receive(:scheme).and_return('https') 320 | allow(request).to receive(:url).and_return('https://example.com') 321 | allow(request).to receive(:xhr?).and_return(true) 322 | allow(request).to receive(:params).and_return('code' => 'valid_code') 323 | 324 | client = double(:client) 325 | auth_code = double(:auth_code) 326 | allow(client).to receive(:auth_code).and_return(auth_code) 327 | expect(subject).to receive(:client).and_return(client) 328 | expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: '/auth/microsoft_graph/callback' }, {}) 329 | 330 | expect(subject).not_to receive(:orig_build_access_token) 331 | subject.instance_variable_set("@env", {}) 332 | subject.send(:build_access_token) 333 | end 334 | 335 | it 'should use a hybrid authorization request_uri if this is an AJAX request (mobile) with a code parameter' do 336 | allow(request).to receive(:scheme).and_return('https') 337 | allow(request).to receive(:url).and_return('https://example.com') 338 | allow(request).to receive(:xhr?).and_return(true) 339 | allow(request).to receive(:params).and_return('code' => 'valid_code', 'callback_url' => 'localhost') 340 | 341 | client = double(:client) 342 | auth_code = double(:auth_code) 343 | allow(client).to receive(:auth_code).and_return(auth_code) 344 | expect(subject).to receive(:client).and_return(client) 345 | expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'localhost' }, {}) 346 | 347 | expect(subject).not_to receive(:orig_build_access_token) 348 | subject.instance_variable_set("@env", {}) 349 | subject.send(:build_access_token) 350 | end 351 | 352 | it 'should use the request_uri from params if this not an AJAX request (request from installed app) with a code parameter' do 353 | allow(request).to receive(:scheme).and_return('https') 354 | allow(request).to receive(:url).and_return('https://example.com') 355 | allow(request).to receive(:xhr?).and_return(false) 356 | allow(request).to receive(:params).and_return('code' => 'valid_code', 'callback_url' => 'callback_url') 357 | 358 | client = double(:client) 359 | auth_code = double(:auth_code) 360 | allow(client).to receive(:auth_code).and_return(auth_code) 361 | expect(subject).to receive(:client).and_return(client) 362 | expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'callback_url' }, {}) 363 | 364 | expect(subject).not_to receive(:orig_build_access_token) 365 | subject.send(:build_access_token) 366 | end 367 | 368 | it 'should read access_token from hash if this is not an AJAX request with a code parameter' do 369 | allow(request).to receive(:scheme).and_return('https') 370 | allow(request).to receive(:url).and_return('https://example.com') 371 | allow(request).to receive(:xhr?).and_return(false) 372 | allow(request).to receive(:params).and_return('access_token' => 'valid_access_token') 373 | expect(subject).to receive(:verify_token).with('valid_access_token').and_return true 374 | expect(subject).to receive(:client).and_return(:client) 375 | 376 | token = subject.send(:build_access_token) 377 | expect(token).to be_instance_of(::OAuth2::AccessToken) 378 | expect(token.token).to eq('valid_access_token') 379 | expect(token.client).to eq(:client) 380 | end 381 | 382 | it 'reads the code from a json request body' do 383 | body = StringIO.new(%({"code":"json_access_token"})) 384 | client = double(:client) 385 | auth_code = double(:auth_code) 386 | 387 | allow(request).to receive(:scheme).and_return('https') 388 | allow(request).to receive(:url).and_return('https://example.com') 389 | allow(request).to receive(:xhr?).and_return(false) 390 | allow(request).to receive(:content_type).and_return('application/json') 391 | allow(request).to receive(:body).and_return(body) 392 | allow(client).to receive(:auth_code).and_return(auth_code) 393 | expect(subject).to receive(:client).and_return(client) 394 | 395 | expect(auth_code).to receive(:get_token).with('json_access_token', { redirect_uri: '/auth/microsoft_graph/callback' }, {}) 396 | 397 | subject.send(:build_access_token) 398 | end 399 | 400 | it 'should use callback_url without query_string if this is not an AJAX request' do 401 | allow(request).to receive(:scheme).and_return('https') 402 | allow(request).to receive(:url).and_return('https://example.com') 403 | allow(request).to receive(:xhr?).and_return(false) 404 | allow(request).to receive(:params).and_return('code' => 'valid_code') 405 | allow(request).to receive(:content_type).and_return('application/x-www-form-urlencoded') 406 | 407 | client = double(:client) 408 | auth_code = double(:auth_code) 409 | allow(client).to receive(:auth_code).and_return(auth_code) 410 | allow(subject).to receive(:callback_url).and_return('callback_url_without_query_string') 411 | 412 | expect(subject).to receive(:client).and_return(client) 413 | expect(auth_code).to receive(:get_token).with('valid_code', { redirect_uri: 'callback_url_without_query_string' }, {}) 414 | subject.send(:build_access_token) 415 | end 416 | end 417 | 418 | describe 'verify_token' do 419 | before(:each) do 420 | subject.options.client_options[:connection_build] = proc do |builder| 421 | builder.request :url_encoded 422 | builder.adapter :test do |stub| 423 | stub.get('/v1.0/me?access_token=valid_access_token') do 424 | [200, { 'Content-Type' => 'application/json; charset=UTF-8' }, JSON.dump( 425 | aud: '000000000000.apps.googleusercontent.com', 426 | id: '123456789', 427 | email: 'example@example.com', 428 | access_type: 'offline', 429 | scope: 'profile email', 430 | expires_in: 436 431 | )] 432 | end 433 | stub.get('/v1.0/me?access_token=invalid_access_token') do 434 | [400, { 'Content-Type' => 'application/json; charset=UTF-8' }, JSON.dump(error_description: 'Invalid Value')] 435 | end 436 | end 437 | end 438 | end 439 | 440 | it 'should verify token if access_token is valid and app_id equals' do 441 | subject.options.client_id = '000000000000.apps.googleusercontent.com' 442 | expect(subject.send(:verify_token, 'valid_access_token')).to eq(true) 443 | end 444 | 445 | it 'should verify token if access_token is valid and app_id authorized' do 446 | subject.options.authorized_client_ids = ['000000000000.apps.googleusercontent.com'] 447 | expect(subject.send(:verify_token, 'valid_access_token')).to eq(true) 448 | end 449 | 450 | it 'should not verify token if access_token is valid but app_id is false' do 451 | expect(subject.send(:verify_token, 'valid_access_token')).to eq(false) 452 | end 453 | 454 | it 'should raise error if access_token is invalid' do 455 | expect do 456 | subject.send(:verify_token, 'invalid_access_token') 457 | end.to raise_error(OAuth2::Error) 458 | end 459 | end 460 | 461 | describe 'Yammer profile endpoint support' do 462 | describe '#profile_endpoint' do 463 | context 'when no profile endpoint is determined' do 464 | it 'defaults to Microsoft Graph profile URL' do 465 | expect(subject.profile_endpoint).to eq('https://graph.microsoft.com/v1.0/me') 466 | end 467 | end 468 | 469 | context 'when profile endpoint is already set' do 470 | before { subject.instance_variable_set(:@profile_endpoint, 'https://custom.endpoint.com') } 471 | 472 | it 'returns the previously set endpoint' do 473 | expect(subject.profile_endpoint).to eq('https://custom.endpoint.com') 474 | end 475 | end 476 | end 477 | 478 | describe '#determine_profile_endpoint' do 479 | let(:request) { double('Request', env: request_env) } 480 | 481 | context 'when scope includes Yammer access_as_user scope' do 482 | let(:request_env) { { 'omniauth.params' => { 'scope' => 'https://api.yammer.com/access_as_user' } } } 483 | 484 | it 'returns Yammer profile URL' do 485 | expect(subject.determine_profile_endpoint(request)).to eq('https://www.yammer.com/api/v1/users/current.json') 486 | end 487 | end 488 | 489 | context 'when scope includes Yammer user_impersonation scope' do 490 | let(:request_env) { { 'omniauth.params' => { 'scope' => 'openid profile https://api.yammer.com/user_impersonation' } } } 491 | 492 | it 'returns Yammer profile URL' do 493 | expect(subject.determine_profile_endpoint(request)).to eq('https://www.yammer.com/api/v1/users/current.json') 494 | end 495 | end 496 | 497 | context 'when scope includes Yammer scope among other scopes' do 498 | let(:request_env) { { 'omniauth.params' => { 'scope' => 'offline_access openid email profile https://api.yammer.com/access_as_user User.Read' } } } 499 | 500 | it 'returns Yammer profile URL' do 501 | expect(subject.determine_profile_endpoint(request)).to eq('https://www.yammer.com/api/v1/users/current.json') 502 | end 503 | end 504 | 505 | context 'when scope includes multiple Yammer scopes' do 506 | let(:request_env) { { 'omniauth.params' => { 'scope' => 'openid profile https://api.yammer.com/access_as_user https://api.yammer.com/user_impersonation' } } } 507 | 508 | it 'returns Yammer profile URL' do 509 | expect(subject.determine_profile_endpoint(request)).to eq('https://www.yammer.com/api/v1/users/current.json') 510 | end 511 | end 512 | 513 | context 'when scope does not include any Yammer scopes' do 514 | let(:request_env) { { 'omniauth.params' => { 'scope' => 'openid profile User.Read' } } } 515 | 516 | it 'returns Microsoft Graph profile URL' do 517 | expect(subject.determine_profile_endpoint(request)).to eq('https://graph.microsoft.com/v1.0/me') 518 | end 519 | end 520 | 521 | context 'when scope is nil' do 522 | let(:request_env) { { 'omniauth.params' => { 'scope' => nil } } } 523 | 524 | it 'returns Microsoft Graph profile URL' do 525 | expect(subject.determine_profile_endpoint(request)).to eq('https://graph.microsoft.com/v1.0/me') 526 | end 527 | end 528 | 529 | context 'when omniauth.params is nil' do 530 | let(:request_env) { { 'omniauth.params' => nil } } 531 | 532 | it 'returns Microsoft Graph profile URL' do 533 | expect(subject.determine_profile_endpoint(request)).to eq('https://graph.microsoft.com/v1.0/me') 534 | end 535 | end 536 | end 537 | end 538 | end 539 | --------------------------------------------------------------------------------