Custom Authentication Flow with AshAuthentication in Phoenix + React (Inertia.js)

Hi everyone! I've been implementing a custom authentication flow using AshAuthentication with Phoenix and React via Inertia.js. I wanted to share my approach and ask for feedback, especially regarding password reset functionality. Working Authentication Actions So far, I've successfully implemented the following actions defined by the AshAuthentication generator in my User Resource:
define :register_with_password
define :sign_in_with_password
define :request_password_reset_token
define :reset_password_with_token
define :register_with_password
define :sign_in_with_password
define :request_password_reset_token
define :reset_password_with_token
Registration Controller Here's how I'm handling registration:
def create(conn, params) do
case Accounts.register_with_password(params) do
{:ok, _user} ->
conn
|> redirect(to: ~p"/successfully-registered")

{:error, error} ->
conn
|> assign_errors(error)
|> redirect(to: ~p"/register")
end
end
def create(conn, params) do
case Accounts.register_with_password(params) do
{:ok, _user} ->
conn
|> redirect(to: ~p"/successfully-registered")

{:error, error} ->
conn
|> assign_errors(error)
|> redirect(to: ~p"/register")
end
end
Login Controller And here's the login implementation:
def create(conn, params) do
case Accounts.sign_in_with_password(params) do
{:ok, user} ->
UserAuth.log_in(conn, user)

{:error, _error} ->
conn
|> put_flash(:error, "Invalid email or password")
|> redirect(to: ~p"/login")
end
end
def create(conn, params) do
case Accounts.sign_in_with_password(params) do
{:ok, user} ->
UserAuth.log_in(conn, user)

{:error, _error} ->
conn
|> put_flash(:error, "Invalid email or password")
|> redirect(to: ~p"/login")
end
end
Password Reset Flow I've implemented the first part of the password reset flow - requesting a reset token:
def create(conn, params) do
Accounts.request_password_reset_token(params)

conn
|> put_flash(
:info,
"If your email is in our system, you will receive an email with instructions to reset your password."
)
|> redirect(to: ~p"/login")
end
def create(conn, params) do
Accounts.request_password_reset_token(params)

conn
|> put_flash(
:info,
"If your email is in our system, you will receive an email with instructions to reset your password."
)
|> redirect(to: ~p"/login")
end
Issue with Password Reset I'm trying to implement the controller to update the password, but I'm getting an error:
def update(conn, params) do
Accounts.reset_password_with_token(params) |> IO.inspect()

conn
|> redirect(to: ~p"/password-reset/#{params["reset_token"]}")
end
def update(conn, params) do
Accounts.reset_password_with_token(params) |> IO.inspect()

