defmodule ExSecrets.Providers.AzureKeyVault do
use ExSecrets.Providers.Base
alias ExSecrets.Utils.Config
@scope "https://vault.azure.net/.default"
@moduledoc """
Azure Key Vault provider provides secrets from an [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) through a rest API.
## Configuration
You can configure this provider as shown below. Either `client_secret` or `client_cert_path` is required to implement authentication.
Client `client_certificate_?` is recommended for security. If you provide both, `client_certificate_?` is used. All other config options are mandatory.
```
config :ex_secrets, :providers, %{
azure_key_vault: %{
tenant_id: "tenant-id",
client_id: "client-id",
client_secret: "client-secret",
client_certificate_string: "base 64 encoded contents of cert.key",
client_certificate_path: "/path/cert.key",
client_certificate_x5t: "x5t",
key_vault_name: "key-vault-name"
}
```
See notes below on setting up authentication.
## Authentication
The service pricipal being used must have a role that can access the secrets eg `Key Vault Secrets User`.
### Secret Authentication
This is prety straing forward. See the stps below from Being Chat
Here is a step-by-step guide to generate a secret for an app on Azure AD App Registration Secrets page:
1. Sign in to the Azure portal.
2. Navigate to the Azure Active Directory > App registrations > Owned applications.
3. Select your application.
4. Click on Certificates & secrets > Client secrets > New client secret.
5. Type a description and an expiration for the client’s secret.
6. Click Add.
[For more information on generating client secrets in Azure AD, please refer to 12](https://o365info.com/create-unlimited-client-secret/)
### Certificate Authentication
MacOS and many Linux distributions come with pre-compiled OpenSSL packages. You can run the following command directly from a shell to confirm if OpenSSL has already been installed.
Create a Certificate Signing Request (.csr file) and generate a private key (.key file) using the following command:
```
openssl req -newkey rsa:4096 -nodes -keyout mycert.key -batch -out mycert.csr
```
This command will generate a simple CSR and download a 4096-bit private key in your current directory for self-signature.
Next, self-sign the certificate using the private key that was just generated:
```
openssl x509 -key mycert.key -in mycert.csr -req -days 3650 -out mycert.crt
```
You may replace validity period 3650 with any number of days you wish. Just be aware that once the period of validity has expired, you will need to replace the certificate with a new one.
Upload the .crt file to Azure portal using the Being Chat steps below.
Here is a step-by-step guide to upload a certificate for an app on Azure AD App Registration Secrets page:
1. Sign in to the Azure portal.
2. Navigate to the Azure Active Directory > App registrations > Owned applications.
3. Select your application.
4. Click on Certificates & secrets > Certificates > Upload certificate.
4. Browse to the `.crt` certificate file saved on your local machine and select it.
5. Type a description for the certificate.
7. Click Add.
Finally generate the `x5t` JWT header required by Entra using the command below. See https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials#assertion-format.
```
openssl x509 -in mycert.crt -fingerprint -noout | sed 's/SHA1 Fingerprint=//g' | sed 's/://g' | xxd -r -ps | base64
```
Save the value as `client_certificate_x5t` in the config.
"""
@headers %{"Content-Type" => "application/x-www-form-urlencoded"}
@process_name :ex_secrets_azure_key_vault
def reset() do
:ok
end
def init(_) do
case get_access_token() do
{:ok, data} ->
{:ok, data}
_ ->
{:ok, %{}}
end
end
def get(name) do
name = name |> String.split("_") |> Enum.join("-")
with process when not is_nil(process) <-
GenServer.whereis(@process_name) do
GenServer.call(@process_name, {:get, name})
else
nil ->
case get_secret(name, %{}, nil) do
{:ok, value, _} -> value
_ -> nil
end
end
end
def set(name, value) do
name = name |> String.split("_") |> Enum.join("-")
with process when not is_nil(process) <-
GenServer.whereis(@process_name) do
GenServer.call(@process_name, {:set, name, value})
else
nil ->
case set_secret(name, value, %{}, nil) do
{:ok, _value, _} ->
:ok
_ ->
:error
end
end
end
def handle_call({:get, name}, _from, state) do
case get_secret(name, state, get_current_epoch()) do
{:ok, secret, state} -> {:reply, secret, state}
_ -> {:reply, nil, state}
end
end
def handle_call({:set, name, value}, _from, state) do
case set_secret(name, value, state, get_current_epoch()) do
{:ok, _secret, state} -> {:reply, :ok, state}
_ -> {:reply, :error, state}
end
end
defp token_uri(tenant_id) do
"https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"
end
defp get_secret(
name,
%{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
state,
current_time
)
when issued_at + expires_in - current_time > 5 do
with {:ok, value} <- get_secret_call(name, access_token),
true <- is_binary(value) do
{:ok, value, state}
else
_ -> {:error, "Failed to get secret"}
end
end
defp get_secret(name, state, _) do
with {:ok, %{"access_token" => access_token} = new_state} <-
get_access_token(),
{:ok, value} <- get_secret_call(name, access_token) do
{:ok, value, state |> Map.merge(new_state) |> Map.put("issued_at", get_current_epoch())}
else
err -> err
end
end
defp set_secret(
name,
value,
%{"access_token" => access_token, "issued_at" => issued_at, "expires_in" => expires_in} =
state,
current_time
)
when issued_at + expires_in - current_time > 5 do
with {:ok, value} <- set_secret_call(name, value, access_token),
true <- is_binary(value) do
{:ok, value, state}
else
err -> err
end
end
defp set_secret(name, value, state, _) do
with {:ok, %{"access_token" => access_token} = new_state} <-
get_access_token(),
{:ok, value} <- set_secret_call(name, value, access_token) do
{:ok, value, state |> Map.merge(new_state) |> Map.put("issued_at", get_current_epoch())}
else
_ -> {:error, "Failed to get secret"}
end
end
defp get_secret_call(name, access_token) do
client = http_adpater()
with {:ok, %{body: body, status_code: 200}} <-
name
|> secret_url()
|> client.get(%{"Authorization" => "Bearer #{access_token}"}),
{:ok, %{"value" => value}} <- Poison.decode(body) do
{:ok, value}
else
_ -> {:error, "Failed to get secret"}
end
end
defp set_secret_call(name, value, access_token) do
client = http_adpater()
with {:ok, %{body: body, status_code: 200}} <-
name
|> secret_url()
|> client.put(Poison.encode!(%{value: value}), %{
"Authorization" => "Bearer #{access_token}",
"content-type" => "application/json"
}),
{:ok, %{"value" => value}} <- Poison.decode(body) do
{:ok, value}
else
err -> err
end
end
defp get_access_token() do
client = http_adpater()
client_secret = Config.provider_config_value(:azure_key_vault, :client_secret)
client_certificate_str =
Config.provider_config_value(:azure_key_vault, :client_certificate_string)
client_certificate_path =
Config.provider_config_value(:azure_key_vault, :client_certificate_path)
tenant_id = Config.provider_config_value(:azure_key_vault, :tenant_id)
with req_body when is_binary(req_body) <-
build_claims_body(%{
"secret" => client_secret,
"cert" => client_certificate_str || client_certificate_path
}),
{:ok, %{body: body, status_code: 200}} <-
tenant_id
|> token_uri()
|> client.post(req_body, @headers),
{:ok, data} <- Poison.decode(body) do
{:ok, data |> Map.put("issued_at", get_current_epoch())}
else
{:error, term} ->
{:error, term}
_ ->
{:error, "Failed to get access token"}
end
end
defp get_cert() do
string = Config.provider_config_value(:azure_key_vault, :client_certificate_string)
path = Config.provider_config_value(:azure_key_vault, :client_certificate_path)
cond do
is_binary(string) -> Base.decode64(string)
is_binary(path) -> File.read(path)
true -> {:error, :no_cert}
end
end
defp build_claims_body(%{"cert" => cert}) when is_binary(cert) do
client_id = Config.provider_config_value(:azure_key_vault, :client_id)
case get_cert() do
{:ok, cert} ->
URI.encode_query(%{
"client_id" => client_id,
"scope" => @scope,
"grant_type" => "client_credentials",
"client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion" => jwt(cert)
})
err ->
err
end
end
defp build_claims_body(%{"secret" => secret}) when is_binary(secret) do
client_id = Config.provider_config_value(:azure_key_vault, :client_id)
URI.encode_query(%{
"client_id" => client_id,
"client_secret" => secret,
"scope" => @scope,
"grant_type" => "client_credentials"
})
end
defp build_claims_body(_), do: {:error, :no_auth}
defp jwt(cert) do
File.write("base64.crt", Base.encode64(cert))
client_id = Config.provider_config_value(:azure_key_vault, :client_id)
client_certificate_x5t =
Config.provider_config_value(:azure_key_vault, :client_certificate_x5t)
tenant_id = Config.provider_config_value(:azure_key_vault, :tenant_id)
t = DateTime.to_unix(DateTime.utc_now())
signer =
Joken.Signer.create("RS256", %{"pem" => cert}, %{
"x5t" => client_certificate_x5t
})
claims = %{
"iss" => client_id,
"sub" => client_id,
"aud" => "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token",
"exp" => t + 1200,
"iat" => t
}
case Joken.encode_and_sign(claims, signer) do
{:ok, jwt, _} -> jwt
_ -> ""
end
end
defp get_current_epoch() do
DateTime.utc_now() |> DateTime.to_unix()
end
defp http_adpater() do
Application.get_env(:ex_secrets, :http_adapter, HTTPoison)
end
defp secret_url(name) do
key_vault_name = Config.provider_config_value(:azure_key_vault, :key_vault_name)
"https://#{key_vault_name}.vault.azure.net/secrets/#{name}?api-version=2016-10-01"
end
def process_name() do
@process_name
end
end