lib/ex_azure_key_vault/client_assertion_auth.ex

defmodule ExAzureKeyVault.ClientAssertionAuth do
  @moduledoc """
  Internal module for getting authentication token for Azure connection using client assertion.
  """
  alias __MODULE__
  alias ExAzureKeyVault.HTTPUtils

  @enforce_keys [:client_id, :tenant_id, :cert_base64_thumbprint, :cert_private_key_pem]
  defstruct(
    client_id: nil,
    tenant_id: nil,
    cert_base64_thumbprint: nil,
    cert_private_key_pem: nil
  )

  @type t :: %__MODULE__{
          client_id: String.t(),
          tenant_id: String.t(),
          cert_base64_thumbprint: String.t(),
          cert_private_key_pem: String.t()
        }

  @doc ~S"""
  Creates `%ExAzureKeyVault.ClientAssertionAuth{}` struct with account tokens and cert data.

  ## Examples

      iex(1)> ExAzureKeyVault.ClientAssertionAuth.new("6f185f82-9909...", "6f1861e4-9909...", "Dss7v2YI3GgCGfl...", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF...")
      %ExAzureKeyVault.ClientAssertionAuth{
        client_id: "6f185f82-9909...",
        tenant_id: "6f1861e4-9909...",
        cert_base64_thumbprint: "Dss7v2YI3GgCGfl...",
        cert_private_key_pem: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF..."
      }

  """
  @spec new(String.t(), String.t(), String.t(), String.t()) :: ClientAssertionAuth.t()
  def new(client_id, tenant_id, cert_base64_thumbprint, cert_private_key_pem) do
    %ClientAssertionAuth{
      client_id: client_id,
      tenant_id: tenant_id,
      cert_base64_thumbprint: cert_base64_thumbprint,
      cert_private_key_pem: cert_private_key_pem
    }
  end

  @doc ~S"""
  Returns bearer token for Azure connection using client assertion.

  ## Examples

      iex(1)> ExAzureKeyVault.ClientAssertionAuth.new("6f185f82-9909...", "6f1861e4-9909...", "Dss7v2YI3GgCGfl...", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF...")
      ...(1)> |> ExAzureKeyVault.ClientAssertionAuth.get_bearer_token()
      {:ok, "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

  """
  @spec get_bearer_token(ClientAssertionAuth.t()) :: {:ok, String.t()} | {:error, any}
  def get_bearer_token(%ClientAssertionAuth{} = params) do
    client_assertion =
      auth_client_assertion(
        params.client_id,
        params.tenant_id,
        params.cert_base64_thumbprint,
        params.cert_private_key_pem
      )

    url = auth_url(params.tenant_id)
    body = auth_body(params.client_id, client_assertion)
    headers = HTTPUtils.headers_form_urlencoded()
    options = HTTPUtils.options_ssl()

    case HTTPoison.post(url, body, headers, options) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
        response = Jason.decode!(body)
        {:ok, "Bearer #{response["access_token"]}"}

      {:ok, %HTTPoison.Response{status_code: status, body: ""}} ->
        HTTPUtils.response_client_error_or_ok(status, url)

      {:ok, %HTTPoison.Response{status_code: status, body: body}} ->
        HTTPUtils.response_client_error_or_ok(status, url, body)

      {:error, %HTTPoison.Error{reason: :nxdomain}} ->
        HTTPUtils.response_server_error(:nxdomain, url)

      {:error, %HTTPoison.Error{reason: reason}} ->
        HTTPUtils.response_server_error(reason)

      _ ->
        {:error, "Something went wrong"}
    end
  end

  @spec auth_url(String.t()) :: String.t()
  defp auth_url(tenant_id) do
    "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"
  end

  @spec auth_body(String.t(), String.t()) :: tuple
  defp auth_body(client_id, client_assertion) do
    {:form,
     [
       grant_type: "client_credentials",
       client_id: client_id,
       client_assertion: client_assertion,
       client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
       scope: "https://vault.azure.net/.default"
     ]}
  end

  @spec auth_client_assertion(String.t(), String.t(), String.t(), String.t()) :: String.t()
  defp auth_client_assertion(client_id, tenant_id, cert_base64_thumbprint, cert_private_key_pem) do
    signer =
      Joken.Signer.create("RS256", %{"pem" => cert_private_key_pem}, %{
        "x5t" => cert_base64_thumbprint
      })

    sub = client_id
    iss = client_id
    jti = Joken.generate_jti()
    nbf = Joken.current_time()
    # in 1 minute
    exp = Joken.current_time() + 60
    aud = auth_url(tenant_id)

    {:ok, claims} =
      Joken.generate_claims(%{}, %{sub: sub, iss: iss, jti: jti, nbf: nbf, exp: exp, aud: aud})

    {:ok, jwt, _} = Joken.encode_and_sign(claims, signer)
    jwt
  end
end