Skip to content

Commit

Permalink
Merge pull request #32 from juliaducey/support_client_assertion
Browse files Browse the repository at this point in the history
Add support for authenticating with Entra (AAD) with a certificate instead of client secret
  • Loading branch information
pond authored Oct 16, 2024
2 parents 5b480b2 + 9c45c52 commit 2b40a51
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 13 deletions.
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 45 additions & 1 deletion lib/omniauth/strategies/azure_activedirectory_v2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
89 changes: 88 additions & 1 deletion spec/omniauth/strategies/azure_activedirectory_v2_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand All @@ -72,7 +160,6 @@
end
end
end

end

describe 'static configuration - german' do
Expand Down

0 comments on commit 2b40a51

Please sign in to comment.