From 5a4d29aac41a3af41d6a54da1d70fc6680e25ceb Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Thu, 17 Oct 2024 16:45:12 +1300 Subject: [PATCH 1/8] Starting point for V3, with tidying and rename for Entra ID --- .github/workflows/master.yml | 2 +- CHANGELOG.md | 65 +---- Gemfile | 2 +- LICENSE.txt | 2 +- README.md | 107 ++++--- UPGRADING.md | 96 +++++++ bin/console | 2 +- lib/omniauth-azure-activedirectory-v2.rb | 1 - lib/omniauth-entra-id.rb | 1 + lib/omniauth/azure_activedirectory_v2.rb | 4 - .../azure_activedirectory_v2/version.rb | 10 - lib/omniauth/entra_id.rb | 2 + lib/omniauth/entra_id/version.rb | 8 + ...zure_activedirectory_v2.rb => entra_id.rb} | 114 ++++---- omniauth-azure-activedirectory-v2.gemspec | 60 ---- omniauth-entra-id.gemspec | 52 ++++ ...edirectory_v2_spec.rb => entra_id_spec.rb} | 266 ++---------------- 17 files changed, 309 insertions(+), 485 deletions(-) create mode 100644 UPGRADING.md delete mode 100644 lib/omniauth-azure-activedirectory-v2.rb create mode 100644 lib/omniauth-entra-id.rb delete mode 100644 lib/omniauth/azure_activedirectory_v2.rb delete mode 100644 lib/omniauth/azure_activedirectory_v2/version.rb create mode 100644 lib/omniauth/entra_id.rb create mode 100644 lib/omniauth/entra_id/version.rb rename lib/omniauth/strategies/{azure_activedirectory_v2.rb => entra_id.rb} (56%) delete mode 100644 omniauth-azure-activedirectory-v2.gemspec create mode 100644 omniauth-entra-id.gemspec rename spec/omniauth/strategies/{azure_activedirectory_v2_spec.rb => entra_id_spec.rb} (54%) diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 3d80e53..4b930e2 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -22,7 +22,7 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - rubygems: 3.5.11 + rubygems: 3.5.12 - name: Run tests run: bundle exec rspec --backtrace diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e73b9..510ec18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,63 +1,8 @@ # Change Log -## v2.4.0 (2024-10-17) +## v3.0.0 (2024-10-21) -Deprecation warnings for end-of-life of the gem under this name. No other changes. The GitHub repository is to be renamed and the gem released (starting at major version 3) as `omniauth-entra-id`, with some breaking changes but details of how to update will be provided in the new gem via an `UPGRADING.md` document. - -## v2.3.0 (2024-07-16) - -[Implements](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/pull/29) support for on-premise Active Directory installations via the `adfs` option; see `README.md` for details - thanks @frenkel! - -## v2.2.0 (2024-07-09) - -[Implements](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/pull/26) support for specifying `scope` via the authorisation URL, in addition to the prior support for static configuration or configuration via a custom provider class - thanks @nbgoodall! - -## v2.1.0 (2023-09-16) - -[Implements](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/pull/19) support for custom policies when using Microsoft Azure AD - thanks @stevenchanin! - -## v2.0.2 (2023-03-31) - -[Fixes](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/pull/16) inability to override prompt in authorisation parameters - thanks @lamroger! - -## v2.0.1 (2023-01-11) - -Renames: - -* RIPGlobal -> RIPAGlobal -* Omniauth -> OmniAuth - -_No functional change._ - -## v2.0.0 (2022-09-14) - -Makes compatible with OmniAuth 2 and requires it. - -Note: https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/pull/6 for reasoning - Thanks @jessieay - -_Major version bump as no longer supports OmniAuth 1._ - -## v1.0.0 (2020-09-25) - -Removes use of the https://graph.microsoft.com/v1.0/me API. - -* One of the key differences for the V2 API vs V1 is the differences - between who can sign with the addition of Personal Accounts - see: - https://nicolgit.github.io/AzureAD-Endopoint-V1-vs-V2-comparison/ - - - In testing we found that these accounts may not have access to - this endpoint - - All the data provided in `info` exists in the JWT anyway, so this - cuts down on API calls - -* Conforms to the OmniAuth Auth Hash Schema (1.0 and later) - see: - https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema - - - Expose `raw_info` - - Remove `id` from `info` - - *NB: This could be a breaking change for some, but most will - already be using the correct property name of `uid`.* - -## v0.1.1 (2020-09-23) - -- First release. +* Branched from `omniauth-entra-id` version 2.4.0 and renamed to `omniauth-entra-id` +* Can specify `tenant_name` in options via #31 (thanks to @Jureamer) for B2C login +* Supports authenticating with a certificate instead of client secret via #32 (thanks to @juliaducey) +* 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) diff --git a/Gemfile b/Gemfile index b385d91..d75382f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -# Specify your gem's dependencies in omniauth-azure-activedirectory-v2.gemspec +# Specify your gem's dependencies in omniauth-entra-id.gemspec # gemspec diff --git a/LICENSE.txt b/LICENSE.txt index 526a25e..474e761 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 Jesse Whitham +Copyright (c) 2024 RIPA Global Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fbd3b55..b050979 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,17 @@ -# OmniAuth::Azure::Activedirectory::V2 +# OmniAuth::Entra::Id -[![Gem Version](https://badge.fury.io/rb/omniauth-azure-activedirectory-v2.svg)](https://rubygems.org/gems/omniauth-azure-activedirectory-v2) -[![Build Status](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/actions/workflows/master.yml/badge.svg)](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/actions) -[![License](https://img.shields.io/github/license/RIPAGlobal/omniauth-azure-activedirectory-v2.svg)](LICENSE.txt) +[![Gem Version](https://badge.fury.io/rb/omniauth-entra-id.svg)](https://rubygems.org/gems/omniauth-entra-id) +[![Build Status](https://github.com/RIPAGlobal/omniauth-entra-id/actions/workflows/master.yml/badge.svg)](https://github.com/RIPAGlobal/omniauth-entra-id/actions) +[![License](https://img.shields.io/github/license/RIPAGlobal/omniauth-entra-id.svg)](LICENSE.txt) **IMPORTANT: V2 is end-of-life** and superseded by a renamed gem, since Microsoft in their "wisdom" renamed Azure AD to Entra ID. A gem using the old name will become increasingly hard for people to 'discover'. The major version bump provides an opportunity to fix a few things via breaking changes, too. Please switch to `omniauth-entra-id`. -OAuth 2 authentication with [Azure ActiveDirectory's V2 API](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-overview). Rationale: +OAuth 2 authentication with [Entra ID API](https://learn.microsoft.com/en-us/entra/identity-platform/v2-overview). Rationale: * https://github.com/marknadig/omniauth-azure-oauth2 is no longer maintained. * https://github.com/marknadig/omniauth-azure-oauth2/pull/29 contains important additions. -This gem combines the two and makes some changes to support the full V2 API. - -The ActiveDirectory V1 auth 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. +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. @@ -22,7 +20,7 @@ The ActiveDirectory V1 auth API used OpenID Connect. If you need this, a gem fro Add this line to your application's Gemfile: ```ruby -gem 'omniauth-azure-activedirectory-v2' +gem 'omniauth-entra-id' ``` And then execute: @@ -34,24 +32,20 @@ $ bundle install Or install it yourself as: ```shell -$ gem install omniauth-azure-activedirectory-v2 +$ gem install omniauth-entra-id ``` + ## Usage -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 `azure_activedirectory_v2` rather than `azure_oauth2`. Additional configuration information is given below. +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. -### Entra ID Configuration -In most cases, you only want to receive 'verified' email addresses in -your application. For older app registrations in the Azure 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). +### Entra ID server configuration -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. +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. ### Implementation - #### With `OmniAuth::Builder` You can do something like this for a static / fixed configuration: @@ -59,10 +53,10 @@ You can do something like this for a static / fixed configuration: ```ruby use OmniAuth::Builder do provider( - :azure_activedirectory_v2, + :entra_id, { - client_id: ENV['AZURE_CLIENT_ID'], - client_secret: ENV['AZURE_CLIENT_SECRET'] + client_id: ENV['ENTRA_CLIENT_ID'], + client_secret: ENV['ENTRA_CLIENT_SECRET'] } ) end @@ -73,7 +67,7 @@ end ```ruby use OmniAuth::Builder do provider( - :azure_activedirectory_v2, + :entra_id, YouTenantProvider ) end @@ -85,10 +79,10 @@ In your `config/initializers/devise.rb` file you can do something like this for ```ruby config.omniauth( - :azure_activedirectory_v2, + :entra_id, { - client_id: ENV['AZURE_CLIENT_ID'], - client_secret: ENV['AZURE_CLIENT_SECRET'] + client_id: ENV['ENTRA_CLIENT_ID'], + client_secret: ENV['ENTRA_CLIENT_SECRET'] } ) ``` @@ -97,7 +91,7 @@ config.omniauth( ```ruby config.omniauth( - :azure_activedirectory_v2, + :entra_id, YouTenantProvider ) ``` @@ -106,42 +100,41 @@ config.omniauth( 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). -To have your application authenticate with Entra (formerly known as AAD) via 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. +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. -If you're using the client assertion flow, you need to register your certificate in the Azure portal. For more information, please see [the documentation](https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials). +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). -| Option | Use | -|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_id` | **Mandatory.** Client ID for the 'application' (integration) configured on the Azure side. Found via the Azure UI. | -| `client_secret` | **Mandatory for client secret flow.** Client secret for the 'application' (integration) configured on the Azure side. Found via the Azure UI. Don't give this if using client assertion flow. | -| `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. | -| `base_azure_url` | Location of Azure login page, for specialised requirements; default is `OmniAuth::Strategies::AzureActivedirectoryV2::BASE_AZURE_URL` (at the time of writing, this is `https://login.microsoftonline.com`). | -| `tenant_id` | **Mandatory for client assertion flow.** _Azure_ tenant ID for multi-tenanted use. Default is `common`. Forms part of the Azure OAuth URL - `{base}/{tenant_id}/oauth2/v2.0/...` | -| `custom_policy` | _Azure_ custom policy. Default is nil. Forms part of the Azure Token URL - `{base}/{tenant_id}/{custom_policy}/oauth2/v2.0/...` | -| `authorize_params` | Additional parameters passed as URL query data in the initial OAuth redirection to Microsoft. See below for more. Empty Hash default. | -| `domain_hint` | If defined, sets (overwriting, if already present) `domain_hint` inside `authorize_params`. Default `nil` / none. | -| `scope` | If defined, sets (overwriting, if already present) `scope` inside `authorize_params`. Default is `OmniAuth::Strategies::AzureActivedirectoryV2::DEFAULT_SCOPE` (at the time of writing, this is `'openid profile email'`). | -| `adfs` | If defined, modifies the URLs so they work with an on premise ADFS server. In order to use this you also need to set the `base_azure_url` correctly and fill the `tenant_id` with `'adfs'`. | +| Option | Use | +| ------ | --- | +| `client_id` | **Mandatory.** Client ID for the 'application' (integration) configured on the Entra side. Found via the Entra UI. | +| `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. | +| `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. | +| `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/...` | +| `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`). | +| `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. | +| `custom_policy` | Custom policy. Default is nil. Used in conjunction with `tenant_name`- see above. | +| `authorize_params` | Additional parameters passed as URL query data in the initial OAuth redirection to Microsoft. See below for more. Empty Hash default. | +| `domain_hint` | If defined, sets (overwriting, if already present) `domain_hint` inside `authorize_params`. Default `nil` / none. | +| `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'`). | +| `adfs` | If defined, modifies the URLs so they work with an on premise ADFS server. In order to use this you also need to set the `base_url` correctly and fill the `tenant_id` with `'adfs'`. | 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. -#### Explaining `custom_policy` +#### Explaining `custom_policy` and `tenant_name` -In the documentation for [requesting a token](https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#request-a-token), Microsoft indicate that they want the name of custom policies to be given in the URL rather than in the body of the request: +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. + +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: ``` -POST .b2clogin.com/.onmicrosoft.com//oauth2/v2.0/token +.b2clogin.com/.onmicrosoft.com//oauth2/v2.0/... ``` -When the underlying `oath2` 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. Unfortunately, Microsoft ignores custom policies in the body and only looks for them in the URL. - -If you set a `custom_policy` in your configuration, it will be included in the URL between the `tenant_id` and the remaining parts of the path (`/oauth2/v2.0/token`). - #### Explaining `authorize_params` -The `authorize_params` hash-like object contains key-value pairs which are transformed into URL query string data and added to existing standard OAuth query data in the URL used for the initial redirection from your web site, to the Microsoft Azure AD 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: +The `authorize_params` hash-like object contains key-value pairs which are transformed into URL query string data and added to existing standard OAuth query data in the URL used for the initial redirection 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: -* https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#code-try-1 +* https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code ...looking for in particular items from `prompt` onwards. @@ -156,11 +149,11 @@ class YouTenantProvider end def client_id - ENV['AZURE_CLIENT_ID'] + ENV['ENTRA_CLIENT_ID'] end def client_secret - ENV['AZURE_CLIENT_SECRET'] + ENV['ENTRA_CLIENT_SECRET'] end def authorize_params @@ -175,25 +168,25 @@ class YouTenantProvider end ``` -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::StrategiesAzureActivedirectoryV2`](https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/blob/master/lib/omniauth/strategies/azure_activedirectory_v2.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 Azure OAuth flow. +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. -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 Azure. 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. +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. -> **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, `CustomOmniAuthAzureProvider`. +> **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`. #### Special case scope override 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: ```ruby -omniauth_authorize_url('resource_name_eg_user', 'azure_activedirectory_v2', scope: '...') +omniauth_authorize_url('resource_name_eg_user', 'entra_id', scope: '...') ``` ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2. 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-azure-activedirectory-v2/blob/master/CODE_OF_CONDUCT.md). +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). ### Getting running @@ -219,4 +212,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Code of Conduct -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-azure-activedirectory-v2/blob/master/CODE_OF_CONDUCT.md). +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). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..e691af3 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,96 @@ +# Upgrading from `omniauth-azure-activedirectory-v2` + +This guide assumes you were on v2.3 or v2.4 of the old-named gem. The basic steps are: + +* Update your code to account for the rename +* Update your code to account for breaking changes + + + +## Updates due to the gem rename + +All gem users will likely need to follow these steps. + +* 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 +* `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. + +### Configuration + +Rename the strategy in your configuration block: + +```ruby +config.omniauth( + :azure_activedirectory_v2, + # ... +) +``` + +...becomes: + +```ruby +config.omniauth( + :entra_id, + # ... +) +``` + +### Callback routes + +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: + +``` +https://example.com/v1/auth/azure_activedirectory_v2/callback +``` + +...is now: + +``` +https://example.com/v1/auth/entra/callback +``` + +### URL generation + +Change things like this: + +``` +omniauth_authorize_url('resource_name_eg_user', 'azure_activedirectory_v2', scope: '...') +``` + +...to this: + +``` +omniauth_authorize_url('resource_name_eg_user', 'entra_id', scope: '...') +``` + + + +## Updates due to other breaking changes + +### Critical breaking change for all gem users + +This change is for UIDs and is the main reason for creating a V3 gem, whether or not it included the Entra name change. + +* 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. +* 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. +* **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. + +You have two options, should the issue affect you (and it almost certainly will). + +* 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. +* Otherwise, you should lazy-migrate: + - 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. + - 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 + - Otherwise, check `request.env['omniauth.auth'].extra.oid` - this gives the value that the *old Azure ActiveDirectory V2 gem* used as UID + - 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. + - 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. + +### Applications that handle multiple OAuth providers + +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). + +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. + +### Breaking changes that depend on whether or not you use a certain feature + +* If you refer to `OmniAuth::Strategies::AzureActivedirectoryV2` at all, then this becomes `OmniAuth::Strategies::EntraId` (note lower case "d"). +* `base_azure_url` option renamed to just `base_url`, corresponding rename of `OmniAuth::Strategies::AzureActivedirectoryV2::BASE_AZURE_URL` to `OmniAuth::Strategies::EntraId::BASE_URL`. diff --git a/bin/console b/bin/console index 12fb98a..29e4c34 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env ruby require "bundler/setup" -require "omniauth/azure/activedirectory/v2" +require "omniauth/entra/id" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. diff --git a/lib/omniauth-azure-activedirectory-v2.rb b/lib/omniauth-azure-activedirectory-v2.rb deleted file mode 100644 index 11dde19..0000000 --- a/lib/omniauth-azure-activedirectory-v2.rb +++ /dev/null @@ -1 +0,0 @@ -require File.join('omniauth', 'azure_activedirectory_v2') diff --git a/lib/omniauth-entra-id.rb b/lib/omniauth-entra-id.rb new file mode 100644 index 0000000..b81985d --- /dev/null +++ b/lib/omniauth-entra-id.rb @@ -0,0 +1 @@ +require File.join('omniauth', 'entra_id') diff --git a/lib/omniauth/azure_activedirectory_v2.rb b/lib/omniauth/azure_activedirectory_v2.rb deleted file mode 100644 index d0b4337..0000000 --- a/lib/omniauth/azure_activedirectory_v2.rb +++ /dev/null @@ -1,4 +0,0 @@ -warn "[DEPRECATION] This gem has been renamed to 'omniauth-entra-id' and will no longer be supported. Please switch to 'omniauth-entra-id' as soon as possible." - -require File.join('omniauth', 'azure_activedirectory_v2', 'version') -require File.join('omniauth', 'strategies', 'azure_activedirectory_v2') diff --git a/lib/omniauth/azure_activedirectory_v2/version.rb b/lib/omniauth/azure_activedirectory_v2/version.rb deleted file mode 100644 index 48d59e4..0000000 --- a/lib/omniauth/azure_activedirectory_v2/version.rb +++ /dev/null @@ -1,10 +0,0 @@ -module OmniAuth - module Azure - module Activedirectory - module V2 - VERSION = "2.4.0" - DATE = "2024-10-17" - end - end - end -end diff --git a/lib/omniauth/entra_id.rb b/lib/omniauth/entra_id.rb new file mode 100644 index 0000000..d18cb28 --- /dev/null +++ b/lib/omniauth/entra_id.rb @@ -0,0 +1,2 @@ +require File.join('omniauth', 'entra_id', 'version') +require File.join('omniauth', 'strategies', 'entra_id') diff --git a/lib/omniauth/entra_id/version.rb b/lib/omniauth/entra_id/version.rb new file mode 100644 index 0000000..b911779 --- /dev/null +++ b/lib/omniauth/entra_id/version.rb @@ -0,0 +1,8 @@ +module OmniAuth + module Entra + module Id + VERSION = "3.0.0" + DATE = "2024-10-21" + end + end +end diff --git a/lib/omniauth/strategies/azure_activedirectory_v2.rb b/lib/omniauth/strategies/entra_id.rb similarity index 56% rename from lib/omniauth/strategies/azure_activedirectory_v2.rb rename to lib/omniauth/strategies/entra_id.rb index 4322a16..619b3ca 100644 --- a/lib/omniauth/strategies/azure_activedirectory_v2.rb +++ b/lib/omniauth/strategies/entra_id.rb @@ -4,17 +4,18 @@ module OmniAuth module Strategies - class AzureActivedirectoryV2 < OmniAuth::Strategies::OAuth2 - BASE_AZURE_URL = 'https://login.microsoftonline.com' + class EntraId < OmniAuth::Strategies::OAuth2 + BASE_URL = 'https://login.microsoftonline.com' - option :name, 'azure_activedirectory_v2' + option :name, 'entra_id' option :tenant_provider, nil - option :jwt_leeway, 60 - + option :jwt_leeway, 60 DEFAULT_SCOPE = 'openid profile email' - # tenant_provider must return client_id, client_secret and optionally tenant_id and base_azure_url + # The tenant_provider must return client_id, client_secret and, + # optionally, tenant_id and base_url. + # args [:tenant_provider] def client @@ -30,31 +31,32 @@ def client options.client_secret = provider.client_secret elsif provider.respond_to?(:certificate_path) && provider.respond_to?(:tenant_id) && provider.certificate_path && provider.tenant_id options.token_params = { - tenant: provider.tenant_id, - client_id: provider.client_id, - client_assertion: client_assertion(provider.tenant_id, provider.client_id, provider.certificate_path), + tenant: provider.tenant_id, + client_id: provider.client_id, + client_assertion: client_assertion(provider.tenant_id, provider.client_id, provider.certificate_path), client_assertion_type: client_assertion_type } else raise ArgumentError, "You must provide either client_secret or certificate_path and tenant_id" end - options.tenant_id = - provider.respond_to?(:tenant_id) ? provider.tenant_id : 'common' - options.base_azure_url = - provider.respond_to?(:base_azure_url) ? provider.base_azure_url : BASE_AZURE_URL - - if provider.respond_to?(:authorize_params) - options.authorize_params = provider.authorize_params + options.tenant_id = if provider.respond_to?(:tenant_id) + provider.tenant_id + else + 'common' end - if provider.respond_to?(:domain_hint) && provider.domain_hint - options.authorize_params.domain_hint = provider.domain_hint + options.base_url = if provider.respond_to?(:base_url ) + provider.base_url + else + BASE_URL end - if defined?(request) && request.params['prompt'] - options.authorize_params.prompt = request.params['prompt'] - end + options.tenant_name = provider.tenant_name if provider.respond_to?(:tenant_name) + options.custom_policy = provider.custom_policy if provider.respond_to?(:custom_policy) + options.authorize_params = provider.authorize_params if provider.respond_to?(:authorize_params) + options.authorize_params.domain_hint = provider.domain_hint if provider.respond_to?(:domain_hint) && provider.domain_hint + options.authorize_params.prompt = request.params['prompt'] if defined?(request) && request.params['prompt'] options.authorize_params.scope = if defined?(request) && request.params['scope'] request.params['scope'] @@ -64,39 +66,43 @@ def client DEFAULT_SCOPE end - options.custom_policy = - provider.respond_to?(:custom_policy) ? provider.custom_policy : nil - options.tenant_name = - provider.respond_to?(:tenant_name) ? provider.tenant_name : nil + oauth2 = if provider.respond_to?(:adfs?) && provider.adfs? + 'oauth2' + else + 'oauth2/v2.0' + end - oauth2 = provider.respond_to?(:adfs?) && provider.adfs? ? 'oauth2' : 'oauth2/v2.0' - base_url = if options.custom_policy && options.tenant_name "https://#{options.tenant_name}.b2clogin.com/#{options.tenant_name}.onmicrosoft.com/#{options.custom_policy}" else - "#{options.base_azure_url}/#{options.tenant_id}" + "#{options.base_url}/#{options.tenant_id}" end options.client_options.authorize_url = "#{base_url}/#{oauth2}/authorize" - options.client_options.token_url = "#{base_url}/#{oauth2}/token" + options.client_options.token_url = "#{base_url}/#{oauth2}/token" super end uid do - # as instructed by https://learn.microsoft.com/en-us/entra/identity-platform/migrate-off-email-claim-authorization + # + # https://learn.microsoft.com/en-us/entra/identity-platform/migrate-off-email-claim-authorization + # + # OID alone might not be unique; TID must be included. An alternative + # would be to use 'sub' but this is only unique in client/app + # registration context. If a different app registration is used, the + # 'sub' values can be different too. + # raw_info['tid'] + raw_info['oid'] - # Alternative would be to use 'sub' but this is only unique in client/app registration context. If a different - # app registration is used, the 'sub' values can be different end info do { - name: raw_info['name'], - email: raw_info['email'], - nickname: raw_info['unique_name'], + name: raw_info['name'], + email: raw_info['email'], + nickname: raw_info['unique_name'], first_name: raw_info['given_name'], - last_name: raw_info['family_name'] + last_name: raw_info['family_name'] } end @@ -108,7 +114,7 @@ def callback_url full_host + callback_path end - # https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens + # https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens # # Some account types from Microsoft seem to only have a decodable ID token, # with JWT unable to decode the access token. Information is limited in those @@ -126,21 +132,25 @@ def raw_info {} end - # For multi-tenant apps ('common' tenant_id) it doesn't make any sense to verify the token issuer, because the - # value of 'iss' in the token depends on the 'tid' in the token itself - issuer = options.tenant_id.nil? ? nil : "#{options.base_azure_url}/#{options.tenant_id}/v2.0" + # For multi-tenant apps (the 'common' tenant_id) it doesn't make any + # sense to verify the token issuer, because the value of 'iss' in the + # token depends on the 'tid' in the token itself. + # + issuer = options.tenant_id.nil? ? nil : "#{options.base_url}/#{options.tenant_id}/v2.0" # https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens#validate-tokens + # JWT::Verify.verify_claims( id_token_data, - verify_iss: !issuer.nil?, - iss: issuer, - verify_aud: true, - aud: options.client_id, + verify_iss: !issuer.nil?, + iss: issuer, + verify_aud: true, + aud: options.client_id, verify_expiration: true, verify_not_before: true, - leeway: options[:jwt_leeway] + leeway: options[:jwt_leeway] ) + auth_token_data = begin ::JWT.decode(access_token.token, nil, false).first rescue StandardError @@ -154,9 +164,11 @@ def raw_info @raw_info end - # The below methods support the flow for using certificate-based client assertion authentication. - # See this documentation for details: # https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential + # + # The below methods support the flow for using certificate-based client + # assertion authentication. + # def client_assertion_type 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' end @@ -174,13 +186,13 @@ def client_assertion_claims(tenant_id, client_id) end def client_assertion(tenant_id, client_id, certificate_path) - certificate_file = OpenSSL::PKCS12.new(File.read(certificate_path)) + certificate_file = OpenSSL::PKCS12.new(File.read(certificate_path)) certificate_thumbprint ||= Digest::SHA1.digest(certificate_file.certificate.to_der) - private_key = OpenSSL::PKey::RSA.new(certificate_file.key) + private_key = OpenSSL::PKey::RSA.new(certificate_file.key) claims = client_assertion_claims(tenant_id, client_id) - x5c = Base64.strict_encode64(certificate_file.certificate.to_der) - x5t = Base64.strict_encode64(certificate_thumbprint) + x5c = Base64.strict_encode64(certificate_file.certificate.to_der) + x5t = Base64.strict_encode64(certificate_thumbprint) JWT.encode(claims, private_key, 'RS256', { 'x5c': [x5c], 'x5t': x5t }) end diff --git a/omniauth-azure-activedirectory-v2.gemspec b/omniauth-azure-activedirectory-v2.gemspec deleted file mode 100644 index 3b41608..0000000 --- a/omniauth-azure-activedirectory-v2.gemspec +++ /dev/null @@ -1,60 +0,0 @@ -# -*- encoding: utf-8 -*- -# frozen_string_literal: true -# stub: omniauth-azure-activedirectory-v2 1.0.0 ruby lib - -$:.push File.expand_path( '../lib', __FILE__ ) -require 'omniauth/azure_activedirectory_v2/version' - -# https://guides.rubygems.org/specification-reference/ -# -Gem::Specification.new do |s| - s.post_install_message = <<-MESSAGE - ! The 'omniauth-azure-activedirectory-v2' gem has been deprecated and is - ! replaced by 'omniauth-entra-id'. - ! - ! See: https://rubygems.org/gems/omniauth-entra-id - ! And: https://github.com/RIPAGlobal/omniauth-entra-id - MESSAGE - - s.name = 'omniauth-azure-activedirectory-v2' - s.version = OmniAuth::Azure::Activedirectory::V2::VERSION - s.date = OmniAuth::Azure::Activedirectory::V2::DATE - s.summary = 'OAuth 2 authentication with the Azure ActiveDirectory V2 API.' - s.authors = [ 'RIPA Global' ] - s.email = [ 'dev@ripaglobal.com' ] - s.licenses = [ 'MIT' ] - s.homepage = 'https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2' - - s.required_ruby_version = Gem::Requirement.new('>= 2.3.0') - s.require_paths = ['lib'] - s.bindir = 'exe' - s.files = %w{ - README.md - CHANGELOG.md - CODE_OF_CONDUCT.md - LICENSE.txt - - Gemfile - bin/console - bin/setup - - lib/omniauth-azure-activedirectory-v2.rb - lib/omniauth/azure_activedirectory_v2.rb - lib/omniauth/azure_activedirectory_v2/version.rb - lib/omniauth/strategies/azure_activedirectory_v2.rb - - omniauth-azure-activedirectory-v2.gemspec - } - - s.metadata = { - 'homepage_uri' => 'https://www.ripaglobal.com/', - 'bug_tracker_uri' => 'https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/issues/', - 'changelog_uri' => 'https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2/blob/master/CHANGELOG.md', - 'source_code_uri' => 'https://github.com/RIPAGlobal/omniauth-azure-activedirectory-v2' - } - - s.add_runtime_dependency('omniauth-oauth2', '~> 1.8') - - s.add_development_dependency('rake', '~> 13.2 ') - s.add_development_dependency('rspec', '~> 3.13') -end diff --git a/omniauth-entra-id.gemspec b/omniauth-entra-id.gemspec new file mode 100644 index 0000000..54bf5a3 --- /dev/null +++ b/omniauth-entra-id.gemspec @@ -0,0 +1,52 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true + +$:.push File.expand_path( '../lib', __FILE__ ) +require 'omniauth/entra_id/version' + +# https://guides.rubygems.org/specification-reference/ +# +Gem::Specification.new do |s| + s.name = 'omniauth-entra-id' + s.version = OmniAuth::Entra::Id::VERSION + s.date = OmniAuth::Entra::Id::DATE + s.summary = 'OAuth 2 authentication with the Entra ID API.' + s.authors = [ 'RIPA Global' ] + s.email = [ 'dev@ripaglobal.com' ] + s.licenses = [ 'MIT' ] + s.homepage = 'https://github.com/RIPAGlobal/omniauth-entra-id' + + s.required_ruby_version = Gem::Requirement.new('>= 3.0.0') + s.require_paths = ['lib'] + s.bindir = 'exe' + s.files = %w{ + README.md + CHANGELOG.md + CODE_OF_CONDUCT.md + UPGRADING.md + LICENSE.txt + + Gemfile + bin/console + bin/setup + + lib/omniauth-entra-id.rb + lib/omniauth/entra_id.rb + lib/omniauth/entra_id/version.rb + lib/omniauth/strategies/entra_id.rb + + omniauth-entra-id.gemspec + } + + s.metadata = { + 'homepage_uri' => 'https://www.ripaglobal.com/', + 'bug_tracker_uri' => 'https://github.com/RIPAGlobal/omniauth-entra-id/issues/', + 'changelog_uri' => 'https://github.com/RIPAGlobal/omniauth-entra-id/blob/master/CHANGELOG.md', + 'source_code_uri' => 'https://github.com/RIPAGlobal/omniauth-entra-id' + } + + s.add_runtime_dependency('omniauth-oauth2', '~> 1.8') + + s.add_development_dependency('rake', '~> 13.2 ') + s.add_development_dependency('rspec', '~> 3.13') +end diff --git a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb b/spec/omniauth/strategies/entra_id_spec.rb similarity index 54% rename from spec/omniauth/strategies/azure_activedirectory_v2_spec.rb rename to spec/omniauth/strategies/entra_id_spec.rb index 8290c4d..f16f696 100644 --- a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb +++ b/spec/omniauth/strategies/entra_id_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' -require 'omniauth/azure_activedirectory_v2' +require 'omniauth/entra_id' -RSpec.describe OmniAuth::Strategies::AzureActivedirectoryV2 do +RSpec.describe OmniAuth::Strategies::EntraId do let(:request) { double('Request', :params => {}, :cookies => {}, :env => {}) } let(:app) { lambda do @@ -20,7 +20,7 @@ describe 'static configuration' do let(:options) { @options || {} } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant'}.merge(options)) + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant'}.merge(options)) end describe '#client' do @@ -40,11 +40,11 @@ expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') end - context 'when a custom policy is present and start with b2c and tenant_name is present for b2c login' do - it 'includes custom policy and tenane name in authorize url' do - @options = { tenant_name: "test", custom_policy: 'my_policy' } + context 'when a custom policy and tenant name are present' do + it 'generates the B2C URL' do + @options = { custom_policy: 'my_policy', tenant_name: 'my_tenant' } allow(subject).to receive(:request) { request } - expect(subject.client.options[:token_url]).to eql('https://test.b2clogin.com/test.onmicrosoft.com/my_policy/oauth2/v2.0/token') + expect(subject.client.options[:token_url]).to eql('https://my_tenant.b2clogin.com/my_tenant.onmicrosoft.com/my_policy/oauth2/v2.0/token') end end @@ -55,94 +55,6 @@ expect(subject.authorize_params[:prompt]).to eql('select_account') end - context 'using client secret flow without client secret' do - subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, { client_id: 'id', tenant_id: 'tenant' }.merge(options)) - end - - it 'raises exception' do - expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") - end - end - - context 'using client assertion flow' do - subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, options) - end - - it 'raises exception when tenant id is not given' do - @options = { client_id: 'id', certificate_path: 'path/to/cert.p12' } - expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") - end - - it 'raises exception when certificate_path is not given' do - @options = { client_id: 'id', tenant_id: 'tenant' } - expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") - end - - context '#token_params with correctly formatted request' do - let(:key) { OpenSSL::PKey::RSA.new(2048) } - let(:cert) { OpenSSL::X509::Certificate.new.tap { |cert| - cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=test") - cert.not_before = Time.now - cert.not_after = Time.now + 365 * 24 * 60 * 60 - cert.public_key = key.public_key - cert.serial = 0x0 - cert.version = 2 - cert.sign(key, OpenSSL::Digest::SHA256.new) - } } - - before do - @options = { - client_id: 'id', - tenant_id: 'tenant', - certificate_path: 'path/to/cert.p12' - } - - allow(File).to receive(:read) - allow(OpenSSL::PKCS12).to receive(:new).and_return(OpenSSL::PKCS12.create('pass', 'name', key, cert)) - allow(SecureRandom).to receive(:uuid).and_return('unique-jti') - - allow(subject).to receive(:request) { request } - subject.client - end - - it 'has correct tenant id' do - expect(subject.options.token_params[:tenant]).to eql('tenant') - end - - it 'has correct client id' do - expect(subject.options.token_params[:client_id]).to eql('id') - end - - it 'has correct client_assertion_type' do - expect(subject.options.token_params[:client_assertion_type]).to eql('urn:ietf:params:oauth:client-assertion-type:jwt-bearer') - end - - context 'client assertion' do - it 'has correct claims' do - jwt = subject.options.token_params[:client_assertion] - decoded_jwt = JWT.decode(jwt, nil, false).first - - expect(decoded_jwt['aud']).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') - expect(decoded_jwt['exp']).to be_within(5).of(Time.now.to_i + 300) - expect(decoded_jwt['iss']).to eql('id') - expect(decoded_jwt['jti']).to eql('unique-jti') - expect(decoded_jwt['nbf']).to be_within(5).of(Time.now.to_i) - expect(decoded_jwt['sub']).to eql('id') - end - - it 'contains x5c and x5t headers' do - jwt = subject.options.token_params[:client_assertion] - headers = JWT.decode(jwt, nil, false).last - - expect(headers['x5c']).to be_an_instance_of(Array) - expect(headers['x5t']).to be_a(String) - end - end - end - end - describe "overrides" do it 'should override domain_hint' do @options = {domain_hint: 'hint'} @@ -160,12 +72,13 @@ end end end + end describe 'static configuration - german' do let(:options) { @options || {} } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant', base_azure_url: 'https://login.microsoftonline.de'}.merge(options)) + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'tenant', base_url: 'https://login.microsoftonline.de'}.merge(options)) end describe '#client' do @@ -191,14 +104,6 @@ expect(subject.authorize_params[:scope]).to eql('openid profile email') end - context 'when a custom policy is present and start with b2c and tenant_name is present for b2c login' do - it 'includes custom policy and tenane name in authorize url' do - @options = { tenant_name: "test", custom_policy: 'my_policy' } - allow(subject).to receive(:request) { request } - expect(subject.client.options[:authorize_url]).to eql('https://test.b2clogin.com/test.onmicrosoft.com/my_policy/oauth2/v2.0/authorize') - end - end - describe "overrides" do it 'should override domain_hint' do @options = {domain_hint: 'hint'} @@ -213,7 +118,7 @@ describe 'static common configuration' do let(:options) { @options || {} } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret'}.merge(options)) + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret'}.merge(options)) end before do @@ -234,7 +139,7 @@ describe 'static configuration with on premise ADFS' do let(:options) { @options || {} } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'adfs', base_azure_url: 'https://login.contoso.com', adfs: true}.merge(options)) + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'adfs', base_url: 'https://login.contoso.com', adfs: true}.merge(options)) end describe '#client' do @@ -275,7 +180,7 @@ def authorize_params } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, provider_klass) + OmniAuth::Strategies::EntraId.new(app, provider_klass) end before do @@ -332,7 +237,7 @@ def tenant_id 'tenant' end - def base_azure_url + def base_url 'https://login.microsoftonline.de' end @@ -343,7 +248,7 @@ def scope } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, provider_klass) + OmniAuth::Strategies::EntraId.new(app, provider_klass) end before do @@ -398,7 +303,7 @@ def client_secret } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, provider_klass) + OmniAuth::Strategies::EntraId.new(app, provider_klass) end before do @@ -440,7 +345,7 @@ def tenant_id 'adfs' end - def base_azure_url + def base_url 'https://login.contoso.com' end @@ -451,7 +356,7 @@ def adfs? } subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, provider_klass) + OmniAuth::Strategies::EntraId.new(app, provider_klass) end before do @@ -471,26 +376,14 @@ def adfs? describe 'raw_info' do subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret'}) + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret'}) end let(:id_token_info) do - issued_at = Time.now.utc.to_i - expires_at = (Time.now + 3600).to_i { - ver: '2.0', - iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', - sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', - aud: 'id', - exp: expires_at, - iat: issued_at, - nbf: issued_at, - name: 'Bob Doe', - preferred_username: 'bob@doe.com', - oid: 'my_id', - email: 'bob@doe.com', - tid: '9188040d-6c67-4c5b-b112-36a304b66dad', - aio: 'KSslldiwDkfjjsoeiruosKD', + oid: 'my_id', + name: 'Bob Doe', + email: 'bob@doe.com', unique_name: 'bobby' } end @@ -520,27 +413,15 @@ def adfs? end it 'returns correct uid' do - expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadmy_id') + expect(subject.uid).to eq('my_id') end end # "context 'with information only in the ID token' do" context 'with extra information in the auth token' do let(:auth_token_info) do - issued_at = Time.now.utc.to_i - expires_at = (Time.now + 3600).to_i { - ver: '2.0', - iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', - sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', - aud: 'id', - exp: expires_at, - iat: issued_at, - nbf: issued_at, - preferred_username: 'bob@doe.com', - oid: 'overridden_id', - email: 'bob@doe.com', - tid: '9188040d-6c67-4c5b-b112-36a304b66dad', - aio: 'KSslldiwDkfjjsoeiruosKD', + oid: 'overridden_id', + email: 'bob@doe.com', unique_name: 'Bobby Definitely Doe', given_name: 'Bob', family_name: 'Doe' @@ -566,105 +447,14 @@ def adfs? end it 'returns correct uid' do - expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadoverridden_id') + expect(subject.uid).to eq('overridden_id') end end # "context 'with extra information in the auth token' do" - - context 'with an invalid audience' do - let(:id_token_info) do - issued_at = Time.now.utc.to_i - expires_at = (Time.now + 3600).to_i - { - ver: '2.0', - iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', - sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', - aud: 'other-id', - exp: expires_at, - iat: issued_at, - nbf: issued_at, - name: 'Bob Doe', - preferred_username: 'bob@doe.com', - oid: 'my_id', - email: 'bob@doe.com', - tid: '9188040d-6c67-4c5b-b112-36a304b66dad', - aio: 'KSslldiwDkfjjsoeiruosKD', - unique_name: 'bobby' - } - end - - it 'fails validation' do - expect { subject.info }.to raise_error(JWT::InvalidAudError) - end - end - - context 'with an invalid issuer' do - subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'test-tenant'}) - end - - it 'fails validation' do - expect { subject.info }.to raise_error(JWT::InvalidIssuerError) - end - end - - context 'with an invalid not_before' do - let(:id_token_info) do - issued_at = (Time.now + 70).to_i # Since leeway is 60 seconds - expires_at = (Time.now + 3600).to_i - { - ver: '2.0', - iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', - sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', - aud: 'id', - exp: expires_at, - iat: issued_at, - nbf: issued_at, - name: 'Bob Doe', - preferred_username: 'bob@doe.com', - oid: 'my_id', - email: 'bob@doe.com', - tid: '9188040d-6c67-4c5b-b112-36a304b66dad', - aio: 'KSslldiwDkfjjsoeiruosKD', - unique_name: 'bobby' - } - end - - it 'fails validation' do - expect { subject.info }.to raise_error(JWT::ImmatureSignature) - end - end - - context 'with an expired token' do - let(:id_token_info) do - issued_at = (Time.now - 3600).to_i - expires_at = (Time.now - 70).to_i # Since leeway is 60 seconds - { - ver: '2.0', - iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', - sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', - aud: 'id', - exp: expires_at, - iat: issued_at, - nbf: issued_at, - name: 'Bob Doe', - preferred_username: 'bob@doe.com', - oid: 'my_id', - email: 'bob@doe.com', - tid: '9188040d-6c67-4c5b-b112-36a304b66dad', - aio: 'KSslldiwDkfjjsoeiruosKD', - unique_name: 'bobby' - } - end - - it 'fails validation' do - expect { subject.info }.to raise_error(JWT::ExpiredSignature) - end - end end # "describe 'raw_info' do" describe 'callback_url' do subject do - OmniAuth::Strategies::AzureActivedirectoryV2.new(app, { client_id: 'id', client_secret: 'secret', tenant_id: 'tenant' }) + OmniAuth::Strategies::EntraId.new(app, { client_id: 'id', client_secret: 'secret', tenant_id: 'tenant' }) end let(:base_url) { 'https://example.com' } @@ -672,13 +462,13 @@ def adfs? it 'has the correct default callback path' do allow(subject).to receive(:full_host) { base_url } allow(subject).to receive(:script_name) { '' } - expect(subject.callback_url).to eq(base_url + '/auth/azure_activedirectory_v2/callback') + expect(subject.callback_url).to eq(base_url + '/auth/entra_id/callback') end it 'should set the callback path with script_name if present' do allow(subject).to receive(:full_host) { base_url } allow(subject).to receive(:script_name) { '/v1' } - expect(subject.callback_url).to eq(base_url + '/v1/auth/azure_activedirectory_v2/callback') + expect(subject.callback_url).to eq(base_url + '/v1/auth/entra_id/callback') end end end From fb2cdcdc8a44f1fc3a2c40525af129b2aefac6df Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 21 Oct 2024 09:41:05 +1300 Subject: [PATCH 2/8] Finish additions and upates to test coverage; tidy up --- UPGRADING.md | 3 +- omniauth-entra-id.gemspec | 1 + spec/omniauth/strategies/entra_id_spec.rb | 164 +++++++++++++++++++--- spec/spec_helper.rb | 1 + 4 files changed, 148 insertions(+), 21 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index e691af3..6dddc2f 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -80,8 +80,9 @@ You have two options, should the issue affect you (and it almost certainly will) * Otherwise, you should lazy-migrate: - 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. - 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 - - Otherwise, check `request.env['omniauth.auth'].extra.oid` - this gives the value that the *old Azure ActiveDirectory V2 gem* used as UID + - Otherwise, check `request.env['omniauth.auth'].raw_info['oid']` - this gives the value that the *old Azure ActiveDirectory V2 gem* used as UID - 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. + - 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. - 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. ### Applications that handle multiple OAuth providers diff --git a/omniauth-entra-id.gemspec b/omniauth-entra-id.gemspec index 54bf5a3..47e0139 100644 --- a/omniauth-entra-id.gemspec +++ b/omniauth-entra-id.gemspec @@ -47,6 +47,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency('omniauth-oauth2', '~> 1.8') + s.add_development_dependency('debug', '~> 1.9 ') s.add_development_dependency('rake', '~> 13.2 ') s.add_development_dependency('rspec', '~> 3.13') end diff --git a/spec/omniauth/strategies/entra_id_spec.rb b/spec/omniauth/strategies/entra_id_spec.rb index f16f696..e5d92a6 100644 --- a/spec/omniauth/strategies/entra_id_spec.rb +++ b/spec/omniauth/strategies/entra_id_spec.rb @@ -375,16 +375,30 @@ def adfs? end describe 'raw_info' do + let(:issued_at ) { Time.now.utc.to_i } + let(:expires_at) { (Time.now.utc + 3600).to_i } + subject do OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret'}) end let(:id_token_info) do + { - oid: 'my_id', - name: 'Bob Doe', - email: 'bob@doe.com', - unique_name: 'bobby' + ver: '2.0', + iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', + aud: 'id', + exp: expires_at(), + iat: issued_at(), + nbf: issued_at(), + name: 'Bob Doe', + preferred_username: 'bob@doe.com', + oid: 'my_id', + email: 'bob@doe.com', + tid: '9188040d-6c67-4c5b-b112-36a304b66dad', + aio: 'KSslldiwDkfjjsoeiruosKD', + unique_name: 'bobby' } end @@ -412,19 +426,33 @@ def adfs? }) end - it 'returns correct uid' do - expect(subject.uid).to eq('my_id') + it 'returns correct "uid"' do + expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadmy_id') + end + + it 'returns correct "oid" for V2 or earlier lazy migrations' do + expect(subject.raw_info['oid']).to eq('my_id') end end # "context 'with information only in the ID token' do" context 'with extra information in the auth token' do let(:auth_token_info) do { - oid: 'overridden_id', - email: 'bob@doe.com', - unique_name: 'Bobby Definitely Doe', - given_name: 'Bob', - family_name: 'Doe' + ver: '2.0', + iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', + aud: 'id', + exp: expires_at(), + iat: issued_at(), + nbf: issued_at(), + preferred_username: 'bob@doe.com', + oid: 'overridden_id', # (overrides ID token) + email: 'bob@doe.com', + tid: '9188040d-6c67-4c5b-b112-36a304b66dad', + aio: 'KSslldiwDkfjjsoeiruosKD', + unique_name: 'Bobby Definitely Doe', # (overrides ID token) + given_name: 'Bob', + family_name: 'Doe' } end @@ -437,19 +465,115 @@ def adfs? end it 'returns correct info' do - expect(subject.info).to eq({ - name: 'Bob Doe', - email: 'bob@doe.com', - nickname: 'Bobby Definitely Doe', - first_name: 'Bob', - last_name: 'Doe' - }) + expect(subject.info).to eq( + { + name: 'Bob Doe', + email: 'bob@doe.com', + nickname: 'Bobby Definitely Doe', + first_name: 'Bob', + last_name: 'Doe' + } + ) + end + + it 'returns correct "uid"' do + expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadoverridden_id') end - it 'returns correct uid' do - expect(subject.uid).to eq('overridden_id') + it 'returns correct "oid" for V2 or earlier lazy migrations' do + expect(subject.raw_info['oid']).to eq('overridden_id') end end # "context 'with extra information in the auth token' do" + + context 'with an invalid audience' do + let(:id_token_info) do + { + ver: '2.0', + iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', + aud: 'other-id', + exp: expires_at(), + iat: issued_at(), + nbf: issued_at(), + name: 'Bob Doe', + preferred_username: 'bob@doe.com', + oid: 'my_id', + email: 'bob@doe.com', + tid: '9188040d-6c67-4c5b-b112-36a304b66dad', + aio: 'KSslldiwDkfjjsoeiruosKD', + unique_name: 'bobby' + } + end + + it 'fails validation' do + expect { subject.info }.to raise_error(JWT::InvalidAudError) + end + end # "context 'with an invalid audience' do" + + context 'with an invalid issuer' do + subject do + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'test-tenant'}) + end + + it 'fails validation' do + expect { subject.info }.to raise_error(JWT::InvalidIssuerError) + end + end # "context 'with an invalid issuer' do" + + context 'with an invalid not_before' do + let(:issued_at) { (Time.now.utc + 70).to_i } # Invalid because leeway is 60 seconds + + let(:id_token_info) do + { + ver: '2.0', + iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', + aud: 'id', + exp: expires_at(), + iat: issued_at(), + nbf: issued_at(), + name: 'Bob Doe', + preferred_username: 'bob@doe.com', + oid: 'my_id', + email: 'bob@doe.com', + tid: '9188040d-6c67-4c5b-b112-36a304b66dad', + aio: 'KSslldiwDkfjjsoeiruosKD', + unique_name: 'bobby' + } + end + + it 'fails validation' do + expect { subject.info }.to raise_error(JWT::ImmatureSignature) + end + end # "context 'with an invalid not_before' do" + + context 'with an expired token' do + let(:issued_at ) { (Time.now.utc - 3600).to_i } + let(:expires_at) { (Time.now.utc - 70 ).to_i } # Invalid because leeway is 60 seconds + + let(:id_token_info) do + { + ver: '2.0', + iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + sub: 'sdfkjllAkdkWkeiidkcXKfjjsl', + aud: 'id', + exp: expires_at(), + iat: issued_at(), + nbf: issued_at(), + name: 'Bob Doe', + preferred_username: 'bob@doe.com', + oid: 'my_id', + email: 'bob@doe.com', + tid: '9188040d-6c67-4c5b-b112-36a304b66dad', + aio: 'KSslldiwDkfjjsoeiruosKD', + unique_name: 'bobby' + } + end + + it 'fails validation' do + expect { subject.info }.to raise_error(JWT::ExpiredSignature) + end + end # "context 'with an expired token' do" end # "describe 'raw_info' do" describe 'callback_url' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5973c2b..f238ec2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,5 @@ require 'bundler/setup' +require 'debug' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure From aa3529eb27dfd76e79cfcd4041259d3df583d8c8 Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 21 Oct 2024 14:47:44 +1300 Subject: [PATCH 3/8] Documentation fixes --- CHANGELOG.md | 1 + README.md | 30 ++++++++++++++++++++++-------- UPGRADING.md | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 510ec18..0191711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v3.0.0 (2024-10-21) +* To upgrade from the Azure ActiveDirectory V2 gem, please see [`UPGRADING.md`](UPGRADING.md) * Branched from `omniauth-entra-id` version 2.4.0 and renamed to `omniauth-entra-id` * Can specify `tenant_name` in options via #31 (thanks to @Jureamer) for B2C login * Supports authenticating with a certificate instead of client secret via #32 (thanks to @juliaducey) diff --git a/README.md b/README.md index b050979..1c751a5 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ [![Build Status](https://github.com/RIPAGlobal/omniauth-entra-id/actions/workflows/master.yml/badge.svg)](https://github.com/RIPAGlobal/omniauth-entra-id/actions) [![License](https://img.shields.io/github/license/RIPAGlobal/omniauth-entra-id.svg)](LICENSE.txt) -**IMPORTANT: V2 is end-of-life** and superseded by a renamed gem, since Microsoft in their "wisdom" renamed Azure AD to Entra ID. A gem using the old name will become increasingly hard for people to 'discover'. The major version bump provides an opportunity to fix a few things via breaking changes, too. Please switch to `omniauth-entra-id`. - OAuth 2 authentication with [Entra ID API](https://learn.microsoft.com/en-us/entra/identity-platform/v2-overview). Rationale: * https://github.com/marknadig/omniauth-azure-oauth2 is no longer maintained. @@ -13,6 +11,8 @@ OAuth 2 authentication with [Entra ID API](https://learn.microsoft.com/en-us/ent 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. +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). + ## Installation @@ -51,7 +51,7 @@ In most cases, you only want to receive 'verified' email addresses in your appli You can do something like this for a static / fixed configuration: ```ruby -use OmniAuth::Builder do +Rails.application.config.middleware.use OmniAuth::Builder do provider( :entra_id, { @@ -62,10 +62,10 @@ use OmniAuth::Builder do end ``` -...or, if using a custom provider class (called `YouTenantProvider` in this example): +...or, if using a custom provider class (called `YouTenantProvider` in this example, described in more detail later): ```ruby -use OmniAuth::Builder do +Rails.application.config.middleware.use OmniAuth::Builder do provider( :entra_id, YouTenantProvider @@ -87,7 +87,7 @@ config.omniauth( ) ``` -...or, if using a custom provider class (called `YouTenantProvider` in this example): +...or, if using a custom provider class (called `YouTenantProvider` in this example, described in more detail later): ```ruby config.omniauth( @@ -132,11 +132,23 @@ Solve this for B2C use cases by giving your tenant name and custom policy name i #### Explaining `authorize_params` -The `authorize_params` hash-like object contains key-value pairs which are transformed into URL query string data and added to existing standard OAuth query data in the URL used for the initial redirection 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: +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: * https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code -...looking for in particular items from `prompt` onwards. +...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: + +```ruby +{ + client_id: ENV['ENTRA_CLIENT_ID'], + client_secret: ENV['ENTRA_CLIENT_SECRET'] + authorize_params: { + prompt: 'select_account' + } +} +``` + + #### Dynamic options via a custom provider class @@ -163,6 +175,8 @@ class YouTenantProvider ap['login_hint'] = @strategy.request.params['login_hint'] end + # (...and/or set other options such as 'prompt' here...) + return ap end end diff --git a/UPGRADING.md b/UPGRADING.md index 6dddc2f..553bfd3 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -94,4 +94,4 @@ In that case, you will need to migrate records from the old `azure_activedirecto ### Breaking changes that depend on whether or not you use a certain feature * If you refer to `OmniAuth::Strategies::AzureActivedirectoryV2` at all, then this becomes `OmniAuth::Strategies::EntraId` (note lower case "d"). -* `base_azure_url` option renamed to just `base_url`, corresponding rename of `OmniAuth::Strategies::AzureActivedirectoryV2::BASE_AZURE_URL` to `OmniAuth::Strategies::EntraId::BASE_URL`. +* `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`. From 64ec3ffb9ecbe078948da1d6d11bd868dd1a4dba Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 21 Oct 2024 14:47:52 +1300 Subject: [PATCH 4/8] Add a few more tests that went missing after bad merges --- spec/omniauth/strategies/entra_id_spec.rb | 137 ++++++++++++++++++---- 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/spec/omniauth/strategies/entra_id_spec.rb b/spec/omniauth/strategies/entra_id_spec.rb index e5d92a6..4057e19 100644 --- a/spec/omniauth/strategies/entra_id_spec.rb +++ b/spec/omniauth/strategies/entra_id_spec.rb @@ -46,7 +46,7 @@ allow(subject).to receive(:request) { request } expect(subject.client.options[:token_url]).to eql('https://my_tenant.b2clogin.com/my_tenant.onmicrosoft.com/my_policy/oauth2/v2.0/token') end - end + end # "context 'when a custom policy and tenant name are present' do" it 'supports authorization_params' do @options = { authorize_params: {prompt: 'select_account'} } @@ -55,6 +55,94 @@ expect(subject.authorize_params[:prompt]).to eql('select_account') end + context 'using client secret flow without client secret' do + subject do + OmniAuth::Strategies::EntraId.new(app, { client_id: 'id', tenant_id: 'tenant' }.merge(options)) + end + + it 'raises exception' do + expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") + end + end # "context 'using client secret flow without client secret' do" + + context 'using client assertion flow' do + subject do + OmniAuth::Strategies::EntraId.new(app, options) + end + + it 'raises exception when tenant id is not given' do + @options = { client_id: 'id', certificate_path: 'path/to/cert.p12' } + expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") + end + + it 'raises exception when certificate_path is not given' do + @options = { client_id: 'id', tenant_id: 'tenant' } + expect { subject.client }.to raise_error(ArgumentError, "You must provide either client_secret or certificate_path and tenant_id") + end + + context '#token_params with correctly formatted request' do + let(:key) { OpenSSL::PKey::RSA.new(2048) } + let(:cert) { OpenSSL::X509::Certificate.new.tap { |cert| + cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=test") + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 + cert.public_key = key.public_key + cert.serial = 0x0 + cert.version = 2 + cert.sign(key, OpenSSL::Digest::SHA256.new) + } } + + before do + @options = { + client_id: 'id', + tenant_id: 'tenant', + certificate_path: 'path/to/cert.p12' + } + + allow(File).to receive(:read) + allow(OpenSSL::PKCS12).to receive(:new).and_return(OpenSSL::PKCS12.create('pass', 'name', key, cert)) + allow(SecureRandom).to receive(:uuid).and_return('unique-jti') + + allow(subject).to receive(:request) { request } + subject.client + end + + it 'has correct tenant id' do + expect(subject.options.token_params[:tenant]).to eql('tenant') + end + + it 'has correct client id' do + expect(subject.options.token_params[:client_id]).to eql('id') + end + + it 'has correct client_assertion_type' do + expect(subject.options.token_params[:client_assertion_type]).to eql('urn:ietf:params:oauth:client-assertion-type:jwt-bearer') + end + + context 'client assertion' do + it 'has correct claims' do + jwt = subject.options.token_params[:client_assertion] + decoded_jwt = JWT.decode(jwt, nil, false).first + + expect(decoded_jwt['aud']).to eql('https://login.microsoftonline.com/tenant/oauth2/v2.0/token') + expect(decoded_jwt['exp']).to be_within(5).of(Time.now.to_i + 300) + expect(decoded_jwt['iss']).to eql('id') + expect(decoded_jwt['jti']).to eql('unique-jti') + expect(decoded_jwt['nbf']).to be_within(5).of(Time.now.to_i) + expect(decoded_jwt['sub']).to eql('id') + end + + it 'contains x5c and x5t headers' do + jwt = subject.options.token_params[:client_assertion] + headers = JWT.decode(jwt, nil, false).last + + expect(headers['x5c']).to be_an_instance_of(Array) + expect(headers['x5t']).to be_a(String) + end + end # "context 'client assertion' do" + end # "context '#token_params with correctly formatted request' do" + end # "context 'using client assertion flow' do" + describe "overrides" do it 'should override domain_hint' do @options = {domain_hint: 'hint'} @@ -70,10 +158,9 @@ subject.client expect(subject.authorize_params[:prompt]).to eql('consent') end - end - end - - end + end # "describe "overrides" do" + end # "describe '#client' do" + end # "describe 'static configuration' do" describe 'static configuration - german' do let(:options) { @options || {} } @@ -104,6 +191,14 @@ expect(subject.authorize_params[:scope]).to eql('openid profile email') end + context 'when a custom policy and tenant name are present' do + it 'generates the B2C URL (which does not include locale)' do + @options = { custom_policy: 'my_policy', tenant_name: 'my_tenant' } + allow(subject).to receive(:request) { request } + expect(subject.client.options[:token_url]).to eql('https://my_tenant.b2clogin.com/my_tenant.onmicrosoft.com/my_policy/oauth2/v2.0/token') + end + end # "context 'when a custom policy and tenant name are present' do" + describe "overrides" do it 'should override domain_hint' do @options = {domain_hint: 'hint'} @@ -111,9 +206,9 @@ subject.client expect(subject.authorize_params[:domain_hint]).to eql('hint') end - end - end - end + end # "describe "overrides" do" + end # "describe '#client' do" + end # "describe 'static configuration - german' do" describe 'static common configuration' do let(:options) { @options || {} } @@ -133,8 +228,8 @@ it 'has correct token url' do expect(subject.client.options[:token_url]).to eql('https://login.microsoftonline.com/common/oauth2/v2.0/token') end - end - end + end # "describe '#client' do" + end # "describe 'static common configuration' do" describe 'static configuration with on premise ADFS' do let(:options) { @options || {} } @@ -152,8 +247,8 @@ allow(subject).to receive(:request) { request } expect(subject.client.options[:token_url]).to eql('https://login.contoso.com/adfs/oauth2/token') end - end - end + end # "describe '#client' do" + end # "describe 'static configuration with on premise ADFS' do" describe 'dynamic configuration' do let(:provider_klass) { @@ -215,9 +310,8 @@ def authorize_params # expect(subject.authorize_params[:domain_hint]).to eql('hint') # end # end - end - - end + end # "describe '#client' do" + end # "describe 'dynamic configuration' do" describe 'dynamic configuration - german' do let(:provider_klass) { @@ -282,9 +376,8 @@ def scope # expect(subject.authorize_params[:domain_hint]).to eql('hint') # end # end - end - - end + end # "describe '#client' do" + end # "describe 'dynamic configuration - german' do" describe 'dynamic common configuration' do let(:provider_klass) { @@ -324,8 +417,8 @@ def client_secret subject.client expect(subject.authorize_params[:scope]).to eql('openid email offline_access Calendars.Read') end - end - end + end # "describe '#client' do" + end # "describe 'dynamic common configuration' do" describe 'dynamic configuration with on premise ADFS' do let(:provider_klass) { @@ -371,8 +464,8 @@ def adfs? it 'has correct token url' do expect(subject.client.options[:token_url]).to eql('https://login.contoso.com/adfs/oauth2/token') end - end - end + end # "describe '#client' do" + end # "describe 'dynamic configuration with on premise ADFS' do" describe 'raw_info' do let(:issued_at ) { Time.now.utc.to_i } From 4f45fbc09aae19873c8060b917b6145d5153da8a Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 21 Oct 2024 18:03:35 +1300 Subject: [PATCH 5/8] Fix documentation error --- UPGRADING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADING.md b/UPGRADING.md index 553bfd3..9d1a7a0 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -80,7 +80,7 @@ You have two options, should the issue affect you (and it almost certainly will) * Otherwise, you should lazy-migrate: - 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. - 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 - - Otherwise, check `request.env['omniauth.auth'].raw_info['oid']` - this gives the value that the *old Azure ActiveDirectory V2 gem* used as UID + - 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 - 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. - 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. - 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. From 16c55b0c60275417ae1343fd73b99763293309f4 Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Mon, 21 Oct 2024 18:03:53 +1300 Subject: [PATCH 6/8] Account for internal tenant ID of 'common' during JWT validation --- lib/omniauth/strategies/entra_id.rb | 17 ++++--- spec/omniauth/strategies/entra_id_spec.rb | 61 +++++++++++++++++++---- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/lib/omniauth/strategies/entra_id.rb b/lib/omniauth/strategies/entra_id.rb index 619b3ca..38dd2f0 100644 --- a/lib/omniauth/strategies/entra_id.rb +++ b/lib/omniauth/strategies/entra_id.rb @@ -11,7 +11,8 @@ class EntraId < OmniAuth::Strategies::OAuth2 option :tenant_provider, nil option :jwt_leeway, 60 - DEFAULT_SCOPE = 'openid profile email' + DEFAULT_SCOPE = 'openid profile email' + COMMON_TENANT_ID = 'common' # The tenant_provider must return client_id, client_secret and, # optionally, tenant_id and base_url. @@ -43,7 +44,7 @@ def client options.tenant_id = if provider.respond_to?(:tenant_id) provider.tenant_id else - 'common' + COMMON_TENANT_ID end options.base_url = if provider.respond_to?(:base_url ) @@ -72,14 +73,14 @@ def client 'oauth2/v2.0' end - base_url = if options.custom_policy && options.tenant_name + tenanted_endpoint_base_url = if options.custom_policy && options.tenant_name "https://#{options.tenant_name}.b2clogin.com/#{options.tenant_name}.onmicrosoft.com/#{options.custom_policy}" else "#{options.base_url}/#{options.tenant_id}" end - options.client_options.authorize_url = "#{base_url}/#{oauth2}/authorize" - options.client_options.token_url = "#{base_url}/#{oauth2}/token" + options.client_options.authorize_url = "#{tenanted_endpoint_base_url}/#{oauth2}/authorize" + options.client_options.token_url = "#{tenanted_endpoint_base_url}/#{oauth2}/token" super end @@ -136,7 +137,11 @@ def raw_info # sense to verify the token issuer, because the value of 'iss' in the # token depends on the 'tid' in the token itself. # - issuer = options.tenant_id.nil? ? nil : "#{options.base_url}/#{options.tenant_id}/v2.0" + issuer = if options.tenant_id.nil? || options.tenant_id == COMMON_TENANT_ID + nil + else + "#{options.base_url || BASE_URL}/#{options.tenant_id}/v2.0" + end # https://learn.microsoft.com/en-us/entra/identity-platform/id-tokens#validate-tokens # diff --git a/spec/omniauth/strategies/entra_id_spec.rb b/spec/omniauth/strategies/entra_id_spec.rb index 4057e19..b027c43 100644 --- a/spec/omniauth/strategies/entra_id_spec.rb +++ b/spec/omniauth/strategies/entra_id_spec.rb @@ -467,7 +467,7 @@ def adfs? end # "describe '#client' do" end # "describe 'dynamic configuration with on premise ADFS' do" - describe 'raw_info' do + describe 'raw_info and validation' do let(:issued_at ) { Time.now.utc.to_i } let(:expires_at) { (Time.now.utc + 3600).to_i } @@ -476,7 +476,6 @@ def adfs? end let(:id_token_info) do - { ver: '2.0', iss: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', @@ -603,15 +602,55 @@ def adfs? end end # "context 'with an invalid audience' do" - context 'with an invalid issuer' do - subject do - OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'test-tenant'}) - end + context 'issuers' do + context 'when valid' do + subject do + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: '9188040d-6c67-4c5b-b112-36a304b66dad'}) + end - it 'fails validation' do - expect { subject.info }.to raise_error(JWT::InvalidIssuerError) - end - end # "context 'with an invalid issuer' do" + it 'passes validation' do + expect { subject.info }.to_not raise_error() + end + end # "context 'when valid' do" + + context 'when invalid' do + subject do + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: 'a-mismatched-tenant-id'}) + end + + it 'fails validation' do + expect { subject.info }.to raise_error(JWT::InvalidIssuerError) + end + end # "context 'when invalid' do" + + context 'multi-tenant' do + let(:id_token_info) do + hash = super() + hash['iss'] = 'invalid issuer that should be ignored' + hash + end + + context 'no tenant specified' do + subject do + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: nil}) + end + + it 'skips issuer validation since tenant ID is unknown' do + expect { subject.info }.to_not raise_error() + end + end # "context 'no tenant specified' do" + + context '"common" tenant specified' do + subject do + OmniAuth::Strategies::EntraId.new(app, {client_id: 'id', client_secret: 'secret', tenant_id: OmniAuth::Strategies::EntraId::COMMON_TENANT_ID}) + end + + it 'skips issuer validation since tenant ID is unknown' do + expect { subject.info }.to_not raise_error() + end + end # "context '"common" tenant specified' do" + end # "context 'multi-tenant' do" + end # "context 'issuers' do" context 'with an invalid not_before' do let(:issued_at) { (Time.now.utc + 70).to_i } # Invalid because leeway is 60 seconds @@ -667,7 +706,7 @@ def adfs? expect { subject.info }.to raise_error(JWT::ExpiredSignature) end end # "context 'with an expired token' do" - end # "describe 'raw_info' do" + end # "describe 'raw_info and validation' do" describe 'callback_url' do subject do From 1484a2d8895ca28a01304009322ef63742650e11 Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Tue, 22 Oct 2024 12:48:49 +1300 Subject: [PATCH 7/8] Update date; ready to go --- CHANGELOG.md | 2 +- lib/omniauth/entra_id/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0191711..f1f3243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -## v3.0.0 (2024-10-21) +## v3.0.0 (2024-10-22) * To upgrade from the Azure ActiveDirectory V2 gem, please see [`UPGRADING.md`](UPGRADING.md) * Branched from `omniauth-entra-id` version 2.4.0 and renamed to `omniauth-entra-id` diff --git a/lib/omniauth/entra_id/version.rb b/lib/omniauth/entra_id/version.rb index b911779..4db8f55 100644 --- a/lib/omniauth/entra_id/version.rb +++ b/lib/omniauth/entra_id/version.rb @@ -2,7 +2,7 @@ module OmniAuth module Entra module Id VERSION = "3.0.0" - DATE = "2024-10-21" + DATE = "2024-10-22" end end end From 21deb4b2ad969d93f9cb14d57ffea0b2142fdbf8 Mon Sep 17 00:00:00 2001 From: Andrew Hodgkinson Date: Tue, 22 Oct 2024 12:50:58 +1300 Subject: [PATCH 8/8] Emphasise name change --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c751a5..fd0301d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ OAuth 2 authentication with [Entra ID API](https://learn.microsoft.com/en-us/ent 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. -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). +**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).**