diff --git a/README.md b/README.md index 93c76aa..b9a2ba4 100644 --- a/README.md +++ b/README.md @@ -98,17 +98,22 @@ 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). -| Option | Use | -| ------ | --- | -| `client_id` | **Mandatory.** Client ID for the 'application' (integration) configured on the Azure side. Found via the Azure UI. | -| `client_secret` | **Mandatory.** Client secret for the 'application' (integration) configured on the Azure side. Found via the Azure UI. | -| `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` | _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'`. | +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. + +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). + +| 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'`. | 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. diff --git a/lib/omniauth/strategies/azure_activedirectory_v2.rb b/lib/omniauth/strategies/azure_activedirectory_v2.rb index ba42f11..27f7dc9 100644 --- a/lib/omniauth/strategies/azure_activedirectory_v2.rb +++ b/lib/omniauth/strategies/azure_activedirectory_v2.rb @@ -23,7 +23,20 @@ def client end options.client_id = provider.client_id - options.client_secret = provider.client_secret + + if provider.respond_to?(:client_secret) && provider.client_secret + 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), + 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 = @@ -117,6 +130,37 @@ 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 + def client_assertion_type + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + end + + def client_assertion_claims(tenant_id, client_id) + { + 'aud' => "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token", + 'exp' => Time.now.to_i + 300, + 'iss' => client_id, + 'jti' => SecureRandom.uuid, + 'nbf' => Time.now.to_i, + 'sub' => client_id, + 'iat' => Time.now.to_i + } + end + + def client_assertion(tenant_id, client_id, 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) + + claims = client_assertion_claims(tenant_id, client_id) + 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 end end end diff --git a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb b/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb index 86c8ab5..8de88d5 100644 --- a/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb +++ b/spec/omniauth/strategies/azure_activedirectory_v2_spec.rb @@ -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::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'} @@ -72,7 +160,6 @@ end end end - end describe 'static configuration - german' do