Ash authentication on mobile

Hello,

I’m trying out the Ash framework for the first time. I am building an API for a mobile app and I want to implement user authentication.

We are in a first-party app and server configuration, so after reading this I’m wondering if the following could be enough:

  • An API endpoint accepting email/password and sending a token
  • An API endpoint using Ash Token features

It’s the first time I’m doing a mobile app so I am not sure. But to me it looks like I do not need to implement OAuth2 or OID Connect.

What do you think?

Thanks!

1 Like

Usually if there is mobile involved, there is the google/ios authentication involved. They basically send you a JWT token instead of username/password that you can check for validity against their public certificates.

2 Likes

If you can leverage oauth2 that would be ideal as ash_authentication has native support for that. :grinning:

Not that you need to, though. You can pretty easily create an endpoint that calls the sign_in_with_password action and extracts the token.

1 Like

I’m trying to understand if I need OAuth2 in my context. What would be the benefits when I am in control of both the app and server?

well, if you wanted to support social sign on, for example. If you want to provide username/password login, you definitely don’t need oauth2 for that. The suggestion isn’t for your application to be an oauth2 provider, that is definitely overkill (and its not something ash_authentication does for you).

1 Like

Yes thanks for the distinction. I see too many apps implementing both OAuth2 provider and clients for authentication and it never makes sense.

For social sign on the token I would get would be provided by, say, facebook. That token would allow me to fetch user data from facebook, or get an OpenID payload. But it is not a token I would use from the mobile app to connect to my API and fetch user data from my server app API. So I still need a custom token delivered by my server app, OAuth2 as a client does not help here. Am I correct ?

Correct. You’d want to implement the password strategy. That strategy will add all the actions that you need to your resources. I don’t think that a JSON endpoint is created for you for signing in, but if not it is something you should be able to do with a phoenix controller.

use Phoenix.Controller

def sign_in(conn, %{"username" => username, "password" => password}) do
  YourUserResource
  |> Ash.Query.for_read(:sign_in_with_password, %{username: username, password: password})
  |> YourApi.read_one()
  |> case do
    {:ok, user} ->
      conn |> put_status(200) |> json(%{token: user.__metadata__.token})
   {:error, error} ->
     # handle errors. You should get back an `Ash.Error.Forbidden` 
     # error with a nested error you can use to provide an error message
  end
end

Yes thank you, that is what I intended to do :slight_smile: So ok for now I’ll just have that special endpoint and an API with token, no OAuth2 needed.

Yep! That sounds right to me :slight_smile:

Hi again! I decided to tackle this tonight.

I am not sure I understand correctly. What exactly is user.__metadata__.token?

For now I’m just trying this from a test:

    email = "test@test.com"
    password = "supersecret"

    payload = %{
      "user" => %{
        "email" => email,
        "password" => password,
        "password_confirmation" => password
      }
    }

    conn = post(conn, ~p"/auth/user/password/register", payload)
    data = json_response(conn, 200))

But I do not understand why the token is present in the user in the metadata. I created a MyApp.Accounts.Token entity like the tutorial said, but now I understand that those are for server-side-only metadata about the tokens. Do they target that same token I found in my user struct metadata?

It seems so.

So now I would like the user to sign-up and then be able to sign-in from different machines and revoke their login from a specific machine.

In the AuthController I did this in the success callback:

    Token
    |> Ash.Changeset.for_create(:store_token,
      token: user.__metadata__.token,
      purpose: "hello world"
    )
    |> Accounts.create!()
    |> dbg()

I seems that the purpose is not relevant for Ash to work, though required. So I can add the machine based on the user agent or a custom header or field. And I can now write a basic test controller like this :smiley:

  def check(conn, params) do
    resp_payload =
      case conn.assigns do
        %{current_user: user} -> %{logged_in: true, as: user.email}
        _ -> %{logged_in: false}
      end

    conn
    |> put_status(200)
    |> json(resp_payload)
  end

And run my full test to see if it worked:

  test "a user can authenticate via API using email and password", %{conn: base_conn} do
    # Create a user from API

    email = "test@test.com"
    password = "supersecret"

    payload = %{
      "user" => %{
        "email" => email,
        "password" => password,
        "password_confirmation" => password
      }
    }

    conn = post(base_conn, ~p"/auth/user/password/register", payload)
    _data_signup = json_response(conn, 200)

    # Now sign in
    conn = post(base_conn, ~p"/auth/user/password/sign_in", payload)
    data_signin = json_response(conn, 200)

    # Try the API without the token
    conn = get(base_conn, ~p"/api/check")
    data_checkwo = json_response(conn, 200)
    assert false == data_checkwo["logged_in"]

    # Try with
    conn =
      base_conn
      |> Plug.Conn.put_req_header(
        "authorization",
        "Bearer #{Map.fetch!(data_signin, "bearer_token")}"
      )
      |> get(~p"/api/check")

    data_checkwith = json_response(conn, 200)
    assert true == data_checkwith["logged_in"]
    assert email == data_checkwith["as"]
  end

