summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/tools/third_party/websockets/docs/topics/authentication.rst
blob: 31bc8e6da8a14dc0ab931fb8df2991f89991b96d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
Authentication
==============

The WebSocket protocol was designed for creating web applications that need
bidirectional communication between clients running in browsers and servers.

In most practical use cases, WebSocket servers need to authenticate clients in
order to route communications appropriately and securely.

:rfc:`6455` stays elusive when it comes to authentication:

    This protocol doesn't prescribe any particular way that servers can
    authenticate clients during the WebSocket handshake. The WebSocket
    server can use any client authentication mechanism available to a
    generic HTTP server, such as cookies, HTTP authentication, or TLS
    authentication.

None of these three mechanisms works well in practice. Using cookies is
cumbersome, HTTP authentication isn't supported by all mainstream browsers,
and TLS authentication in a browser is an esoteric user experience.

Fortunately, there are better alternatives! Let's discuss them.

System design
-------------

Consider a setup where the WebSocket server is separate from the HTTP server.

Most servers built with websockets to complement a web application adopt this
design because websockets doesn't aim at supporting HTTP.

The following diagram illustrates the authentication flow.

.. image:: authentication.svg

Assuming the current user is authenticated with the HTTP server (1), the
application needs to obtain credentials from the HTTP server (2) in order to
send them to the WebSocket server (3), who can check them against the database
of user accounts (4).

Usernames and passwords aren't a good choice of credentials here, if only
because passwords aren't available in clear text in the database.

Tokens linked to user accounts are a better choice. These tokens must be
impossible to forge by an attacker. For additional security, they can be
short-lived or even single-use.

Sending credentials
-------------------

Assume the web application obtained authentication credentials, likely a
token, from the HTTP server. There's four options for passing them to the
WebSocket server.

1. **Sending credentials as the first message in the WebSocket connection.**

   This is fully reliable and the most secure mechanism in this discussion. It
   has two minor downsides:

   * Authentication is performed at the application layer. Ideally, it would
     be managed at the protocol layer.

   * Authentication is performed after the WebSocket handshake, making it
     impossible to monitor authentication failures with HTTP response codes.

2. **Adding credentials to the WebSocket URI in a query parameter.**

   This is also fully reliable but less secure. Indeed, it has a major
   downside:

   * URIs end up in logs, which leaks credentials. Even if that risk could be
     lowered with single-use tokens, it is usually considered unacceptable.

   Authentication is still performed at the application layer but it can
   happen before the WebSocket handshake, which improves separation of
   concerns and enables responding to authentication failures with HTTP 401.

3. **Setting a cookie on the domain of the WebSocket URI.**

   Cookies are undoubtedly the most common and hardened mechanism for sending
   credentials from a web application to a server. In an HTTP application,
   credentials would be a session identifier or a serialized, signed session.

   Unfortunately, when the WebSocket server runs on a different domain from
   the web application, this idea bumps into the `Same-Origin Policy`_. For
   security reasons, setting a cookie on a different origin is impossible.

   The proper workaround consists in:

   * creating a hidden iframe_ served from the domain of the WebSocket server
   * sending the token to the iframe with postMessage_
   * setting the cookie in the iframe

   before opening the WebSocket connection.

   Sharing a parent domain (e.g. example.com) between the HTTP server (e.g.
   www.example.com) and the WebSocket server (e.g. ws.example.com) and setting
   the cookie on that parent domain would work too.

   However, the cookie would be shared with all subdomains of the parent
   domain. For a cookie containing credentials, this is unacceptable.

.. _Same-Origin Policy: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
.. _iframe: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe
.. _postMessage: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage

4. **Adding credentials to the WebSocket URI in user information.**

   Letting the browser perform HTTP Basic Auth is a nice idea in theory.

   In practice it doesn't work due to poor support in browsers.

   As of May 2021:

   * Chrome 90 behaves as expected.

   * Firefox 88 caches credentials too aggressively.

     When connecting again to the same server with new credentials, it reuses
     the old credentials, which may be expired, resulting in an HTTP 401. Then
     the next connection succeeds. Perhaps errors clear the cache.

     When tokens are short-lived or single-use, this bug produces an
     interesting effect: every other WebSocket connection fails.

   * Safari 14 ignores credentials entirely.

Two other options are off the table:

1. **Setting a custom HTTP header**

   This would be the most elegant mechanism, solving all issues with the options
   discussed above.

   Unfortunately, it doesn't work because the `WebSocket API`_ doesn't support
   `setting custom headers`_.

.. _WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
.. _setting custom headers: https://github.com/whatwg/html/issues/3062

2. **Authenticating with a TLS certificate**

   While this is suggested by the RFC, installing a TLS certificate is too far
   from the mainstream experience of browser users. This could make sense in
   high security contexts. I hope developers working on such projects don't
   take security advice from the documentation of random open source projects.

