Correct way to login users when they attempt to start a websocket in Plug.Router? URL parameters vs. sec-websocket-protocol field?

There are generally two basic ways for a client to send a user:pass combo or token on attempting to start a websocket with an Elixir server.

I am testing initially with Javascript client just for the sake of it and simplicity.

1) URL Parameters (not working):

In theory a Javascript client could do something like this:

const username = 'yourUsername'; const password = 'yourPassword';
const encodedCredentials = btoa('${username}:${password}');
const sock = new WebSocket('ws://localhost:5001/websocket?auth=${encodedCredentials}');

Or supersimple for testing, just run via F12 in web browser console and check Elixir for response:

sock  = new WebSocket("ws://localhost:5001/websocket?auth=whatever");

In theory the websocket it set up like this should be able to parse this like:

    get "/websocket" do

        #check headers or params for token or user pass
        IO.inspect(conn.req_headers)
        IO.inspect(conn.params)
        IO.inspect(conn.path_params)
        IO.inspect(conn.query_params)
        IO.puts("FINISHED INSPECTING HEADERS");
        #try to login with this info and if successful proceed:

        conn
        |> WebSockAdapter.upgrade(My.UserWebSock, [], timeout: 60_000)
        |> halt()
    end

However, this doesn’t seem to find the query parameters. I can see my Elixir gets the headers in full, but none of IO.inspect(conn.params) IO.inspect(conn.path_params) or IO.inspect(conn.query_params) show the added auth info. They are all inspected as empty maps %{}

Why is this? Any solution for this approach? Why might the URL parameters not be going through?

2) Use SEC-WEBSOCKET-PROTOCOL field:

The other approach is you can use the free field of the WebSocket protocol like this:

const base64auth = btoa("user:pass");
const ws  = new WebSocket("ws://localhost:5001/websocket",  ["userpass", base64auth])

and/or

const base64token = btoa(token);
const ws  = new WebSocket("ws://localhost:5001/websocket",  ["token", base64token])

This free field takes a list of strings and shows up in Elixir easily under IO.inspect(conn.headers) so this at least works. You can then just have a case for checking if first entry is “userpass” or “token” to handle them differently on the Router there.

This at least works. In one way it is better also as the user’s system (if web browser) won’t store any sensitive info in the browser history (as it is not part of the url).

Thoughts?

3) Bad option not worth discussing

(The third option is to start the websocket freely and then request the token/auth after but this is resource foolish in case of attack so not worth discussing further in my opinion.)

What do you think? How do you usually do this? Why is option #1 not working? Thanks for any help.

As a follow up question on method 2, which seems to be the only legitimate option, one can simulate this with console in Web browser F12 as follows:

Javascript

const base64val = btoa("[{userpass, pass}, {token, tokenstring}]")
sock = new WebSocket("ws://localhost:5001/websocket", encodeURIComponent(base64val)); 

Elixir

The on the Elixir side we have:

get "/websocket" do

        IO.inspect(conn.req_headers) 

        req_headers = conn.req_headers 
        {_sec_websocket_protocol, auth_string_list } = Enum.find(req_headers, fn x ->
            {term_a, _term_b} = x;
            term_a == "sec-websocket-protocol"
        end)

        IO.inspect(Base.decode64(URI.decode(auth_string_list)))

As a result we get precisely:

{:ok, "[{userpass, pass}, {token, tokenstring}]"}

But then we need to parse the string value "[{userpass, pass}, {token, tokenstring}]" into a list or something to get the values out in a useful way.

ChatGPT says:

To parse a string like "[{userpass, pass}, {token, tokenstring}]" into a list in Elixir, you can use Code.eval_string/1, which evaluates the string as Elixir code. However, you need to be careful with the input to ensure it’s safe. Here’s how you can do it:

string = "[{userpass, pass}, {token, tokenstring}]"

# Parse the string into a list
parsed_list = Code.eval_string(string) |> elem(0)
IO.inspect(parsed_list)  # Outputs: [userpass: pass, token: tokenstring]

Safety: Using Code.eval_string/1 can be dangerous if you’re evaluating untrusted input, as it could execute arbitrary code. Make sure the input is controlled and safe.

Atoms: In the resulting list, if you want to keep the keys as atoms, you might want to convert them explicitly, as they will be in the format of userpass and token. You could achieve this by using a mapping function after parsing:

string = "[{userpass, pass}, {token, tokenstring}]"
parsed_list =
  string
  |> Code.eval_string()
  |> elem(0)
  |> Enum.map(fn {key, value} -> {String.to_atom(to_string(key)), value} end)

IO.inspect(parsed_list)  # Outputs: [userpass: pass, token: tokenstring]

This method gives you a list of tuples with the keys as atoms.

So the point is they are saying to use Code.eval_string but also warning that an attacker could use this to run scripts on our systems.

What might be a safe or reasonable method to transmit and decode the data in the sec-websocket-protocol field?

Thanks again for any thoughts.

Edit I worked out a custom way of passing in the data I needed into this sec-websocket-protocol field and decoding it safely without needing Code.eval_string. So perhaps that’s that. It’s just funny we have such simple “Bearer” and “Basic” auth headers in HTTP but no obvious convention for token/user/pass storage in websockets. :man_shrugging: