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(®ister_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 