Is there built-in solution in AshAuthentication to support user created api keys?

Hey all, I was wondering if there is a built-in solution in AshAuthentication to support user created api keys to allow access to some API in an Ash resource (in my case, using AshGraphql).

I thought about using JWT, but it was not clear to me if I would be able to create multiple tokens for the user and also if AshAuthentication would keep them persistend into the User resource or it would just validate the token (meaning that I can’t revoke them).

1 Like

Not currently no :slight_smile: You could either create a custom auth strategy or implement your own system that leverages the user resource but has its own api token resource backing the access.

Ah, I see, I will read this documentation about creating custom strategies and see if I can come up with something Defining Custom Authentication Strategies — ash_authentication v4.5.3 :slight_smile:

1 Like

So, I was able to create a custom strategy that seems to work great, I don’t think it is done yet since I want to try generate the fields and changes automatically in the resource, but regardless, here is the implementation.

api_token.ex

defmodule Core.Ash.Auth.Strategies.ApiToken do
  @moduledoc false

  alias __MODULE__.{Dsl, Transformer}

  use AshAuthentication.Strategy.Custom, entity: Dsl.dsl()

  @type t :: %__MODULE__{
          identity_field: atom,
          name: atom,
          resource: module,
          sign_in_action_name: atom,
          strategy_module: module,
          key_param_name: atom,
          signing_secret: (keyword, module -> String.t()) | String.t(),
          signing_algorithm: String.t()
        }

  defstruct identity_field: :jti,
            name: :api_token,
            resource: nil,
            sign_in_action_name: nil,
            strategy_module: __MODULE__,
            key_param_name: :api_key,
            signing_secret: nil,
            signing_algorithm: nil

  defdelegate transform(strategy, dsl_state), to: Transformer
end

