# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT

defmodule AshAuthentication.Strategy.OAuth2.Plug do
  @moduledoc """
  Handlers for incoming OAuth2 HTTP requests.
  """

  alias Ash.Error.Framework.AssumptionFailed
  alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2}
  alias Assent.HTTPAdapter.Finch
  alias Plug.Conn
  import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1, get_context: 1]
  import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
  import Plug.Conn

  @raw_config_attrs [
    :auth_method,
    :client_authentication_method,
    :id_token_signed_response_alg,
    :id_token_ttl_seconds,
    :openid_configuration_uri
  ]

  @doc """
  Perform the request phase of OAuth2.

  Builds a redirection URL based on the provider configuration and redirects the
  user to that endpoint.
  """
  @spec request(Conn.t(), OAuth2.t()) :: Conn.t()
  # sobelow_skip ["XSS.SendResp"]
  def request(conn, strategy) do
    with {:ok, config} <- config_for(strategy, %{conn: conn}),
         {:ok, config} <- maybe_add_nonce(config, strategy, %{conn: conn}),
         {:ok, session_key} <- session_key(strategy),
         {:ok, %{session_params: session_params, url: url}} <-
           strategy.assent_strategy.authorize_url(config) do
      conn
      |> put_session(session_key, session_params)
      |> put_resp_header("location", url)
      |> send_resp(:found, "Redirecting to #{strategy.name}")
    else
      {:error, reason} -> store_authentication_result(conn, {:error, reason})
    end
  end

  @doc """
  Perform the callback phase of OAuth2.

  Responds to a user being redirected back from the remote authentication
  provider, and validates the passed options, ultimately registering or
  signing-in a user if the authentication was successful.
  """
  @spec callback(Conn.t(), OAuth2.t()) :: Conn.t()
  def callback(conn, strategy) do
    with {:ok, session_key} <- session_key(strategy),
         {:ok, config} <- config_for(strategy, %{conn: conn}),
         session_params when is_map(session_params) <- get_session(conn, session_key),
         conn <- delete_session(conn, session_key),
         config <- Keyword.put(config, :session_params, session_params),
         {:ok, %{user: user, token: token}} <-
           strategy.assent_strategy.callback(config, conn.params),
         action_opts <- action_opts(conn),
         {:ok, user} <-
           register_or_sign_in_user(
             strategy,
             %{user_info: user, oauth_tokens: token},
             action_opts
           ) do
      store_authentication_result(conn, {:ok, user})
    else
      nil ->
        store_authentication_result(conn, {:error, nil})

      {:error, reason} ->
        store_authentication_result(conn, {:error, reason})
    end
  end

  defp action_opts(conn) do
    [actor: get_actor(conn), tenant: get_tenant(conn), context: get_context(conn) || %{}]
    |> Enum.reject(&is_nil(elem(&1, 1)))
  end

  defp config_for(strategy, context) do
    config =
      strategy
      |> Map.take(@raw_config_attrs)

    with {:ok, config} <- add_secret_value(config, strategy, :base_url, context),
         {:ok, config} <-
           add_secret_value(config, strategy, :authorize_url, !!strategy.base_url, context),
         {:ok, config} <-
           add_secret_value(config, strategy, :client_id, !!strategy.base_url, context),
         {:ok, config} <-
           add_secret_value(config, strategy, :client_secret, !!strategy.base_url, context),
         {:ok, config} <-
           add_secret_value(config, strategy, :token_url, !!strategy.base_url, context),
         {:ok, config} <-
           add_secret_value(config, strategy, :code_verifier, !!strategy.code_verifier, context),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :authorization_params,
             !!strategy.authorization_params,
             context
           ),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :openid_configuration,
             !strategy.openid_configuration,
             context
           ),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :team_id,
             strategy.assent_strategy != Assent.Strategy.Apple,
             context
           ),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :private_key_id,
             strategy.assent_strategy != Assent.Strategy.Apple,
             context
           ),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :private_key_path,
             strategy.assent_strategy != Assent.Strategy.Apple,
             context
           ),
         {:ok, config} <-
           add_secret_value(config, strategy, :trusted_audiences, true, context),
         {:ok, config} <- add_http_adapter(config),
         {:ok, config} <-
           add_secret_value(
             config,
             strategy,
             :user_url,
             !!strategy.authorize_url || !!strategy.base_url,
             context
           ),
         {:ok, redirect_uri} <- build_redirect_uri(strategy, context),
         {:ok, jwt_algorithm} <-
           Info.authentication_tokens_signing_algorithm(strategy.resource) do
      config =
        config
        |> Map.put(:jwt_algorithm, jwt_algorithm)
        |> Map.put(:redirect_uri, redirect_uri)
        |> Map.update(:client_authentication_method, nil, &to_string/1)
        |> Enum.reject(&is_nil(elem(&1, 1)))

      {:ok, config}
    end
  end

  defp register_or_sign_in_user(strategy, params, opts) when strategy.registration_enabled?,
    do: Strategy.action(strategy, :register, params, opts)

  defp register_or_sign_in_user(strategy, params, opts),
    do: Strategy.action(strategy, :sign_in, params, opts)

  # We need to temporarily store some information about the request in the
  # session so that we can verify that there hasn't been a CSRF-related attack.
  defp session_key(strategy) do
    case Info.authentication_subject_name(strategy.resource) do
      {:ok, subject_name} ->
        {:ok, "#{subject_name}/#{strategy.name}"}

      :error ->
        {:error,
         AssumptionFailed.exception(
           message: "Resource `#{inspect(strategy.resource)}` has no subject name"
         )}
    end
  end

  # With OpenID Connect we can pass a "nonce" value into the assent strategy
  # which is an additional way to ensure that the callback matches the request.
  defp maybe_add_nonce(config, strategy, context) do
    case fetch_secret(strategy, :nonce, context) do
      {:ok, value} when is_binary(value) and byte_size(value) > 0 ->
        {:ok, Keyword.put(config, :nonce, value)}

      {:ok, false} ->
        {:ok, config}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp add_secret_value(config, strategy, secret_name, allow_nil? \\ false, context) do
    case fetch_secret(strategy, secret_name, context) do
      {:ok, nil} when allow_nil? ->
        {:ok, config}

      {:ok, nil} ->
        path = [:authentication, :strategies, strategy.name, secret_name]
        {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}

      {:ok, value} when is_binary(value) and byte_size(value) > 0 ->
        {:ok, Map.put(config, secret_name, value)}

      {:ok, list} when is_list(list) ->
        {:ok, Map.put(config, secret_name, list)}

      {:ok, map} when is_map(map) ->
        {:ok, Map.put(config, secret_name, map)}

      {:ok, boolean} when is_boolean(boolean) ->
        {:ok, Map.put(config, secret_name, boolean)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp fetch_secret(strategy, secret_name, context) do
    path = [:authentication, :strategies, strategy.name, secret_name]

    with {:ok, {secret_module, secret_opts}} <- Map.fetch(strategy, secret_name),
         {:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <-
           AshAuthentication.Secret.secret_for(
             secret_module,
             path,
             strategy.resource,
             secret_opts,
             context
           ) do
      {:ok, secret}
    else
      {:ok, secret} ->
        {:ok, secret}

      _ ->
        {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)}
    end
  end

  defp build_redirect_uri(strategy, context) do
    with {:ok, subject_name} <- Info.authentication_subject_name(strategy.resource),
         {:ok, redirect_uri} <- fetch_secret(strategy, :redirect_uri, context),
         {:ok, uri} <- URI.new(redirect_uri) do
      suffix = Path.join([to_string(subject_name), to_string(strategy.name), "callback"])
      # Don't append the path if the secret ends with the path already
      path =
        if String.ends_with?(uri.path, suffix) do
          uri.path
        else
          Path.join([uri.path || "/", suffix])
        end

      {:ok, to_string(%URI{uri | path: path})}
    else
      :error ->
        {:error,
         AssumptionFailed.exception(
           message: "Resource `#{inspect(strategy.resource)}` has no subject name"
         )}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp add_http_adapter(config) do
    http_adapter =
      Application.get_env(
        :ash_authentication,
        :http_adapter,
        {Finch, supervisor: AshAuthentication.Finch}
      )

    {:ok, Map.put(config, :http_adapter, http_adapter)}
  end
end
