lib/ash_authentication/strategies/magic_link/transformer.ex

defmodule AshAuthentication.Strategy.MagicLink.Transformer do
  @moduledoc """
  DSL transformer for magic links.
  """

  alias Ash.Resource
  alias AshAuthentication.Strategy.MagicLink
  alias Spark.Dsl.Transformer
  import AshAuthentication.Utils
  import AshAuthentication.Validations
  import AshAuthentication.Strategy.Custom.Helpers
  require Logger

  @doc false
  @spec transform(MagicLink.t(), dsl_state) :: {:ok, MagicLink.t() | dsl_state} | {:error, any}
        when dsl_state: map
  def transform(strategy, dsl_state) do
    with :ok <-
           validate_token_generation_enabled(
             dsl_state,
             "Token generation must be enabled for magic links to work."
           ),
         strategy <- maybe_set_sign_in_action_name(strategy),
         strategy <- maybe_set_request_action_name(strategy),
         strategy <- maybe_set_lookup_action_name(strategy),
         strategy <- maybe_transform_token_lifetime(strategy),
         {:ok, dsl_state} <-
           maybe_build_action(
             dsl_state,
             strategy.sign_in_action_name,
             &build_sign_in_action(&1, strategy)
           ),
         {:ok, dsl_state} <-
           maybe_build_action(
             dsl_state,
             strategy.request_action_name,
             &build_request_action(&1, strategy)
           ),
         :ok <- warn_on_require_interaction(strategy) do
      dsl_state =
        dsl_state
        |> then(
          &register_strategy_actions(
            [
              strategy.sign_in_action_name,
              strategy.request_action_name,
              strategy.lookup_action_name
            ],
            &1,
            strategy
          )
        )
        |> put_strategy(strategy)

      {:ok, dsl_state}
    end
  end

  defp maybe_transform_token_lifetime(strategy) when is_integer(strategy.token_lifetime),
    do: %{strategy | token_lifetime: {strategy.token_lifetime, :minutes}}

  defp maybe_transform_token_lifetime(strategy), do: strategy

  # sobelow_skip ["DOS.StringToAtom"]
  defp maybe_set_sign_in_action_name(strategy) when is_nil(strategy.sign_in_action_name),
    do: %{strategy | sign_in_action_name: String.to_atom("sign_in_with_#{strategy.name}")}

  defp maybe_set_sign_in_action_name(strategy), do: strategy

  # sobelow_skip ["DOS.StringToAtom"]
  defp maybe_set_request_action_name(strategy) when is_nil(strategy.request_action_name),
    do: %{strategy | request_action_name: String.to_atom("request_#{strategy.name}")}

  defp maybe_set_request_action_name(strategy), do: strategy

  # sobelow_skip ["DOS.StringToAtom"]
  defp maybe_set_lookup_action_name(strategy) when is_nil(strategy.lookup_action_name),
    do: %{strategy | lookup_action_name: String.to_atom("get_by_#{strategy.identity_field}")}

  defp maybe_set_lookup_action_name(strategy), do: strategy

  defp build_sign_in_action(dsl_state, strategy) do
    if strategy.registration_enabled? do
      arguments = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
          name: strategy.token_param_name,
          type: :string,
          allow_nil?: false
        )
      ]

      changes = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
          change: MagicLink.SignInChange
        )
      ]

      metadata = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :create], :metadata,
          name: :token,
          type: :string,
          allow_nil?: false
        )
      ]

      identity =
        Enum.find(Ash.Resource.Info.identities(dsl_state), fn identity ->
          identity.keys == [strategy.identity_field]
        end)

      Transformer.build_entity(Resource.Dsl, [:actions], :create,
        name: strategy.sign_in_action_name,
        arguments: arguments,
        changes: changes,
        metadata: metadata,
        upsert?: true,
        upsert_identity: identity.name,
        upsert_fields: [strategy.identity_field]
      )
    else
      arguments = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
          name: strategy.token_param_name,
          type: :string,
          allow_nil?: false
        )
      ]

      preparations = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
          preparation: MagicLink.SignInPreparation
        )
      ]

      metadata = [
        Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
          name: :token,
          type: :string,
          allow_nil?: false
        )
      ]

      Transformer.build_entity(Resource.Dsl, [:actions], :read,
        name: strategy.sign_in_action_name,
        arguments: arguments,
        preparations: preparations,
        metadata: metadata,
        get?: true
      )
    end
  end

  defp build_request_action(dsl_state, strategy) do
    identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)

    arguments = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
        name: strategy.identity_field,
        type: identity_attribute.type,
        allow_nil?: false
      )
    ]

    preparations = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
        preparation: MagicLink.RequestPreparation
      )
    ]

    Transformer.build_entity(Resource.Dsl, [:actions], :read,
      name: strategy.request_action_name,
      arguments: arguments,
      preparations: preparations
    )
  end

  defp warn_on_require_interaction(strategy) when strategy.require_interaction?, do: :ok

  defp warn_on_require_interaction(strategy) do
    bypassing_error? =
      :ash_authentication
      |> Application.get_env(:bypass_require_interaction_for_magic_link?, false)

    if bypassing_error? do
      :ok
    else
      Logger.warning(fn ->
        """
        `require_interaction?` should be set to true on the #{inspect(strategy.name)}
        magic link strategy for #{inspect(strategy.resource)}. Without it, magic links
        use a `GET` endpoint for signing in. Some  email clients and security tools
        (e.g., Outlook, virus scanners, and email previewers) may automatically follow
        these links, unintentionally consuming the sign in token making it unavailable
        to the end user.

        In addition to setting `require_interaction?` you will need to make sure that
        ash_authentication_phoenix is updated, and that you add `magic_sign_in_route`
        to your router.

            magic_sign_in_route(
              MyApp.Accounts.User,
              # your magic link strategy name here
              :magic_link,
              auth_routes_prefix: "/auth",
              overrides: [MyAppWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default],
              # the route will default `/<the_strategy_name>/:magic_link`
              # use these options to keep your currently issued magic link emails compatible
              # if you use this option, make sure to place it *above* `auth_routes` in your router.
              path: "/auth/user/magic_link",
              token_as_route_param?: false
            )

        If you would like to keep the old behaviour and remove this warning then you can
        do so by adding the following configuration

            config :ash_authentication.
              :bypass_require_interaction_for_magic_link?, true
        """
      end)
    end
  end
end