api_token/actions.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.Actions do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.ApiToken

  alias AshAuthentication.Errors
  alias Ash.Query

  @spec sign_in(ApiToken.t(), map, keyword) ::
          {:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
  def sign_in(strategy, params, options) do
    strategy.resource
    |> Query.new()
    |> Query.set_context(%{private: %{ash_authentication?: true}})
    |> Query.for_read(strategy.sign_in_action_name, params, options)
    |> Ash.read()
    |> case do
      {:ok, [entry]} ->
        {:ok, entry}

      {:ok, []} ->
        {:error,
         Errors.AuthenticationFailed.exception(
           strategy: strategy,
           caused_by: %{
             module: __MODULE__,
             strategy: strategy,
             action: :sign_in,
             message: "Query returned no entries"
           }
         )}

      {:ok, _entries} ->
        {:error,
         Errors.AuthenticationFailed.exception(
           strategy: strategy,
           caused_by: %{
             module: __MODULE__,
             strategy: strategy,
             action: :sign_in,
             message: "Query returned too many entries"
           }
         )}

      {:error, error} when is_exception(error) ->
        {:error,
         Errors.AuthenticationFailed.exception(
           strategy: strategy,
           caused_by: error
         )}

      {:error, error} ->
        {:error,
         Errors.AuthenticationFailed.exception(
           strategy: strategy,
           caused_by: %{
             module: __MODULE__,
             strategy: strategy,
             action: :sign_in,
             message: "Query returned error: #{inspect(error)}"
           }
         )}
    end
  end
end

api_token/dsl.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.Dsl do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.{Custom, ApiToken}

  import AshAuthentication.Utils, only: [to_sentence: 2]
  import Joken.Signer, only: [algorithms: 0]

  @doc false
  @spec dsl :: Custom.entity()
  def dsl do
    %Spark.Dsl.Entity{
      name: :api_token,
      describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
      args: [{:optional, :name, :api_token}],
      hide: [:name],
      target: ApiToken,
      examples: [
        """
        api_token do
         name_field :name
        end
        """
      ],
      schema: [
        name: [
          type: :atom,
          doc: """
          The strategy name.
          """,
          required: true
        ],
        sign_in_action_name: [
          type: :atom,
          doc:
            "The name to use for the sign in action. Defaults to `sign_in_with_<strategy_name>`",
          required: false
        ],
        key_param_name: [
          type: :atom,
          doc: """
          The name of the token parameter in the incoming sign-in request.
          """,
          default: :api_key,
          required: false
        ],
        signing_algorithm: [
          type: :string,
          doc:
            "The algorithm to use for token signing. Available signing algorithms are; #{to_sentence(algorithms(), final: "and")}.",
          default: hd(algorithms())
        ],
        signing_secret: [
          type:
            {:or,
             [
               {:spark_function_behaviour, AshAuthentication.Secret,
                {AshAuthentication.SecretFunction, 2}},
               :string
             ]},
          doc:
            "The secret used to sign tokens. Takes either a module which implements the `AshAuthentication.Secret` behaviour, a 2 arity anonymous function or a string."
        ]
      ]
    }
  end
end

api_token/jwt.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.Jwt do
  @moduledoc false

  alias Ash.Resource
  alias AshAuthentication.{Info, Jwt.Config}

  require Logger

  @type token :: String.t()
  @type claims :: %{required(String.t()) => String.t() | number | boolean | claims}

  @spec create_token(Resource.t(), extra_claims :: map, options :: keyword) ::
          {:ok, token, claims} | :error
  def create_token(resource, extra_claims, opts \\ []) do
    strategy = AshAuthentication.Info.strategy!(resource, :api_token)

    default_claims = Config.default_claims(resource, opts)
    signer = token_signer(strategy, resource, opts)

    subject = Info.authentication_subject_name!(resource)

    extra_claims = Map.put(extra_claims, "sub", subject)

    with {:error, reason} <- Joken.generate_and_sign(default_claims, extra_claims, signer) do
      Logger.error(
        "Failed to generate token for #{inspect(resource)}: #{inspect(reason, pretty: true)}"
      )

      :error
    end
  end

  @spec peek(token) :: {:ok, claims} | {:error, any}
  def peek(token), do: Joken.peek_claims(token)

  @spec verify(token, Resource.t(), opts :: keyword) :: {:ok, claims, Resource.t()} | :error
  def verify(token, resource, opts \\ []) do
    strategy = AshAuthentication.Info.strategy!(resource, :api_token)

    with signer <- token_signer(strategy, resource, []),
         {:ok, claims} <- Joken.verify(token, signer),
         defaults <- default_claims(resource, opts),
         {:ok, claims} <- Joken.validate(defaults, claims, resource) do
      {:ok, claims, resource}
    else
      _ -> :error
    end
  end

  @spec default_claims(Resource.t(), keyword) :: Joken.token_config()
  def default_claims(resource, opts \\ []) do
    token_lifetime =
      opts
      |> Keyword.fetch(:token_lifetime)
      |> case do
        {:ok, lifetime} -> lifetime_to_seconds(lifetime)
        :error -> token_lifetime(resource)
      end

    {:ok, vsn} = :application.get_key(:ash_authentication, :vsn)

    vsn =
      vsn
      |> to_string()
      |> Version.parse!()
      |> then(&%{&1 | pre: []})

    Joken.Config.default_claims(default_exp: token_lifetime)
    |> Joken.Config.add_claim(
      "iss",
      fn -> Config.generate_issuer(vsn) end,
      &Config.validate_issuer/3
    )
    |> Joken.Config.add_claim(
      "aud",
      fn -> Config.generate_audience(vsn) end,
      &Config.validate_audience(&1, &2, &3, vsn)
    )
  end

  defp token_signer(strategy, resource, _opts) do
    signing_secret =
      case strategy.signing_secret do
        secret when is_binary(secret) ->
          secret

        {secret_module, secret_opts} ->
          ~w(authentication api_token signing_secret)a
          |> secret_module.secret_for(resource, secret_opts)
          |> case do
            {:ok, secret} when is_binary(secret) ->
              secret

            _ ->
              raise "Missing JWT signing secret."
          end
      end

    algorithm = strategy.signing_algorithm

    Joken.Signer.create(algorithm, signing_secret)
  end

  defp token_lifetime(resource) do
    resource
    |> Info.authentication_tokens_token_lifetime!()
    |> lifetime_to_seconds()
  end

  defp lifetime_to_seconds({seconds, :seconds}), do: seconds
  defp lifetime_to_seconds({minutes, :minutes}), do: minutes * 60
  defp lifetime_to_seconds({hours, :hours}), do: hours * 60 * 60
  defp lifetime_to_seconds({days, :days}), do: days * 60 * 60 * 24
end

api_token/plug.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.Plug do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.ApiToken

  alias AshAuthentication.Strategy

  import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]

  @doc """
  Sign in via api token.
  """
  @spec sign_in(Conn.t(), ApiToken.t()) :: Conn.t()
  def sign_in(conn, strategy) do
    params = Map.take(conn.params, [to_string(strategy.key_param_name)])

    result = Strategy.action(strategy, :sign_in, params, [])

    store_authentication_result(conn, result)
  end
