lib/azure_ad_openid.ex

defmodule AzureADOpenId do
  @moduledoc """
  Azure Active Directory authentication using OpenID.
  """

  alias AzureADOpenId.Strategy
  alias AzureADOpenId.Verify

  @type uri :: String.t()
  @type config_values :: {:tenant, String.t()} | {:client_id, String.t()}
  @type config :: nil | [config_values]
  @type id_token :: map()
  @type callback_response :: {:ok, id_token} | {:error, String.t(), String.t()}

  # Plug.Conn.t
  @type conn :: map()

  @doc """
  Get an access token using the client credentials authorisation strategy for
  machine to machine authentication. Requires a client secret.
  """
  def get_access_token!(config \\ nil) do
    config = config || get_config()
    Strategy.ClientCredentials.get_token!(config)
  end

  @doc """
  Verify an access token.
  """
  def verify_access_token!(access_token, config \\ nil) do
    config = config || get_config()
    Verify.Token.access_token!(access_token, config)
  end

  @doc """
  Get a redirect url for authorization using Azure Active Directory login.
  """
  @spec authorize_url!(uri, config) :: uri
  def authorize_url!(redirect_uri, config \\ nil) do
    config = config || get_config()
    Strategy.AuthCode.authorize_url!(redirect_uri, config)
  end

  @doc """
  Handles and validates the `t:id_token/0` in the callback response. The redirect_uri used in the
  `authorize_url!/1` function should redirect to a path that uses this funtion.
  """
  @spec handle_callback!(conn, config) :: callback_response
  def handle_callback!(conn, config \\ nil) do
    config = config || get_config()

    case Map.get(conn, :params) do
      %{"id_token" => id_token, "code" => code} ->
        verify_id_token(id_token, code, config)

      %{"error" => error, "error_description" => error_description} ->
        {:error, error, error_description}

      _ ->
        {:error, "missing_code_or_token", "Missing code or id_token"}
    end
  end

  defp verify_id_token(id_token, code, config) do
    try do
      claims = Verify.Token.id_token!(id_token, code, config)
      {:ok, claims}
    rescue
      error in RuntimeError ->
        {:error, "failed_auth_callback", error.message}
    end
  end

  @doc """
  Returns the redirect url for logging out of Azure Active Directory.
  """
  @spec logout_url(uri) :: uri
  def logout_url(redirect_uri) when is_binary(redirect_uri) do
    logout_url(get_config(), redirect_uri)
  end

  @doc """
  Returns the redirect url for logging out of Azure Active Directory.
  """
  @spec logout_url(config, uri | nil) :: uri
  def logout_url(config \\ nil, redirect_uri \\ nil) do
    config = config || get_config()

    tenant = config[:tenant]
    client_id = config[:client_id]

    url = "https://login.microsoftonline.com/#{tenant}/oauth2/logout?client_id=#{client_id}"

    if is_binary(redirect_uri) do
      redirect_uri = URI.encode_www_form(redirect_uri)
      url <> "?post_logout_redirect_uri=" <> redirect_uri
    else
      url
    end
  end

  @doc """
  Checks if the library is configured with the standard Elixir configuration (i.e. using
  the config files).
  """
  @spec configured?() :: boolean()
  def configured?() do
    configset = get_config()

    configset != nil &&
      Keyword.has_key?(configset, :tenant) &&
      Keyword.has_key?(configset, :client_id)
  end

  defp get_config() do
    Application.get_env(:azure_ad_openid, AzureADOpenId)
  end

  @doc """
  Returns a human readable user name from an `t:id_token/0`. This is useful as the
  Azure Active Directory `t:id_token/0` can be very inconsistent in how user names are stored.
  """
  @spec get_user_name(id_token) :: String.t()
  def get_user_name(token) do
    cond do
      token["family_name"] && token["given_name"] ->
        name = token["given_name"] <> " " <> token["family_name"]
        format_name(name)

      token["upn"] ->
        format_name(token["upn"])

      token["name"] ->
        format_name(token["name"])

      token["email"] ->
        format_name(token["email"])

      true ->
        "No Name"
    end
  end

  defp format_name(name) do
    if should_format(name) do
      name
      |> String.split(["@", "_"])
      |> hd
      |> String.split([".", " "])
      |> Enum.map(&capitalize/1)
      |> Enum.join(" ")
    else
      name
    end
  end

  defp capitalize(name) do
    if has_upper?(name) do
      name
    else
      String.capitalize(name)
    end
  end

  @valid_upper String.graphemes("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
  defp has_upper?(name), do: String.contains?(name, @valid_upper)

  defp should_format(name) do
    cond do
      String.contains?(name, [".", "@", "_"]) ->
        true

      has_upper?(name) ->
        false

      true ->
        true
    end
  end
end