Guy14

Guy14

Setting up AshAuthentication with AshJson and Policies for an API

Hello :slight_smile:

Context

After a while, I’m getting hands on Ash and Elixir again (for a school project, and in a few months for a bigger project).

I’m very happy of how it’s designed and how easy it is to create API with generated documentation with SwaggerUi out-of-the box, and with generated routes that follows the JsonAPI standard. This is really incredible!

I’m now getting my hands on the Authentication / Authorization part of it, and it appears that:

  • setting up the Authentication (with email / password) was quite easy with AshAuthentication
  • setting Authorizations through policies look very straightforward too

But struggle on a behaviour I don’t understand well.

Description

When calling a route that implements this very simple policy, my User is never revrieved from (valid) bearer token I’m passing in the request:

policy action(:read) do
      authorize_if actor_present()
    end

Here are my logs:

[info] GET /api/v1/users
[debug] Processing with CesizenWeb.AshJsonApiRouter
  Parameters: %{}
  Pipelines: [:api]
[debug] QUERY OK source="tokens" db=0.6ms idle=247.4ms
SELECT TRUE FROM "tokens" AS t0 WHERE (t0."purpose"::text::text = $1::text::text) AND (t0."jti"::text::text = $2::text::text) LIMIT 1 ["revocation", "30u2ch5fnufa9h76vo0034a2"]
↳ anonymous fn/5 in AshSql.AggregateQuery.add_single_aggs/5, at: lib/aggregate_query.ex:119
[debug] QUERY OK source="tokens" db=0.4ms idle=249.8ms
SELECT t0."subject", t0."created_at", t0."expires_at", t0."extra_data", t0."jti", t0."purpose", t0."updated_at" FROM "tokens" AS t0 WHERE (t0."jti"::text::text = $1::text::text) AND (t0."purpose"::text::text = $2::text::text) AND (t0."expires_at"::timestamp::timestamp > $3::timestamp::timestamp) ["30u2ch5fnufa9h76vo0034a2", "user", ~U[2025-05-04 10:00:09.794338Z]]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:785
[debug] Auth failure - Activity: {nil, nil}
[debug] Failure reason: :not_found
[info] Sent 403 in 6ms

I’m pretty sure that my token is valid, you can see the jit in the logs corresponds to the last line in my tokens table:

And it refers to a valid user (see the uuid corresponds to the first entry in my users table):

In the API response I received:

{
  "errors": [
    {
      "code": "forbidden",
      "id": "5c8058ba-ec08-4fa6-84a6-8fc5f6c7606c",
      "status": "403",
      "title": "Forbidden",
      "detail": "forbidden"
    }
  ],
  "jsonapi": {
    "version": "1.0"
  }
}

My current implementation

Below is a part of my Cesizen.Router module:

defmodule CesizenWeb.Router do
  use CesizenWeb, :router

  import Cesizen.AuthPlug

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {CesizenWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug :load_from_bearer
    plug Cesizen.AuthPlug
  end

  scope "/api/v1" do
    pipe_through [:api]

    forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
      path: "/api/v1/open_api",
      default_model_expand_depth: 4

    forward "/", CesizenWeb.AshJsonApiRouter
  end

[…]

end

Below is my Cesizen.AuthPlug module:

defmodule Cesizen.AuthPlug do
  use AshAuthentication.Plug,
    otp_app: :cesizen,
    domain: :user

  require Logger

  def handle_success(conn, activity, user, token) do
    Logger.debug("Auth success - Activity: #{inspect(activity)}")
    Logger.debug("User: #{inspect(user)}")
    Logger.debug("Token: #{inspect(token)}")

    if is_api_request?(conn) do
      conn
      |> assign(:authentication_success, true)
      |> assign(:authentication_token, token)
      |> assign(:current_user, user)
    else
      conn
      |> store_in_session(user)
      |> send_resp(
        200,
        EEx.eval_string(
          """
          <h2>Welcome back <%= @user.email %></h2>
          """,
          user: user
        )
      )
    end
  end

  def handle_failure(conn, activity, reason) do
    Logger.debug("Auth failure - Activity: #{inspect(activity)}")
    Logger.debug("Failure reason: #{inspect(reason)}")

    if is_api_request?(conn) do
      conn
      |> assign(:authentication_success, false)
      |> assign(:authentication_error, reason)
    else
      conn
      |> send_resp(401, "<h2>Incorrect email or password</h2>")
    end
  end

  defp is_api_request?(conn) do
    accept = get_req_header(conn, "accept")
    "application/json" in accept || "application/vnd.api+json" in accept
  end
end

Below is my Cesizen.Accounts domain:

alias Cesizen.Accounts.User

defmodule Cesizen.Accounts do
  use Ash.Domain, otp_app: :cesizen, extensions: [AshJsonApi.Domain]

  resources do
    resource User do
      define :create_user, action: :create
      define :list_users, action: :read
      define :update_user, action: :update
      define :delete_user, action: :destroy
    end

    resource Cesizen.Accounts.Token
  end
end

And below is my simplified Cesizen.Accounts.User Resource:

defmodule Cesizen.Accounts.User do
  use Ash.Resource,
    otp_app: :cesizen,
    domain: Cesizen.Accounts,
    extensions: [AshAuthentication, AshJsonApi.Resource],
    authorizers: [Ash.Policy.Authorizer],
    data_layer: AshPostgres.DataLayer

  json_api do
    type "user"

    routes do
      base "/users"

      post :sign_in_with_password do
        route "/login"

        metadata fn _subject, user, _request ->
          %{token: user.__metadata__.token}
        end
      end

      […]
    end
  end

  postgres do
    table "users"
    repo Cesizen.Repo
  end

  attributes do
    uuid_primary_key :id

    attribute :email, :ci_string, allow_nil?: false, public?: true
    attribute :name, :ci_string, allow_nil?: false, public?: true

    attribute :role, :atom do
      allow_nil? false
      constraints one_of: [:user, :admin]
      default :user
      public? true
    end

    timestamps()

    attribute :hashed_password, :string do
      allow_nil? false
      sensitive? true
    end

    attribute :confirmed_at, :utc_datetime_usec
  end

  identities do
    identity :unique_email, [:email]
  end

  code_interface do
    define :login, action: :sign_in_with_password
  end

  actions do
    defaults [:read, :destroy, update: :*]

    read :sign_in_with_password do
      description "Attempt to sign in using a email and password."
      get? true

      argument :email, :ci_string do
        description "The email to use for retrieving the user."
        allow_nil? false
      end

      argument :password, :string do
        description "The password to check for the matching user."
        allow_nil? false
        sensitive? true
      end

      # validates the provided email and password and generates a token
      prepare AshAuthentication.Strategy.Password.SignInPreparation

      metadata :token, :string do
        description "A JWT that can be used to authenticate the user."
        allow_nil? false
      end
    end

    create :register_with_password do
      description "Register a new user with a email and password."

      argument :email, :ci_string do
        allow_nil? false
      end

      argument :password, :string do
        description "The proposed password for the user, in plain text."
        allow_nil? false
        constraints min_length: 8
        sensitive? true
      end

      argument :password_confirmation, :string do
        description "The proposed password for the user (again), in plain text."
        allow_nil? false
        sensitive? true
      end

      # Sets the email from the argument
      change set_attribute(:email, arg(:email))

      # Hashes the provided password
      change AshAuthentication.Strategy.Password.HashPasswordChange

      # Generates an authentication token for the user
      change AshAuthentication.GenerateTokenChange

      # validates that the password matches the confirmation
      validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation

      metadata :token, :string do
        description "A JWT that can be used to authenticate the user."
        allow_nil? false
      end
    end
  end

  authentication do
    tokens do
      enabled? true
      token_resource Cesizen.Accounts.Token
      store_all_tokens? true
      require_token_presence_for_authentication? true

      signing_secret fn _, _ ->
        Application.fetch_env(:cesizen, :token_signing_secret)
      end

      add_ons do
        log_out_everywhere do
          apply_on_password_change? true
        end
      end
    end

    strategies do
      password :password do
        identity_field :email
        # sign_in_tokens_enabled? true

        resettable do
          sender Cesizen.Accounts.User.Senders.SendPasswordResetEmail
          # these configurations will be the default in a future release
          password_reset_action_name :reset_password_with_token
          request_password_reset_action_name :request_password_reset_token
        end
      end
    end

    add_ons do
      confirmation :confirm_new_user do
        monitor_fields [:email]
        confirm_on_create? true
        confirm_on_update? false
        require_interaction? true
        confirmed_at_field :confirmed_at

        auto_confirm_actions [
          :create,
          :sign_in_with_magic_link,
          :reset_password_with_token
        ]

        sender Cesizen.Accounts.User.Senders.SendNewUserConfirmationEmail
      end
    end
  end

  policies do
    policy action(:read) do
      authorize_if actor_present()
    end

    policy action(:sign_in_with_password) do
      authorize_if always()
    end
  end
end

Additional documentation

All my codebase is also available on this public repo (branch “need-help-with-ash-policies”):
https://github.com/gcugnet/cesizen/tree/need-help-with-ash-policies

Marked As Solved

Guy14

Guy14

@zachdaniel thank you again for you answer.

I already added the plug :load_from_bearer.

But it doesn’t set the :actor to the connexion, and I missed to complete it with the plug :set_actor, :user.

I played with the AshAuthentication.Plug.Helpers functions in a homemade plug like that to understand what was done:

CesizenWeb.Router

  import AshAuthentication.Plug.Helpers

  def my_plug(conn, _opts) do
    conn
    |> AshAuthentication.Plug.Helpers.retrieve_from_bearer(conn, :cesizen)
    |> AshAuthentication.Plug.Helpers.set_actor(conn, :user)
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug :my_plug
  end

By reading the library code and with a few dbg() on the connexion, I understood that:

  • retrieve_from_bearer/2 sets the current_user (or current_<auth resource>) to the connexion assigns.
  • set_actor/2 sets the actor to the connexion assigns.

So it finally works like that also:

  import AshAuthentication.Plug.Helpers

  pipeline :api do
    plug :accepts, ["json"]
    plug :retrieve_from_bearer, :cesizen
    plug :set_actor, :user
  end

I wanted to propose a fix to this doc, by adding import AshAuthentication.Plug.Helpers and plug :set_actor, :user, but apparently it has already been done on the Github project (but not on the current hexdocs page): Get Started — ash_authentication_phoenix v2.17.1

By doing it with igniter for AshAuthenticationPhoenix, everything is correctly setup (I should have followed that).
But for now, I don’t need all the other stuff that goes with AshAuthenticationPhoenix, simple AshAuthentication already seems functional for my current needs.

I think a little word on the AshAuthentication Get Started doc about the :actor that is setup on the connexion assigns (and how it works with Policies) would be a nice add, but I don’t know precisely where to put it.

Thank you for the great work on this amazing framework :slight_smile:

Also Liked

zachdaniel

zachdaniel

Creator of Ash

You also need the same bypass on the token resource.

zachdaniel

zachdaniel

Creator of Ash

@Guy14 looking at your code again, I think you may just be missing plug :load_from_bearer in your :api pipeline.

zachdaniel

zachdaniel

Creator of Ash

:partying_face: Glad you got it working. PRs welcome to the docs, especially for things that would have helped you avoid this issue :person_bowing:

Where Next?

Popular in Questions Top

Kurisu
For example for a current url like http://localhost:4000/cosmetic/products?_utf8=✓&amp;query=perfume&amp;page=2, I would like to get: ...
New
skosch
To my knowledge, put_in, Map.update etc. all have the one limitation of not automatically creating intermediate keys when needed (for exa...
New
JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
johnnyicon
Hi all, I’ve just started learning Elixir and Phoenix Framework, so please pardon my n00bness at this stage. I’m trying to use Postgres...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New
earth10
Hi, I’m just starting to build a side-project with Elixir and Phoenix and doing some basic test with Elixir alone. What strikes me is th...
New
ycv005
I have followed this StackOverflow post to install the specific version of Erlang. And When I am running mix ecto.setup then getting fol...
New
JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
openscript
Hello! Sorry for this astonishing simple question, but I’m really stuck. I try to set up the intellij-elixir plugin, but I don’t know ho...
New
PeterCarter
There are pre-rolled solutions for other frameworks that do work. However, Phoenix does not seem to have these. Have people had good expe...
New

Other popular topics Top

senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
Nvim
Anybody knows a comprehensive comparison of Django and Phoenix, thanks for the help. Where are they similar? Where do they differ the m...
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID&lt;0.412.0&gt; terminating ** (Postgrex.Error) FATAL...
New
JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 53690 245
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
SoCreat
i’m a new one to elixir which editor can i use vs code? or atom? Thanks! :smiley:
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
sergio
Kind of like when jquery came out, it was super necessary. Existing drag and drop libraries have a bunch of baggage to support old browse...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement