A few questions regarding Phoenix API authentication for a mobile app

Here comes a question about using Phoenix.Token and API authentication more generally.

I am dipping my toes in the water of native app development (using React Native … hopefully in the not-to-distant future LiveView Native is a viable alternative). I have a side project where a mobile app on each platform has become a must-have.

The app has pretty basic business requirements. A user should (1) be able to log in to their organisation, (2) subscribe to changes to a limit set of resources and (3) receive push notifications when the status of the resource changes. Pretty barebones. The user should remain logged in until they log out or uninstall the app from their phone.

I have been back and forth on the issue of authentication. The API surface is quite minimal, and the data exposed to the user is both read-only and low sensitivity in nature.

I would love to hear other people’s experience in setting up authentication for native apps. Here is my approach so far:

  • Passwordless authentication is used. The user enters their email into the app and is sent a magic link / login code.
  • The login code is validated and a Phoenix.Token created containing session_id (sessions are stored in the database). The token is signed with max_age: :infinity.
  • The token is returned in the response and stored in secure storage on the device.
  • Future API requests include the access token in the authorization header (i.e. as a bearer token).
  • Any incoming requests to /api/:resource are handled by a Plug. The token is validated and the session retreived from the database. The user_id is assigned to the conn.

The above solution has some obvious downsides:

  • Tokens are not refreshed. A Dockyard article names a BREACH attack as a possible attack vector.
  • For each request the session is retreived from the database. I don’t think is a huge issue.

And finally … here is some code:

# AppAuth module

def handle_login(:email, email) do
	with {:ok, staff} <- staff_exists?(email),
		 {:ok, login_code} <- LoginCode.create_login_code(staff),
		 {:ok, email} <- Notifier.deliver_login_code(staff, login_code.code) do
	  {:ok, email}
	else
	  error -> error
	end
end

def handle_login(:code, email, code, device_id) do
	with {:ok, %{staff_id: staff_id}} <- LoginCode.verify_login_code(email, code),
	       {:ok, session} <- Session.create_session(staff_id, device_id),
	       access_token <- AccessToken.generate_access_token(session.id) do
	   {:ok, access_token}
	else
	   error -> error
        end
end

# Plug

def call(conn, _opts) do
	with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
	       {:ok, session_id} = AccessToken.verify_access_token(token),
	       {:ok, %{staff_id: staff_id}} <- Session.get_session(session_id) do
	    assign(conn, :staff_id, staff_id)
	else
	    _ ->
		conn
		|> # ...
		|> halt()

	end
end

So to the question(s):

  • Is this secure (enough)? Any glaring misteps?
  • Downsides to not refreshing the token?
  • Downsides to looking up the session on each request? For context, I anticipating serving < 2000 users (but hopefully more!).

Thanks for reading!