Now I realize that there is not really a question in my message :smiley: I’ll try to create another resource and have readable by the owner only.

I added this in my router:

  import AshAuthentication.Plug.Helpers

And the new plug here:


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

    # Ash
    plug :load_from_bearer
    plug :set_actor, :user
  end

But in the controller Ash.get_actor is always nil. I have seen that the plug only sets it in conn.private.ash.actor. So I guess I should write a custom plug to forbid some api routes to be accessed without authentication. Is that how you would do it? I do not need any public data on those routes.

Or would you rather just always set an actor on the action, even if it is nil, and let the policies reject the calls?

Thank you for reading, sorry for that unstructured post :smiley: it took me a couple hours to write because I implemented the code along.

Got a couple things:

The set_actor plug puts the actor in an idiomatic place on the conn. But it doesn’t call Ash.set_actor/1, that is an “opt-in” tool for storing the actor in the process dictionary.

What I would generally suggest (and will be default in Ash 3.0) is to set this in your api:

authorization do
  authorize :by_default
end

Then authorization will always be running unless you explicitly pass authorize?: false. Not passing an actor is equivalent to actor: nil, but in the default setup, not passing an actor is equivalent to authorize?: false.

If there is a “current actor” then I’d set it as the actor. If its an authentication action, then you can do things like authorize_unless actor_present() to only allow calling it without an actor. You could also add forbid_if always() to make it so that it can only be called with authorize?: false. That is a good way to make something internal only (because api clients and things like that can’t pass authorize?: false.

2 Likes

Thanks, I’m moving slowly but it works. I have set the policies to allow users to read their own inventories only.

I removed the default :create action on the inventory and defined it like this, with the relationships:

actions do
    defaults([:read, :update, :destroy])

    create :create do
      accept([:title])
      argument(:owner, :uuid, allow_nil?: false)
      change(manage_relationship(:owner, :owner, type: :append))
    end
end

  relationships do
    belongs_to :owner, User do
      api(Accounts)
      allow_nil?(false)
    end
  end

Because an inventory always need to belong to a user.

And now I can pass the user.id as :owner to my action, from the controller:

  def create(conn, params) do
    user = auth_actor(conn)

    params =
      params
      |> Map.delete("owner")
      |> Map.put(:owner, user.id)

    Inventory
    |> Ash.Changeset.for_create(:create, params, actor: user)
    |> Inventories.create()
    |> on_ok do
      inventory ->
        conn |> put_status(201) |> render("inventory.json", %{inventory: inventory})
    end
  end

  def show(conn, %{"id" => id}) do
    id
    |> Inventory.get_by_id(actor: auth_actor(conn))
    |> on_ok do
      inventory ->
        conn |> put_status(200) |> render("inventory.json", %{inventory: inventory})
    end
  end

Finally I can define my policies in the Inventory resource like this:

  policies do
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
    end

    policy action_type(:create) do
      authorize_if(always())
    end
  end

For it to work I had to add the following to my User resource:

  actions do
    defaults([:read])
  end

I guess it’s because the policy checker needs to read from the user resource. Which I do not understand because I pass the actor in Inventory.get_by_id(actor: auth_actor(conn)) and I believe that authorize_if(relates_to_actor_via(:owner)) should only compare the id from the inventory resource to that actor id. But I believe it is because the :owner relationship could point to a FK that is not on the primary key of the user, and that field could not be loaded by default in the actor.

@zachdaniel I’ll answer to this topic as a discover log of your framework. Do not feel obligated to answer. I hope it can help some people following the same path. Though I have a question: with that setup, I get a Ash.Error.Query.NotFound error when the actor is not the actual owner. Is it possible to get a policy error? Because I want users to be able to authorize other users to read and/or change an inventory, via a new resource (called Allowance or something). So I really need to know from the client app if the inventory exists or not when not authorized.

Thank you a lot for your support so far.

Edit: the formatting has a lot of parentheses because for some reason my VSCode adds them. It looks like ElixirLS is always compiling. I found a github issue about this. At some point I stopped fighting and learnt to love the parens :smiley:

Edit: I tried with this but nobody can access the resource:

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
    end

But I do get a Forbidden error though.

I modified the relates_to_actor_via function to make it print some debug and raise. It does not seem to be called when used by forbid_unless!

@zachdaniel I found this example I’ll try that :slight_smile:

Edit:

Well I tried different combinations but it will not work

    # Results in Not Found
    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
      authorize_if(always())
    end

    # Results in Not Found
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
      forbid_if(always())
    end

    # Results in Not Found
    policy action_type(:read) do
      authorize_if(relates_to_actor_via(:owner))
    end

I also tried a custom simple check but it does not receive a changeset like in the Discord example. I get a query though, but with that I would end up building a filter too.

I feel like as the action is defined with get?(true) it should allow to run filters after the entity has been pulled from the database instead of building a filter.

Have you read through the policies guide? Ash Framework

Yes but I could not understand how to not use a filter check, except when using a custom check but that check receives a query and not the entity, so it will be a custom fetch in DB and check … so why not just check in the controller.

Might also be worth pointing out that we have a JSON:API extension that will do all of the json api work for you. Ash Framework

So there are a couple things I think you need to know.

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
    end

This policy will always fail. A policy is read from top to bottom, and something in the policy must set the status to authorized. If nothing does, then we assume forbidden.

    policy action_type(:read) do
      forbid_unless(relates_to_actor_via(:owner))
      authorize_if always()
    end

would do it, but what you probably want instead would be

    policy action_type(:read) do
      authorize_if relates_to_actor_via(:owner)
    end

Returning a Forbidden when filter checks don’t match actually a security issue, which is why we favor filter checks wherever possible. For security reasons, we hide from the outside world that the thing you tried to read exists. i.e an endpoint like /users?username=foo returning Forbidden when there is a user with that username, and Not Found when there isn’t.

I think in your case the custom check may be the way to go, and/or a manual/basic action or one of the other escape hatches that we offer for running custom behavior.

Generic Actions (more at the bottom of the page): Ash Framework

Manual Actions:

You could also model it as two resources:
one resource that everyone can see, and one that only some of them can see. Then wether or not the user can see the related thing could be a calculation: Ash Framework

For the simplest case, I think you might even just do something like this and bypass policies:


actions do
  defaults [:read]

  action :get_inventory_by_id do
    get? true
    argument :id, :uuid, allow_nil?: false
    filter expr(id == ^arg(:id)

    prepare fn query, _ -> 
      Ash.Query.after_action(query, fn _query, results -> 
        if YourApi.can?(__MODULE__, :read, data: results) do
          {:ok, results}
        else
          {:error, Ash.Error.Forbidden.exception()}
        end
      end)
    end
  end
end

policies do
  # let anyone run this action
  policy action(:get_inventory_by_id) do
    authorize_if always()
  end
end

See the actions guide for more information on lifecycle hooks around actions (i.e Ash.Query.after_action and prepare).

If you are going to be handwriting your controllers, I’d also suggest looking into the code_interface. Ash Framework

For that example action above, you could do:

code_interface do
  define_for Inventories
  define :get_by_id, args: [:id]
end

And then in your controller you could then do

Inventory.get_by_id(id)

Yes I have the custom actions from the tutorial! That’s neat.

I understand why policies would fake a not-found result, but I do not think it should be a default, or at least an opt-out would be great.

So I have this:

def show(conn, %{"id" => id}) do
    id
    |> Inventory.get_by_id(actor: auth_actor(conn), verbose?: true, authorize?: true)
    |> on_ok do
      inventory ->
        conn |> put_status(200) |> render("inventory.json", %{inventory: inventory})
    end
  end

And tried with this:

  authorization do
    authorize :always # also tried :by_default
  end

And this:

 actions do
    defaults([:read, :update, :destroy])

    read :by_id do
      argument(:id, :uuid, allow_nil?: false)
      get?(true)
      filter(expr(id == ^arg(:id)))

      prepare(fn query, ctx ->
        Ash.Query.after_action(query, fn query, results ->
          if Inventories.can?(query, ctx.actor, data: results, authorize?: true) do
            {:ok, results}
          else
            {:error, Ash.Error.Forbidden.exception("nope")}
          end
        end)
      end)
    end

I can see this log:

23:27:41.836 request_id=F1wN6pBOa0NzB0wAAAUB [debug] process MyApp.Inventories.Inventory.by_id: 1 Skipping check due to `authorize?: false`

And the check always returns true, it is not forbidding anyone.

I’ll just handle this in the controller for now, but thank you a lot for the support, it’s nice to try a new framework!