conn
|> redirect(to: ~p"/password-reset/#{params["reset_token"]}")
end
The error I'm getting is:
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Invalid.InvalidPrimaryKey{
resource: Contactly.Accounts.User,
value: %{
"password" => "newpass",
"password_confirmation" => "newpass",
"reset_token" => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3QiOiJyZXNldF9wYXNzd29yZF93aXRoX3Rva2VuIiwiYXVkIjoifj4gNC43IiwiZXhwIjoxNzQ2NDcxMzU2LCJpYXQiOjE3NDYyMTIxNTYsImlzcyI6IkFzaEF1dGhlbnRpY2F0aW9uIHY0LjcuNiIsImp0aSI6IjMwdHNsZDM4cjY4YnVzdmE4azAwN2YyNyIsIm5iZiI6MTc0NjIxMjE1Niwic3ViIjoidXNlcj9pZD01ZDdlZDRiNS1jMmM5LTQ0MTQtYWRmMi0wMzc0ZDdmMmIxZGEifQ.UbkDMiFAm9jbydOpA9dIiYRGK5vGrspb-jGNic25eL8"
},
splode: Ash.Error,
bread_crumbs: [],
vars: [],
path: [],
stacktrace: #Splode.Stacktrace<>,
class: :invalid
}
]
}}
{:error,
%Ash.Error.Invalid{
errors: [
%Ash.Error.Invalid.InvalidPrimaryKey{
resource: Contactly.Accounts.User,
value: %{
"password" => "newpass",
"password_confirmation" => "newpass",
"reset_token" => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3QiOiJyZXNldF9wYXNzd29yZF93aXRoX3Rva2VuIiwiYXVkIjoifj4gNC43IiwiZXhwIjoxNzQ2NDcxMzU2LCJpYXQiOjE3NDYyMTIxNTYsImlzcyI6IkFzaEF1dGhlbnRpY2F0aW9uIHY0LjcuNiIsImp0aSI6IjMwdHNsZDM4cjY4YnVzdmE4azAwN2YyNyIsIm5iZiI6MTc0NjIxMjE1Niwic3ViIjoidXNlcj9pZD01ZDdlZDRiNS1jMmM5LTQ0MTQtYWRmMi0wMzc0ZDdmMmIxZGEifQ.UbkDMiFAm9jbydOpA9dIiYRGK5vGrspb-jGNic25eL8"
},
splode: Ash.Error,
bread_crumbs: [],
vars: [],
path: [],
stacktrace: #Splode.Stacktrace<>,
class: :invalid
}
]
}}
It seems like there's an issue with a "primary key", but I'm not sure what this means since I am passing all the expected parameters (password, password_confirmation, token). So, how should I properly implement this? Thanks in advance for any help or insights!
10 Replies
ZachDaniel
ZachDaniel•3w ago
I believe that you are just missing the first argument to Accounts.reset_password_with_token which is the user or an identifier for the user i.e Accounts.reset_password_with_token(user, params) or Accounts.reset_password_with_token(user_id, params) As that is an update action, it takes the thing being updated as the first argument 🙂
Joan Gavelán
Joan GavelánOP•3w ago
Yeah... I was missing that, silly mistake on my part. So after some research I ended up with the following implementation:
def update(conn, params) do
with {:ok, claims, _resource} <- AshAuthentication.Jwt.verify(params["reset_token"], User),
{:ok, user} <- Accounts.get_by_subject(%{subject: claims["sub"]}, authorize?: false),
{:ok, _user} <- Accounts.reset_password_with_token(user, params, authorize?: false) do
conn
|> put_flash(:success, "Password reset successful.")
|> redirect(to: ~p"/login")
else
{:error, %Ash.Error.Invalid{} = error} ->
conn
|> assign_errors(error)
|> redirect(to: ~p"/password-reset/#{params["reset_token"]}")

_ ->
conn
|> put_flash(:error, "The token is invalid or expired. Please request a new one.")
|> redirect(to: ~p"/password-reset")
end
end
def update(conn, params) do
with {:ok, claims, _resource} <- AshAuthentication.Jwt.verify(params["reset_token"], User),
{:ok, user} <- Accounts.get_by_subject(%{subject: claims["sub"]}, authorize?: false),
{:ok, _user} <- Accounts.reset_password_with_token(user, params, authorize?: false) do
conn
|> put_flash(:success, "Password reset successful.")
|> redirect(to: ~p"/login")
else
{:error, %Ash.Error.Invalid{} = error} ->
conn
|> assign_errors(error)
|> redirect(to: ~p"/password-reset/#{params["reset_token"]}")

_ ->
conn
|> put_flash(:error, "The token is invalid or expired. Please request a new one.")
|> redirect(to: ~p"/password-reset")
end
end
What do you think about it?
ZachDaniel
ZachDaniel•3w ago
That seems reasonable to me 🙂
Joan Gavelán
Joan GavelánOP•3w ago
Cool. I'd like your input on the final piece of my authentication flow - confirming new users. I'm requiring users to confirm their accounts in order to log in. This is my implementation so far:
def generate_user_confirmation_token(user) do
now = DateTime.utc_now()
changeset = Ash.Changeset.for_update(user, :update, %{"confirmed_at" => now})

Contactly.Accounts.User
|> AshAuthentication.Info.strategy!(:confirm_new_user)
|> AshAuthentication.AddOn.Confirmation.confirmation_token(changeset, user)
end

def confirm_user(token) do
Contactly.Accounts.User
|> AshAuthentication.Info.strategy!(:confirm_new_user)
|> AshAuthentication.AddOn.Confirmation.Actions.confirm(%{"confirm" => token})
end
def generate_user_confirmation_token(user) do
now = DateTime.utc_now()
changeset = Ash.Changeset.for_update(user, :update, %{"confirmed_at" => now})

