├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── master.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── UPGRADING.md ├── bin ├── console └── setup ├── lib ├── omniauth-entra-id.rb └── omniauth │ ├── entra_id.rb │ ├── entra_id │ └── version.rb │ └── strategies │ └── entra_id.rb ├── omniauth-entra-id.gemspec └── spec ├── omniauth └── strategies │ └── entra_id_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: Ruby ${{ matrix.ruby }} 13 | strategy: 14 | matrix: 15 | ruby: ['3.0', '3.1', '3.2', '3.3', '3.4'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | rubygems: 3.5.12 26 | 27 | - name: Run tests 28 | run: bundle exec rspec --backtrace 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | 3 | /.bundle/ 4 | /.yardoc 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | 15 | # ide 16 | .idea 17 | 18 | # built gems 19 | *.gem 20 | 21 | # macOS metadata 22 | .DS_Store 23 | 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v3.1.1 (2025-09-12) 4 | 5 | * Validates the JWT `iss` (issuer) claim for `consumers` tenants properly, using the global GUID for that use case, resolving #51 (reported by @2called-chaos) 6 | 7 | ## v3.1.0 (2025-06-17) 8 | 9 | * Provides a way to ignore TID when constructing a user UID, easing migration from v2.x, via the new `ignore_tid` option, resolving #42 (reported by @s-andringa) 10 | * Handles a missing (`nil`) TID, via #46 (thanks to @frenkel) 11 | * Ruby 3.4 "officially" supported through coverage in CI, via #47 (thanks to @hakeem0114) 12 | * Supports JWT gem v2.9.x or v3.x, via #48 (thanks to @djpremier) 13 | 14 | ## v3.0.1 (2024-11-21) 15 | 16 | * Fixes a minor error in [`UPGRADING.md`](UPGRADING.md) reported in #38, via #40 (thanks to @kennethgeerts) 17 | * Fixes incorrect attempt to verify JWT token issuer when the AD FS tenant `adfs` is specified, via #39 (thanks to @washu) 18 | 19 | ## v3.0.0 (2024-10-22) 20 | 21 | * To upgrade from the Azure ActiveDirectory V2 gem, please see [`UPGRADING.md`](UPGRADING.md) 22 | * Branched from `omniauth-azure-activedirectory-v2` version 2.4.0 and renamed to `omniauth-entra-id` 23 | * Can specify `tenant_name` in options via #31 (thanks to @Jureamer) for B2C login 24 | * Supports authenticating with a certificate instead of client secret via #32 (thanks to @juliaducey) 25 | * ID token extraction and validation is improved; long-standing fault with UID generation from OIDs (see #33) addressed via #34 (thanks to @tom-brouwer-bex) 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dev@ripaglobal.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in omniauth-entra-id.gemspec 4 | # 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 RIPA Global 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OmniAuth::Entra::Id 2 | 3 | [![Gem Version](https://badge.fury.io/rb/omniauth-entra-id.svg)](https://rubygems.org/gems/omniauth-entra-id) 4 | [![Build Status](https://github.com/RIPAGlobal/omniauth-entra-id/actions/workflows/master.yml/badge.svg)](https://github.com/RIPAGlobal/omniauth-entra-id/actions) 5 | [![License](https://img.shields.io/github/license/RIPAGlobal/omniauth-entra-id.svg)](LICENSE.txt) 6 | 7 | OAuth 2 authentication with [Entra ID API](https://learn.microsoft.com/en-us/entra/identity-platform/v2-overview). Rationale: 8 | 9 | * https://github.com/marknadig/omniauth-azure-oauth2 is no longer maintained. 10 | * https://github.com/marknadig/omniauth-azure-oauth2/pull/29 contains important additions. 11 | 12 | This gem combines the two and makes some changes to support the Entra API. The old ActiveDirectory V1 API used OpenID Connect. If you need this, a gem from Microsoft [is available here](https://github.com/AzureAD/omniauth-azure-activedirectory), but seems to be abandoned. 13 | 14 | **If upgrading from older versions of this gem under its old name of "Azure ActiveDirectory V2", please follow the instructions in [`UPGRADING.md`](UPGRADING.md).** 15 | 16 | 17 | 18 | ## Installation 19 | 20 | Add this line to your application's Gemfile: 21 | 22 | ```ruby 23 | gem 'omniauth-entra-id' 24 | ``` 25 | 26 | And then execute: 27 | 28 | ```shell 29 | $ bundle install 30 | ``` 31 | 32 | Or install it yourself as: 33 | 34 | ```shell 35 | $ gem install omniauth-entra-id 36 | ``` 37 | 38 | 39 | 40 | ## Usage 41 | 42 | Please start by reading https://github.com/marknadig/omniauth-azure-oauth2 for basic configuration and background information. Note that with this gem, you must use strategy name `entra_id` rather than `azure_oauth2`. Additional configuration information is given below. 43 | 44 | ### Entra ID server configuration 45 | 46 | In most cases, you only want to receive 'verified' email addresses in your application. For older app registrations in the Entra portal, this may need to be [enabled explicitly](https://learn.microsoft.com/en-us/graph/applications-authenticationbehaviors?tabs=http#prevent-the-issuance-of-email-claims-with-unverified-domain-owners). It's [enabled by default](https://learn.microsoft.com/en-us/entra/identity-platform/migrate-off-email-claim-authorization#how-do-i-protect-my-application-immediately) for new multi-tenant app registrations made after June 2023. 47 | 48 | ### Implementation 49 | #### With `OmniAuth::Builder` 50 | 51 | You can do something like this for a static / fixed configuration: 52 | 53 | ```ruby 54 | Rails.application.config.middleware.use OmniAuth::Builder do 55 | provider( 56 | :entra_id, 57 | { 58 | client_id: ENV['ENTRA_CLIENT_ID'], 59 | client_secret: ENV['ENTRA_CLIENT_SECRET'] 60 | } 61 | ) 62 | end 63 | ``` 64 | 65 | ...or, if using a custom provider class (called `YouTenantProvider` in this example, described in more detail later): 66 | 67 | ```ruby 68 | Rails.application.config.middleware.use OmniAuth::Builder do 69 | provider( 70 | :entra_id, 71 | YouTenantProvider 72 | ) 73 | end 74 | ``` 75 | 76 | #### With Devise 77 | 78 | In your `config/initializers/devise.rb` file you can do something like this for a static / fixed configuration: 79 | 80 | ```ruby 81 | config.omniauth( 82 | :entra_id, 83 | { 84 | client_id: ENV['ENTRA_CLIENT_ID'], 85 | client_secret: ENV['ENTRA_CLIENT_SECRET'] 86 | } 87 | ) 88 | ``` 89 | 90 | ...or, if using a custom provider class (called `YouTenantProvider` in this example, described in more detail later): 91 | 92 | ```ruby 93 | config.omniauth( 94 | :entra_id, 95 | YouTenantProvider 96 | ) 97 | ``` 98 | 99 | ### Configuration options 100 | 101 | All of the items listed below are optional, unless noted otherwise. They can be provided either in a static configuration Hash as shown in examples above, or via *read accessor instance methods* in a provider class (more on this later). 102 | 103 | To have your application authenticate with Entra via a client secret, specify `client_secret`. If you instead want to use certificate-based authentication via client assertion, give the `certificate_path` and `tenant_id` instead. You should provide only `client_secret` or `certificate_path`, not both. 104 | 105 | If you're using the client assertion flow, you need to register your certificate in the Entra portal. For more information, please see [the documentation](https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials). 106 | 107 | | Option | Use | 108 | | ----------------------------- | --- | 109 | | `client_id` | **Mandatory.** Client ID for the 'application' (integration) configured on the Entra side. Found via the Entra UI. | 110 | | `client_secret` | **Mandatory for client secret flow.** Client secret for the 'application' (integration) configured on the Entra side. Found via the Entra UI. Don't give this if using client assertion flow. | 111 | | `certificate_path` | **Mandatory for client assertion flow.** Don't give this if using a client secret instead of client assertion. This should be the filepath to a PKCS#12 file. | 112 | | `tenant_id` | **Mandatory for client assertion flow.** Entra Tenant ID for multi-tenanted use. Default is `common`. Forms part of the Entra OAuth URL - `{base}/{tenant_id}/oauth2/v2.0/...` | 113 | | `base_url` | Location of Entra login page, for specialised requirements; default is `OmniAuth::Strategies::EntraId::BASE_URL` (at the time of writing, this is `https://login.microsoftonline.com`). | 114 | | `tenant_name` | For what is currently known by its old name of "Azure ActiveDirectory B2C" (and only active if `custom_policy` is also provided - see below), set the tenancy name to constructs the correct B2C endpoint of `{tenant_name}.b2clogin.com/{tenant_name}.onmicrosoft.com/{custom_policy>}" and uses that for auth calls. This is a convenience feature; the `base_entra_url` option could also be manually built up in the same way. | 115 | | `custom_policy` | Custom policy. Default is nil. Used in conjunction with `tenant_name`- see above. | 116 | | `authorize_params` | Additional parameters passed as URL query data in the initial OAuth redirection to Microsoft. See below for more. Empty Hash default. | 117 | | `domain_hint` | If defined, sets (overwriting, if already present) `domain_hint` inside `authorize_params`. Default `nil` / none. | 118 | | `scope` | If defined, sets (overwriting, if already present) `scope` inside `authorize_params`. Default is `OmniAuth::Strategies::EntraId::DEFAULT_SCOPE` (at the time of writing, this is `'openid profile email'`). | 119 | | `ìgnore_tid?` or `ignore_tid` | If set to `true`, tenant ID (TID) is *not* included for a user's UID from Entra. Use if you are confident that an Entra OID will be globally unique and have existing OID-only UIDs in use. Default is `false`; both TID and OID are used to form a UID. Note that the option name variation without the question mark only works for directly-specified options; provider classes must always define an override method called `ignore_tid?`. | 120 | | `adfs?` or `adfs` | If set to `true`, modifies the URLs so they work with an on-premise AD FS server (Active Directory Federation Services). In order to use this you also need to set the `base_url` correctly and fill the `tenant_id` with `'adfs'`. Note that the option name variation without the question mark only works for directly-specified options; provider classes must always define an override method called `adfs?`. | 121 | 122 | In addition, as a special case, if the request URL contains a query parameter `prompt`, then this will be written into `authorize_params` under that key, overwriting if present any other value there. Note that this comes from the current request URL at the time OAuth flow is commencing, _not_ via static options Hash data or via a custom provider class - but you _could_ just as easily set `scope` inside a custom `authorize_params` returned from a provider class, as shown in an example later; the request URL query mechanism is just another way of doing the same thing. 123 | 124 | #### Explaining `custom_policy` and `tenant_name` 125 | 126 | When using Azure ActiveDirectory B2C - which seems to be distinct from Entra ID and not renamed as of October 2024 - tenants can define custom policies. With normal OAuth use cases, when the underlying `oauth2` gem creates the request for getting a token via POST, it places all `params` (which would include anything you've provided in the normal configuration to name your custom policy) in the `body` of the request. This would not work. Microsoft's documentation indicates that when [requesting a token](https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#request-a-token), they want the name of custom policies to be given in the URL rather than in the body of the request. They ignore a custom policy specified in the body. 127 | 128 | Solve this for B2C use cases by giving your tenant name and custom policy name in the relevant configuration options. This causes a base URL to be constructed as follows: 129 | 130 | ``` 131 | .b2clogin.com/.onmicrosoft.com//oauth2/v2.0/... 132 | ``` 133 | 134 | #### Explaining `authorize_params` 135 | 136 | The `authorize_params` hash-like object contains key-value pairs which are added to existing standard OAuth data in the initial `POST` request made by this gem from your web site, to the Microsoft Entra login page, at the start of OAuth flow. You can find these listed some way down the table just below an OAuth URL example at: 137 | 138 | * https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code 139 | 140 | ...looking for in particular items from `prompt` onwards. For example, Microsoft say that a prompt option of `select_account` will always lead to the account selection UI to be shown at login, whether or not the user is currently signed into a Microsoft Entra ID account in that browser session. You would active it using options that look something like this in your OmniAuth Builder or Devise setup code: 141 | 142 | ```ruby 143 | { 144 | client_id: ENV['ENTRA_CLIENT_ID'], 145 | client_secret: ENV['ENTRA_CLIENT_SECRET'] 146 | authorize_params: { 147 | prompt: 'select_account' 148 | } 149 | } 150 | ``` 151 | 152 | 153 | 154 | #### Dynamic options via a custom provider class 155 | 156 | Documentation mentioned earlier at https://github.com/marknadig/omniauth-azure-oauth2#usage gives an example of setting tenant ID dynamically via a custom provider class. We can also use that class in other ways. For example, let's rewrite it thus: 157 | 158 | ```ruby 159 | class YouTenantProvider 160 | def initialize(strategy) 161 | @strategy = strategy 162 | end 163 | 164 | def client_id 165 | ENV['ENTRA_CLIENT_ID'] 166 | end 167 | 168 | def client_secret 169 | ENV['ENTRA_CLIENT_SECRET'] 170 | end 171 | 172 | def authorize_params 173 | ap = {} 174 | 175 | if @strategy.request && @strategy.request.params['login_hint'] 176 | ap['login_hint'] = @strategy.request.params['login_hint'] 177 | end 178 | 179 | # (...and/or set other options such as 'prompt' here...) 180 | 181 | return ap 182 | end 183 | end 184 | ``` 185 | 186 | In this example, we're providing custom `authorize_params`. You can just return a standard Ruby Hash here, using lower case String or Symbol keys. The `strategy` value given to the initializer is an instance of [`OmniAuth::Strategies::EntraId`](https://github.com/RIPAGlobal/omniauth-entra-id/blob/master/lib/omniauth/strategies/entra_id.rb) which is a subclass of [`OmniAuth::Strategies::OAuth2`](https://www.rubydoc.info/gems/omniauth-oauth2/1.8.0/OmniAuth/Strategies/OAuth2), but that's not all that helpful! What's more useful is to know that **the Rails `request` object is available via `@strategy.request` and, likewise, the session store via `@strategy.session`**. This gives you a lot of flexibility for responding to an inbound request or user session, varying the parameters used for the Entra OAuth flow. 187 | 188 | In method `#authorize_params` above, the request object is used to look for a `login_hint` query string entry, set in whichever view(s) is/are presented by your application for use when your users need to be redirected to the OmniAuth controller in order to kick off OAuth with Entra. The value is copied into the `authorize_params` Hash. Earlier, it was mentioned that there was a special case of `prompt` being pulled from the request URL query data, but that this could also be done via a custom provider - here, you can see how; just check `@strategy.request.params['prompt']` and copy that into `authorize_params` if preset. 189 | 190 | > **NB:** Naming things is hard! The predecessor gem used the name `YouTenantProvider` since it was focused on custom tenant provision, but if using this in a more generic way, perhaps consider a more generic name such as, say, `CustomOmniAuthEntraProvider`. 191 | 192 | #### Special case scope override 193 | 194 | If required and more convenient, you can specify a custom `scope` value via generation of an authorisation URL including that required `scope`, rather than by using a custom provider class with `def scope...end` method. Include the `scope` value in your call to generate the URL thus: 195 | 196 | ```ruby 197 | omniauth_authorize_url('resource_name_eg_user', 'entra_id', scope: '...') 198 | ``` 199 | 200 | 201 | 202 | ## Contributing 203 | 204 | Bug reports and pull requests are welcome on GitHub at https://github.com/RIPAGlobal/omniauth-entra-id. This project is intended to be a safe, welcoming space for collaboration so contributors must adhere to the [code of conduct](https://github.com/RIPAGlobal/omniauth-entra-id/blob/master/CODE_OF_CONDUCT.md). 205 | 206 | ### Getting running 207 | 208 | * Fork the repository 209 | * Check out your fork 210 | * `cd` into the repository 211 | * `bin/setup` 212 | * `bundle exec rspec` to make sure all the tests run 213 | 214 | ### Making changes 215 | 216 | * Make your change 217 | * Add tests and check that `bundle exec rspec` still runs successfully 218 | * For new features (rather than bug fixes), update `README.md` with details 219 | 220 | 221 | 222 | ## License 223 | 224 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 225 | 226 | 227 | 228 | ## Code of Conduct 229 | 230 | Everyone interacting in this project's codebases, issue trackers, chat rooms and mailing lists must follow the [code of conduct](https://github.com/RIPAGlobal/omniauth-entra-id/blob/master/CODE_OF_CONDUCT.md). 231 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading from `omniauth-azure-activedirectory-v2` 2 | 3 | This guide assumes you were on v2.3 or v2.4 of the old-named gem. The basic steps are: 4 | 5 | * Update your code to account for the rename 6 | * Update your code to account for breaking changes 7 | 8 | 9 | 10 | ## Updates due to the gem rename 11 | 12 | All gem users will likely need to follow these steps. 13 | 14 | * In general, searching project-wide for `azure_activedirectory_v2` and replacing with `entra_id` and, likewise, for the hyphenated `azure-activedirectory-v2` and replacing with `entra-id`, will cover a lot of use cases 15 | * `README.md` always included examples with environment variables that were named as illustrations only; these have changed from e.g. `AZURE_CLIENT_ID` to `ENTRA_CLIENT_ID` just for internal consistency, but while renaming your own related environment variables or constants (should you use any) may help with code base understanding and consistency, it's not essential. Those names are part of _your_ code base, not part of code in this gem. 16 | 17 | ### Configuration 18 | 19 | Rename the strategy in your configuration block: 20 | 21 | ```ruby 22 | config.omniauth( 23 | :azure_activedirectory_v2, 24 | # ... 25 | ) 26 | ``` 27 | 28 | ...becomes: 29 | 30 | ```ruby 31 | config.omniauth( 32 | :entra_id, 33 | # ... 34 | ) 35 | ``` 36 | 37 | ### Callback routes 38 | 39 | Depending on how you handle callbacks from OmniAuth, you might need to update routes or controllers handling shared routes to account for the name change. The old callback URL of: 40 | 41 | ``` 42 | https://example.com/v1/auth/azure_activedirectory_v2/callback 43 | ``` 44 | 45 | ...is now: 46 | 47 | ``` 48 | https://example.com/v1/auth/entra_id/callback 49 | ``` 50 | 51 | ### URL generation 52 | 53 | Change things like this: 54 | 55 | ``` 56 | omniauth_authorize_url('resource_name_eg_user', 'azure_activedirectory_v2', scope: '...') 57 | ``` 58 | 59 | ...to this: 60 | 61 | ``` 62 | omniauth_authorize_url('resource_name_eg_user', 'entra_id', scope: '...') 63 | ``` 64 | 65 | 66 | 67 | ## Updates due to other breaking changes 68 | ### Change affecting all gem users 69 | #### UIDs will change 70 | 71 | This change is for UIDs and is the main reason for creating a V3 gem, whether or not it included the Entra name change. 72 | 73 | * The UID returned by OmniAuth for a user previously depended upon the `oid` (object ID) returned by Microsoft. As noted in #33 and fixed in #34, this _might not be unique_ and tenant ID (`tid`) is supposed to be considered too. 74 | * Out-of-box, Entra ID will do this. If you were an Azure ActiveDirectory V2 (old-name gem, version 2.x) user, then you will have been receiving different UIDs based only on the `oid` from Microsoft. 75 | * **The change of OID might break the connection between a previously-registered and logged in user and a new login** as usually, you need to store the OmniAuth UID somewhere alongside or within your User records when a user is "connected to" an external OAuth service such as Entra ID. 76 | * **However, there is a strong argument that TID is not needed** - see https://github.com/pond/omniauth-entra-id/issues/42 for a good argument to that end. 77 | 78 | You have three options, should the issue affect you (and it almost certainly will). 79 | 80 | * If you are confident that you still only need the OID, set the `ignore_tid` option to `true` alongside `client_id` and `client_secret` in your OmniAuth Entra ID initialiser or your custom provider class given to that initialiser, if you use one. See the top-level `README.md` for more. 81 | * If you can determine the tenant IDs for all users in your database, you can just migrate the UIDs. The new UID is just a simple concatenation of tenant ID and object ID, so treating the UID as a string, add the tenant ID as a prefix without any other changes in your migration and things should work fine thereafter. 82 | * Otherwise, you should lazy-migrate: 83 | - As usual, in your OAuth callback handler, `request.env['omniauth.auth'].uid` gives the UID - but now that's the "new" Entra gem's value which includes tenant ID. 84 | - If you can find a user with that ID, then all good - they've been migrated already or got connected to Entra *after* you started using the updated gem 85 | - Otherwise, check `request.env['omniauth.auth'].extra.dig('raw_info', 'oid')` - this gives the value that the *old Azure ActiveDirectory V2 gem* used as UID 86 | - Look up the user with this ID. If you find them, great; remember to migrate their record by updating their stored auth ID to the new `request.env['omniauth.auth'].uid` value. 87 | - For better security add something like an indexed boolean column indicating whether or not the user has been thus migrated and only perform old OID lookups on users which have not yet been migrated. 88 | - If the user can't be found by either means, then they've not been connected to your system yet. Your existing handling path for such a condition applies. 89 | 90 | #### Applications that handle multiple OAuth providers 91 | 92 | If your user records contain users that have 'connected' to more than one kind of OAuth provider, then as well as the third party's UID being stored for future logins, you'll most likely have stored the OmniAuth provider name too so that the UID can be looked up in a provider's context (there's no guarantee, of course, that UIDs are unique *between providers* since they're entirely independent entities with their own strategies for allocating unique IDs). 93 | 94 | In that case, you will need to migrate records from the old `azure_activedirectory_v2` name to `entra_id`. **Zero-downtime deployment of this change would be very hard since your codebase would need to update from the Azure ActiveDirectory V2 gem to the Entra ID gem with the migration running simultaneously**, so if you need to do such a migration, then you probably should plan for a small maintenance window. At the scheduled time, go into maintenance mode, migrate, deploy, and restore normal service. Even without this, though, the 'worst that can happen' (in theory!) would be temporary user login failures. Either the Entra gem will be causing you to look for a user with an `entra_id` provider but the migration to set this hasn't run yet, or the other way round, with the old gem looking for the old provider name but it's already updated. 95 | 96 | #### Example migration code 97 | 98 | Suppose you support multiple providers and your User table stores their chosen provider in a column `auth_provider`, with their UID in `auth_uid`. An (irreversible) database migration might do this: 99 | 100 | ```ruby 101 | def up 102 | add_column :users, :migrated_to_entra, :boolean 103 | 104 | User 105 | .where(auth_provider: 'azure_activedirectory_v2') 106 | .update_all( 107 | auth_provider: 'entra_id', 108 | migrated_to_entra: false 109 | ) 110 | end 111 | 112 | def down 113 | raise ActiveRecord::IrreversibleMigration 114 | end 115 | ``` 116 | 117 | This means the `migrated_to_entra` column is only ever `false` for existing users that were linked using the V2 gem. Everything else will be at `NULL`. Now you lazy-move those users to the new UID format in the code you use to look up a user in your OmniAuth OAuth 2 callback handler, e.g. via a method such as this: 118 | 119 | ```ruby 120 | # Here, "raw_info" comes from e.g.: 121 | # 122 | # request.env['omniauth.auth'].extra&.dig('raw_info') || {} 123 | # 124 | def find_user_for_omniauth(auth_provider:, auth_uid:, raw_info:) 125 | found_user = User.find_by( 126 | auth_provider: auth_provider, 127 | auth_uid: auth_uid 128 | ) 129 | 130 | if found_user.nil? && auth_provider == 'entra_id' 131 | found = User.find_by( 132 | auth_provider: 'entra_id', 133 | auth_uid: raw_info['oid'], 134 | migrated_to_entra: false 135 | ) 136 | 137 | if found 138 | found.update_columns( 139 | auth_uid: auth_uid, 140 | migrated_to_entra: true 141 | ) 142 | end 143 | end 144 | 145 | return found_user 146 | end 147 | ``` 148 | 149 | Once there are no rows with a `migrated_to_entra` value of `false` for _active_ users, you will be able to drop the column and remove the lazy migration code. 150 | 151 | ### Other changes that might affect you 152 | 153 | * If you refer to `OmniAuth::Strategies::AzureActivedirectoryV2` at all, then this becomes `OmniAuth::Strategies::EntraId` (note lower case "d"). 154 | * `base_azure_url` option renamed to just `base_url` with corresponding rename of `OmniAuth::Strategies::AzureActivedirectoryV2::BASE_AZURE_URL` to `OmniAuth::Strategies::EntraId::BASE_URL`. 155 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "omniauth/entra/id" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/omniauth-entra-id.rb: -------------------------------------------------------------------------------- 1 | require File.join('omniauth', 'entra_id') 2 | -------------------------------------------------------------------------------- /lib/omniauth/entra_id.rb: -------------------------------------------------------------------------------- 1 | require File.join('omniauth', 'entra_id', 'version') 2 | require File.join('omniauth', 'strategies', 'entra_id') 3 | -------------------------------------------------------------------------------- /lib/omniauth/entra_id/version.rb: -------------------------------------------------------------------------------- 1 | module OmniAuth 2 | module Entra 3 | module Id 4 | VERSION = "3.1.1" 5 | DATE = "2025-09-12" 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/omniauth/strategies/entra_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'omniauth-oauth2' 4 | 5 | module OmniAuth 6 | module Strategies 7 | class EntraId < OmniAuth::Strategies::OAuth2 8 | BASE_URL = 'https://login.microsoftonline.com' 9 | 10 | option :name, 'entra_id' 11 | option :tenant_provider, nil 12 | option :ignore_tid, false 13 | option :jwt_leeway, 60 14 | 15 | DEFAULT_SCOPE = 'openid profile email' 16 | COMMON_TENANT_ID = 'common' 17 | AD_FS_TENANT_ID = 'adfs' 18 | ORGANIZATIONS_TENANT_ID = 'organizations' 19 | CONSUMERS_TENANT_ID = 'consumers' 20 | CONSUMERS_TENANT_GUID = '9188040d-6c67-4c5b-b112-36a304b66dad' 21 | 22 | # The tenant_provider argument is how the provider class is eventually 23 | # passed to us, if one is used instead of an options Hash. 24 | # 25 | args [:tenant_provider] 26 | 27 | def client 28 | provider = if options.tenant_provider 29 | options.tenant_provider.new(self) 30 | else 31 | options 32 | end 33 | 34 | options.client_id = provider.client_id 35 | 36 | if provider.respond_to?(:client_secret) && provider.client_secret 37 | options.client_secret = provider.client_secret 38 | elsif provider.respond_to?(:certificate_path) && provider.respond_to?(:tenant_id) && provider.certificate_path && provider.tenant_id 39 | options.token_params = { 40 | tenant: provider.tenant_id, 41 | client_id: provider.client_id, 42 | client_assertion: client_assertion(provider.tenant_id, provider.client_id, provider.certificate_path), 43 | client_assertion_type: client_assertion_type 44 | } 45 | else 46 | raise ArgumentError, "You must provide either client_secret or certificate_path and tenant_id" 47 | end 48 | 49 | options.tenant_id = if provider.respond_to?(:tenant_id) 50 | provider.tenant_id 51 | else 52 | COMMON_TENANT_ID 53 | end 54 | 55 | options.base_url = if provider.respond_to?(:base_url ) 56 | provider.base_url 57 | else 58 | BASE_URL 59 | end 60 | 61 | options.tenant_name = provider.tenant_name if provider.respond_to?(:tenant_name) 62 | options.custom_policy = provider.custom_policy if provider.respond_to?(:custom_policy) 63 | options.authorize_params = provider.authorize_params if provider.respond_to?(:authorize_params) 64 | options.authorize_params.domain_hint = provider.domain_hint if provider.respond_to?(:domain_hint) && provider.domain_hint 65 | options.ignore_tid = provider.ignore_tid? if provider.respond_to?(:ignore_tid?) && provider.ignore_tid? 66 | options.authorize_params.prompt = request.params['prompt'] if defined?(request) && request.params['prompt'] 67 | 68 | options.authorize_params.scope = if defined?(request) && request.params['scope'] 69 | request.params['scope'] 70 | elsif provider.respond_to?(:scope) && provider.scope 71 | provider.scope 72 | else 73 | DEFAULT_SCOPE 74 | end 75 | 76 | oauth2 = if provider.respond_to?(:adfs?) && provider.adfs? 77 | 'oauth2' 78 | else 79 | 'oauth2/v2.0' 80 | end 81 | 82 | tenanted_endpoint_base_url = if options.custom_policy && options.tenant_name 83 | "https://#{options.tenant_name}.b2clogin.com/#{options.tenant_name}.onmicrosoft.com/#{options.custom_policy}" 84 | else 85 | "#{options.base_url}/#{options.tenant_id}" 86 | end 87 | 88 | options.client_options.authorize_url = "#{tenanted_endpoint_base_url}/#{oauth2}/authorize" 89 | options.client_options.token_url = "#{tenanted_endpoint_base_url}/#{oauth2}/token" 90 | 91 | super 92 | end 93 | 94 | uid do 95 | # 96 | # Note 1: 97 | # 98 | # https://learn.microsoft.com/en-us/entra/identity-platform/migrate-off-email-claim-authorization 99 | # 100 | # OID alone might not be unique; TID must be included. An alternative 101 | # would be to use 'sub' but this is only unique in client/app 102 | # registration context. If a different app registration is used, the 103 | # 'sub' values can be different too... 104 | # 105 | # Note 2: 106 | # 107 | # https://github.com/pond/omniauth-entra-id/issues/42 108 | # 109 | # ...but not everyone agrees on the necessity of a TID and if migrating 110 | # from an earlier version of this gem where user data already includes 111 | # OID-only identifiers, you might elect to avoid a difficult migration 112 | # by opting out - set the "ignore_tid" option to 'true'. 113 | # 114 | # NB: If the TID is missing or blank the UID uses only the OID, just as 115 | # if the "ignore_tid" option were set. 116 | # 117 | if options.ignore_tid? || raw_info['tid'].nil? 118 | raw_info['oid'] 119 | else 120 | raw_info['tid'] + raw_info['oid'] 121 | end 122 | end 123 | 124 | info do 125 | { 126 | name: raw_info['name'], 127 | email: raw_info['email'], 128 | nickname: raw_info['unique_name'], 129 | first_name: raw_info['given_name'], 130 | last_name: raw_info['family_name'] 131 | } 132 | end 133 | 134 | extra do 135 | { raw_info: raw_info } 136 | end 137 | 138 | def callback_url 139 | full_host + callback_path 140 | end 141 | 142 | # https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens 143 | # 144 | # Some account types from Microsoft seem to only have a decodable ID token, 145 | # with JWT unable to decode the access token. Information is limited in those 146 | # cases. Other account types provide an expanded set of data inside the auth 147 | # token, which does decode as a JWT. 148 | # 149 | # Merge the two, allowing the expanded auth token data to overwrite the ID 150 | # token data if keys collide, and use this as raw info. 151 | # 152 | def raw_info 153 | if @raw_info.nil? 154 | id_token_data = begin 155 | ::JWT.decode(access_token.params['id_token'], nil, false).first 156 | rescue StandardError 157 | {} 158 | end 159 | 160 | # For multi-tenant apps (the 'common' tenant_id) it doesn't make any 161 | # sense to verify the token issuer, because the value of 'iss' in the 162 | # token depends on the 'tid' in the token itself. We should also skip 163 | # for AD FS local instances, as we don't put a valid tenant ID in its 164 | # place, but "adfs" (see AD_FS_TENANT_ID) instead. 165 | # 166 | # TODO: Unclear about approach to use for ORGANIZATIONS_TENANT_ID. 167 | # 168 | do_not_verify = ( 169 | options.tenant_id.nil? || 170 | options.tenant_id == COMMON_TENANT_ID || 171 | options.tenant_id == AD_FS_TENANT_ID 172 | ) 173 | 174 | issuer = if do_not_verify 175 | nil 176 | elsif options.tenant_id == CONSUMERS_TENANT_ID 177 | "#{options.base_url || BASE_URL}/#{CONSUMERS_TENANT_GUID}/v2.0" 178 | else 179 | "#{options.base_url || BASE_URL}/#{options.tenant_id}/v2.0" 180 | end 181 | 182 | # https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens#validate-tokens 183 | # 184 | verify_params = { 185 | aud: options.client_id, 186 | exp: { leeway: options.jwt_leeway }, 187 | nbf: { leeway: options.jwt_leeway } 188 | } 189 | verify_params[:iss] = issuer unless issuer.nil? 190 | 191 | ::JWT::Claims.verify_payload!(id_token_data, verify_params) 192 | 193 | auth_token_data = begin 194 | ::JWT.decode(access_token.token, nil, false).first 195 | rescue StandardError 196 | {} 197 | end 198 | 199 | id_token_data.merge!(auth_token_data) 200 | @raw_info = id_token_data 201 | end 202 | 203 | @raw_info 204 | end 205 | 206 | # https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential 207 | # 208 | # The below methods support the flow for using certificate-based client 209 | # assertion authentication. 210 | # 211 | def client_assertion_type 212 | 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' 213 | end 214 | 215 | def client_assertion_claims(tenant_id, client_id) 216 | { 217 | 'aud' => "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token", 218 | 'exp' => Time.now.to_i + 300, 219 | 'iss' => client_id, 220 | 'jti' => SecureRandom.uuid, 221 | 'nbf' => Time.now.to_i, 222 | 'sub' => client_id, 223 | 'iat' => Time.now.to_i 224 | } 225 | end 226 | 227 | def client_assertion(tenant_id, client_id, certificate_path) 228 | certificate_file = OpenSSL::PKCS12.new(File.read(certificate_path)) 229 | certificate_thumbprint ||= Digest::SHA1.digest(certificate_file.certificate.to_der) 230 | private_key = OpenSSL::PKey::RSA.new(certificate_file.key) 231 | 232 | claims = client_assertion_claims(tenant_id, client_id) 233 | x5c = Base64.strict_encode64(certificate_file.certificate.to_der) 234 | x5t = Base64.strict_encode64(certificate_thumbprint) 235 | 236 | JWT.encode(claims, private_key, 'RS256', { 'x5c': [x5c], 'x5t': x5t }) 237 | end 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /omniauth-entra-id.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | $:.push File.expand_path( '../lib', __FILE__ ) 5 | require 'omniauth/entra_id/version' 6 | 7 | # https://guides.rubygems.org/specification-reference/ 8 | # 9 | Gem::Specification.new do |s| 10 | s.name = 'omniauth-entra-id' 11 | s.version = OmniAuth::Entra::Id::VERSION 12 | s.date = OmniAuth::Entra::Id::DATE 13 | s.summary = 'OAuth 2 authentication with the Entra ID API.' 14 | s.authors = [ 'RIPA Global' ] 15 | s.email = [ 'dev@ripaglobal.com' ] 16 | s.licenses = [ 'MIT' ] 17 | s.homepage = 'https://github.com/pond/omniauth-entra-id' 18 | 19 | s.required_ruby_version = Gem::Requirement.new('>= 3.0.0') 20 | s.require_paths = ['lib'] 21 | s.bindir = 'exe' 22 | s.files = %w{ 23 | README.md 24 | CHANGELOG.md 25 | CODE_OF_CONDUCT.md 26 | UPGRADING.md 27 | LICENSE.txt 28 | 29 | Gemfile 30 | bin/console 31 | bin/setup 32 | 33 | lib/omniauth-entra-id.rb 34 | lib/omniauth/entra_id.rb 35 | lib/omniauth/entra_id/version.rb 36 | lib/omniauth/strategies/entra_id.rb 37 | 38 | omniauth-entra-id.gemspec 39 | } 40 | 41 | s.metadata = { 42 | 'homepage_uri' => s.homepage, 43 | 'bug_tracker_uri' => 'https://github.com/pond/omniauth-entra-id/issues/', 44 | 'changelog_uri' => 'https://github.com/pond/omniauth-entra-id/blob/master/CHANGELOG.md', 45 | 'source_code_uri' => 'https://github.com/pond/omniauth-entra-id' 46 | } 47 | 48 | s.add_runtime_dependency 'jwt', '>= 2.9.2' 49 | s.add_runtime_dependency 'omniauth-oauth2', '~> 1.8' 50 | 51 | s.add_development_dependency 'debug', '~> 1.11' 52 | s.add_development_dependency 'rake', '~> 13.3' 53 | s.add_development_dependency 'rspec', '~> 3.13' 54 | end 55 | -------------------------------------------------------------------------------- /spec/omniauth/strategies/entra_id_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'omniauth/entra_id' 3 | 4 | RSpec.describe OmniAuth::Strategies::EntraId do 5 | let(:request) { double('Request', :params => {}, :cookies => {}, :env => {}) } 6 | let(:app) { 7 | lambda do 8 | [200, {}, ["Hello."]] 9 | end 10 | } 11 | 12 | before do 13 | OmniAuth.config.test_mode = true 14 | end 15 | 16 | after do 17 | OmniAuth.config.test_mode = false 18 | end 19 | 20 | describe 'static configuration' do 21 | let(:options) { @options || {} } 22 | subject do 23 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant'}.merge(options)) 24 | end 25 | 26 | describe '#client' do 27 | it 'has correct authorize url' do 28 | allow(subject).to receive(:request) { request } 29 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize') 30 | end 31 | 32 | it 'has correct authorize params' do 33 | allow(subject).to receive(:request) { request } 34 | subject.client 35 | expect(subject.authorize_params[:domain_hint]).to be_nil 36 | end 37 | 38 | it 'has correct token url' do 39 | allow(subject).to receive(:request) { request } 40 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') 41 | end 42 | 43 | context 'when a custom policy and tenant name are present' do 44 | it 'generates the B2C URL' do 45 | @options = { custom_policy: 'my_policy', tenant_name: 'my_tenant' } 46 | allow(subject).to receive(:request) { request } 47 | expect(subject.client.options[:token_url]).to eql('https://my_tenant.b2clogin.com/my_tenant.onmicrosoft.com/my_policy/oauth2/v2.0/token') 48 | end 49 | end # "context 'when a custom policy and tenant name are present' do" 50 | 51 | it 'supports authorization_params' do 52 | @options = { authorize_params: {prompt: 'select_account'} } 53 | allow(subject).to receive(:request) { request } 54 | subject.client 55 | expect(subject.authorize_params[:prompt]).to eql('select_account') 56 | end 57 | 58 | context 'using client secret flow without client secret' do 59 | subject do 60 | OmniAuth::Strategies::EntraId.new(app, { client_id: 'id', tenant_id: 'tenant' }.merge(options)) 61 | end 62 | 63 | it 'raises exception' do 64 | expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") 65 | end 66 | end # "context 'using client secret flow without client secret' do" 67 | 68 | context 'using client assertion flow' do 69 | subject do 70 | OmniAuth::Strategies::EntraId.new(app, options) 71 | end 72 | 73 | it 'raises exception when tenant id is not given' do 74 | @options = { client_id: 'id', certificate_path: 'path/to/cert.p12' } 75 | expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") 76 | end 77 | 78 | it 'raises exception when certificate_path is not given' do 79 | @options = { client_id: 'id', tenant_id: 'tenant' } 80 | expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") 81 | end 82 | 83 | context '#token_params with correctly formatted request' do 84 | let(:key) { OpenSSL::PKey::RSA.new(2048) } 85 | let(:cert) { OpenSSL::X509::Certificate.new.tap { |cert| 86 | cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=test") 87 | cert.not_before = Time.now 88 | cert.not_after = Time.now + 365 * 24 * 60 * 60 89 | cert.public_key = key.public_key 90 | cert.serial = 0x0 91 | cert.version = 2 92 | cert.sign(key, OpenSSL::Digest::SHA256.new) 93 | } } 94 | 95 | before do 96 | @options = { 97 | client_id: 'id', 98 | tenant_id: 'tenant', 99 | certificate_path: 'path/to/cert.p12' 100 | } 101 | 102 | allow(File).to receive(:read) 103 | allow(OpenSSL::PKCS12).to receive(:new).and_return(OpenSSL::PKCS12.create('pass', 'name', key, cert)) 104 | allow(SecureRandom).to receive(:uuid).and_return('unique-jti') 105 | 106 | allow(subject).to receive(:request) { request } 107 | subject.client 108 | end 109 | 110 | it 'has correct tenant id' do 111 | expect(subject.options.token_params[:tenant]).to eql('tenant') 112 | end 113 | 114 | it 'has correct client id' do 115 | expect(subject.options.token_params[:client_id]).to eql('id') 116 | end 117 | 118 | it 'has correct client_assertion_type' do 119 | expect(subject.options.token_params[:client_assertion_type]).to eql('urn:ietf:params:oauth:client-assertion-type:jwt-bearer') 120 | end 121 | 122 | context 'client assertion' do 123 | it 'has correct claims' do 124 | jwt = subject.options.token_params[:client_assertion] 125 | decoded_jwt = JWT.decode(jwt, nil, false).first 126 | 127 | expect(decoded_jwt['aud']).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') 128 | expect(decoded_jwt['exp']).to be_within(5).of(Time.now.to_i + 300) 129 | expect(decoded_jwt['iss']).to eql('id') 130 | expect(decoded_jwt['jti']).to eql('unique-jti') 131 | expect(decoded_jwt['nbf']).to be_within(5).of(Time.now.to_i) 132 | expect(decoded_jwt['sub']).to eql('id') 133 | end 134 | 135 | it 'contains x5c and x5t headers' do 136 | jwt = subject.options.token_params[:client_assertion] 137 | headers = JWT.decode(jwt, nil, false).last 138 | 139 | expect(headers['x5c']).to be_an_instance_of(Array) 140 | expect(headers['x5t']).to be_a(String) 141 | end 142 | end # "context 'client assertion' do" 143 | end # "context '#token_params with correctly formatted request' do" 144 | end # "context 'using client assertion flow' do" 145 | 146 | describe "overrides" do 147 | it 'should override domain_hint' do 148 | @options = {domain_hint: 'hint'} 149 | allow(subject).to receive(:request) { request } 150 | subject.client 151 | expect(subject.authorize_params[:domain_hint]).to eql('hint') 152 | end 153 | 154 | it 'overrides prompt via query parameter' do 155 | @options = { authorize_params: {prompt: 'select_account'} } 156 | override_request = double('Request', :params => {'prompt'.to_s => 'consent'}, :cookies => {}, :env => {}) 157 | allow(subject).to receive(:request) { override_request } 158 | subject.client 159 | expect(subject.authorize_params[:prompt]).to eql('consent') 160 | end 161 | end # "describe "overrides" do" 162 | end # "describe '#client' do" 163 | end # "describe 'static configuration' do" 164 | 165 | describe 'static configuration - german' do 166 | let(:options) { @options || {} } 167 | subject do 168 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant', base_url: 'https://login.microsoftonline.de'}.merge(options)) 169 | end 170 | 171 | describe '#client' do 172 | it 'has correct authorize url' do 173 | allow(subject).to receive(:request) { request } 174 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.de/tenant/oauth2/v2.0/authorize') 175 | end 176 | 177 | it 'has correct authorize params' do 178 | allow(subject).to receive(:request) { request } 179 | subject.client 180 | expect(subject.authorize_params[:domain_hint]).to be_nil 181 | end 182 | 183 | it 'has correct token url' do 184 | allow(subject).to receive(:request) { request } 185 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.de/tenant/oauth2/v2.0/token') 186 | end 187 | 188 | it 'has correct authorize_params' do 189 | allow(subject).to receive(:request) { request } 190 | subject.client 191 | expect(subject.authorize_params[:scope]).to eql('openid profile email') 192 | end 193 | 194 | context 'when a custom policy and tenant name are present' do 195 | it 'generates the B2C URL (which does not include locale)' do 196 | @options = { custom_policy: 'my_policy', tenant_name: 'my_tenant' } 197 | allow(subject).to receive(:request) { request } 198 | expect(subject.client.options[:token_url]).to eql('https://my_tenant.b2clogin.com/my_tenant.onmicrosoft.com/my_policy/oauth2/v2.0/token') 199 | end 200 | end # "context 'when a custom policy and tenant name are present' do" 201 | 202 | describe "overrides" do 203 | it 'should override domain_hint' do 204 | @options = {domain_hint: 'hint'} 205 | allow(subject).to receive(:request) { request } 206 | subject.client 207 | expect(subject.authorize_params[:domain_hint]).to eql('hint') 208 | end 209 | end # "describe "overrides" do" 210 | end # "describe '#client' do" 211 | end # "describe 'static configuration - german' do" 212 | 213 | describe 'static common configuration' do 214 | let(:options) { @options || {} } 215 | subject do 216 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret'}.merge(options)) 217 | end 218 | 219 | before do 220 | allow(subject).to receive(:request) { request } 221 | end 222 | 223 | describe '#client' do 224 | it 'has correct authorize url' do 225 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.com/common/oauth2/v2.0/authorize') 226 | end 227 | 228 | it 'has correct token url' do 229 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/common/oauth2/v2.0/token') 230 | end 231 | end # "describe '#client' do" 232 | end # "describe 'static common configuration' do" 233 | 234 | describe 'static configuration with on premise AD FS' do 235 | let(:options) { @options || {} } 236 | 237 | subject do 238 | OmniAuth::Strategies::EntraId.new( 239 | app, 240 | { 241 | client_id: 'id', 242 | client_secret: 'secret', 243 | tenant_id: 'adfs', 244 | base_url: 'https://login.contoso.com' 245 | }.merge(options) # 'adfs' or 'adfs?' is set here, by defining @options 246 | ) 247 | end 248 | 249 | shared_examples 'an integration aware of AD FS' do 250 | describe 'wherein #client' do 251 | it 'has correct authorize url' do 252 | allow(subject).to receive(:request) { request } 253 | expect(subject.client.options[:authorize_url]).to eql('https://login.contoso.com/adfs/oauth2/authorize') 254 | end 255 | 256 | it 'has correct token url' do 257 | allow(subject).to receive(:request) { request } 258 | expect(subject.client.options[:token_url]).to eql('https://login.contoso.com/adfs/oauth2/token') 259 | end 260 | end # "describe 'wherein #client' do" 261 | end # "shared_examples 'an integration aware of AD FS wherein' do" 262 | 263 | context ':adfs option variant' do 264 | before :each do 265 | @options = { adfs: true } 266 | end 267 | 268 | it_behaves_like 'an integration aware of AD FS' 269 | end # "context ':adfs option variant' do" 270 | 271 | context ':adfs? option variant' do 272 | before :each do 273 | @options = { adfs?: true } 274 | end 275 | 276 | it_behaves_like 'an integration aware of AD FS' 277 | end # "context ':adfs? option variant' do" 278 | end # "describe 'static configuration with on premise AD FS' do" 279 | 280 | describe 'dynamic configuration' do 281 | let(:provider_klass) { 282 | Class.new { 283 | def initialize(strategy) 284 | end 285 | 286 | def client_id 287 | 'id' 288 | end 289 | 290 | def client_secret 291 | 'secret' 292 | end 293 | 294 | def tenant_id 295 | 'tenant' 296 | end 297 | 298 | def authorize_params 299 | { custom_option: 'value' } 300 | end 301 | } 302 | } 303 | 304 | subject do 305 | OmniAuth::Strategies::EntraId.new(app, provider_klass) 306 | end 307 | 308 | before do 309 | allow(subject).to receive(:request) { request } 310 | end 311 | 312 | describe '#client' do 313 | it 'has correct authorize url' do 314 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize') 315 | end 316 | 317 | it 'has correct authorize params' do 318 | subject.client 319 | expect(subject.authorize_params[:domain_hint]).to be_nil 320 | expect(subject.authorize_params[:custom_option]).to eql('value') 321 | end 322 | 323 | it 'has correct token url' do 324 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') 325 | end 326 | 327 | it 'has correct authorize_params' do 328 | subject.client 329 | expect(subject.authorize_params[:scope]).to eql('openid profile email') 330 | end 331 | 332 | # todo: how to get this working? 333 | # describe "overrides" do 334 | # it 'should override domain_hint' do 335 | # provider_klass.domain_hint = 'hint' 336 | # subject.client 337 | # expect(subject.authorize_params[:domain_hint]).to eql('hint') 338 | # end 339 | # end 340 | end # "describe '#client' do" 341 | end # "describe 'dynamic configuration' do" 342 | 343 | describe 'dynamic configuration - german' do 344 | let(:provider_klass) { 345 | Class.new { 346 | def initialize(strategy) 347 | end 348 | 349 | def client_id 350 | 'id' 351 | end 352 | 353 | def client_secret 354 | 'secret' 355 | end 356 | 357 | def tenant_id 358 | 'tenant' 359 | end 360 | 361 | def base_url 362 | 'https://login.microsoftonline.de' 363 | end 364 | 365 | def scope 366 | 'Calendars.ReadWrite email offline_access User.Read' 367 | end 368 | } 369 | } 370 | 371 | subject do 372 | OmniAuth::Strategies::EntraId.new(app, provider_klass) 373 | end 374 | 375 | before do 376 | allow(subject).to receive(:request) { request } 377 | end 378 | 379 | describe '#client' do 380 | it 'has correct authorize url' do 381 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.de/tenant/oauth2/v2.0/authorize') 382 | end 383 | 384 | it 'has correct authorize params' do 385 | subject.client 386 | expect(subject.authorize_params[:domain_hint]).to be_nil 387 | end 388 | 389 | it 'has correct token url' do 390 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.de/tenant/oauth2/v2.0/token') 391 | end 392 | 393 | it 'has correct scope' do 394 | subject.client 395 | expect(subject.authorize_params[:scope]).to eql('Calendars.ReadWrite email offline_access User.Read') 396 | end 397 | 398 | # todo: how to get this working? 399 | # describe "overrides" do 400 | # it 'should override domain_hint' do 401 | # provider_klass.domain_hint = 'hint' 402 | # subject.client 403 | # expect(subject.authorize_params[:domain_hint]).to eql('hint') 404 | # end 405 | # end 406 | end # "describe '#client' do" 407 | end # "describe 'dynamic configuration - german' do" 408 | 409 | describe 'dynamic common configuration' do 410 | let(:provider_klass) { 411 | Class.new { 412 | def initialize(strategy) 413 | end 414 | 415 | def client_id 416 | 'id' 417 | end 418 | 419 | def client_secret 420 | 'secret' 421 | end 422 | } 423 | } 424 | 425 | subject do 426 | OmniAuth::Strategies::EntraId.new(app, provider_klass) 427 | end 428 | 429 | before do 430 | allow(subject).to receive(:request) { request } 431 | end 432 | 433 | describe '#client' do 434 | it 'has correct authorize url' do 435 | expect(subject.client.options[:authorize_url]).to eql('https://login.microsoftonline.com/common/oauth2/v2.0/authorize') 436 | end 437 | 438 | it 'has correct token url' do 439 | expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/common/oauth2/v2.0/token') 440 | end 441 | 442 | it 'has correct scope from request params' do 443 | request.params['scope'] = 'openid email offline_access Calendars.Read' 444 | subject.client 445 | expect(subject.authorize_params[:scope]).to eql('openid email offline_access Calendars.Read') 446 | end 447 | end # "describe '#client' do" 448 | end # "describe 'dynamic common configuration' do" 449 | 450 | describe 'dynamic configuration with on premise AD FS' do 451 | let(:provider_klass) { 452 | Class.new { 453 | def initialize(strategy) 454 | end 455 | 456 | def client_id 457 | 'id' 458 | end 459 | 460 | def client_secret 461 | 'secret' 462 | end 463 | 464 | def tenant_id 465 | 'adfs' 466 | end 467 | 468 | def base_url 469 | 'https://login.contoso.com' 470 | end 471 | 472 | def adfs? 473 | true 474 | end 475 | } 476 | } 477 | 478 | subject do 479 | OmniAuth::Strategies::EntraId.new(app, provider_klass) 480 | end 481 | 482 | before do 483 | allow(subject).to receive(:request) { request } 484 | end 485 | 486 | describe '#client' do 487 | it 'has correct authorize url' do 488 | expect(subject.client.options[:authorize_url]).to eql('https://login.contoso.com/adfs/oauth2/authorize') 489 | end 490 | 491 | it 'has correct token url' do 492 | expect(subject.client.options[:token_url]).to eql('https://login.contoso.com/adfs/oauth2/token') 493 | end 494 | end # "describe '#client' do" 495 | end # "describe 'dynamic configuration with on premise AD FS' do" 496 | 497 | describe 'raw_info and validation' do 498 | let(:issued_at ) { Time.now.utc.to_i } 499 | let(:expires_at) { (Time.now.utc + 3600).to_i } 500 | let(:id_token_info) do 501 | { 502 | ver: '2.0', 503 | iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', 504 | sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', 505 | aud: 'id', 506 | exp: expires_at(), 507 | iat: issued_at(), 508 | nbf: issued_at(), 509 | name: 'Bob Doe', 510 | preferred_username: 'bob@doe.com', 511 | oid: 'my_id', 512 | email: 'bob@doe.com', 513 | tid: '9188040d-6c67-4c5b-b112-36a304b66dad', 514 | aio: 'KSslldiwDkfjjsoeiruosKD', 515 | unique_name: 'bobby' 516 | } 517 | end 518 | 519 | let(:id_token) do 520 | JWT.encode(id_token_info, 'secret') 521 | end 522 | 523 | let(:access_token) do 524 | double(:token => SecureRandom.uuid, :params => {'id_token' => id_token}) 525 | end 526 | 527 | # Override or add construction options entries by setting @options in a 528 | # "before :context" hook - that is BEFORE CONTEXT, since that runs before 529 | # the "before :each" hooks a few lines below here which cause 'subject' to 530 | # be evaluated. Ignored if @provider_klass is set (see below). 531 | # 532 | let(:options) do 533 | @options || {} 534 | end 535 | 536 | # Override options hash with provider class by setting @provider_klass in a 537 | # "before :context" hook - that is BEFORE CONTEXT, since that runs before 538 | # the "before :each" hooks a few lines below here which cause 'subject' to 539 | # be evaluated. If set, @options is ignored (see above). 540 | # 541 | let(:provider_klass) do 542 | @provider_klass || nil 543 | end 544 | 545 | subject do 546 | if provider_klass.nil? 547 | OmniAuth::Strategies::EntraId.new( 548 | app, 549 | { client_id: 'id', client_secret: 'secret' }.merge(options) 550 | ) 551 | else 552 | OmniAuth::Strategies::EntraId.new( 553 | app, 554 | provider_klass 555 | ) 556 | end 557 | end 558 | 559 | before :each do 560 | allow(subject).to receive(:access_token) { access_token } 561 | allow(subject).to receive(:request) { request } 562 | end 563 | 564 | context 'with information only in the ID token' do 565 | it 'returns correct info' do 566 | expect(subject.info).to eq({ 567 | name: 'Bob Doe', 568 | email: 'bob@doe.com', 569 | nickname: 'bobby', 570 | first_name: nil, 571 | last_name: nil 572 | }) 573 | end 574 | 575 | it 'returns correct "uid"' do 576 | expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadmy_id') 577 | end 578 | 579 | it 'returns correct "oid" for V2 or earlier lazy migrations' do 580 | expect(subject.raw_info['oid']).to eq('my_id') 581 | end 582 | end # "context 'with information only in the ID token' do" 583 | 584 | context 'with a TID which is' do 585 | context 'missing' do 586 | let(:id_token_info) do 587 | info = super() 588 | info.delete(:tid) 589 | info 590 | end 591 | 592 | it 'returns a UID comprising of the OID only' do 593 | expect(subject.uid).to eq('my_id') 594 | end 595 | end # "context'"missing' do" 596 | 597 | context 'blank' do 598 | let(:id_token_info) do 599 | info = super() 600 | info[:tid] = '' 601 | info 602 | end 603 | 604 | it 'returns a UID comprising of the OID only' do 605 | expect(subject.uid).to eq('my_id') 606 | end 607 | end # "context 'blank' do" 608 | end # "context 'with a TID which is' do" 609 | 610 | context 'when configured to ignore the TID' do 611 | context 'using the :ignore_tid option variant' do 612 | before :context do 613 | @options = { ignore_tid: true } 614 | end 615 | 616 | it 'returns a UID comprising of the OID only' do 617 | expect(subject.uid).to eq('my_id') 618 | end 619 | end # "context 'using the :ignore_tid option variant' do" 620 | 621 | context 'using the :ignore_tid? option variant' do 622 | before :context do 623 | @options = { ignore_tid?: true } 624 | end 625 | 626 | it 'returns a UID comprising of the OID only' do 627 | expect(subject.uid).to eq('my_id') 628 | end 629 | end # "context 'using the :ignore_tid? option variant' do" 630 | 631 | context 'using a custom provider class' do 632 | before :context do 633 | @provider_klass = Class.new { 634 | def initialize(strategy) 635 | end 636 | 637 | def client_id 638 | 'id' 639 | end 640 | 641 | def client_secret 642 | 'secret' 643 | end 644 | 645 | def ignore_tid? 646 | true 647 | end 648 | } 649 | end 650 | 651 | it 'returns a UID comprising of the OID only' do 652 | subject.client 653 | expect(subject.uid).to eq('my_id') 654 | end 655 | end # "context 'using a custom provider class' do" 656 | end # "context 'when configured to ignore the TID' do" 657 | 658 | context 'with extra information in the auth token' do 659 | let(:auth_token_info) do 660 | { 661 | ver: '2.0', 662 | iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', 663 | sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', 664 | aud: 'id', 665 | exp: expires_at(), 666 | iat: issued_at(), 667 | nbf: issued_at(), 668 | preferred_username: 'bob@doe.com', 669 | oid: 'overridden_id', # (overrides ID token) 670 | email: 'bob@doe.com', 671 | tid: '9188040d-6c67-4c5b-b112-36a304b66dad', 672 | aio: 'KSslldiwDkfjjsoeiruosKD', 673 | unique_name: 'Bobby Definitely Doe', # (overrides ID token) 674 | given_name: 'Bob', 675 | family_name: 'Doe' 676 | } 677 | end 678 | 679 | let(:auth_token) do 680 | JWT.encode(auth_token_info, 'secret') 681 | end 682 | 683 | let(:access_token) do 684 | double(:token => auth_token, :params => {'id_token' => id_token}) 685 | end 686 | 687 | it 'returns correct info' do 688 | expect(subject.info).to eq( 689 | { 690 | name: 'Bob Doe', 691 | email: 'bob@doe.com', 692 | nickname: 'Bobby Definitely Doe', 693 | first_name: 'Bob', 694 | last_name: 'Doe' 695 | } 696 | ) 697 | end 698 | 699 | it 'returns correct "uid"' do 700 | expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadoverridden_id') 701 | end 702 | 703 | it 'returns correct "oid" for V2 or earlier lazy migrations' do 704 | expect(subject.raw_info['oid']).to eq('overridden_id') 705 | end 706 | end # "context 'with extra information in the auth token' do" 707 | 708 | context 'with an invalid audience' do 709 | let(:id_token_info) do 710 | { 711 | ver: '2.0', 712 | iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', 713 | sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', 714 | aud: 'other-id', 715 | exp: expires_at(), 716 | iat: issued_at(), 717 | nbf: issued_at(), 718 | name: 'Bob Doe', 719 | preferred_username: 'bob@doe.com', 720 | oid: 'my_id', 721 | email: 'bob@doe.com', 722 | tid: '9188040d-6c67-4c5b-b112-36a304b66dad', 723 | aio: 'KSslldiwDkfjjsoeiruosKD', 724 | unique_name: 'bobby' 725 | } 726 | end 727 | 728 | it 'fails validation' do 729 | expect { subject.info }.to raise_error(JWT::InvalidAudError) 730 | end 731 | end # "context 'with an invalid audience' do" 732 | 733 | context 'issuers' do 734 | context 'when valid' do 735 | subject do 736 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: '9188040d-6c67-4c5b-b112-36a304b66dad'}) 737 | end 738 | 739 | it 'passes validation' do 740 | expect { subject.info }.to_not raise_error() 741 | end 742 | end # "context 'when valid' do" 743 | 744 | context 'when invalid' do 745 | subject do 746 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'a-mismatched-tenant-id'}) 747 | end 748 | 749 | it 'fails validation' do 750 | expect { subject.info }.to raise_error(JWT::InvalidIssuerError) 751 | end 752 | end # "context 'when invalid' do" 753 | 754 | context 'multi-tenant, AD FS' do 755 | let(:id_token_info) do 756 | hash = super() 757 | hash['iss'] = 'invalid issuer that should be ignored' 758 | hash 759 | end 760 | 761 | context 'no tenant specified' do 762 | subject do 763 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: nil}) 764 | end 765 | 766 | it 'skips issuer validation since tenant ID is unknown' do 767 | expect { subject.info }.to_not raise_error() 768 | end 769 | end # "context 'no tenant specified' do" 770 | 771 | context '"common" tenant specified' do 772 | subject do 773 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: OmniAuth::Strategies::EntraId::COMMON_TENANT_ID}) 774 | end 775 | 776 | it 'skips issuer validation since tenant ID is unknown' do 777 | expect { subject.info }.to_not raise_error() 778 | end 779 | end # "context '"common" tenant specified' do" 780 | 781 | context '"adfs" tenant specified' do 782 | subject do 783 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: OmniAuth::Strategies::EntraId::AD_FS_TENANT_ID}) 784 | end 785 | 786 | it 'skips issuer validation since tenant ID is unknown' do 787 | expect { subject.info }.to_not raise_error() 788 | end 789 | end # "context '"common" tenant specified' do" 790 | 791 | context '"consumers" tenant specified' do 792 | subject do 793 | OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: OmniAuth::Strategies::EntraId::CONSUMERS_TENANT_ID}) 794 | end 795 | 796 | it 'raises an error as the issuer *is* checked' do 797 | expect { subject.info }.to raise_error(JWT::InvalidIssuerError) 798 | end 799 | 800 | context 'with the "magic" tenant ID' do 801 | let(:id_token_info) do 802 | hash = super() 803 | hash['iss'] = 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0' 804 | hash 805 | end 806 | 807 | it 'raises no error' do 808 | expect { subject.info }.to_not raise_error() 809 | end 810 | end # "context 'with the "magic" tenant ID' do" 811 | end # "context '"consumers" tenant specified' do" 812 | end # "context 'multi-tenant, AD FS' do" 813 | end # "context 'issuers' do" 814 | 815 | context 'with an invalid not_before' do 816 | let(:issued_at) { (Time.now.utc + 70).to_i } # Invalid because leeway is 60 seconds 817 | 818 | let(:id_token_info) do 819 | { 820 | ver: '2.0', 821 | iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', 822 | sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', 823 | aud: 'id', 824 | exp: expires_at(), 825 | iat: issued_at(), 826 | nbf: issued_at(), 827 | name: 'Bob Doe', 828 | preferred_username: 'bob@doe.com', 829 | oid: 'my_id', 830 | email: 'bob@doe.com', 831 | tid: '9188040d-6c67-4c5b-b112-36a304b66dad', 832 | aio: 'KSslldiwDkfjjsoeiruosKD', 833 | unique_name: 'bobby' 834 | } 835 | end 836 | 837 | it 'fails validation' do 838 | expect { subject.info }.to raise_error(JWT::ImmatureSignature) 839 | end 840 | end # "context 'with an invalid not_before' do" 841 | 842 | context 'with an expired token' do 843 | let(:issued_at ) { (Time.now.utc - 3600).to_i } 844 | let(:expires_at) { (Time.now.utc - 70 ).to_i } # Invalid because leeway is 60 seconds 845 | 846 | let(:id_token_info) do 847 | { 848 | ver: '2.0', 849 | iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', 850 | sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', 851 | aud: 'id', 852 | exp: expires_at(), 853 | iat: issued_at(), 854 | nbf: issued_at(), 855 | name: 'Bob Doe', 856 | preferred_username: 'bob@doe.com', 857 | oid: 'my_id', 858 | email: 'bob@doe.com', 859 | tid: '9188040d-6c67-4c5b-b112-36a304b66dad', 860 | aio: 'KSslldiwDkfjjsoeiruosKD', 861 | unique_name: 'bobby' 862 | } 863 | end 864 | 865 | it 'fails validation' do 866 | expect { subject.info }.to raise_error(JWT::ExpiredSignature) 867 | end 868 | end # "context 'with an expired token' do" 869 | end # "describe 'raw_info and validation' do" 870 | 871 | describe 'callback_url' do 872 | subject do 873 | OmniAuth::Strategies::EntraId.new(app, { client_id: 'id', client_secret: 'secret', tenant_id: 'tenant' }) 874 | end 875 | 876 | let(:base_url) { 'https://example.com' } 877 | 878 | it 'has the correct default callback path' do 879 | allow(subject).to receive(:full_host) { base_url } 880 | allow(subject).to receive(:script_name) { '' } 881 | expect(subject.callback_url).to eq(base_url + '/auth/entra_id/callback') 882 | end 883 | 884 | it 'should set the callback path with script_name if present' do 885 | allow(subject).to receive(:full_host) { base_url } 886 | allow(subject).to receive(:script_name) { '/v1' } 887 | expect(subject.callback_url).to eq(base_url + '/v1/auth/entra_id/callback') 888 | end 889 | end 890 | end 891 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'debug' 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = ".rspec_status" 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | --------------------------------------------------------------------------------