Skip to content

Commit b4aa682

Browse files
committed
Support transport agnostic token passing in channels
For WebSocket, the `Sec-WebSocket-Protocol` header is used. For LongPoll, an `Authorization` header is passed instead. Fixes #5778.
1 parent 52698bb commit b4aa682

File tree

8 files changed

+138
-22
lines changed

8 files changed

+138
-22
lines changed

assets/js/phoenix/ajax.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import {
55

66
export default class Ajax {
77

8-
static request(method, endPoint, accept, body, timeout, ontimeout, callback){
8+
static request(method, endPoint, headers, body, timeout, ontimeout, callback){
99
if(global.XDomainRequest){
1010
let req = new global.XDomainRequest() // IE8, IE9
1111
return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback)
1212
} else {
1313
let req = new global.XMLHttpRequest() // IE7+, Firefox, Chrome, Opera, Safari
14-
return this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback)
14+
return this.xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback)
1515
}
1616
}
1717

@@ -31,10 +31,12 @@ export default class Ajax {
3131
return req
3232
}
3333

34-
static xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback){
34+
static xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback){
3535
req.open(method, endPoint, true)
3636
req.timeout = timeout
37-
req.setRequestHeader("Content-Type", accept)
37+
for (let [key, value] of Object.entries(headers)) {
38+
req.setRequestHeader(key, value)
39+
}
3840
req.onerror = () => callback && callback(null)
3941
req.onreadystatechange = () => {
4042
if(req.readyState === XHR_STATES.complete && callback){

assets/js/phoenix/longpoll.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ let arrayBufferToBase64 = (buffer) => {
1515

1616
export default class LongPoll {
1717

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

6065
poll(){
61-
this.ajax("GET", "application/json", null, () => this.ontimeout(), resp => {
66+
const headers = {"Accept": "application/json"}
67+
if (this.authToken) {
68+
headers["Authorization"] = `Bearer ${this.authToken}`
69+
}
70+
this.ajax("GET", headers, null, () => this.ontimeout(), resp => {
6271
if(resp){
6372
var {status, token, messages} = resp
6473
this.token = token
@@ -160,13 +169,13 @@ export default class LongPoll {
160169
}
161170
}
162171

163-
ajax(method, contentType, body, onCallerTimeout, callback){
172+
ajax(method, headers, body, onCallerTimeout, callback){
164173
let req
165174
let ontimeout = () => {
166175
this.reqs.delete(req)
167176
onCallerTimeout()
168177
}
169-
req = Ajax.request(method, this.endpointURL(), contentType, body, this.timeout, ontimeout, resp => {
178+
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
170179
this.reqs.delete(req)
171180
if(this.isActive()){ callback(resp) }
172181
})

assets/js/phoenix/socket.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ import Timer from "./timer"
8686
* Defaults to 20s (double the server long poll timer).
8787
*
8888
* @param {(Object|function)} [opts.params] - The optional params to pass when connecting
89+
* @param {string} [opts.authToken] - the optional authentication token to be exposed on the server
90+
* under the `:auth_token` connect_info key.
8991
* @param {string} [opts.binaryType] - The binary type to use for binary WebSocket frames.
9092
*
9193
* Defaults to "arraybuffer"
@@ -176,6 +178,7 @@ export default class Socket {
176178
this.reconnectTimer = new Timer(() => {
177179
this.teardown(() => this.connect())
178180
}, this.reconnectAfterMs)
181+
this.authToken = opts.authToken
179182
}
180183

181184
/**
@@ -345,7 +348,13 @@ export default class Socket {
345348
transportConnect(){
346349
this.connectClock++
347350
this.closeWasClean = false
348-
this.conn = new this.transport(this.endPointURL())
351+
let protocols = ["phoenix"]
352+
// Sec-WebSocket-Protocol based token
353+
// (longpoll uses Authorization header instead)
354+
if (this.authToken) {
355+
protocols.push(`base64url.bearer.authorization.phx.${btoa(this.authToken).replace("=", "")}`)
356+
}
357+
this.conn = new this.transport(this.endPointURL(), protocols)
349358
this.conn.binaryType = this.binaryType
350359
this.conn.timeout = this.longpollerTimeout
351360
this.conn.onopen = () => this.onConnOpen()

guides/real_time/channels.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,24 @@ That's all there is to our basic chat app. Fire up multiple browser tabs and you
385385

386386
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).
387387

388-
### Step 1 - Assign a Token in the Connection
388+
### Step 1 - Enable the `auth_token` functionality in the socket
389+
390+
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.
391+
392+
```elixir
393+
defmodule HelloWeb.Endpoint do
394+
use Phoenix.Endpoint, otp_app: :hello
395+
396+
socket "/socket", HelloWeb.UserSocket,
397+
websocket: [connect_info: [:auth_token]],
398+
longpoll: false,
399+
auth_token: true
400+
401+
...
402+
end
403+
```
404+
405+
### Step 2 - Assign a Token in the Connection
389406

390407
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.
391408

@@ -408,7 +425,7 @@ end
408425

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

411-
### Step 2 - Pass the Token to the JavaScript
428+
### Step 3 - Pass the Token to the JavaScript
412429

413430
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:
414431

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

420-
### Step 3 - Pass the Token to the Socket Constructor and Verify
437+
### Step 4 - Pass the Token to the Socket Constructor and Verify
421438

422-
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:
439+
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:
423440

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

438455
```javascript
439-
let socket = new Socket("/socket", {params: {token: window.userToken}})
456+
let socket = new Socket("/socket", {authToken: window.userToken})
440457
```
441458

442459
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`.
443460

444-
### Step 4 - Connect to the socket in JavaScript
461+
### Step 5 - Connect to the socket in JavaScript
445462

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

448465
```javascript
449-
let socket = new Socket("/socket", {params: {token: window.userToken}})
466+
let socket = new Socket("/socket", {authToken: window.userToken})
450467
socket.connect()
451468
```
452469

lib/phoenix/endpoint.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,8 @@ defmodule Phoenix.Endpoint do
698698
:check_origin,
699699
:check_csrf,
700700
:code_reloader,
701-
:connect_info
701+
:connect_info,
702+
:auth_token
702703
]
703704

704705
websocket =
@@ -730,6 +731,7 @@ defmodule Phoenix.Endpoint do
730731

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

741743
paths =
742744
if longpoll do
745+
longpoll = put_auth_token(longpoll, opts[:auth_token])
743746
config = Phoenix.Socket.Transport.load_config(longpoll, Phoenix.Transports.LongPoll)
744747
plug_init = {endpoint, socket, config}
745748
{conn_ast, match_path} = socket_path(path, config)
@@ -751,6 +754,9 @@ defmodule Phoenix.Endpoint do
751754
paths
752755
end
753756

757+
defp put_auth_token(true, enabled), do: [auth_token: enabled]
758+
defp put_auth_token(opts, enabled), do: Keyword.put(opts, :auth_token, enabled)
759+
754760
defp socket_path(path, config) do
755761
end_path_fragment = Keyword.fetch!(config, :path)
756762

@@ -834,6 +840,17 @@ defmodule Phoenix.Endpoint do
834840
HTTP/HTTPS connection drainer will still run, and apply to all connections.
835841
Set it to `false` to disable draining.
836842
843+
* `auth_token` - a boolean that enables the use of the channels client's auth_token option.
844+
The exact token exchange mechanism depends on the transport:
845+
846+
* the websocket transport, this enables a token to be passed through the `Sec-WebSocket-Protocol` header.
847+
* the longpoll transport, this allows the token to be passed through the `Authorization` header.
848+
849+
The token is available in the `connect_info` as `:auth_token`, which must be separately enabled in the
850+
corresponding `websocket` or `longpoll` configurations.
851+
852+
Custom transports might implement their own mechanism.
853+
837854
You can also pass the options below on `use Phoenix.Socket`.
838855
The values specified here override the value in `use Phoenix.Socket`.
839856

lib/phoenix/socket/transport.ex

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ defmodule Phoenix.Socket.Transport do
261261

262262
connect_info =
263263
Enum.map(connect_info, fn
264-
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers] ->
264+
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers, :auth_token] ->
265265
key
266266

267267
{:session, session} ->
@@ -485,6 +485,9 @@ defmodule Phoenix.Socket.Transport do
485485
{:session, session} ->
486486
{:session, connect_session(conn, endpoint, session, opts)}
487487

488+
:auth_token ->
489+
{:auth_token, conn.private[:__phoenix_transport_auth_token]}
490+
488491
{key, val} ->
489492
{key, val}
490493
end
@@ -549,7 +552,7 @@ defmodule Phoenix.Socket.Transport do
549552
with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
550553
csrf_state when is_binary(csrf_state) <-
551554
Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]) do
552-
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
555+
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
553556
end
554557
end
555558

lib/phoenix/transports/long_poll.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ defmodule Phoenix.Transports.LongPoll do
138138

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

141+
conn = maybe_auth_token_from_header(conn, opts[:auth_token])
142+
141143
connect_info =
142144
Transport.connect_info(conn, endpoint, keys, Keyword.take(opts, @connect_info_opts))
143145

@@ -265,6 +267,18 @@ defmodule Phoenix.Transports.LongPoll do
265267
)
266268
end
267269

270+
defp maybe_auth_token_from_header(conn, true) do
271+
case Plug.Conn.get_req_header(conn, "authorization") do
272+
[] ->
273+
conn
274+
275+
["Bearer " <> token | _] ->
276+
Plug.Conn.put_private(conn, :__phoenix_transport_auth_token, token)
277+
end
278+
end
279+
280+
defp maybe_auth_token_from_header(conn, _), do: conn
281+
268282
defp status_json(conn) do
269283
send_json(conn, %{"status" => conn.status || 200})
270284
end

lib/phoenix/transports/websocket.ex

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ defmodule Phoenix.Transports.WebSocket do
1717

1818
@connect_info_opts [:check_csrf]
1919

20+
@auth_token_prefix "base64url.bearer.authorization.phx."
21+
2022
import Plug.Conn
2123

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

3739
def call(%{method: "GET"} = conn, {endpoint, handler, opts}) do
40+
subprotocols =
41+
if opts[:auth_token] do
42+
# when using Sec-WebSocket-Protocol for passing an auth token
43+
# the server must reply with one of the subprotocols in the request;
44+
# therefore we include "phoenix" as allowed subprotocol and include it on the client
45+
["phoenix" | Keyword.get(opts, :subprotocols, [])]
46+
else
47+
opts[:subprotocols]
48+
end
49+
3850
conn
3951
|> fetch_query_params()
4052
|> Transport.code_reload(endpoint, opts)
4153
|> Transport.transport_log(opts[:transport_log])
4254
|> Transport.check_origin(handler, endpoint, opts)
43-
|> Transport.check_subprotocols(opts[:subprotocols])
55+
|> maybe_auth_token_from_header(opts[:auth_token])
56+
|> Transport.check_subprotocols(subprotocols)
4457
|> case do
4558
%{halted: true} = conn ->
4659
conn
@@ -82,4 +95,36 @@ defmodule Phoenix.Transports.WebSocket do
8295
def call(conn, _), do: send_resp(conn, 400, "")
8396

8497
def handle_error(conn, _reason), do: send_resp(conn, 403, "")
98+
99+
defp maybe_auth_token_from_header(conn, true) do
100+
case get_req_header(conn, "sec-websocket-protocol") do
101+
[] ->
102+
conn
103+
104+
[subprotocols_header | _] ->
105+
request_subprotocols =
106+
subprotocols_header
107+
|> Plug.Conn.Utils.list()
108+
|> Enum.split_with(&String.starts_with?(&1, @auth_token_prefix))
109+
110+
case request_subprotocols do
111+
{[@auth_token_prefix <> encoded_token], actual_subprotocols} ->
112+
token = Base.decode64!(encoded_token, padding: false)
113+
114+
conn
115+
|> put_private(:__phoenix_transport_auth_token, token)
116+
|> set_actual_subprotocols(actual_subprotocols)
117+
118+
_ ->
119+
conn
120+
end
121+
end
122+
end
123+
124+
defp maybe_auth_token_from_header(conn, _), do: conn
125+
126+
defp set_actual_subprotocols(conn, []), do: delete_req_header(conn, "sec-websocket-protocol")
127+
128+
defp set_actual_subprotocols(conn, subprotocols),
129+
do: put_req_header(conn, "sec-websocket-protocol", Enum.join(subprotocols, ", "))
85130
end

0 commit comments

Comments
 (0)