end

api_token/sign_in_preparation.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.SignInPreparation do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.ApiToken.Jwt

  alias AshAuthentication.Info
  alias Ash.{Query, Resource.Preparation}

  use Ash.Resource.Preparation

  require Ash.Query

  import Ash.Expr

  @doc false
  @impl true
  @spec prepare(Query.t(), keyword, Preparation.Context.t()) :: Query.t()
  def prepare(query, _opts, context) do
    with {:ok, strategy} <- Info.strategy_for_action(query.resource, query.action.name),
         api_key when is_binary(api_key) <- Query.get_argument(query, strategy.key_param_name),
         {:ok, %{"act" => token_action} = claims, _} <-
           Jwt.verify(api_key, query.resource, Ash.Context.to_opts(context)),
         ^token_action <- to_string(strategy.sign_in_action_name) do
      %{"jti" => jti} = claims

      identity_field = strategy.identity_field

      query
      |> Query.set_context(%{private: %{ash_authentication?: true}})
      |> Query.filter(^ref(identity_field) == ^jti)
    else
      _ ->
        Query.do_filter(query, false)
    end
  end
end

api_token/strategy.ex

defimpl AshAuthentication.Strategy, for: Core.Ash.Auth.Strategies.ApiToken do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.ApiToken

  alias AshAuthentication.Strategy
  alias Ash.Resource
  alias Plug.Conn

  @doc false
  @spec name(ApiToken.t()) :: atom
  def name(strategy), do: strategy.name

  @doc false
  @spec phases(ApiToken.t()) :: [Strategy.phase()]
  def phases(_strategy), do: [:sign_in]

  @doc false
  @spec actions(ApiToken.t()) :: [Strategy.action()]
  def actions(_strategy), do: [:sign_in]

  @doc false
  @spec method_for_phase(ApiToken.t(), atom) :: Strategy.http_method()
  def method_for_phase(_strategy, :sign_in), do: :get

  @doc false
  @spec routes(ApiToken.t()) :: [Strategy.route()]
  def routes(strategy) do
    subject_name = AshAuthentication.Info.authentication_subject_name!(strategy.resource)

    [{"/#{subject_name}/#{strategy.name}", :sign_in}]
  end

  @doc false
  @spec plug(ApiToken.t(), Strategy.phase(), Conn.t()) :: Conn.t()
  def plug(strategy, :sign_in, conn), do: ApiToken.Plug.sign_in(conn, strategy)

  @doc false
  @spec action(ApiToken.t(), Strategy.action(), map, keyword) ::
          :ok | {:ok, Resource.record()} | {:error, any}
  def action(strategy, :sign_in, params, options),
    do: ApiToken.Actions.sign_in(strategy, params, options)

  @doc false
  @spec tokens_required?(ApiToken.t()) :: false
  def tokens_required?(_strategy), do: false
end

api_token/transformer.ex

