Making a TeaL Websocket command-line client#

In this tutorial, our objective will be to build a simple command-line client to bind a callback state using the TeaL Websocket Dispatcher, and report callback events on the provided state.

The targetted usage is the following:

$ ./bind_state.py --final-redirect-url https://example.org/callback?state=123

The following state has been bound with the provided parameters:

* Redirect URL: https://teapots.fr/callback
* Error URL: https://teapots.fr/errback
* State: 2734b0cdc9e60403

The received events will be displayed hereafter:

---

A callback has been received at 2023-08-19T12:59:59.789012!
https://teapots.fr/callback?state=2734b0cdc9e60403&code=abcdefg

A callback has been received at 2023-08-19T13:05:02.123456!
https://teapots.fr/errback?state=2734b0cdc9e60403&error=login

In order to build this script, we will use Python 3.11+ with Click, Websockets and Requests. A complete version of the script is available at bind_state.py, this tutorial is about remaking it piece by piece.

Preparing the command-line#

We start by creating a command-line using Click. This code can be considered boilerplate for your script, since learning how to write scripts is out of the scope of this tutorial.

import click


@click.command()
@click.option('--final-redirect-url', required=True)
def cli(final_redirect_url: str) -> None:
    """Bind a callback state on TeaL, and read resulting events."""
    # Your code goes here!


if __name__ == '__main__':
    cli()

Finding out TeaL parameters#

In order to find out most parameters to use with our script, you first need to configure a host and a password for the dispatcher. This can be configured in global constants:

DISPATCHER_BASEURL = 'http://localhost:8081'
DISPATCHER_PASSWORD = 'abc'

Once this is done, we can call the metadata route, for retrieving information we will need later. We can just do this:

from requests import get

response = get(
    DISPATCHER_BASEURL + '/.well-known/teapots-teal-metadata',
    timeout=10,
)
data = response.json()

teal_redirect_url = data['callback_url']
teal_error_url = data['errback_url']
teal_websocket_url = data['websocket_url']

Note

The output from this endpoint resembles the following:

{
    "server_version": "0.2",
    "websocket_url": "ws://localhost:8081/websocket",
    "callback_url": "http://localhost:8080/callback",
    "errback_url": "http://localhost:8080/errback"
}

Connecting to the dispatcher#

The websocket dispatcher uses HTTP Basic authentication, as defined in RFC 2617. This means that you must provide an Authorization header on the initial HTTP handshake to be able to connect.

We need to prepare our authorization header value first, by doing the following:

from base64 import b64encode

auth_header = 'Basic ' + b64encode(
    ('anonymous:' + DISPATCHER_PASSWORD).encode('ascii'),
).decode('ascii')

Now that this header is ready, we can connect to the websocket dispatcher using the URL we’ve obtained in the last section:

from websockets.sync.client import connect

websocket = connect(
    teal_websocket_url,
    additional_headers={'Authorization': auth_header},
)

Binding a state#

In order to bind a callback state, we need to generate one locally. For doing this, we will use secrets.token_hex():

from secrets import token_hex

state = token_hex(8)

Note

The TeaL Websocket Dispatcher doesn’t generate a state for us, because it assumes that:

  • A client may want to redefine parameters for a given callback, by re-creating it.

  • A client may know of constraints on the state generation, that it can apply directly.

Now that we are connected to the dispatcher, the first message we need to send contains two parts:

  • The first part, called “creation”, describes the callback state we want to register, and the parameters to apply.

  • The second part, called “registration”, describes the events we want to register to; here, the events regarding the callback state we’ve registered.

Both these parts could be sent in different messages, but we send it into one directly here, for simplicity, with the following code:

from json import dumps

expires_at = datetime.utcnow() + timedelta(days=7)
websocket.send(dumps({
    'create': {
        'type': 'callback',
        'state': state,
        'final_redirect_url': final_redirect_url,
        'with_fragment': False,
        'expires_at': expires_at.isoformat(),
    },
    'register_to': {
        'type': 'callback',
        'state': state,
    },
}))

You can now display the initial input for the script, as presented in the target in the introduction:

print()
print('The following state has been bound with the provided parameters:')
print('')
print('* Redirect URL:', teal_redirect_url)
print('* Error URL:', teal_error_url)
print('* State:', state)
print()
print('The received events will be displayed hereafter:')
print()
print('---')

Receiving callback events#

In order to receive callback events, you can use websockets.sync.client.ClientConnection.recv():

from json import loads

while True:
    message = websocket.recv()
    data = loads(message)

    ...

A typical message signalling a callback event will resemble this:

{
    "type": "callback",
    "timestamp": "2023-08-18T23:51:41.694793",
    "url": "http://localhost:8080/callback?state=123&key=omg",
    "state": "123"
}

In order to use the same display as present in the target, you can use the following code:

print()
print('A callback has been received at ' + data['timestamp'] + '!')
print(data['url'])