How to Support Multiple Unique Identifiers (Username, Email, Mobile, etc.) for sign_in_with_password in AshAuthentication?

Hi everyone,

I’m currently practicing with Elixir, Phoenix, LiveView, and Ash—so I’m not an expert yet, but eager to learn.

I’m building a Phoenix app with Ash + AshAuthentication and want users to log in with whichever unique attribute they prefer—today that’s username or email, but soon it will include mobile phone numbers and other optional fields.

# sign_in_with_password (snippet)
read :sign_in_with_password do
  # thinking of replacing :username with :login for flexibility
  argument :login, :string
  argument :password, :string, sensitive?: true

  prepare fn changeset, _ ->
    login_val = Ash.Changeset.get_argument(changeset, :login)

    user =
      Helpdesk.Accounts.User
      |> Ash.Query.filter(
           expr(username == ^login_val or email == ^login_val /* add mobile later */)
         )
      |> Helpdesk.Accounts.read_one!()

    if user && Helpdesk.Accounts.check_password(user, Ash.Changeset.get_argument(changeset, :password)) do
      Ash.Changeset.put_context(changeset, :user, user)
    else
      Ash.Changeset.add_error(changeset, "Invalid credentials")
    end
  end
  prepare AshAuthentication.Strategy.Password.SignInPreparation
  metadata :token, :string
end

Relevant pieces:

  • Strategies
strategies do
  password :password do
    register_action_accept [:username] # open to :mobile next
    hash_provider AshAuthentication.BcryptProvider
  end
end
  • register_with_password
create :register_with_password do
  argument :username, :ci_string
  argument :email,    :ci_string
  argument :password, :string, sensitive?: true
  argument :password_confirmation, :string, sensitive?: true
end
  • attributes & identities
attributes do
  attribute :username, :string
  attribute :email,    :ci_string
  # planning: attribute :mobile, :string
end

identities do
  identity :unique_username, [:username]
  identity :unique_email,    [:email]
  # will add :unique_mobile,  [:mobile]
end

Question
Is there an idiomatic Ash/AshAuthentication way to centralize authentication over any present unique field (username, email, mobile, etc.) without manually extending ‎expr(...) each time?

I’m considering:

  1. Accepting a generic ‎:login argument and dynamically building a filter across all identity fields.
  2. Creating separate sign-in actions per identifier.
  3. Using a custom data layer or policy trick I’ve missed.

Are there other recommended patterns or best practices for this in Ash/AshAuthentication?

I’m open to any solution that’s clean and maintainable, even examples or links to docs that tackle this pattern.

Thanks for your time!

I certainly had this sort of thing in mind in the original design for ash_authentication My suggestion is just to have multiple different password strategies. It is a little annoying that we don’t support “sign in with any of these identities” but I think the solution to that is to make a generic action that dispatches to the appropriate strategy. You can use the same pattern for a generic registration action. Sadly this won’t work with the auto-generated UI from ash_authentication_phoenix, but it’s not that hard to code up a login form.

authentication do
  password :email_and_password do
    identity_field :email
  end

  password :username_and_password do
    identity_field :username
  end

  password :mobile_and_password do
    identity_field :mobile
  end
end

actions do
  action :sign_in, :struct do
    argument :email, :ci_string, public?: true, sensitive?: true
    argument :username. :ci_string, public?: true
    argument :mobile, :string, public?: true, sensitive?: true # you probably want a custom type for this
    argument :password, :string, public?: true, sensitive?: true
    argument :password_confirmation, :string, public?: sensitive?: true

    validate present([:email, :username, :mobile], exactly: 1)
    validate confirm(:password, :password_confirmation)

    constraints instance_of: __MODULE__

    run fn input, context ->
      opts = Ash.Context.to_ops(context)
      case input do
        input when is_struct(input.email) ->
          __MODULE__
          |> Ash.Query.for_read(:sign_in_with_email_and_password, input.arguments)
          |> Ash.read(opts)
        input when is_struct(input.username) ->
          __MODULE__
          |> Ash.Query.for_read(:sign_in_with_username_and_password, input.arguments)
          |> Ash.read(opts)
        input when is_binary(input.mobile) ->
          __MODULE__
          |> Ash.Query.for_read(:sign_in_with_mobile_and_password, input.arguments)
          |> Ash.read(opts)
      end
    end
  end

  identities do
    identity :unique_email, [:email], nils_distinct?: true
    identity :unique_username, [:username], nils_distinct?: true
    identity :unique_mobile, [:mobile], nils_distinct?: true
  end
end
2 Likes

Thanks so much for your clear explanation—really appreciate it!

Just to clarify my understanding:

I should define multiple password strategies in ‎authentication for each unique identifier, remove the old read :sign_in_with_password action, and then implement the generic action :sign_in with multiple password strategies as you described.

My login form can have a single identifier field (for email, username, or mobile) and a password field. Then, I detect which identifier type the user entered and route to the correct strategy. For example:

def handle_event("submit", %{"user" => %{"identifier" => identifier, "password" => password}}, socket) do
  params =
    cond do
      Regex.match?(~r/^\d+$/, identifier) ->
        %{mobile: identifier, password: password}
      String.contains?(identifier, "@") ->
        %{email: identifier, password: password}
      true ->
        %{username: identifier, password: password}
    end
  
  # form =
  #   AshPhoenix.Form.for_action(App.Accounts.User, :sign_in_with_password,
  #     api: App.Accounts,
  #     as: "user"
  #   )

  form =
    AshPhoenix.Form.for_action(App.Accounts.User, :sign_in,
      api: App.Accounts,
      as: "user"
    )
    |> AshPhoenix.Form.validate(params)

  # ...POST if valid?
end

Is this the right approach?

Thanks again for your help!