Magic Links Tutorial

View Source

With a mix task

You can use mix ash_authentication.add_strategy magic_link to install this strategy. The rest of the guide is in the case that you wish to proceed manually.

# ...

strategies do
  # add these lines -->
  magic_link do
    identity_field :email
    registration_enabled? true
    require_interaction? true

    sender(Example.Accounts.User.Senders.SendMagicLink)
  end
  # <-- add these lines
end

# ...

Registration Enabled

When registration is enabled, signing in with magic is a create action that upserts the user by email. This allows a user who does not exist to request a magic link and sign up with one action.

Registration Disabled (default)

When registration is disabled, signing in with magic link is a read action.

Require Interaction

Some email clients, virus scanners, etc will retrieve a link automatically without user interaction, causing the magic link token to be consumed and thus fail when the user clicks the link. The mitigate this we now default to requiring that the user click a "sign in" button to ensure that retrieving the confirmation page does not actually consume the token. By default if a GET request is sent to the magic link endpoint a very simple form is served which submits to the same URL with the same token parameter as a POST. You probably don't want to serve this page to users in production. You can work around this by placing your own page at the same path before it in the router, or changing the email link to a different URL.

See also AshAuthentication.Phoenix.Router.magic_sign_in_route/3.

Configuration

By default, when an invalid magic link token is provided, the sign-in action returns an empty result (for backwards compatibility). However, this makes it difficult to distinguish between a successful sign-in and a failed sign-in due to an invalid token.

To return an error when an invalid token is provided (recommended), add the following to your configuration:

config :ash_authentication, return_error_on_invalid_magic_link_token?: true

This is especially important if you're using the AuditLog add-on, as it ensures failed sign-in attempts are logged correctly. This configuration is automatically added when you use mix ash_authentication.add_strategy magic_link. In the next major version, returning an error will be the default behavior.

Create an email sender and email template

Inside /lib/example/accounts/user/senders/send_magic_link.ex

defmodule Example.Accounts.User.Senders.SendMagicLink do
  @moduledoc """
  Sends a magic link
  """
  use AshAuthentication.Sender
  use ExampleWeb, :verified_routes

  @impl AshAuthentication.Sender
  def send(user_or_email, token, _) do
    # will be a user if the token relates to an existing user
    # will be an email if there is no matching user (such as during sign up)
    Example.Accounts.Emails.deliver_magic_link(
      user_or_email,
      url(~p"/auth/user/magic_link/?token=#{token}")
    )
  end
end

Inside /lib/example/accounts/emails.ex

# ...

def deliver_magic_link(user, url) do
  if !url do
    raise "Cannot deliver reset instructions without a url"
  end

  email = case user do
    %{email: email} -> email
    email -> email
  end

  deliver(email, "Magic Link", """
  <html>
    <p>
      Hi #{email},
    </p>

    <p>
      <a href="#{url}">Click here</a> to login.
    </p>
  <html>
  """)
end

# ...

Request timing and account enumeration

The request action returns :ok whether or not the identity matches a user. The response body therefore does not reveal which accounts exist. The response time is a different matter. When the identity matches, the action mints a token and calls your sender inline. When it does not match, it does neither. A synchronous SMTP or HTTP-API send takes tens to hundreds of milliseconds. That gap is easy to measure, so it separates known identities from unknown ones.

The sender is the part you control, and it is the part that leaks. Make it asynchronous. Enqueue a job from your send/3 callback and return :ok straight away, then deliver the message from the job. That keeps delivery latency off both paths. The callback's return value is ignored anyway, so returning early gives up nothing — see AshAuthentication.Sender. What remains is one JWT signing operation, about 27 microseconds. Neither path writes to the database unless you enable store_all_tokens?. A difference that small is not measurable across a network.

This only applies when registration_enabled? is false, which is the default. With registration enabled, both paths mint a token and call the sender. The remaining difference is then around 3 microseconds.