Home Posts Tags Post Search Tag Search

Post 77

Ash Framework 06 - Authentication

Published on: 2025-10-07 Tags: elixir, Blog, Side Project, LiveView, Ecto, Html/CSS, Phoenix, Ash, Framework
Chapter 5: Authentication: Who Are You? (107)
    Introducing AshAuthentication
        There is 2 part to Authentication for ASH, the core ash_authentication and then the ash_authentication_phoenix. For this chapter we will just be letting the ash installer do most of the work but will be going over each step as is needed. First lets get the right Igniter to install the ash_authentication
            mix igniter.install ash_authentication

        New Domain, Who's This?
            So as before we were working with domains and we had Tunez.Music, Authentication is an other type here that will be used to deal with the users. We just made Tunez.Accounts that will have 2 resources, Tunez.Accounts.User and Tunez.Accounts.Token

            Tunez.Accounts.User in lib/tunez/accounts/user.ex is for the Users that we will make and deal with. There is also a DB setup for users in the users database, it doesn't have much at the moment besides ID, and some boilerplate for the configuration

        Tokens and Secrets and Config, Oh My!
            Tokens, via Tunez.Accounts.Token resource are the secret sauce for AshAuthentication. This is how we will identify a user. They will be provided upon request for every session that a user will need. It should be good right out the box but there are ways to change settings, in the mean time the only thing we need to do now to make this all work is to set up a strategy for the Authentication.

    Setting Up Password Authentication
        The normal way that most people will use Authentication is to set up a password and user name and then enter those in-order to access the website. There are other ways of doing this like; OAuth and magic links but we will use the password strategy for this site. It is a simple command line to take care of this. This will add in these bits of code:
            2 new attributes for Tunez.Accounts.User email and hashed_password
            Strategies block added to Authentication config in Tunez.Accounts.User resource
            confirmation add-on added to the add_ons block as part of authentication config in Tunez.Accounts.User, this will require the user to authenticate their emails.
            A whole set of actions in Tunez.Accounts.Users for signing in. 
            2 modules to handle sending email confirmations, and password reset.

            mix ash_authentication.add_strategy password 
        
        With the mix done you will now need to mix ash.migrate to set up the new repo and db
            mix ash.migrate

        Testing Authentication Actions in iex
            One of the actions that we created with the generator is create:register_with_password action. This should take an: email, password, and password_confirmation and creates a user record. Lets test it out.
                iex -S mix
                iex(1)> Tunez.Accounts.User
                Tunez.Accounts.User                
                iex(2)> |> Ash.Changeset.for_create(:register_with_password, %{email: <<email>>, password: "supersecret", password_confirmation: "supersecret"})
                #Ash.Changeset<
                domain: Tunez.Accounts,
                action_type: :create,
                action: :register_with_password,
                attributes: %{email: #Ash.CiString<"***email***">},
                relationships: %{},
                arguments: %{
                    password: "**redacted**",
                    email: #Ash.CiString<"***email***">,
                    password_confirmation: "**redacted**"
                },
                errors: [],
                data: %Tunez.Accounts.User{
                    id: nil,
                    email: nil,
                    confirmed_at: nil,
                    __meta__: #Ecto.Schema.Metadata<:built, "users">
                },
                valid?: true
                >
                iex(3)> |> Ash.create!(authorize?: false)
                [debug] QUERY OK db=0.3ms idle=527.3ms
                begin []
            
            This did a few things added a new user into the db, created a token for the user to authenticate and confirm their email, generated an email that will be sent out (This wont be done in dev but all the pluming will be there for it.) Now what can we do with this?  Lets try to authenticate with a faux form to do so.
                iex(1)> Tunez.Accounts.User
                Tunez.Accounts.User
                iex(2)> |> Ash.Query.for_read(:sign_in_with_password, %{email: "***email***", password: "supersecret"})
                #Ash.Query<
                resource: Tunez.Accounts.User,
                action: :sign_in_with_password,
                arguments: %{
                    password: "**redacted**",
                    email: #Ash.CiString<"***email***">
                },
                filter: #Ash.Filter<email == #Ash.CiString<"***email***"> and not  is_nil(hashed_password) == "**redacted**">
                >
                iex(3)> |> Ash.read(authorize?: false)
                [debug] QUERY OK source="users" db=1.9ms decode=1.6ms queue=0.8ms idle=765.6ms
                SELECT u0."id", u0."email", u0."confirmed_at", u0."hashed_password" FROM "users" AS u0 WHERE (u0."email"::citext = ($1::citext)::citext) AND (NOT (u0."hashed_password"::text IS NULL)) ...
                ↳ AshPostgres.DataLayer.bulk_create/3, at: lib/data_layer.ex:2024
                {:ok,
                [
                %Tunez.Accounts.User{
                    id: "cd36f5a7-9f3e-460b-bd45-cdc92367dbfe",
                    email: #Ash.CiString<"***email***">,
                    confirmed_at: nil,
                    __meta__: #Ecto.Schema.Metadata<:loaded, "users">
                }
                ]}

            Don't forget each line but you should have the user in the db and then you should be able to find the user with these commands. You can even check the token for the user with the right permissions.
                iex(4)> {:ok, [user]} = v()
                {:ok,
                [
                %Tunez.Accounts.User{
                    id: "cd36f5a7-9f3e-460b-bd45-cdc92367dbfe",
                    email: #Ash.CiString<"***email***">,
                    confirmed_at: nil,
                    __meta__: #Ecto.Schema.Metadata<:loaded, "users">
                }
                ]}
                iex(5)> user.__metadata__.token
                "eyJhbGciOiJIUzI1NiIsInR5cCI6Ik..."

            Now lets test out the verification by use the JSON Wet Token (JWT)
                iex(6)> AshAuthentication.Jwt.verify(user.__metadata__.token, :tunez)
                [debug] QUERY OK source="tokens" db=0.5ms queue=0.6ms idle=1175.7ms
                SELECT TRUE FROM "tokens" AS t0 WHERE (t0."purpose"::text = $1::text) AND (t0."jti"::text = $2::text) LIMIT 1 ...
                "purpose" => "user",
                "sub" => "user?id=cd36f5a7-9f3e-460b-bd45-cdc92367dbfe"
                }, Tunez.Accounts.User}

            The interesting part is the purpose and the sub, you can use a JWY's for a variety of reasons for this case we are verifying a user. Now that we have the JWT we can see if a user is using the right JWT and their token.
                iex(15)> {:ok, claims, resource} = v()
                {:ok, %{...}, Tunez.Accounts.User}
                iex(16)> AshAuthentication.subject_to_user(claims["sub"], resource)
                SELECT u0."id", u0."confirmed_at", u0."hashed_password", u0."email" FROM
                "users" AS u0 WHERE (u0."id"::uuid::uuid = $1::uuid::uuid) [«uuid»]
                {:ok, %Tunez.Accounts.User{email: #Ash.CiString<«your email»>, ...}}

            You don't need to worry about all of this but again a user will log in and then receive a token that will work for only them.

    Automatic UIs with AshAuthenticationPhoenix
        Now that we have a way to create a user and then get their token we need a UI for that. We will not use an other igniter to get this done, the command is below and it will:
            Set the config file in .igniter.exs this is the first generator that needs a specific configuration
            TunezWeb.AuthOverrides module that will customize the look, see (lib/tunez_web/auth_overrides.ex)
            TunezWeb.AuthController module to securely process sign-in requests. (in lib/tunez_web/controller)
            TunezWeb.LiveUserAuth module providing a set of hooks we can use in live-views (lib/tunez_web/live_user_auth.ex)
            updates our router to handle the new plugs and routes (lib/tunez_web/router.ex)
            mix igniter.install ash_authentication_phoenix

        Okay that is all taken care of but as of right now Igniter doesn't know how to patch JavaScript or CSS files. We need to add Tailwinds live-view paths to the assets. Head to /assets/css/app.css and add in this line
            /* ... */
            @source "../../lib/tunez_web";
            @source "../../deps/ash_authentication_phoenix";
            
            @plugin "@tailwindcss/forms";
            /* ... */

        Now you can head to /sign-in to see the login page. You can create a user (try the one you already made) you will then be redirected to the home page, we will not work on making it look like you are logged in.

        Showing the Currently Authenticated User
            It is normal for it to show the current user in the top right so let's do that now. Head to lib/tunez_web/components/layouts.ex and lets add in a line to show the current user.
                <div class="flex items-center w-full p-4 pb-2 border-b-2 border-primary-600">
                    <div class="flex-1 mr-4">
                        <% # ... %>
                    </div>
                    <.user_info current_user={@current_user} socket={@socket} />
                </div>
            
            Now we have the line that will show the current user but its not public and we now need to dig into why its not showing the current user.

            "Digging into AshAuthenticationPhoenix's Generated Router Code"
                Most everything the generator did is perfect but there are a few things that it didn't do for us. It set up some pipelines for load_from_bearer, and load_from_session, these are for the JWT and are able to load the record for the user. But live-view works differently we need to find a way to get the session info into the current session as live-view knows that they are authenticated but not the session that was created for the user. 

                This is where live_session comes in. It's wrapped in ash with AshAuthentication, ash_authentication_live_session, this will make sure that when new sessions are spawned they will have the correct data and will still be secure. So for Tunez we need to make sure that the routes that need to be logged in will be in the ash_authentication_live_session block. so these first routes need to be moved into the correct block. lib/tunez_web/router.ex
                    scope "/", TunezWeb do
                        pipe_through :browser
                        # This is the block of routes to move
                        live "/", Artists.IndexLive
                        # ...
                        live "/albums/:id/edit", Albums.FormLive, :edit
                        auth_routes AuthController, Tunez.Accounts.User, path: "/auth"
                        # ...
                        # will be moved to this should be higher up in the block of code.

                    scope "/", TunezWeb do
                        pipe_through :browser
                        ash_authentication_live_session :authenticated_routes do
                        # This is the location that the block of routes should be moved to
                        live "/", Artists.IndexLive
                        # ...
                        live "/albums/:id/edit", Albums.FormLive, :edit
                        end
                    end

        Stylin' and Profilin' with AuthOverrides
            There is a file that was created to change some of the standard components class names and url images etc. Let's head to lib/tunez_web/auth_overrides.ex. It can be hard to figure out exactly what we want to override here. To maybe help the submit button is an input. Let's override the input component. Within the file you will see some syntax for doing some of the overrides. 
                # add this into the file
                override AshAuthentication.Phoenix.Components.Password.Input do
                    set :submit_class, "bg-primary-600 text-white my-4 py-3 px-5 text-sm"
                end

            There it is the button is now purple, keep in mind that anything you do here you will change for every instance of the component. They provided a set of overrides in lib/tunez_web/auth_overrides_sample.txt but if don't see it here it is for you.
                alias AshAuthentication.Phoenix.Components

                override Components.Banner do
                    set :image_url, nil
                    set :dark_image_url, nil
                    set :text_class, "text-8xl text-accent-400"
                    set :text, "♫"
                end

                override Components.Password do
                    set :toggler_class, "flex-none text-primary-600 px-2 first:pl-0 last:pr-0"
                end

                override Components.Password.Input do
                    set :field_class, "mt-4"
                    set :label_class, "block text-sm font-medium leading-6 text-zinc-800"
                    set :input_class, TunezWeb.CoreComponents.form_input_styles()

                    set :input_class_with_error, [
                        TunezWeb.CoreComponents.form_input_styles(),
                        "!border-error-400 focus:!border-error-600 focus:!ring-error-100"
                    ]

                    set :submit_class, [
                        "phx-submit-loading:opacity-75 my-4 py-3 px-5 text-sm",
                        "bg-primary-600 hover:bg-primary-700 text-white",
                        "rounded-lg font-medium leading-none cursor-pointer"
                    ]

                    set :error_ul, "mt-2 flex gap-2 text-sm leading-6 text-error-600"
                end

                override Components.MagicLink do
                    set :request_flash_text, "Check your email for a sign-in link!"
                end

                override Components.MagicLink.Input do
                    set :submit_class, [
                        "phx-submit-loading:opacity-75 my-8 mx-auto py-3 px-5 text-sm",
                        "bg-primary-600 hover:bg-primary-700 text-white",
                        "rounded-lg font-medium leading-none block cursor-pointer"
                    ]
                end

                override Components.Confirm.Input do
                    set :submit_class, [
                        "phx-submit-loading:opacity-75 my-8 mx-auto py-3 px-5 text-sm",
                        "bg-primary-600 hover:bg-primary-700 text-white",
                        "rounded-lg font-medium leading-none block cursor-pointer"
                    ]
                end

        Why Do Users Always Forget Their Passwords!?
            When we did the initial setup for AshAuthentication we did 2 things (senders) that will send emails for confirmation and password reset. SendNewUser ConfirmationEmail SendPasswordResetEmail.

            Phoenix apps come with Swoosh that is designed for sending emails. Each sender function does two things: body/1 (generates content) and send/3 that is for constructing and sending the email. There is going to need a email provider to send the emails. However during production you will need to have a place to hold those emails, it will be at http://localhost:4000/dev/mailbox. 

    Setting Up Magic Link Authentication
        Nowadays there is ways to setup some magic links that will just be sent to a user in order to just use the link to log-in. Ash has this in the box. Lets try the magic link method. With the command below it will do the following:
            Add a new magic_link authentication strategy to the Tunez.Accounts.User resource, lib/tunez/accounts/user.ex
            Add to new actions sign_in_with_magic_link and request_magic_link in Tunez.Accounts.User resource.
            Remove allow_nil? false, on the hashed_password in Tunez.Accounts.User resource.
            Add a new sender module responsible for generating the magic link email, in lib/tunez/accounts/user/senders/send_magic_link_email.ex
            mix ash_authentication.add_strategy magic_link

            Then as always migrate
            mix ash.migrate

        Boom We now have the 2 options.

        Debugging When Authentication Goes Wrong
            We might need to debug, with more robust error messages during dev. In order to do that we can head to /config/dev.exs and add this to the bottom.
                config :ash_authentication, debug_authentication_failures?: true

            Restart the server and head to the same magic link and you should see more within the error log, what is it telling you is that the link has already been used. Without the robust logging you might never be able to understand what is happening. 

        Can We Allow Authentication over Our API?
            We can even do all of this over API, First let's set up the command then we can start to lock down the paths over api.
                mix ash.extend Tunez.Accounts.User json_api

            This made is sure that we have some new actions and then we need to add in a new route with the action register_with_password action. Head to lib/tunez/accounts.ex
                defmodule Tunez.Accounts do
                    use Ash.Domain, otp_app: :tunez, extensions: [AshJsonApi.Domain]

                    json_api do
                        routes do
                        base_route "/users", Tunez.Accounts.User do
                            post(:register_with_password, route: "/register")
                        end
                        end
                    end

                    # ...
                end

            Okay so we have the new route and much more but at the time we can't use the API as there is too much locked down and we don't want people to change peoples password as the API could allow people to do that.