Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions assets/js/phoenix/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {

export default class Ajax {

static request(method, endPoint, accept, body, timeout, ontimeout, callback){
static request(method, endPoint, headers, body, timeout, ontimeout, callback){
if(global.XDomainRequest){
let req = new global.XDomainRequest() // IE8, IE9
return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback)
} else {
let req = new global.XMLHttpRequest() // IE7+, Firefox, Chrome, Opera, Safari
return this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback)
return this.xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback)
}
}

Expand All @@ -31,10 +31,12 @@ export default class Ajax {
return req
}

static xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback){
static xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback){
req.open(method, endPoint, true)
req.timeout = timeout
req.setRequestHeader("Content-Type", accept)
for (let [key, value] of Object.entries(headers)) {
req.setRequestHeader(key, value)
}
req.onerror = () => callback && callback(null)
req.onreadystatechange = () => {
if(req.readyState === XHR_STATES.complete && callback){
Expand Down
17 changes: 13 additions & 4 deletions assets/js/phoenix/longpoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ let arrayBufferToBase64 = (buffer) => {

export default class LongPoll {

constructor(endPoint){
constructor(endPoint, protocols){
// we only support subprotocols for authToken
// ["phoenix", "base64url.bearer.authorization.phx.BASE64_ENCODED_TOKEN"]
if (protocols.length === 2 && protocols[1].startsWith("base64url.bearer.authorization.phx.")) {
this.authToken = atob(protocols[1].slice("base64url.bearer.authorization.phx.".length))
}
this.endPoint = null
this.token = null
this.skipHeartbeat = true
Expand Down Expand Up @@ -58,7 +63,11 @@ export default class LongPoll {
isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting }

poll(){
this.ajax("GET", "application/json", null, () => this.ontimeout(), resp => {
const headers = {"Accept": "application/json"}
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`
}
this.ajax("GET", headers, null, () => this.ontimeout(), resp => {
if(resp){
var {status, token, messages} = resp
this.token = token
Expand Down Expand Up @@ -160,13 +169,13 @@ export default class LongPoll {
}
}

ajax(method, contentType, body, onCallerTimeout, callback){
ajax(method, headers, body, onCallerTimeout, callback){
let req
let ontimeout = () => {
this.reqs.delete(req)
onCallerTimeout()
}
req = Ajax.request(method, this.endpointURL(), contentType, body, this.timeout, ontimeout, resp => {
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
this.reqs.delete(req)
if(this.isActive()){ callback(resp) }
})
Expand Down
11 changes: 10 additions & 1 deletion assets/js/phoenix/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ import Timer from "./timer"
* Defaults to 20s (double the server long poll timer).
*
* @param {(Object|function)} [opts.params] - The optional params to pass when connecting
* @param {string} [opts.authToken] - the optional authentication token to be exposed on the server
* under the `:auth_token` connect_info key.
* @param {string} [opts.binaryType] - The binary type to use for binary WebSocket frames.
*
* Defaults to "arraybuffer"
Expand Down Expand Up @@ -176,6 +178,7 @@ export default class Socket {
this.reconnectTimer = new Timer(() => {
this.teardown(() => this.connect())
}, this.reconnectAfterMs)
this.authToken = opts.authToken
}

/**
Expand Down Expand Up @@ -345,7 +348,13 @@ export default class Socket {
transportConnect(){
this.connectClock++
this.closeWasClean = false
this.conn = new this.transport(this.endPointURL())
let protocols = ["phoenix"]
// Sec-WebSocket-Protocol based token
// (longpoll uses Authorization header instead)
if (this.authToken) {
protocols.push(`base64url.bearer.authorization.phx.${btoa(this.authToken).replace(/=/g, "")}`)
}
this.conn = new this.transport(this.endPointURL(), protocols)
this.conn.binaryType = this.binaryType
this.conn.timeout = this.longpollerTimeout
this.conn.onopen = () => this.onConnOpen()
Expand Down
13 changes: 12 additions & 1 deletion assets/test/channel_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ const defaultRef = 1
const defaultTimeout = 10000

class WSMock {
constructor(){}
constructor(url, protocols){
this.url = url
this.protocols = protocols
}
close(){}
send(){}
}
Expand Down Expand Up @@ -58,6 +61,14 @@ describe("with transport", function (){
expect(joinPush.event).toBe("phx_join")
expect(joinPush.timeout).toBe(1234)
})

it("sets subprotocols when authToken is provided", function (){
const authToken = "1234"
const socket = new Socket("/socket", {authToken})

socket.connect()
expect(socket.conn.protocols).toEqual(["phoenix", "base64url.bearer.authorization.phx.MTIzNA"])
})
})

describe("updating join params", function (){
Expand Down
35 changes: 26 additions & 9 deletions guides/real_time/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,24 @@ That's all there is to our basic chat app. Fire up multiple browser tabs and you

When we connect, we'll often need to authenticate the client. Fortunately, this is a 4-step process with [Phoenix.Token](https://hexdocs.pm/phoenix/Phoenix.Token.html).

### Step 1 - Assign a Token in the Connection
### Step 1 - Enable the `auth_token` functionality in the socket

Phoenix supports a transport agnostic way to pass an authentication token to the server. To enable this, we need to pass the `:auth_token` option to the socket declaration in our `Endpoint` module and configure the `connect_info` to include the `:auth_token` key.

```elixir
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello

socket "/socket", HelloWeb.UserSocket,
websocket: [connect_info: [:auth_token]],
longpoll: false,
auth_token: true

...
end
```

### Step 2 - Assign a Token in the Connection

Let's say we have an authentication plug in our app called `OurAuth`. When `OurAuth` authenticates a user, it sets a value for the `:current_user` key in `conn.assigns`. Since the `current_user` exists, we can simply assign the user's token in the connection for use in the layout. We can wrap that behavior up in a private function plug, `put_user_token/2`. This could also be put in its own module as well. To make this all work, we just add `OurAuth` and `put_user_token/2` to the browser pipeline.

Expand All @@ -408,7 +425,7 @@ end

Now our `conn.assigns` contains the `current_user` and `user_token`.

### Step 2 - Pass the Token to the JavaScript
### Step 3 - Pass the Token to the JavaScript

Next, we need to pass this token to JavaScript. We can do so inside a script tag in `lib/hello_web/components/layouts/root.html.heex` right above the app.js script, as follows:

Expand All @@ -417,14 +434,14 @@ Next, we need to pass this token to JavaScript. We can do so inside a script tag
<script src={~p"/assets/app.js"}></script>
```

### Step 3 - Pass the Token to the Socket Constructor and Verify
### Step 4 - Pass the Token to the Socket Constructor and Verify

We also need to pass the `:params` to the socket constructor and verify the user token in the `connect/3` function. To do so, edit `lib/hello_web/channels/user_socket.ex`, as follows:
We also need to pass the `:auth_token` to the socket constructor and verify the user token in the `connect/3` function. To do so, edit `lib/hello_web/channels/user_socket.ex`, as follows:

```elixir
def connect(%{"token" => token}, socket, _connect_info) do
def connect(_params_, socket, connect_info) do
# max_age: 1209600 is equivalent to two weeks in seconds
case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
case Phoenix.Token.verify(socket, "user socket", connect_info[:auth_token], max_age: 1209600) do
{:ok, user_id} ->
{:ok, assign(socket, :current_user, user_id)}
{:error, reason} ->
Expand All @@ -436,17 +453,17 @@ end
In our JavaScript, we can use the token set previously when constructing the Socket:

```javascript
let socket = new Socket("/socket", {params: {token: window.userToken}})
let socket = new Socket("/socket", {authToken: window.userToken})
```

We used `Phoenix.Token.verify/4` to verify the user token provided by the client. `Phoenix.Token.verify/4` returns either `{:ok, user_id}` or `{:error, reason}`. We can pattern match on that return in a `case` statement. With a verified token, we set the user's id as the value to `:current_user` in the socket. Otherwise, we return `:error`.

### Step 4 - Connect to the socket in JavaScript
### Step 5 - Connect to the socket in JavaScript

With authentication set up, we can connect to sockets and channels from JavaScript.

```javascript
let socket = new Socket("/socket", {params: {token: window.userToken}})
let socket = new Socket("/socket", {authToken: window.userToken})
socket.connect()
```

Expand Down
19 changes: 18 additions & 1 deletion lib/phoenix/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,8 @@ defmodule Phoenix.Endpoint do
:check_origin,
:check_csrf,
:code_reloader,
:connect_info
:connect_info,
:auth_token
]

websocket =
Expand Down Expand Up @@ -730,6 +731,7 @@ defmodule Phoenix.Endpoint do

paths =
if websocket do
websocket = put_auth_token(websocket, opts[:auth_token])
config = Phoenix.Socket.Transport.load_config(websocket, Phoenix.Transports.WebSocket)
plug_init = {endpoint, socket, config}
{conn_ast, match_path} = socket_path(path, config)
Expand All @@ -740,6 +742,7 @@ defmodule Phoenix.Endpoint do

paths =
if longpoll do
longpoll = put_auth_token(longpoll, opts[:auth_token])
config = Phoenix.Socket.Transport.load_config(longpoll, Phoenix.Transports.LongPoll)
plug_init = {endpoint, socket, config}
{conn_ast, match_path} = socket_path(path, config)
Expand All @@ -751,6 +754,9 @@ defmodule Phoenix.Endpoint do
paths
end

defp put_auth_token(true, enabled), do: [auth_token: enabled]
defp put_auth_token(opts, enabled), do: Keyword.put(opts, :auth_token, enabled)

defp socket_path(path, config) do
end_path_fragment = Keyword.fetch!(config, :path)

Expand Down Expand Up @@ -834,6 +840,17 @@ defmodule Phoenix.Endpoint do
HTTP/HTTPS connection drainer will still run, and apply to all connections.
Set it to `false` to disable draining.

* `auth_token` - a boolean that enables the use of the channels client's auth_token option.
The exact token exchange mechanism depends on the transport:

* the websocket transport, this enables a token to be passed through the `Sec-WebSocket-Protocol` header.
* the longpoll transport, this allows the token to be passed through the `Authorization` header.

The token is available in the `connect_info` as `:auth_token`, which must be separately enabled in the
corresponding `websocket` or `longpoll` configurations.

Custom transports might implement their own mechanism.

You can also pass the options below on `use Phoenix.Socket`.
The values specified here override the value in `use Phoenix.Socket`.

Expand Down
7 changes: 5 additions & 2 deletions lib/phoenix/socket/transport.ex
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ defmodule Phoenix.Socket.Transport do

connect_info =
Enum.map(connect_info, fn
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers] ->
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers, :auth_token] ->
key

{:session, session} ->
Expand Down Expand Up @@ -485,6 +485,9 @@ defmodule Phoenix.Socket.Transport do
{:session, session} ->
{:session, connect_session(conn, endpoint, session, opts)}

:auth_token ->
{:auth_token, conn.private[:__phoenix_transport_auth_token]}

{key, val} ->
{key, val}
end
Expand Down Expand Up @@ -549,7 +552,7 @@ defmodule Phoenix.Socket.Transport do
with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
csrf_state when is_binary(csrf_state) <-
Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]) do
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
end
end

Expand Down
14 changes: 14 additions & 0 deletions lib/phoenix/transports/long_poll.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ defmodule Phoenix.Transports.LongPoll do

keys = Keyword.get(opts, :connect_info, [])

conn = maybe_auth_token_from_header(conn, opts[:auth_token])

connect_info =
Transport.connect_info(conn, endpoint, keys, Keyword.take(opts, @connect_info_opts))

Expand Down Expand Up @@ -265,6 +267,18 @@ defmodule Phoenix.Transports.LongPoll do
)
end

defp maybe_auth_token_from_header(conn, true) do
case Plug.Conn.get_req_header(conn, "authorization") do
[] ->
conn

["Bearer " <> token | _] ->
Plug.Conn.put_private(conn, :__phoenix_transport_auth_token, token)
end
end

defp maybe_auth_token_from_header(conn, _), do: conn

defp status_json(conn) do
send_json(conn, %{"status" => conn.status || 200})
end
Expand Down
47 changes: 46 additions & 1 deletion lib/phoenix/transports/websocket.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ defmodule Phoenix.Transports.WebSocket do

@connect_info_opts [:check_csrf]

@auth_token_prefix "base64url.bearer.authorization.phx."

import Plug.Conn

alias Phoenix.Socket.{V1, V2, Transport}
Expand All @@ -35,12 +37,23 @@ defmodule Phoenix.Transports.WebSocket do
def init(opts), do: opts

def call(%{method: "GET"} = conn, {endpoint, handler, opts}) do
subprotocols =
if opts[:auth_token] do
# when using Sec-WebSocket-Protocol for passing an auth token
# the server must reply with one of the subprotocols in the request;
# therefore we include "phoenix" as allowed subprotocol and include it on the client
["phoenix" | Keyword.get(opts, :subprotocols, [])]
else
opts[:subprotocols]
end

conn
|> fetch_query_params()
|> Transport.code_reload(endpoint, opts)
|> Transport.transport_log(opts[:transport_log])
|> Transport.check_origin(handler, endpoint, opts)
|> Transport.check_subprotocols(opts[:subprotocols])
|> maybe_auth_token_from_header(opts[:auth_token])
|> Transport.check_subprotocols(subprotocols)
Copy link
Member

@josevalim josevalim Feb 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, it feels we should just bring this function to this module and have it deal with both auth_token and subprotocols at once, or move auth_token to check subprotocols. Transport is meant to have transport agnostic functions, and that one is clearly websockets related, so it shouldn't exist in the first place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is part of the public API, so we can't really change it, can we?
https://hexdocs.pm/phoenix/Phoenix.Socket.Transport.html#check_subprotocols/2

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we cannot, but maybe we can move the auth token handling there anyway?

|> case do
%{halted: true} = conn ->
conn
Expand Down Expand Up @@ -82,4 +95,36 @@ defmodule Phoenix.Transports.WebSocket do
def call(conn, _), do: send_resp(conn, 400, "")

def handle_error(conn, _reason), do: send_resp(conn, 403, "")

defp maybe_auth_token_from_header(conn, true) do
case get_req_header(conn, "sec-websocket-protocol") do
[] ->
conn

[subprotocols_header | _] ->
request_subprotocols =
subprotocols_header
|> Plug.Conn.Utils.list()
|> Enum.split_with(&String.starts_with?(&1, @auth_token_prefix))

case request_subprotocols do
{[@auth_token_prefix <> encoded_token], actual_subprotocols} ->
token = Base.decode64!(encoded_token, padding: false)

conn
|> put_private(:__phoenix_transport_auth_token, token)
|> set_actual_subprotocols(actual_subprotocols)

_ ->
conn
end
end
end

defp maybe_auth_token_from_header(conn, _), do: conn

defp set_actual_subprotocols(conn, []), do: delete_req_header(conn, "sec-websocket-protocol")

defp set_actual_subprotocols(conn, subprotocols),
do: put_req_header(conn, "sec-websocket-protocol", Enum.join(subprotocols, ", "))
end
Loading