Guy14

Guy14

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 OP

@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? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews