Ash authentication magic link not working

Hi,

I have a need to use the magic link strategy in ash authentication. I’ve added the relevant code and get a magic link that I can put in the address bar.

However I have changed registration_enabled? false and changed the action to be a read action only as follows as I don’t want to create a user if they don’t exist:

    read :sign_in_with_magic_link do
      description "Sign in a user with magic link if they exist."

      argument :token, :string do
        description "The token from the magic link that was sent to the user"
        allow_nil? false
      end

      prepare AshAuthentication.Strategy.MagicLink.SignInPreparation

      metadata :token, :string do
        allow_nil? false
      end
    end

I get the following error: no function clause matching in URI.decode_query/3. Either I am not setting up this action correctly or AshAuthentication.Strategy.MagicLink.SignInPreparation doesn’t seem correct in the sense I can’t see how the primary key part is correct (its nil)

    subject_name =
      query.resource
      |> Info.authentication_subject_name!()
      |> to_string()
    # require IEx; IEx.pry()
    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()

Thanks for any help

Disabling registration via registration_enabled? false is sufficient to, well, disable registration.

You don’t need to change the type of action, the code is written to work with a create - the reason there is both a SignInPreparation and a SignInChange is because there’s a difference between the previous built-in version of actions, and the new explicit versions that are generated in controllers. Use the new version, it works :slight_smile:

Thanks for info, however when I did leave it as a create I run into this problem:

    create :sign_in_with_magic_link do
      description "Sign in a user with magic link if they exist."

      argument :token, :string do
        description "The token from the magic link that was sent to the user"
        allow_nil? false
      end

      upsert? false
      upsert_identity :unique_email
      upsert_fields [:email]

      change AshAuthentication.Strategy.MagicLink.SignInChange

      metadata :token, :string do
        allow_nil? false
      end
    end

Upon navigating to a sign-in link generated I get Found an action of type create while looking for an action of type read

Part of the stacktrace shows this is called |> Query.for_read(strategy.sign_in_action_name, params, options) in lib/ash_authentication/strategies/magic_link/actions.ex

Can you share the backtrace, and how you’re calling this action? And what version of AshAuthentication you’re using?

Yep sure!

In my User Resource the strategy is defined

    strategies do
      magic_link do
        identity_field :email
        registration_enabled? false

        sender WedEvents.Accounts.User.Senders.SendMagicLinkEmail
      end

and the sign action is specified. This is the same action that is create via codegen.

`    create :sign_in_with_magic_link do
      description "Sign in a user with magic link if they exist."

      argument :token, :string do
        description "The token from the magic link that was sent to the user"
        allow_nil? false
      end

      upsert? true
      upsert_identity :unique_email
      upsert_fields [:email]

      change AshAuthentication.Strategy.MagicLink.SignInChange

      metadata :token, :string do
        allow_nil? false
      end
    end

Version is 4.3.3 as can be seen in the stacktrace below

ArgumentError at GET /auth/user/magic_link/

Exception:

** (ArgumentError) Found an action of type create while looking for an action of type read

Perhaps you've passed a changeset with the incorrect action type?

    (ash 3.4.43) lib/ash/resource/info.ex:614: Ash.Resource.Info.action/3
    (ash 3.4.43) lib/ash/query/query.ex:531: Ash.Query.for_read/4
    (ash_authentication 4.3.3) lib/ash_authentication/strategies/magic_link/actions.ex:82: AshAuthentication.Strategy.MagicLink.Actions.sign_in/3
    (ash_authentication 4.3.3) lib/ash_authentication/strategies/magic_link/plug.ex:44: AshAuthentication.Strategy.MagicLink.Plug.sign_in/2
    (ash_authentication 4.3.3) lib/ash_authentication/plug/dispatcher.ex:29: AshAuthentication.Plug.Dispatcher.call/2
    (phoenix 1.7.14) lib/phoenix/router/route.ex:42: Phoenix.Router.Route.call/2
    (phoenix 1.7.14) lib/phoenix/router.ex:484: Phoenix.Router.__call__/5
    (wed_events 0.1.0) lib/wed_events_web/endpoint.ex:1: WedEventsWeb.Endpoint.plug_builder_call/2
    (wed_events 0.1.0) deps/plug/lib/plug/debugger.ex:136: WedEventsWeb.Endpoint."call (overridable 3)"/2
    (wed_events 0.1.0) lib/wed_events_web/endpoint.ex:1: WedEventsWeb.Endpoint.call/2
    (phoenix 1.7.14) lib/phoenix/endpoint/sync_code_reload_plug.ex:22: Phoenix.Endpoint.SyncCodeReloadPlug.do_call/4
    (bandit 1.6.0) lib/bandit/pipeline.ex:127: Bandit.Pipeline.call_plug!/2
    (bandit 1.6.0) lib/bandit/pipeline.ex:36: Bandit.Pipeline.run/4
    (bandit 1.6.0) lib/bandit/http1/handler.ex:12: Bandit.HTTP1.Handler.handle_data/3
    (bandit 1.6.0) lib/bandit/delegating_handler.ex:18: Bandit.DelegatingHandler.handle_data/3
    (bandit 1.6.0) /home/deepc/Repos/wed_events/deps/thousand_island/lib/thousand_island/handler.ex:385: Bandit.DelegatingHandler.handle_info/2
    (stdlib 5.2.3) gen_server.erl:1095: :gen_server.try_handle_info/3
    (stdlib 5.2.3) gen_server.erl:1183: :gen_server.handle_msg/6
    (stdlib 5.2.3) proc_lib.erl:241: :proc_lib.init_p_do_apply/3

Code:

lib/ash/resource/info.ex

609   
610           %{type: ^type} = action -&gt;
611             action
612   
613           %{type: found_type} -&gt;
614&gt;            raise ArgumentError, """
615             Found an action of type #{found_type} while looking for an action of type #{type}
616   
617             Perhaps you've passed a changeset with the incorrect action type?
618             """
619         end

lib/ash/query/query.ex

526       query =
527         query
528         |&gt; Map.put(:params, Map.merge(query.params, Map.new(args)))
529         |&gt; set_context(Keyword.get(opts, :context, %{}))
530   
531&gt;      action = Ash.Resource.Info.action(query.resource, action_name, :read)
532   
533       if action do
534         name = fn -&gt;
535           "query:" &lt;&gt; Ash.Resource.Info.trace_name(query.resource) &lt;&gt; ":#{action_name}"
536         end

lib/ash_authentication/strategies/magic_link/actions.ex

77           |&gt; Keyword.put_new_lazy(:domain, fn -&gt; Info.domain!(strategy.resource) end)
78   
79         strategy.resource
80         |&gt; Query.new()
81         |&gt; Query.set_context(%{private: %{ash_authentication?: true}})
82&gt;        |&gt; Query.for_read(strategy.sign_in_action_name, params, options)
83         |&gt; Ash.read()
84         |&gt; case do
85           {:ok, [user]} -&gt;
86             {:ok, user}
87   

lib/ash_authentication/strategies/magic_link/plug.ex

39       params =
40         conn.params
41         |&gt; Map.take([to_string(strategy.token_param_name)])
42   
43       opts = opts(conn)
44&gt;      result = Strategy.action(strategy, :sign_in, params, opts)
45       store_authentication_result(conn, result)
46     end
47   
48     defp subject_params(conn, strategy) do
49       subject_name =

lib/ash_authentication/plug/dispatcher.ex

24     @spec call(Conn.t(), config | any) :: Conn.t()
25     def call(conn, {phase, strategy, return_to}) do
26       activity = {Strategy.name(strategy), phase}
27   
28       strategy
29&gt;      |&gt; Strategy.plug(phase, conn)
30       |&gt; get_authentication_result()
31       |&gt; case do
32         {conn, _} when conn.state not in @unsent -&gt;
33           conn
34   

lib/phoenix/router/route.ex

37     @doc "Used as a plug on forwarding"
38     def call(%{path_info: path, script_name: script} = conn, {fwd_segments, plug, opts}) do
39       new_path = path -- fwd_segments
40       {base, ^new_path} = Enum.split(path, length(path) - length(new_path))
41       conn = %{conn | path_info: new_path, script_name: script ++ base}
42&gt;      conn = plug.call(conn, plug.init(opts))
43       %{conn | path_info: path, script_name: script}
44     end
45   
46     @doc """
47     Receives the verb, path, plug, options and helper

lib/phoenix/router.ex

479           :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)
480           halted_conn
481   
482         %Plug.Conn{} = piped_conn -&gt;
483           try do
484&gt;            plug.call(piped_conn, plug.init(opts))
485           else
486             conn -&gt;
487               measurements = %{duration: System.monotonic_time() - start}
488               metadata = %{metadata | conn: conn}
489               :telemetry.execute([:phoenix, :router_dispatch, :stop], measurements, metadata)

lib/wed_events_web/endpoint.ex

1&gt;  defmodule WedEventsWeb.Endpoint do
2     use Phoenix.Endpoint, otp_app: :wed_events
3   
4     # The session will be stored in the cookie and signed,
5     # this means its contents can be read but not tampered with.
6     # Set :encryption_salt if you would also like to encrypt it.

deps/plug/lib/plug/debugger.ex

131             case conn do
132               %Plug.Conn{path_info: ["__plug__", "debugger", "action"], method: "POST"} -&gt;
133                 Plug.Debugger.run_action(conn)
134   
135               %Plug.Conn{} -&gt;
136&gt;                super(conn, opts)
137             end
138           rescue
139             e in Plug.Conn.WrapperError -&gt;
140               %{conn: conn, kind: kind, reason: reason, stack: stack} = e
141               Plug.Debugger.__catch__(conn, kind, reason, stack, @plug_debugger)

lib/wed_events_web/endpoint.ex

1&gt;  defmodule WedEventsWeb.Endpoint do
2     use Phoenix.Endpoint, otp_app: :wed_events
3   
4     # The session will be stored in the cookie and signed,
5     # this means its contents can be read but not tampered with.
6     # Set :encryption_salt if you would also like to encrypt it.

lib/phoenix/endpoint/sync_code_reload_plug.ex

17   
18     def call(conn, {endpoint, opts}), do: do_call(conn, endpoint, opts, true)
19   
20     defp do_call(conn, endpoint, opts, retry?) do
21       try do
22&gt;        endpoint.call(conn, opts)
23       rescue
24         exception in [UndefinedFunctionError] -&gt;
25           case exception do
26             %UndefinedFunctionError{module: ^endpoint} when retry? -&gt;
27               # Sync with the code reloader and retry once

lib/bandit/pipeline.ex

122       end
123     end
124   
125     @spec call_plug!(Plug.Conn.t(), plug_def()) :: Plug.Conn.t() | no_return()
126     defp call_plug!(%Plug.Conn{} = conn, {plug, plug_opts}) when is_atom(plug) do
127&gt;      case plug.call(conn, plug_opts) do
128         %Plug.Conn{} = conn -&gt; conn
129         other -&gt; raise("Expected #{plug}.call/2 to return %Plug.Conn{} but got: #{inspect(other)}")
130       end
131     end
132   

lib/bandit/pipeline.ex

31         conn = build_conn!(transport, method, request_target, headers, opts)
32         span = Bandit.Telemetry.start_span(:request, measurements, Map.put(metadata, :conn, conn))
33   
34         try do
35           conn
36&gt;          |&gt; call_plug!(plug)
37           |&gt; maybe_upgrade!()
38           |&gt; case do
39             {:no_upgrade, conn} -&gt;
40               %Plug.Conn{adapter: {_mod, adapter}} = conn = commit_response!(conn)
41               Bandit.Telemetry.stop_span(span, adapter.metrics, %{conn: conn})

lib/bandit/http1/handler.ex

7     @impl ThousandIsland.Handler
8     def handle_data(data, socket, state) do
9       transport = %Bandit.HTTP1.Socket{socket: socket, buffer: data, opts: state.opts}
10       connection_span = ThousandIsland.Socket.telemetry_span(socket)
11   
12&gt;      case Bandit.Pipeline.run(transport, state.plug, connection_span, state.opts) do
13         {:ok, transport} -&gt; maybe_keepalive(transport, state)
14         {:error, _reason} -&gt; {:close, state}
15         {:upgrade, _transport, :websocket, opts} -&gt; do_websocket_upgrade(opts, state)
16       end
17     end

lib/bandit/delegating_handler.ex

13       |&gt; handle_bandit_continuation(socket)
14     end
15   
16     @impl ThousandIsland.Handler
17     def handle_data(data, socket, %{handler_module: handler_module} = state) do
18&gt;      handler_module.handle_data(data, socket, state)
19       |&gt; handle_bandit_continuation(socket)
20     end
21   
22     @impl ThousandIsland.Handler
23     def handle_shutdown(socket, %{handler_module: handler_module} = state) do

/home/deepc/Repos/wed_events/deps/thousand_island/lib/thousand_island/handler.ex

380               {%ThousandIsland.Socket{socket: raw_socket} = socket, state}
381             )
382             when msg in [:tcp, :ssl] do
383           ThousandIsland.Telemetry.untimed_span_event(socket.span, :async_recv, %{data: data})
384   
385&gt;          __MODULE__.handle_data(data, socket, state)
386           |&gt; handle_continuation(socket)
387         end
388   
389         def handle_info(
390               {msg, raw_socket},

gen_server.erl

No code available.

gen_server.erl

No code available.

proc_lib.erl

No code available.

Connection details

Params

%{"token" =&gt; "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3QiOiJzaWduX2luX3dpdGhfbWFnaWNfbGluayIsImF1ZCI6In4-IDQuMyIsImV4cCI6MTczMjE5NjQwMywiaWF0IjoxNzMyMTk1ODAzLCJpZGVudGl0eSI6ImFkbWluQGV4YW1wbGUuY29tIiwiaXNzIjoiQXNoQXV0aGVudGljYXRpb24gdjQuMy4zIiwianRpIjoiMzA0dnRwdjI5NnYxYnEwbjBrMDAyaGM5IiwibmJmIjoxNzMyMTk1ODAzLCJzdWIiOiJ1c2VyIn0.eZ2sVQlRMKTxrmHaAzdrDahoLcU08T-Ko89qNR2r0So"}

Request info

  • URI: http://127.0.0.1:4000/auth/user/magic_link/
  • Query string: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3QiOiJzaWduX2luX3dpdGhfbWFnaWNfbGluayIsImF1ZCI6In4-IDQuMyIsImV4cCI6MTczMjE5NjQwMywiaWF0IjoxNzMyMTk1ODAzLCJpZGVudGl0eSI6ImFkbWluQGV4YW1wbGUuY29tIiwiaXNzIjoiQXNoQXV0aGVudGljYXRpb24gdjQuMy4zIiwianRpIjoiMzA0dnRwdjI5NnYxYnEwbjBrMDAyaGM5IiwibmJmIjoxNzMyMTk1ODAzLCJzdWIiOiJ1c2VyIn0.eZ2sVQlRMKTxrmHaAzdrDahoLcU08T-Ko89qNR2r0So

Headers

  • accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7
  • accept-encoding: gzip, deflate, br, zstd
  • accept-language: en-US,en;q=0.9
  • connection: keep-alive
  • cookie: _wed_events_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYUXRkcTlhUXE3VTN6aWtrUmNkUGdYQjBJ.HXqgXcm-2bHHXpqazHqG0FX_i6qJuYv2oTOY4jhJVxk
  • host: 127.0.0.1:4000
  • sec-ch-ua: “Microsoft Edge”;v=“131”, “Chromium”;v=“131”, “Not_A Brand”;v=“24”
  • sec-ch-ua-mobile: ?0
  • sec-ch-ua-platform: “Windows”
  • sec-fetch-dest: document
  • sec-fetch-mode: navigate
  • sec-fetch-site: none
  • sec-fetch-user: ?1
  • upgrade-insecure-requests: 1
  • user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0

Session

%{"_csrf_token" =&gt; "Qtdq9aQq7U3zikkRcdPgXB0I"}

Oh hold on I see an open issue about this, on GitHub Magic link strategy with disabled registration doesn't work · Issue #828 · team-alembic/ash_authentication · GitHub

So it should work with registration disabled, but it doesn’t right now :frowning:

Ah yep! Thanks. I’ll see if I can tackle a fix. Any pointers would help since I’m not totally familiar with Ash’s API’s yet