Let's experiment!
-----------------

The `experiments/authentication`_ directory demonstrates these techniques.

Run the experiment in an environment where websockets is installed:

.. _experiments/authentication: https://github.com/python-websockets/websockets/tree/main/experiments/authentication

.. code-block:: console

    $ python experiments/authentication/app.py
    Running on http://localhost:8000/

When you browse to the HTTP server at http://localhost:8000/ and you submit a
username, the server creates a token and returns a testing web page.

This page opens WebSocket connections to four WebSocket servers running on
four different origins. It attempts to authenticate with the token in four
different ways.

First message
.............

As soon as the connection is open, the client sends a message containing the
token:

.. code-block:: javascript

    const websocket = new WebSocket("ws://.../");
    websocket.onopen = () => websocket.send(token);

    // ...

At the beginning of the connection handler, the server receives this message
and authenticates the user. If authentication fails, the server closes the
connection:

.. code-block:: python

    async def first_message_handler(websocket):
        token = await websocket.recv()
        user = get_user(token)
        if user is None:
            await websocket.close(CloseCode.INTERNAL_ERROR, "authentication failed")
            return

        ...

Query parameter
...............

The client adds the token to the WebSocket URI in a query parameter before
opening the connection:

.. code-block:: javascript

    const uri = `ws://.../?token=${token}`;
    const websocket = new WebSocket(uri);

    // ...

The server intercepts the HTTP request, extracts the token and authenticates
the user. If authentication fails, it returns an HTTP 401:

.. code-block:: python

    class QueryParamProtocol(websockets.WebSocketServerProtocol):
        async def process_request(self, path, headers):
            token = get_query_parameter(path, "token")
            if token is None:
                return http.HTTPStatus.UNAUTHORIZED, [], b"Missing token\n"

            user = get_user(token)
            if user is None:
                return http.HTTPStatus.UNAUTHORIZED, [], b"Invalid token\n"

            self.user = user

    async def query_param_handler(websocket):
        user = websocket.user

        ...

Cookie
......

The client sets a cookie containing the token before opening the connection.

The cookie must be set by an iframe loaded from the same origin as the
WebSocket server. This requires passing the token to this iframe.

.. code-block:: javascript

    // in main window
    iframe.contentWindow.postMessage(token, "http://...");

    // in iframe
    document.cookie = `token=${data}; SameSite=Strict`;

    // in main window
    const websocket = new WebSocket("ws://.../");

    // ...

This sequence must be synchronized between the main window and the iframe.
This involves several events. Look at the full implementation for details.

The server intercepts the HTTP request, extracts the token and authenticates
the user. If authentication fails, it returns an HTTP 401:

.. code-block:: python

    class CookieProtocol(websockets.WebSocketServerProtocol):
        async def process_request(self, path, headers):
            # Serve iframe on non-WebSocket requests
            ...

            token = get_cookie(headers.get("Cookie", ""), "token")
            if token is None:
                return http.HTTPStatus.UNAUTHORIZED, [], b"Missing token\n"

            user = get_user(token)
            if user is None:
                return http.HTTPStatus.UNAUTHORIZED, [], b"Invalid token\n"

            self.user = user

    async def cookie_handler(websocket):
        user = websocket.user

        ...

User information
................

The client adds the token to the WebSocket URI in user information before
opening the connection:

.. code-block:: javascript

    const uri = `ws://token:${token}@.../`;
    const websocket = new WebSocket(uri);

    // ...

Since HTTP Basic Auth is designed to accept a username and a password rather
than a token, we send ``token`` as username and the token as password.

The server intercepts the HTTP request, extracts the token and authenticates
the user. If authentication fails, it returns an HTTP 401:

.. code-block:: python

    class UserInfoProtocol(websockets.BasicAuthWebSocketServerProtocol):
        async def check_credentials(self, username, password):
            if username != "token":
                return False

            user = get_user(password)
            if user is None:
                return False

            self.user = user
            return True

    async def user_info_handler(websocket):
        user = websocket.user

        ...

Machine-to-machine authentication
---------------------------------

When the WebSocket client is a standalone program rather than a script running
in a browser, there are far fewer constraints. HTTP Authentication is the best
solution in this scenario.

To authenticate a websockets client with HTTP Basic Authentication
(:rfc:`7617`), include the credentials in the URI:

.. code-block:: python

    async with websockets.connect(
        f"wss://{username}:{password}@example.com",
    ) as websocket:
        ...

(You must :func:`~urllib.parse.quote` ``username`` and ``password`` if they
contain unsafe characters.)

To authenticate a websockets client with HTTP Bearer Authentication
(:rfc:`6750`), add a suitable ``Authorization`` header:

.. code-block:: python

    async with websockets.connect(
        "wss://example.com",
        extra_headers={"Authorization": f"Bearer {token}"}
    ) as websocket:
        ...