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”):

It looks like you perhaps removed the bypass that ash authentication places at the top.

Hello @zachdaniel, and thank you for your response!

I just tried it that way, and unfortunately it didn’t solved my issue.

Inside Cesizen.Accounts.User:

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

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

    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      authorize_if always()
    end
  end

It just seems to add one more query before the failure logs:

[info] GET /api/v1/users
[debug] Processing with CesizenWeb.AshJsonApiRouter
  Parameters: %{}
  Pipelines: [:api]
[debug] QUERY OK source="tokens" db=2.4ms queue=0.1ms idle=817.0ms
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", "30u8or16d4sbcr5hp80002m2"]
↳ anonymous fn/5 in AshSql.AggregateQuery.add_single_aggs/5, at: lib/aggregate_query.ex:119
[debug] QUERY OK source="tokens" db=0.9ms idle=823.3ms
SELECT t0."subject", t0."purpose", t0."updated_at", t0."created_at", t0."expires_at", t0."extra_data", t0."jti" 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) ["30u8or16d4sbcr5hp80002m2", "user", ~U[2025-05-05 06:20:41.497526Z]]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:785
[debug] QUERY OK source="users" db=2.4ms idle=826.2ms
SELECT u0."id", u0."name", u0."role", u0."email", u0."hashed_password", u0."confirmed_at", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id"::uuid::uuid = $1::uuid::uuid) ["05556a4a-cf53-4c08-970d-842a207964da"]
↳ 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 24ms

Even while commenting the :read action policies, I have the same output. I have to explicitly define it as always() authorized to access my /users route (that calls the :read action).

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

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

    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      authorize_if always()
    end
  end

The bypass needs to go at the top. Please review the policies guide on the ash hexdocs :slight_smile:

1 Like

You also need the same bypass on the token resource.

2 Likes

Thank you for your feedbacks!

The good policy was already set in my token resource.

I already travelled the Ash Policies documentation and I think I understand it not so bad, but haven’t found the solution yet.

Thanks to the detailed logs, I can see the problem is that even if my user should be retrieved by AshAuthentication, it is not set as the actor.

[info] GET /api/v1/users
[debug] Processing with CesizenWeb.AshJsonApiRouter
  Parameters: %{}
  Pipelines: [:api]
[error] Successful authorization: Cesizen.Accounts.Token.revoked?


Policy Breakdown
unknown actor

  Bypass: AshAuthentication can interact with the token resource | 🌟:

    condition: AshAuthentication is performing this interaction

    authorize if: always true | ✓ | 🌟

  No one aside from AshAuthentication can interact with the tokens resource. | ⛔:

    condition: always true

    forbid if: always true | ✓ | ⛔


[error] Successful authorization: Cesizen.Accounts.Token.read


Policy Breakdown
unknown actor

  Bypass: AshAuthentication can interact with the token resource | 🌟:

    condition: AshAuthentication is performing this interaction

    authorize if: always true | ✓ | 🌟

  No one aside from AshAuthentication can interact with the tokens resource. | ⛔:

    condition: always true

    forbid if: always true | ✓ | ⛔


[debug] QUERY OK source="tokens" db=0.6ms queue=0.6ms idle=666.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", "30u8or16d4sbcr5hp80002m2"]
↳ anonymous fn/5 in AshSql.AggregateQuery.add_single_aggs/5, at: lib/aggregate_query.ex:119
[error] Successful authorization: Cesizen.Accounts.Token.get_token


Policy Breakdown
unknown actor

  Bypass: AshAuthentication can interact with the token resource | 🌟:

    condition: AshAuthentication is performing this interaction

    authorize if: always true | ✓ | 🌟

  No one aside from AshAuthentication can interact with the tokens resource. | ⛔:

    condition: always true

    forbid if: always true | ✓ | ⛔


[debug] QUERY OK source="tokens" db=2.8ms queue=0.6ms idle=674.1ms
SELECT t0."subject", t0."updated_at", t0."created_at", t0."jti", t0."expires_at", t0."purpose", t0."extra_data" 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) ["30u8or16d4sbcr5hp80002m2", "user", ~U[2025-05-05 17:40:13.995955Z]]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:785
[error] Successful authorization: Cesizen.Accounts.User.get_by_subject


Policy Breakdown
unknown actor

  Bypass: Policy | 🌟:

    condition: AshAuthentication is performing this interaction

    authorize if: always true | ✓ | 🌟


[debug] QUERY OK source="users" db=0.8ms queue=1.0ms idle=684.7ms
SELECT u0."id", u0."name", u0."role", u0."inserted_at", u0."updated_at", u0."email", u0."hashed_password", u0."confirmed_at" FROM "users" AS u0 WHERE (u0."id"::uuid::uuid = $1::uuid::uuid) ["05556a4a-cf53-4c08-970d-842a207964da"]
↳ 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
[error] Cesizen.Accounts.User.read


Policy Breakdown
unknown actor

  Policy | 🔎:

    condition: action == :read

    authorize if: AshAuthentication is performing this interaction | ✘ | 🔎    
    authorize if: actor is present | ✘ | 🔎

SAT Solver statement: 

 "action == :read" and
  (("action == :read" and
      ("AshAuthentication is performing this interaction" or "actor is present")) or
     not "action == :read")

The uuid is valid:

And I tried to play within the console successfully:

iex(15)> User |> Ash.Query.for_read(:read, %{}, actor: user) |> Ash.read!()
[debug] QUERY OK source="users" db=1.0ms idle=1350.5ms
SELECT u0."id", u0."name", u0."role", u0."updated_at", u0."confirmed_at", u0."email", u0."hashed_password", u0."inserted_at" FROM "users" AS u0 []
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:785
[
  #Cesizen.Accounts.User<
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: "05556a4a-cf53-4c08-970d-842a207964da",
    email: #Ash.CiString<"guillaume@cugnet.eu">,
    name: #Ash.CiString<"Guillaume">,
    role: :admin,
    inserted_at: ~U[2025-05-03 10:40:00.929558Z],
    updated_at: ~U[2025-05-03 10:40:00.929558Z],
    confirmed_at: ~U[2025-05-03 10:40:00.915721Z],
    aggregates: %{},
    calculations: %{},
    ...
  >,
  #Cesizen.Accounts.User<
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: "3c554576-0760-494c-9f10-d651d01d8c94",
    email: #Ash.CiString<"herve@proton.me">,
    name: #Ash.CiString<"Hervé">,
    role: :user,
    inserted_at: ~U[2025-05-03 10:40:01.200456Z],
    updated_at: ~U[2025-05-03 10:40:01.200456Z],
    confirmed_at: ~U[2025-05-03 10:40:01.200393Z],
    aggregates: %{},
    calculations: %{},
    ...
  >
]

Below is my commented Policies block, but now it is correct and allows AshAuthentication to proceed all its checks.

  policies do
    # bypass always() do
    #   # This apparently allows AshAuthentication to `:read`.
    #   authorize_if AshAuthentication.Checks.AshAuthenticationInteraction
    # end

    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      # This also allows AshAuthentication to `:read`.
      authorize_if always()
    end

    policy action(:read) do
      # This doesn’t work because the actor is not set.
      authorize_if actor_present()
      # The policy will return `:authorized` only here when `:read` is called
      # through the API, because apparently AshAuthentication failed to set the actor.
      # authorize_if always()
    end

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

So my problem comes from the fact the actor is not properly set. I don’t think the solution is in the Resource, or I’m lost. I’ll try to debug further within AshAuthentication code to see where the failure starts (just after the user has been retrieved from the DB I guess).

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

2 Likes

@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.6.3

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:

: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:

2 Likes