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