Guy14
Setting up AshAuthentication with AshJson and Policies for an API
Hello ![]()
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
@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/2sets thecurrent_user(orcurrent_<auth resource>) to the connexion assigns.set_actor/2sets theactorto 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 ![]()
Also Liked
zachdaniel
You also need the same bypass on the token resource.
zachdaniel
zachdaniel
Glad you got it working. PRs welcome to the docs, especially for things that would have helped you avoid this issue ![]()
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance












