lib/ueberauth/strategy/okta/oauth.ex

defmodule Ueberauth.Strategy.Okta.OAuth do
  @moduledoc """
  An implementation of OAuth2 for Okta.

  Supported options:
  * `:site` - (**required**) Full request URL
  * `:client_id` - (**required**) Okta client ID
  * `:client_secret` - (**required**) Okta client secret
  * `:authorize_url` - default:  "/oauth2/v1/authorize",
  * `:token_url` - default:  "/oauth2/v1/token",
  * `:userinfo_url` - default:  "/oauth2/v1/userinfo"
  * `:authorization_server_id` - If supplied, URLs for the request will be adjusted to include
    the custom Okta Authorization Server ID
  * Any `OAuth2.Client` option

  These options can be provided with the provider settings, or under the `Ueberauth.Strategy.Okta.OAuth` scope:

  ```elixir
  config :ueberauth, Ueberauth.Strategy.Okta.OAuth,
  site: "https://your-doman.okta.com"
  client_id: System.get_env("OKTA_CLIENT_ID"),
  client_secret: System.get_env("OKTA_CLIENT_SECRET")
  ```

  ### Multiple Providers (Multitenant)

  To support multiple providers, scope the settings to the same provider key you
  used when configuring `Ueberauth`:

  ```elixir
  config :ueberauth, Ueberauth,
  providers: [
    okta: {Ueberauth.Strategy.Okta, []}
  ]

  config :ueberauth, Ueberauth.Strategy.Okta.OAuth,
  okta: [
    site: "https://your-doman.okta.com"
    client_id: System.get_env("OKTA_CLIENT_ID"),
    client_secret: System.get_env("OKTA_CLIENT_SECRET")
  ]
  ```

  Scoped OAuth settings will take precedence over the global settings
  """
  use OAuth2.Strategy

  alias OAuth2.{Client, Strategy.AuthCode}

  @doc """
  Construct a client for requests to Okta.

  Intended for use from Ueberauth.Strategy.Okta but supplying options for usage
  outside the normal callback phase of Ueberauth. See OAuth2.Client.t() for
  available options.
  """
  def client(opts \\ []) do
    opts
    |> configure_url(:authorize, "/authorize")
    |> configure_url(:token, "/token")
    |> validate_config_option!(:client_id)
    |> validate_config_option!(:client_secret)
    |> validate_config_option!(:site)
    |> Keyword.put(:strategy, __MODULE__)
    |> Client.new()
    |> Client.put_serializer("application/json", Jason)
  end

  @doc """
  Provides the authorize url for the request phase of Ueberauth.
  """
  def authorize_url!(params \\ [], client_opts \\ []) do
    client_opts
    |> client()
    |> Client.authorize_url!(params)
  end

  def get_user_info(headers \\ [], opts \\ []) do
    userinfo_url =
      opts
      |> configure_url(:userinfo, "/userinfo")
      |> Keyword.fetch!(:userinfo_url)

    client(opts)
    |> Client.get(userinfo_url, headers, opts)
    |> case do
      {:ok, %{status_code: 200, body: user}} -> {:ok, user}
      {:ok, result} -> {:error, result}
      err -> err
    end
  end

  def get_token(params \\ [], options \\ []), do: Client.get_token(client(options), params)

  # Strategy Callbacks

  @impl OAuth2.Strategy
  def authorize_url(client, params) do
    client
    |> put_param(:nonce, Base.encode16(:crypto.strong_rand_bytes(32)))
    |> AuthCode.authorize_url(params)
  end

  @impl OAuth2.Strategy
  def get_token(client, params, headers) do
    client
    |> put_header("Accept", "application/json")
    |> validate_code(params)
    |> put_param(:grant_type, "authorization_code")
    |> put_param(:redirect_uri, client.redirect_uri)
    |> basic_auth()
    |> put_headers(headers)
  end

  defp validate_code(client, params) do
    code = Keyword.get(params, :code, client.params["code"])

    unless code do
      raise OAuth2.Error, reason: "Missing required key `code` for `#{inspect(__MODULE__)}`"
    end

    put_param(client, :code, code)
  end

  defp validate_config_option!(config, key) when is_list(config) do
    case Keyword.take(config, [key]) do
      [] ->
        raise "[Ueberauth.Strategy.Okta.OAuth] missing required key: #{inspect(key)} "

      [{_, ""}] ->
        raise "[Ueberauth.Strategy.Okta.OAuth] #{inspect(key)} is an empty string"

      [{:site, "http" <> _}] ->
        config

      [{:site, val}] ->
        raise "[Ueberauth.Strategy.Okta.OAuth] invalid :site - #{inspect(val)}"

      [{_, val}] when is_binary(val) ->
        config

      _ ->
        raise "[Ueberauth.Strategy.Okta.OAuth] #{inspect(key)} must be a string"
    end
  end

  defp validate_config_option!(_, _) do
    raise "[Ueberauth.Strategy.Okta.OAuth] strategy options must be a keyword list"
  end

  # Constructs default values for e.g. authorize_url based on the authorization_server_id, if it's
  # provided. Falls back to the global default for Okta. If the relevant config option is
  # already in opts (e.g. authorize_url), the existing option is always preferred.
  defp configure_url(opts, prefix, path) do
    Keyword.put_new_lazy(opts, :"#{prefix}_url", fn ->
      case opts[:authorization_server_id] do
        nil ->
          "/oauth2/v1#{path}"

        authorization_server_id when is_binary(authorization_server_id) ->
          "/oauth2/#{authorization_server_id}/v1#{path}"
      end
    end)
  end
end