Sven Gehring's Blog

I write about software, engineering and stupidly fun side projects.

Elixir: Testing protected Phoenix controllers

2017-10-25 5 min read programming Sven Gehring

Testing protected endpoints in Phoenix controllers is a topic that sparks confusion - at best, and controversy at worst - amongst a surprising lot of people. When using Guardian or other pluggable ways of authorizing requests, this behaviour has to be taken into consideration for controller tests.

Multiple pull requests in the Guardian repository were working towards a solution for this, Guardian Backdoor, which has now been moved into its own repository.

While this will certainly solve this issue for Guardian users in the future, let’s explore a simple, fast approach for authorization bypassing that can be used with any kind of plug based authentication pipeline.

Disassembling our authorization plug

In a very simple authorization plug, there’s usually not too much complexity going on. The provided means of authorization, usually an authorization header, is fetched from the request, its content is verified and the request is either permitted or rejected. The key point to note here is that this function plug is only directly responsible for fetching a user id from a given session token, the conn and the assigned user id is then passed on to another function for further processing.

The function plug below demonstrates a simple implementation. The auth token is fetched from the request via get_req_header/2 and verified using a custom function Token.verify_user_for_token/1. If the token is valid, the fetched information is passed on to another function conn_assign_user_details/2, which further processes it. If the token is invalid, send_unauthorized_if_not_faked/1 will be called.

1
2
3
4
5
6
7
8
def authenticate_user_token(conn, _opts) do
  with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
       {:ok, %{:id => user_id}} <- Token.verify_user_for_token(token),
    conn_assign_user_details(conn, user_id)
  else
    _ -> send_unauthorized_if_not_faked(conn)
  end
end

Hold on, if not faked? What’s that supposed to mean?!

To fake or not to fake

By defining different implementations for send_unauthorized_if_not_faked/1 depending on our app’s environment, we can effectively wrap sending of the error response in :prod / :dev, while evaluating our fake test data in the :test environment. This way, we can guarantee that none of our purposefully implemented backdoor code can leak into production code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if @env == :test do
  defp send_unauthorized_if_not_faked(conn) do
    # Implement testing backdoor
    conn
  end
else
  defp send_unauthorized_if_not_faked(conn) do
    send_unauthorized_response(conn)
  end
end

With these two functions, the foundation for our backdoor is complete. We have effectively implemented a way to inject code into our plug pipeline that will only ever be executed in the :test environment. So, let’s head on to the actual fake user implementation. For my implementation, I chose to blatantly plagiarize a concept used in the original Guardian Backdoor, by using query parameters for faking user authorization.

Try the backdoor!

Let’s quickly break down the expected behaviour before presenting a huge block of implementation.

  • A request is received normally and declined due to not containing any auth information
  • If running in :test env, instead of simply sending back 403 and halting the pipeline, a fake user is evaluated
  • For this, the query parameter as_user is evaluated (eg. ?as_user=1 fakes user with ID 1)
  • If a fake user is found, its ID is assigned to the conn in the same way the auth plug would assign a valid user id
  • The request then continues through the pipeline as it normally would
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@spec send_unauthorized_if_not_faked(Plug.Conn.t) :: Plug.Conn.t
if @env == :test do
  defp send_unauthorized_if_not_faked(conn) do
    conn |> append_testing_backdoor()
  end
else
  defp send_unauthorized_if_not_faked(conn) do
    conn |> send_unauthorized_response()
  end
end

if @env == :test do
  @spec append_testing_backdoor(Plug.Conn.t) :: Plug.Conn.t
  defp append_testing_backdoor(conn) do
    case Map.get(conn.query_params, "as_user") do
      nil -> conn |> send_unauthorized_response()
      as_user_id -> conn |> conn_assign_user_details(as_user_id)
    end
  end
end

Test helpers to the rescue

This is all good and well, I hear you say, but I don’t exactly feel like appending ?as_user=n to all of my tests. Well, neither do I, so let’s wrap this up by adding some test helpers that allow us to add the query parameter in a more clean way. The test helper below generates the query parameter from a passed user struct.

def with_user(user), do: "?as_user=#{user.id}"

Through the backdoor!

With our test helper in place and imported either in our test module or in all test modules by adding it to the conn_case test support, we can now request an endpoint as a faked user with ease. Phoenix automatically generates a helper function (yourcontrollername_path/3) for using in your tests, which returns the request path. Using string concatenation, we can simply append our fake user data. In this example I am using ex_machina for inserting the user fixture but that is obviously up to you.

1
2
3
4
5
6
test "accessing a protected route" do
  user = insert(:user, [username: "Sven"])
  conn = conn |> get(user_path(conn, :show, "self") <> with_user(user))

  # assert results
end

Similarly, I implemented a with_role/1 helper that allows simulating a request with a certain permission level by generating a user fixture with the given role in the test helper and returning with_user/1 for that user. This approach open a lot of possibilities for the little time it takes to implement.

comments powered by Disqus