diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex index edfe9be..fc48c55 100644 --- a/dev/dev_server/test_page.ex +++ b/dev/dev_server/test_page.ex @@ -199,6 +199,50 @@ defmodule DevServer.TestPage do ) end + defp render_strategy(strategy, phase, options) + when is_struct(strategy, Strategy.MagicLink) and phase == :request do + EEx.eval_string( + ~s""" +
+
+ <%= @strategy.name %> request + +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, options) + when is_struct(strategy, Strategy.MagicLink) and phase == :sign_in do + EEx.eval_string( + ~s""" +
+
+ <%= @strategy.name %> sign in + +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + defp render_strategy(strategy, phase, _options) do inspect({strategy, phase}) end diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index c24aecb..e351a93 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -108,6 +108,7 @@ defmodule AshAuthentication do Info, Strategy.Auth0, Strategy.Github, + Strategy.MagicLink, Strategy.OAuth2, Strategy.Password } @@ -117,7 +118,10 @@ defmodule AshAuthentication do use Spark.Dsl.Extension, sections: dsl(), dsl_patches: - Enum.flat_map([Confirmation, Auth0, Github, OAuth2, Password], & &1.dsl_patches()), + Enum.flat_map( + [Confirmation, Auth0, Github, OAuth2, Password, MagicLink], + & &1.dsl_patches() + ), transformers: [ AshAuthentication.Transformer, AshAuthentication.Strategy.Custom.Transformer diff --git a/lib/ash_authentication/add_ons/confirmation/transformer.ex b/lib/ash_authentication/add_ons/confirmation/transformer.ex index 57ca148..b9dddbb 100644 --- a/lib/ash_authentication/add_ons/confirmation/transformer.ex +++ b/lib/ash_authentication/add_ons/confirmation/transformer.ex @@ -18,7 +18,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do @spec transform(Confirmation.t(), map) :: {:ok, Confirmation.t() | map} | {:error, Exception.t()} def transform(strategy, dsl_state) do - with :ok <- validate_token_generation_enabled(dsl_state), + with :ok <- + validate_token_generation_enabled( + dsl_state, + "Token generation must be enabled for password resets to work." + ), :ok <- validate_monitor_fields(dsl_state, strategy), {:ok, dsl_state} <- maybe_build_action( diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex index 42e5d9d..7ea7b91 100644 --- a/lib/ash_authentication/jwt.ex +++ b/lib/ash_authentication/jwt.ex @@ -89,6 +89,8 @@ defmodule AshAuthentication.Jwt do def token_for_user(user, extra_claims \\ %{}, opts \\ []) do resource = user.__struct__ + {purpose, opts} = Keyword.pop(opts, :purpose, :user) + default_claims = Config.default_claims(resource, opts) signer = Config.token_signer(resource, opts) @@ -105,21 +107,21 @@ defmodule AshAuthentication.Jwt do end with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer), - :ok <- maybe_store_token(token, resource, user) do + :ok <- maybe_store_token(token, resource, user, purpose) do {:ok, token, claims} else {:error, _reason} -> :error end end - defp maybe_store_token(token, resource, user) do + defp maybe_store_token(token, resource, user, purpose) do if Info.authentication_tokens_store_all_tokens?(resource) do with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do TokenResource.Actions.store_token( token_resource, %{ "token" => token, - "purpose" => "user" + "purpose" => to_string(purpose) }, context: %{ ash_authentication: %{ diff --git a/lib/ash_authentication/strategies/custom.ex b/lib/ash_authentication/strategies/custom.ex index e379551..eca75b6 100644 --- a/lib/ash_authentication/strategies/custom.ex +++ b/lib/ash_authentication/strategies/custom.ex @@ -84,7 +84,10 @@ defmodule AshAuthentication.Strategy.Custom do _ -> raise CompileError, - "You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`." + file: __ENV__.file, + line: __ENV__.line, + description: + "You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`." end use Spark.Dsl.Extension, diff --git a/lib/ash_authentication/strategies/magic_link.ex b/lib/ash_authentication/strategies/magic_link.ex new file mode 100644 index 0000000..621bd2a --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link.ex @@ -0,0 +1,149 @@ +defmodule AshAuthentication.Strategy.MagicLink do + alias __MODULE__.{Dsl, Transformer, Verifier} + + @moduledoc """ + Strategy for authentication using a magic link. + + In order to use magic link authentication your resource needs to meet the + following minimum requirements: + + 1. Have a primary key. + 2. A uniquely constrained identity field (eg `username` or `email`) + 3. Have tokens enabled. + + There are other options documented in the DSL. + + ### Example + + ```elixir + defmodule MyApp.Accounts.User do + use Ash.Resource, + extensions: [AshAuthentication] + + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + end + + authentication do + api MyApp.Accounts + + strategies do + magic_link do + identity_field :email + sender fn user, token, _opts -> + MyApp.Emails.deliver_magic_link(user, token) + end + end + end + end + + identities do + identity :unique_email, [:email] + end + end + ``` + + ## Actions + + By default the magic link strategy will automatically generate the request and + sign-in actions for you, however you're free to define them yourself. If you + do, then the action will be validated to ensure that all the needed + configuration is present. + + If you wish to work with the actions directly from your code you can do so via + the `AshAuthentication.Strategy` protocol. + + ### Examples + + Requesting that a magic link token is sent for a user: + + iex> strategy = Info.strategy!(Example.User, :magic_link) + ...> user = build_user() + ...> Strategy.action(strategy, :request, %{"username" => user.username}) + :ok + + Signing in using a magic link token: + + ...> {:ok, token} = MagicLink.request_token_for(strategy, user) + ...> {:ok, signed_in_user} = Strategy.action(strategy, :sign_in, %{"token" => token}) + ...> signed_in_user.id == user + true + + ## Plugs + + The magic link strategy provides plug endpoints for both request and sign-in + actions. + + If you wish to work with the plugs directly, you can do so via the + `AshAuthentication.Strategy` protocol. + + ### Examples: + + Dispatching to plugs directly: + + iex> strategy = Info.strategy!(Example.User, :magic_link) + ...> user = build_user() + ...> conn = conn(:post, "/user/magic_link/request", %{"user" => %{"username" => user.username}}) + ...> conn = Strategy.plug(strategy, :request, conn) + ...> {_conn, {:ok, nil}} = Plug.Helpers.get_authentication_result(conn) + + ...> {:ok, token} = MagicLink.request_token_for(strategy, user) + ...> conn = conn(:get, "/user/magic_link", %{"token" => token}) + ...> conn = Strategy.plug(strategy, :sign_in, conn) + ...> {_conn, {:ok, signed_in_user}} = Plug.Helpers.get_authentication_result(conn) + ...> signed_in_user.id == user.id + true + + ## DSL Documentation + + #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} + """ + + defstruct identity_field: :username, + name: nil, + request_action_name: nil, + resource: nil, + sender: nil, + sign_in_action_name: nil, + single_use_token?: true, + token_lifetime: 10, + token_param_name: :token + + use AshAuthentication.Strategy.Custom, entity: Dsl.dsl() + + alias Ash.Resource + alias AshAuthentication.Jwt + + @type t :: %__MODULE__{ + identity_field: atom, + name: atom, + request_action_name: atom, + resource: module, + sender: {module, keyword}, + single_use_token?: boolean, + sign_in_action_name: atom, + token_lifetime: pos_integer(), + token_param_name: atom + } + + defdelegate transform(strategy, dsl_state), to: Transformer + defdelegate verify(strategy, dsl_state), to: Verifier + + @doc """ + Generate a magic link token for a user. + + Used by `AshAuthentication.Strategy.MagicLink.RequestPreparation`. + """ + @spec request_token_for(t, Resource.record()) :: {:ok, binary} | :error + def request_token_for(strategy, user) + when is_struct(strategy, __MODULE__) and is_struct(user, strategy.resource) do + case Jwt.token_for_user(user, %{"act" => strategy.sign_in_action_name}, + token_lifetime: strategy.token_lifetime * 60, + purpose: :magic_link + ) do + {:ok, token, _claims} -> {:ok, token} + :error -> :error + end + end +end diff --git a/lib/ash_authentication/strategies/magic_link/actions.ex b/lib/ash_authentication/strategies/magic_link/actions.ex new file mode 100644 index 0000000..34a0a11 --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/actions.ex @@ -0,0 +1,84 @@ +defmodule AshAuthentication.Strategy.MagicLink.Actions do + @moduledoc """ + Actions for the magic link strategy. + + Provides the code interface for working with user resources for providing + magic links. + """ + + alias Ash.{Query, Resource} + alias AshAuthentication.{Errors, Info, Strategy.MagicLink} + + @doc """ + Request a magic link for a user. + """ + @spec request(MagicLink.t(), map, keyword) :: :ok | {:error, any} + def request(strategy, params, options) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Query.new() + |> Query.set_context(%{private: %{ash_authentication?: true}}) + |> Query.for_read(strategy.request_action_name, params) + |> api.read(options) + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Attempt to sign a user in via magic link. + """ + @spec sign_in(MagicLink.t(), map, keyword) :: + {:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()} + def sign_in(strategy, params, options) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Query.new() + |> Query.set_context(%{private: %{ash_authentication?: true}}) + |> Query.for_read(strategy.sign_in_action_name, params) + |> api.read(options) + |> case do + {:ok, [user]} -> + {:ok, user} + + {:ok, []} -> + {:error, + Errors.AuthenticationFailed.exception( + caused_by: %{ + module: __MODULE__, + strategy: strategy, + action: :sign_in, + message: "Query returned no users" + } + )} + + {:ok, _users} -> + {:error, + Errors.AuthenticationFailed.exception( + caused_by: %{ + module: __MODULE__, + strategy: strategy, + action: :sign_in, + message: "Query returned too many users" + } + )} + + {:error, error} when is_exception(error) -> + {:error, Errors.AuthenticationFailed.exception(caused_by: error)} + + {:error, error} -> + {:error, + Errors.AuthenticationFailed.exception( + caused_by: %{ + module: __MODULE__, + strategy: strategy, + action: :sign_in, + message: "Query returned error: #{inspect(error)}" + } + )} + end + end +end diff --git a/lib/ash_authentication/strategies/magic_link/dsl.ex b/lib/ash_authentication/strategies/magic_link/dsl.ex new file mode 100644 index 0000000..6b447fd --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/dsl.ex @@ -0,0 +1,91 @@ +defmodule AshAuthentication.Strategy.MagicLink.Dsl do + @moduledoc false + + alias AshAuthentication.Strategy.{Custom, MagicLink} + alias Spark.Dsl.Entity + + @doc false + @spec dsl :: Custom.entity() + def dsl do + %Entity{ + name: :magic_link, + describe: "Strategy for authenticating using local users with a magic link", + args: [{:optional, :name, :magic_link}], + hide: [:name], + target: MagicLink, + schema: [ + name: [ + type: :atom, + doc: "Uniquely identifies the strategy.", + required: true + ], + identity_field: [ + type: :atom, + doc: """ + The name of the attribute which uniquely identifies the user. + + Usually something like `username` or `email_address`. + """, + default: :username + ], + token_lifetime: [ + type: :pos_integer, + doc: """ + How long the sign in token is valid, in minutes. + """, + default: 10 + ], + request_action_name: [ + type: :atom, + doc: """ + The name to use for the request action. + + If not present it will be generated by prepending the strategy name + with `request_`. + """, + required: false + ], + single_use_token?: [ + type: :boolean, + doc: """ + Automatically revoke the token once it's been used for sign in. + """, + default: true + ], + sign_in_action_name: [ + type: :atom, + doc: """ + The name to use for the sign in action. + + If not present it will be generated by prepending the strategy name + with `sign_in_with_`. + """, + required: false + ], + token_param_name: [ + type: :atom, + doc: """ + The name of the token parameter in the incoming sign-in request. + """, + default: :token, + required: false + ], + sender: [ + type: + {:spark_function_behaviour, AshAuthentication.Sender, + {AshAuthentication.SenderFunction, 3}}, + doc: """ + How to send the magic link to the user. + + Allows you to glue sending of magic links to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. + + Accepts a module, module and opts, or a function that takes a record, reset token and options. + + See `AshAuthentication.Sender` for more information. + """, + required: true + ] + ] + } + end +end diff --git a/lib/ash_authentication/strategies/magic_link/plug.ex b/lib/ash_authentication/strategies/magic_link/plug.ex new file mode 100644 index 0000000..0f41e13 --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/plug.ex @@ -0,0 +1,61 @@ +defmodule AshAuthentication.Strategy.MagicLink.Plug do + @moduledoc """ + Plugs for the magic link strategy. + + Handles requests and sign-ins. + """ + + alias AshAuthentication.{Info, Strategy, Strategy.MagicLink} + alias Plug.Conn + import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1] + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + + @doc """ + Handle a request for a magic link. + + Retrieves form parameters from nested within the subject name, eg: + + ``` + %{ + "user" => %{ + "email" => "marty@mcfly.me" + } + } + ``` + """ + @spec request(Conn.t(), MagicLink.t()) :: Conn.t() + def request(conn, strategy) do + params = subject_params(conn, strategy) + opts = opts(conn) + result = Strategy.action(strategy, :request, params, opts) + store_authentication_result(conn, result) + end + + @doc """ + Sign in via magic link. + """ + @spec sign_in(Conn.t(), MagicLink.t()) :: Conn.t() + def sign_in(conn, strategy) do + params = + conn.params + |> Map.take([to_string(strategy.token_param_name)]) + + opts = opts(conn) + result = Strategy.action(strategy, :sign_in, params, opts) + store_authentication_result(conn, result) + end + + defp subject_params(conn, strategy) do + subject_name = + strategy.resource + |> Info.authentication_subject_name!() + |> to_string() + + Map.get(conn.params, subject_name, %{}) + end + + defp opts(conn) do + [actor: get_actor(conn), tenant: get_tenant(conn)] + |> Enum.reject(&is_nil(elem(&1, 1))) + end +end diff --git a/lib/ash_authentication/strategies/magic_link/request_preparation.ex b/lib/ash_authentication/strategies/magic_link/request_preparation.ex new file mode 100644 index 0000000..9bfb40e --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/request_preparation.ex @@ -0,0 +1,43 @@ +defmodule AshAuthentication.Strategy.MagicLink.RequestPreparation do + @moduledoc """ + Prepare a query for a magic link request. + + This preparation performs three jobs, one before the query executes and two + after: + 1. it constraints the query to match the identity field passed to the action. + 2. if there is a user returned by the query, then + a. generate a magic link token and + b. publish a notification. + + Always returns an empty result. + """ + use Ash.Resource.Preparation + alias Ash.{Query, Resource.Preparation} + alias AshAuthentication.{Info, Strategy.MagicLink} + require Ash.Query + + @doc false + @impl true + @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() + def prepare(query, _opts, _context) do + strategy = Info.strategy_for_action!(query.resource, query.action.name) + + identity_field = strategy.identity_field + identity = Query.get_argument(query, identity_field) + + query + |> Query.filter(ref(^identity_field) == ^identity) + |> Query.after_action(&after_action(&1, &2, strategy)) + end + + defp after_action(_query, [user], %{sender: {sender, send_opts}} = strategy) do + case MagicLink.request_token_for(strategy, user) do + {:ok, token} -> sender.send(user, token, send_opts) + _ -> nil + end + + {:ok, []} + end + + defp after_action(_, _, _), do: {:ok, []} +end diff --git a/lib/ash_authentication/strategies/magic_link/sign_in_preparation.ex b/lib/ash_authentication/strategies/magic_link/sign_in_preparation.ex new file mode 100644 index 0000000..e0f30a7 --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/sign_in_preparation.ex @@ -0,0 +1,51 @@ +defmodule AshAuthentication.Strategy.MagicLink.SignInPreparation do + @moduledoc """ + Prepare a query for sign in. + """ + + use Ash.Resource.Preparation + alias AshAuthentication.{Info, Jwt, TokenResource} + alias Ash.{Query, Resource, Resource.Preparation} + require Ash.Query + + @doc false + @impl true + @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() + def prepare(query, _otps, _context) do + subject_name = + query.resource + |> Info.authentication_subject_name!() + |> to_string() + + with {:ok, strategy} <- Info.strategy_for_action(query.resource, query.action.name), + token when is_binary(token) <- Query.get_argument(query, strategy.token_param_name), + {:ok, %{"act" => token_action, "sub" => subject}, _} <- + Jwt.verify(token, query.resource), + ^token_action <- to_string(strategy.sign_in_action_name), + %URI{path: ^subject_name, query: primary_key} <- URI.parse(subject) do + primary_key = + primary_key + |> URI.decode_query() + |> Enum.to_list() + + query + |> Query.set_context(%{private: %{ash_authentication?: true}}) + |> Query.filter(^primary_key) + |> Query.after_action(fn + query, [record] -> + if strategy.single_use_token? do + token_resource = Info.authentication_tokens_token_resource!(query.resource) + :ok = TokenResource.revoke(token_resource, token) + end + + {:ok, token, _claims} = Jwt.token_for_user(record) + {:ok, [Resource.put_metadata(record, :token, token)]} + + _query, [] -> + {:ok, []} + end) + else + _ -> Query.limit(query, 0) + end + end +end diff --git a/lib/ash_authentication/strategies/magic_link/strategy.ex b/lib/ash_authentication/strategies/magic_link/strategy.ex new file mode 100644 index 0000000..0ed313d --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/strategy.ex @@ -0,0 +1,48 @@ +defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.MagicLink do + @moduledoc false + alias Ash.Resource + alias AshAuthentication.{Info, Strategy, Strategy.MagicLink} + alias Plug.Conn + + @doc false + @spec name(MagicLink.t()) :: atom + def name(strategy), do: strategy.name + + @doc false + @spec phases(MagicLink.t()) :: [Strategy.phase()] + def phases(_strategy), do: [:request, :sign_in] + + @doc false + @spec actions(MagicLink.t()) :: [Strategy.action()] + def actions(_strategy), do: [:request, :sign_in] + + @doc false + @spec method_for_phase(MagicLink.t(), atom) :: Strategy.http_method() + def method_for_phase(_strategy, :request), do: :post + def method_for_phase(_strategy, :sign_in), do: :get + + @doc false + @spec routes(MagicLink.t()) :: [Strategy.route()] + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + [ + {"/#{subject_name}/#{strategy.name}/request", :request}, + {"/#{subject_name}/#{strategy.name}", :sign_in} + ] + end + + @doc false + @spec plug(MagicLink.t(), Strategy.phase(), Conn.t()) :: Conn.t() + def plug(strategy, :request, conn), do: MagicLink.Plug.request(conn, strategy) + def plug(strategy, :sign_in, conn), do: MagicLink.Plug.sign_in(conn, strategy) + + @doc false + @spec action(MagicLink.t(), Strategy.action(), map, keyword) :: + :ok | {:ok, Resource.record()} | {:error, any} + def action(strategy, :request, params, options), + do: MagicLink.Actions.request(strategy, params, options) + + def action(strategy, :sign_in, params, options), + do: MagicLink.Actions.sign_in(strategy, params, options) +end diff --git a/lib/ash_authentication/strategies/magic_link/transformer.ex b/lib/ash_authentication/strategies/magic_link/transformer.ex new file mode 100644 index 0000000..1b3e3d8 --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/transformer.ex @@ -0,0 +1,116 @@ +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 + + @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), + {: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) + ) do + dsl_state = + dsl_state + |> then( + ®ister_strategy_actions( + [strategy.sign_in_action_name, strategy.request_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: :"sign_in_with_#{strategy.name}"} + + defp maybe_set_sign_in_action_name(strategy), do: strategy + + defp maybe_set_request_action_name(strategy) when is_nil(strategy.request_action_name), + do: %{strategy | request_action_name: :"request_#{strategy.name}"} + + defp maybe_set_request_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.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 + + 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 +end diff --git a/lib/ash_authentication/strategies/magic_link/verifier.ex b/lib/ash_authentication/strategies/magic_link/verifier.ex new file mode 100644 index 0000000..3e1db34 --- /dev/null +++ b/lib/ash_authentication/strategies/magic_link/verifier.ex @@ -0,0 +1,88 @@ +defmodule AshAuthentication.Strategy.MagicLink.Verifier do + @moduledoc """ + DSL verifier for magic links. + """ + + alias AshAuthentication.{Strategy.MagicLink} + alias Spark.Error.DslError + import AshAuthentication.Validations + import AshAuthentication.Validations.Action + import AshAuthentication.Validations.Attribute + + @doc false + @spec verify(MagicLink.t(), map) :: :ok | {:error, Exception.t()} + def verify(strategy, dsl_state) do + with {:ok, identity_attribute} <- validate_identity_attribute(dsl_state, strategy), + :ok <- validate_request_action(dsl_state, strategy, identity_attribute) do + validate_sign_in_action(dsl_state, strategy) + end + end + + defp validate_identity_attribute(dsl_state, strategy) do + with {:ok, identity_attribute} <- find_attribute(dsl_state, strategy.identity_field), + :ok <- + validate_attribute_unique_constraint( + dsl_state, + [strategy.identity_field], + strategy.resource + ) do + {:ok, identity_attribute} + end + end + + defp validate_request_action(dsl_state, strategy, identity_attribute) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.request_action_name), + :ok <- validate_action_has_argument(action, strategy.identity_field), + :ok <- + validate_action_argument_option( + action, + strategy.identity_field, + :type, + [identity_attribute.type] + ), + :ok <- + validate_action_argument_option(action, strategy.identity_field, :allow_nil?, [ + false + ]), + :ok <- validate_action_has_preparation(action, MagicLink.RequestPreparation), + :ok <- validate_field_in_values(action, :type, [:read]) do + :ok + else + {:error, message} when is_binary(message) -> + {:error, + DslError.exception( + path: [:actions, :read, strategy.request_action_name, :type], + mesasge: message + )} + + {:error, exception} when is_exception(exception) -> + {:error, exception} + end + end + + defp validate_sign_in_action(dsl_state, strategy) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name), + :ok <- validate_action_has_argument(action, strategy.token_param_name), + :ok <- + validate_action_argument_option(action, strategy.token_param_name, :type, [ + :string, + Ash.Type.String + ]), + :ok <- + validate_action_argument_option(action, strategy.token_param_name, :allow_nil?, [false]), + :ok <- validate_action_has_preparation(action, MagicLink.SignInPreparation), + :ok <- validate_field_in_values(action, :type, [:read]) do + :ok + else + {:error, message} when is_binary(message) -> + {:error, + DslError.exception( + path: [:actions, :read, strategy.sign_in_action_name, :type], + mesasge: message + )} + + {:error, exception} when is_exception(exception) -> + {:error, exception} + end + end +end diff --git a/lib/ash_authentication/strategies/password/dsl.ex b/lib/ash_authentication/strategies/password/dsl.ex index 48c7ecf..9ccb8cb 100644 --- a/lib/ash_authentication/strategies/password/dsl.ex +++ b/lib/ash_authentication/strategies/password/dsl.ex @@ -103,7 +103,7 @@ defmodule AshAuthentication.Strategy.Password.Dsl do doc: """ The name to use for the sign in action. - If not present it will be generated by prependign the strategy name + If not present it will be generated by prepending the strategy name with `sign_in_with_`. """, required: false diff --git a/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex index 03c8798..e5348a9 100644 --- a/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex +++ b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex @@ -1,12 +1,15 @@ defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do @moduledoc """ Prepare a query for a password reset request. + This preparation performs three jobs, one before the query executes and two - after. - Firstly, it constraints the query to match the identity field passed to the - action. - Secondly, if there is a user returned by the query, then generate a reset - token and publish a notification. Always returns an empty result. + after: + 1. it constraints the query to match the identity field passed to the action. + 2. if there is a user returned by the query, then + a. generate a reset token and + b. publish a notification. + + Always returns an empty result. """ use Ash.Resource.Preparation alias Ash.{Query, Resource.Preparation} diff --git a/lib/ash_authentication/strategies/password/sign_in_preparation.ex b/lib/ash_authentication/strategies/password/sign_in_preparation.ex index b785502..734f3cf 100644 --- a/lib/ash_authentication/strategies/password/sign_in_preparation.ex +++ b/lib/ash_authentication/strategies/password/sign_in_preparation.ex @@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do """ use Ash.Resource.Preparation alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt} - alias Ash.{Query, Resource.Preparation} + alias Ash.{Query, Resource, Resource.Preparation} require Ash.Query @doc false @@ -81,7 +81,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do defp maybe_generate_token(record) do if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do {:ok, token, _claims} = Jwt.token_for_user(record) - %{record | __metadata__: Map.put(record.__metadata__, :token, token)} + Resource.put_metadata(record, :token, token) else record end diff --git a/lib/ash_authentication/strategies/password/transformer.ex b/lib/ash_authentication/strategies/password/transformer.ex index 7219a8f..aab5359 100644 --- a/lib/ash_authentication/strategies/password/transformer.ex +++ b/lib/ash_authentication/strategies/password/transformer.ex @@ -217,7 +217,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do metadata = if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do [ - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :metadata, + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata, name: :token, type: :string, allow_nil?: false diff --git a/lib/ash_authentication/validations.ex b/lib/ash_authentication/validations.ex index d8b8921..8f70e03 100644 --- a/lib/ash_authentication/validations.ex +++ b/lib/ash_authentication/validations.ex @@ -111,16 +111,11 @@ defmodule AshAuthentication.Validations do @doc """ Ensure that token generation is enabled for the resource. """ - @spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()} - def validate_token_generation_enabled(dsl_state) do + @spec validate_token_generation_enabled(Dsl.t(), binary) :: :ok | {:error, Exception.t()} + def validate_token_generation_enabled(dsl_state, message) do if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state), do: :ok, - else: - {:error, - DslError.exception( - path: [:tokens], - message: "Token generation must be enabled for password resets to work." - )} + else: {:error, DslError.exception(path: [:tokens], message: message)} end @doc """ diff --git a/mix.exs b/mix.exs index 97a0ec8..036b6d5 100644 --- a/mix.exs +++ b/mix.exs @@ -60,8 +60,11 @@ defmodule AshAuthentication.MixProject do ], Strategies: [ AshAuthentication.Strategy, - AshAuthentication.Strategy.Password, - AshAuthentication.Strategy.OAuth2 + AshAuthentication.Strategy.Auth0, + AshAuthentication.Strategy.Github, + AshAuthentication.Strategy.MagicLink, + AshAuthentication.Strategy.OAuth2, + AshAuthentication.Strategy.Password ], "Add ons": [ AshAuthentication.AddOn.Confirmation diff --git a/test/ash_authentication/strategies/magic_link_test.exs b/test/ash_authentication/strategies/magic_link_test.exs new file mode 100644 index 0000000..ec18b67 --- /dev/null +++ b/test/ash_authentication/strategies/magic_link_test.exs @@ -0,0 +1,21 @@ +defmodule AshAuthentication.Strategy.MagicLinkTest do + @moduledoc false + use DataCase, async: true + + import Plug.Test + alias AshAuthentication.{Info, Jwt, Plug, Strategy, Strategy.MagicLink} + + doctest MagicLink + + describe "request_token_for/2" do + test "it generates a sign in token" do + user = build_user() + strategy = Info.strategy!(Example.User, :magic_link) + + assert {:ok, token} = MagicLink.request_token_for(strategy, user) + + assert {:ok, claims} = Jwt.peek(token) + assert claims["act"] == to_string(strategy.sign_in_action_name) + end + end +end diff --git a/test/ash_authentication/strategies/password_test.exs b/test/ash_authentication/strategies/password_test.exs index ff0366c..fcec60f 100644 --- a/test/ash_authentication/strategies/password_test.exs +++ b/test/ash_authentication/strategies/password_test.exs @@ -22,7 +22,6 @@ defmodule AshAuthentication.Strategy.PasswordTest do strategy = %Password{resettable: [resettable], resource: user.__struct__} assert {:ok, token} = Password.reset_token_for(strategy, user) - assert {:ok, claims} = Jwt.peek(token) assert claims["act"] == to_string(resettable.password_reset_action_name) end diff --git a/test/support/example/user.ex b/test/support/example/user.ex index 6e8ec5c..9d720b1 100644 --- a/test/support/example/user.ex +++ b/test/support/example/user.ex @@ -197,6 +197,16 @@ defmodule Example.User do case_sensitive?(false) name_field(:username) end + + magic_link do + sender fn user, token, _opts -> + Logger.debug("Magic link request for #{user.username}, token #{inspect(token)}") + end + end + end + + tokens do + store_all_tokens? true end end