defmodule Core.Ash.Auth.Strategies.ApiToken.Transformer do
  @moduledoc false

  alias Core.Ash.Auth.Strategies.ApiToken

  alias Ash.Resource
  alias Spark.Dsl.Transformer

  import AshAuthentication.Utils
  # import AshAuthentication.Validations
  import AshAuthentication.Strategy.Custom.Helpers

  @doc false
  @spec transform(ApiToken.t(), dsl_state) :: {:ok, ApiToken.t() | dsl_state} | {:error, any}
        when dsl_state: map
  def transform(strategy, dsl_state) do
    with strategy <- maybe_set_sign_in_action_name(strategy),
         {:ok, dsl_state} <-
           maybe_build_action(
             dsl_state,
             strategy.sign_in_action_name,
             &build_sign_in_action(&1, strategy)
           ) do
      dsl_state =
        dsl_state
        |> then(&register_strategy_actions([strategy.sign_in_action_name], &1, strategy))
        |> put_strategy(strategy)

      {:ok, dsl_state}
    end
  end

  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

  defp build_sign_in_action(_dsl_state, strategy) do
    arguments = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
        name: strategy.key_param_name,
        type: :string,
        allow_nil?: false
      )
    ]

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

    metadata = [
      Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
        name: :api_key,
        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

Finally, here is an example of a resource using it:

defmodule Core.Marketplace.Accounts.ApiKey do
  @moduledoc false

  # api_key = Core.Marketplace.Accounts.ApiKey |> Ash.Changeset.for_create(:create, %{user_id: "0191fc9e-a7c7-792d-aabf-c04841439658"}) |> Ash.create!()
  # strategy = AshAuthentication.Info.strategy!(Core.Marketplace.Accounts.ApiKey, :api_token); AshAuthentication.Strategy.action(strategy, :sign_in, %{"api_key" => api_key.full_token})

  use Ash.Resource,
    domain: Core.Marketplace.Accounts,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshAuthentication, Core.Ash.Auth.Strategies.ApiToken]

  attributes do
    attribute :jti, :string do
      primary_key? true
      writable? true
      allow_nil? false

      constraints max_length: 64
    end

    attribute :full_token, :string, allow_nil?: false

    attribute :expires_at, :utc_datetime_usec

    attribute :roles, {:array, :atom} do
      allow_nil? false

      default []

      constraints min_length: 0, nil_items?: false
    end

    timestamps()
  end

  relationships do
    belongs_to :user, Core.Marketplace.Accounts.User do
      allow_nil? false
    end
  end

  policies do
    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      authorize_if always()
    end
  end

  authentication do
    domain Core.Marketplace.Accounts

    strategies do
      api_token do
        signing_secret fn _, _ ->
          Application.fetch_env(:core, :token_signing_secret)
        end
      end
    end
  end

  postgres do
    table "api_keys"

    references do
      reference :user, on_update: :update, on_delete: :delete
    end

    migration_types jti: {:varchar, 64}
    migration_defaults roles: "[]"

    repo Core.Repo
  end

  identities do
    identity :unique_jti, [:jti]
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true

      accept [:user_id]

      change fn changeset, _context ->
        Ash.Changeset.before_action(changeset, fn changeset ->
          strategy = AshAuthentication.Info.strategy!(__MODULE__, :api_token)

          args = %{"act" => strategy.sign_in_action_name, "identity" => strategy.identity_field}

          {:ok, token, claims} =
            Core.Ash.Auth.Strategies.ApiToken.Jwt.create_token(__MODULE__, args,
              token_lifetime: {10, :seconds}
            )

          %{"jti" => jti, "exp" => expires_at} = claims

          expires_at = DateTime.from_unix!(expires_at)

          changeset
          |> Ash.Changeset.force_change_attribute(:jti, jti)
          |> Ash.Changeset.force_change_attribute(:full_token, token)
          |> Ash.Changeset.force_change_attribute(:expires_at, expires_at)
        end)
      end
    end
  end
end

It kinda work similar to the TokenResource but you control the token deletion and creation in the resource. The action will create and persist a token in the ApiKey resource and return it, the field full_token will contain the api_key that the user can use to sign-in to your apis.

Also, the api_key supports an expiration date so they can be revoked by deleting the resource in the DB or if the token expires too.

Fell free to use it and also give me some feedback :slight_smile:

1 Like