Skip to content

Commit db8eac8

Browse files
authored
Support transport agnostic token passing in channels (#6086)
* Support transport agnostic token passing in channels For WebSocket, the `Sec-WebSocket-Protocol` header is used. For LongPoll, a custom header is passed instead. Fixes #5778.
1 parent fbef2f3 commit db8eac8

File tree

10 files changed

+162
-25
lines changed

10 files changed

+162
-25
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/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export const TRANSPORTS = {
2727
export const XHR_STATES = {
2828
complete: 4
2929
}
30+
export const AUTH_TOKEN_PREFIX = "base64url.bearer.phx."

assets/js/phoenix/longpoll.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
SOCKET_STATES,
3-
TRANSPORTS
3+
TRANSPORTS,
4+
AUTH_TOKEN_PREFIX
45
} from "./constants"
56

67
import Ajax from "./ajax"
@@ -15,7 +16,12 @@ let arrayBufferToBase64 = (buffer) => {
1516

1617
export default class LongPoll {
1718

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

6066
poll(){
61-
this.ajax("GET", "application/json", null, () => this.ontimeout(), resp => {
67+
const headers = {"Accept": "application/json"}
68+
if(this.authToken){
69+
headers["X-Phoenix-AuthToken"] = this.authToken
70+
}
71+
this.ajax("GET", headers, null, () => this.ontimeout(), resp => {
6272
if(resp){
6373
var {status, token, messages} = resp
6474
this.token = token
@@ -160,13 +170,13 @@ export default class LongPoll {
160170
}
161171
}
162172

163-
ajax(method, contentType, body, onCallerTimeout, callback){
173+
ajax(method, headers, body, onCallerTimeout, callback){
164174
let req
165175
let ontimeout = () => {
166176
this.reqs.delete(req)
167177
onCallerTimeout()
168178
}
169-
req = Ajax.request(method, this.endpointURL(), contentType, body, this.timeout, ontimeout, resp => {
179+
req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => {
170180
this.reqs.delete(req)
171181
if(this.isActive()){ callback(resp) }
172182
})

assets/js/phoenix/socket.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
DEFAULT_VSN,
77
SOCKET_STATES,
88
TRANSPORTS,
9-
WS_CLOSE_NORMAL
9+
WS_CLOSE_NORMAL,
10+
AUTH_TOKEN_PREFIX
1011
} from "./constants"
1112

1213
import {
@@ -86,6 +87,8 @@ import Timer from "./timer"
8687
* Defaults to 20s (double the server long poll timer).
8788
*
8889
* @param {(Object|function)} [opts.params] - The optional params to pass when connecting
90+
* @param {string} [opts.authToken] - the optional authentication token to be exposed on the server
91+
* under the `:auth_token` connect_info key.
8992
* @param {string} [opts.binaryType] - The binary type to use for binary WebSocket frames.
9093
*
9194
* Defaults to "arraybuffer"
@@ -176,6 +179,7 @@ export default class Socket {
176179
this.reconnectTimer = new Timer(() => {
177180
this.teardown(() => this.connect())
178181
}, this.reconnectAfterMs)
182+
this.authToken = opts.authToken
179183
}
180184

181185
/**
@@ -345,7 +349,13 @@ export default class Socket {
345349
transportConnect(){
346350
this.connectClock++
347351
this.closeWasClean = false
348-
this.conn = new this.transport(this.endPointURL())
352+
let protocols = ["phoenix"]
353+
// Sec-WebSocket-Protocol based token
354+
// (longpoll uses Authorization header instead)
355+
if (this.authToken) {
356+
protocols.push(`${AUTH_TOKEN_PREFIX}${btoa(this.authToken).replace(/=/g, "")}`)
357+
}
358+
this.conn = new this.transport(this.endPointURL(), protocols)
349359
this.conn.binaryType = this.binaryType
350360
this.conn.timeout = this.longpollerTimeout
351361
this.conn.onopen = () => this.onConnOpen()

assets/test/channel_test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ const defaultRef = 1
77
const defaultTimeout = 10000
88

99
class WSMock {
10-
constructor(){}
10+
constructor(url, protocols){
11+
this.url = url
12+
this.protocols = protocols
13+
}
1114
close(){}
1215
send(){}
1316
}
@@ -58,6 +61,14 @@ describe("with transport", function (){
5861
expect(joinPush.event).toBe("phx_join")
5962
expect(joinPush.timeout).toBe(1234)
6063
})
64+
65+
it("sets subprotocols when authToken is provided", function (){
66+
const authToken = "1234"
67+
const socket = new Socket("/socket", {authToken})
68+
69+
socket.connect()
70+
expect(socket.conn.protocols).toEqual(["phoenix", "base64url.bearer.phx.MTIzNA"])
71+
})
6172
})
6273

6374
describe("updating join params", function (){

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: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,8 @@ defmodule Phoenix.Endpoint do
708708
:check_origin,
709709
:check_csrf,
710710
:code_reloader,
711-
:connect_info
711+
:connect_info,
712+
:auth_token
712713
]
713714

714715
websocket =
@@ -740,6 +741,7 @@ defmodule Phoenix.Endpoint do
740741

741742
paths =
742743
if websocket do
744+
websocket = put_auth_token(websocket, opts[:auth_token])
743745
config = Phoenix.Socket.Transport.load_config(websocket, Phoenix.Transports.WebSocket)
744746
plug_init = {endpoint, socket, config}
745747
{conn_ast, match_path} = socket_path(path, config)
@@ -750,6 +752,7 @@ defmodule Phoenix.Endpoint do
750752

751753
paths =
752754
if longpoll do
755+
longpoll = put_auth_token(longpoll, opts[:auth_token])
753756
config = Phoenix.Socket.Transport.load_config(longpoll, Phoenix.Transports.LongPoll)
754757
plug_init = {endpoint, socket, config}
755758
{conn_ast, match_path} = socket_path(path, config)
@@ -761,6 +764,9 @@ defmodule Phoenix.Endpoint do
761764
paths
762765
end
763766

767+
defp put_auth_token(true, enabled), do: [auth_token: enabled]
768+
defp put_auth_token(opts, enabled), do: Keyword.put(opts, :auth_token, enabled)
769+
764770
defp socket_path(path, config) do
765771
end_path_fragment = Keyword.fetch!(config, :path)
766772

@@ -844,6 +850,16 @@ defmodule Phoenix.Endpoint do
844850
HTTP/HTTPS connection drainer will still run, and apply to all connections.
845851
Set it to `false` to disable draining.
846852
853+
* `auth_token` - a boolean that enables the use of the channels client's auth_token option.
854+
The exact token exchange mechanism depends on the transport:
855+
856+
* the websocket transport, this enables a token to be passed through the `Sec-WebSocket-Protocol` header.
857+
* the longpoll transport, this allows the token to be passed through the `Authorization` header.
858+
859+
The token is available in the `connect_info` as `:auth_token`.
860+
861+
Custom transports might implement their own mechanism.
862+
847863
You can also pass the options below on `use Phoenix.Socket`.
848864
The values specified here override the value in `use Phoenix.Socket`.
849865

lib/phoenix/socket/transport.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,17 @@ defmodule Phoenix.Socket.Transport do
259259
def load_config(config) do
260260
{connect_info, config} = Keyword.pop(config, :connect_info, [])
261261

262+
connect_info =
263+
if config[:auth_token] do
264+
# auth_token is included by default when enabled
265+
[:auth_token | connect_info]
266+
else
267+
connect_info
268+
end
269+
262270
connect_info =
263271
Enum.map(connect_info, fn
264-
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers] ->
272+
key when key in [:peer_data, :trace_context_headers, :uri, :user_agent, :x_headers, :auth_token] ->
265273
key
266274

267275
{:session, session} ->
@@ -485,6 +493,9 @@ defmodule Phoenix.Socket.Transport do
485493
{:session, session} ->
486494
{:session, connect_session(conn, endpoint, session, opts)}
487495

496+
:auth_token ->
497+
{:auth_token, conn.private[:phoenix_transport_auth_token]}
498+
488499
{key, val} ->
489500
{key, val}
490501
end
@@ -549,7 +560,7 @@ defmodule Phoenix.Socket.Transport do
549560
with csrf_token when is_binary(csrf_token) <- conn.params["_csrf_token"],
550561
csrf_state when is_binary(csrf_state) <-
551562
Plug.CSRFProtection.dump_state_from_session(session[csrf_token_key]) do
552-
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
563+
Plug.CSRFProtection.valid_state_and_csrf_token?(csrf_state, csrf_token)
553564
end
554565
end
555566

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, "x-phoenix-authtoken") do
272+
[] ->
273+
conn
274+
275+
[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

0 commit comments

Comments
 (0)