NFIBrokerage / slipstream

A slick WebSocket client for Phoenix Channels
https://hex.pm/packages/slipstream
Apache License 2.0
155 stars 18 forks source link

How to pass params? #39

Closed treeman closed 3 years ago

treeman commented 3 years ago

I'm trying out this library to create a client that connects to a pretty standard Phoenix channel on the server. I'd like to use token based authentication, as shown in the Phoenix docs. This is how it looks on the server:

  def connect(%{"token" => token}, socket, _connect_info) do
    case Tokens.authenticate_token(token) do
      ...

And the question is: how do I pass the token from the client? There's no params option in the configuration and I don't know how headers/mint_opts can be used here.

the-mikedavis commented 3 years ago

:wave: hello!

I believe you should be able to pass params to c:Phoenix.Socket.connect/3 by adding URL query-params to the URL you're using to connect.

For example we use this trick in the test cases to simulate c:connect/3 returning :error:

# test/support/lib/slipstream_web/channels/user_socket.ex
  ..

  @impl Phoenix.Socket
  def connect(%{"reject" => "yes"}, _socket, _connect_info) do
    :error
  end

  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  ..

And the %{"reject" => "yes"} params are passed as URL query-params in the :uri in configuration

# test/slipstream/integration_test.exs
  ..

  describe "given c:Phoenix.Socket.connect/3 returns :error" do
    setup do
      [config: [uri: "ws://localhost:4001/socket/websocket?reject=yes"]]
    end

    test "the socket is disconnected with :upgrade_failure reason", c do
      import Slipstream

      assert {:error, {:upgrade_failure, %{status_code: 403}}} =
               c.config
               |> connect!()
               |> await_connect(15_000)
    end
  end

  ..

So you should be able to pass the token by URL-encoding it in the :uri configuration option

Slipstream.connect(uri: "https://example.org/socket/websocket?token=asdfasdfasdf")
treeman commented 3 years ago

Wonderful, thank you!

I wonder, if params are sent over the uri isn't there a risk of a man-in-the-middle that can see and copy the token?

the-mikedavis commented 3 years ago

I think you're right, URL params are generally discouraged for any sort of security mechanisms these days. If I remember correctly, one vector had something to do with sending traffic through a proxy and the proxy having knowledge of tokens. Not to mention that if you have any observability tooling or logging keeping track of requests, the tokens will probably make their way into the observability data or log stash which is not great. I known GitHub will complain if you try to send an API token through URL params. I'm actually kinda surprised the Phoenix.Socket docs still recommend doing token verification by passing tokens as URL params :thinking:

One way around using URL params would be to do token checking in the [c:Phoenix.Channel.join/3]( ) callback. In fact those docs have an example of an implementation that checks authorization. You would pass the token with Slipstream.join(socket, topic, %{"token" => my_token}), which would send the token in a WebSocket frame instead of a URL param. In order to use this for authorization, you need to allow all WebSocket connections in the c:Phoenix.Socket.connect/3 callback, which would allow a hypothetical attacker to connect many WebSocket clients to the server without authorization. I imagine WebSocket clients just sitting on a connection are not very expensive, but that could potentially add up to a DDoS.

Another approach more in-line with what GitHub recommends in that blog post is to pass authorization as a header, such as with Bearer token authorization. It looks like this is not a super small change because headers are not exposed by default in c:Phoenix.Socket.connect/3.

First you need to configure the Phoenix.Endpoint.socket/3 to pass :x_headers in the connect_info argument:

defmodule MyAppWeb.Endpoint do
  ..

  socket("/socket", MyAppWeb.UserSocket,
    websocket: [connect_info: [:x_headers]],
    longpoll: false
  )

  ..
end

And then you can pass the token in an x-header (any header beginning with "x-", case insensitive)

Slipstream.connect(uri: my_uri, headers: [{"x-my-token", my_token}])

Then the token will be available in connect_info:

defmodule MyAppWeb.UserSocket do
  ..

  def connect(_params, socket, %{x_headers: x_headers}) do
    with {:ok, proposed_token} <-
           Enum.find_value(x_headers, :error, fn {k, v} -> k == "x-my-token" && {:ok, v} end),
         :ok <- Tokens.authenticate_token(proposed_token) do
      {:ok, socket}
    else
      _ ->
        :error
    end
  end

  ..
end
treeman commented 3 years ago

Thanks again for a great reply, your code works nicely for me.

I did indeed follow the Socket/Token docs. I opened an issue in the Phoenix repo for this.