Contactly.Accounts.User
|> AshAuthentication.Info.strategy!(:confirm_new_user)
|> AshAuthentication.AddOn.Confirmation.confirmation_token(changeset, user)
end

def confirm_user(token) do
Contactly.Accounts.User
|> AshAuthentication.Info.strategy!(:confirm_new_user)
|> AshAuthentication.AddOn.Confirmation.Actions.confirm(%{"confirm" => token})
end
And the controller using these functions:
def create(conn, %{"email" => email}) do
with {:ok, user} <- Accounts.get_user_by_email(email),
{:ok, token} <- Accounts.generate_user_confirmation_token(user) do
SendNewUserConfirmationEmail.send(user, token, [])
end

conn
|> put_flash(
:info,
"If your email exists in our system, you will receive a confirmation email."
)
|> redirect(to: ~p"/login")
end

def update(conn, %{"token" => token}) do
case Accounts.confirm_user(token) do
{:ok, _user} ->
conn
|> put_flash(:success, "Your account has been confirmed successfully!")
|> redirect(to: "/login")

{:error, _error} ->
conn
|> put_flash(:error, "The token is invalid or expired.")
|> redirect(to: "/confirm-user")
end
end
def create(conn, %{"email" => email}) do
with {:ok, user} <- Accounts.get_user_by_email(email),
{:ok, token} <- Accounts.generate_user_confirmation_token(user) do
SendNewUserConfirmationEmail.send(user, token, [])
end

conn
|> put_flash(
:info,
"If your email exists in our system, you will receive a confirmation email."
)
|> redirect(to: ~p"/login")
end

def update(conn, %{"token" => token}) do
case Accounts.confirm_user(token) do
{:ok, _user} ->
conn
|> put_flash(:success, "Your account has been confirmed successfully!")
|> redirect(to: "/login")

{:error, _error} ->
conn
|> put_flash(:error, "The token is invalid or expired.")
|> redirect(to: "/confirm-user")
end
end
What do you think? It works but I think there might be a more "Ash" way to do it
ZachDaniel
ZachDaniel•3w ago
The only thing I might suggest is to have a generic action that does those multistep flows
action :get_and_send_user_confirmation_token do
argument ....

run fn input, _ ->
...
end
end
action :get_and_send_user_confirmation_token do
argument ....

run fn input, _ ->
...
end
end
Joan Gavelán
Joan GavelánOP•3w ago
I found myself having to set authorize?: false almost on every function I have to use. Is this a normal pattern or am I not getting something here?
define :reset_password_with_token, default_options: [authorize?: false]
define :get_user_by_email, args: [:email], default_options: [authorize?: false]
define :get_by_subject, default_options: [authorize?: false]
define :change_password, default_options: [authorize?: false]
define :reset_password_with_token, default_options: [authorize?: false]
define :get_user_by_email, args: [:email], default_options: [authorize?: false]
define :get_by_subject, default_options: [authorize?: false]
define :change_password, default_options: [authorize?: false]
I think I could skip this setting authorize: :when_requested option in the authorization block in my User resource but I read that this is not best practice. Could you please guide me if this is how it's supposed to be?
ZachDaniel
ZachDaniel•3w ago
So it is "working as intended" There are policies on your user resource that allow AshAuthentication to call these actions, which it does by setting a special context. You could make a custom "system actor" for example, or set a context of your own that allows it to be performed If you do end up using authorize? false, don't do it using default options. Add that option at the places where it's called to make it clear "this bypasses resource policies"
Joan Gavelán
Joan GavelánOP•3w ago
I'll have to read more about authorization and policies to better understand all of this.
Joan Gavelán
Joan GavelánOP•3w ago
Alright. My custom auth is implemented. In case anyone wants to check out the commit: https://github.com/joangavelan/contactly_ash/commit/d4c0c812bcfb6a83558581bac14e87f59221d965 Thanks a lot Zach for your help. I'll mark this as solved.
ZachDaniel
ZachDaniel•3w ago
Yes, definitely read through the policies guide 🙂

Did you find this page helpful?