diff --git a/README.md b/README.md index 2e0b865..9119fde 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,15 @@ $ gem install omniauth-azure-activedirectory-v2 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. -### Configuration +### 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). + +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` diff --git a/lib/omniauth/strategies/azure_activedirectory_v2.rb b/lib/omniauth/strategies/azure_activedirectory_v2.rb index abf9141..03f9c95 100644 --- a/lib/omniauth/strategies/azure_activedirectory_v2.rb +++ b/lib/omniauth/strategies/azure_activedirectory_v2.rb @@ -9,6 +9,8 @@ class AzureActivedirectoryV2 < OmniAuth::Strategies::OAuth2 option :name, 'azure_activedirectory_v2' option :tenant_provider, nil + option :jwt_leeway, 60 + DEFAULT_SCOPE = 'openid profile email' @@ -64,12 +66,17 @@ def client super end - uid { raw_info['oid'] } + uid do + # as instructed by https://learn.microsoft.com/en-us/entra/identity-platform/migrate-off-email-claim-authorization + 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'] || raw_info['upn'], + email: raw_info['email'], nickname: raw_info['unique_name'], first_name: raw_info['given_name'], last_name: raw_info['family_name'] @@ -101,6 +108,22 @@ def raw_info rescue StandardError {} 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" + + # 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_expiration: true, + verify_not_before: true, + leeway: options[:jwt_leeway] + ) auth_token_data = begin ::JWT.decode(access_token.token, nil, false).first rescue StandardError diff --git a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb b/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb index 3fa8911..85a3024 100644 --- a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb +++ b/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb @@ -380,10 +380,22 @@ def adfs? end let(:id_token_info) do + issued_at = Time.now.utc.to_i + expires_at = (Time.now + 3600).to_i { - oid: 'my_id', - name: 'Bob Doe', - email: 'bob@doe.com', + 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 @@ -413,15 +425,27 @@ def adfs? end it 'returns correct uid' do - expect(subject.uid).to eq('my_id') + expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadmy_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 { - oid: 'overridden_id', - email: 'bob@doe.com', + 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', unique_name: 'Bobby Definitely Doe', given_name: 'Bob', family_name: 'Doe' @@ -447,9 +471,100 @@ def adfs? end it 'returns correct uid' do - expect(subject.uid).to eq('overridden_id') + expect(subject.uid).to eq('9188040d-6c67-4c5b-b112-36a304b66dadoverridden_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