Skip to content

Commit

Permalink
Discuss authentication in docs.
Browse files Browse the repository at this point in the history
  • Loading branch information
aaugustin committed May 24, 2021
1 parent f7a6268 commit 920207c
Show file tree
Hide file tree
Showing 4 changed files with 414 additions and 0 deletions.
2 changes: 2 additions & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ datastructures
django
dyno
fractalideas
iframe
IPv
iterable
keepalive
Expand Down Expand Up @@ -65,3 +66,4 @@ websocket
websockets
ws
wss
www
348 changes: 348 additions & 0 deletions docs/topics/authentication.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 a 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 on 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/aaugustin/websockets/tree/main/experiments/authentication

.. code:: 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:: 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:: python
async def first_message_handler(websocket, path):
token = await websocket.recv()
user = get_user(token)
if user is None:
await websocket.close(1011, "authentication failed")
return
...
Query parameter
...............

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

.. code:: 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 a HTTP 401:

.. code:: 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, path):
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:: 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 a HTTP 401:

.. code:: 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, path):
user = websocket.user
...
User information
................

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

.. code:: 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 a HTTP 401:

.. code:: 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, path):
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:: 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:: python
async with websockets.connect(
"wss://example.com",
extra_headers={"Authorization": f"Bearer {token}"}
) as websocket:
...
Loading

0 comments on commit 920207c

Please sign in to comment.