From 95172492c31fa1e210a3d82db49ef294fe0dec4a Mon Sep 17 00:00:00 2001 From: maxkahan Date: Wed, 22 Jan 2025 11:20:32 +0000 Subject: [PATCH] add jwt generation, new archive api options --- CHANGES.md | 4 +++ opentok/opentok.py | 51 ++++++++++++++++++++++++-------- sample/HelloWorld/helloworld.py | 6 ++-- tests/helpers.py | 38 ++++++++++++++---------- tests/test_http_options.py | 1 + tests/test_session.py | 12 ++++---- tests/test_token_generation.py | 52 +++++++++++++++------------------ 7 files changed, 96 insertions(+), 68 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6953b02..0bee19c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# Release 3.10.0 +- Add new `max_bitrate` option for archives +- Change to create JWTs by default in the `Client.generate_token` method. T1 tokens can still be created. + # Release 3.9.2 - Migrate from using `python-jose` with native-python cryptographic backend to the `pyjwt` package diff --git a/opentok/opentok.py b/opentok/opentok.py index 5a1a31b..1468eab 100644 --- a/opentok/opentok.py +++ b/opentok/opentok.py @@ -6,7 +6,7 @@ import time # generate_token import hmac # _sign_string import hashlib -from typing import List # use for type hinting +from typing import List import requests # create_session, archiving import json # archiving import platform # user-agent @@ -174,6 +174,7 @@ def generate_token( expire_time=None, data=None, initial_layout_class_list=[], + use_jwt=True, ): """ Generates a token for a given session. @@ -212,6 +213,9 @@ def generate_token( `live streaming broadcasts `_ and `composed archives `_ + :param bool use_jwt: Whether to use JWT tokens or not. If set to False, the token will be a + plain text token. If set to True (the default), the token will be a JWT. + :rtype: The token string. """ @@ -287,7 +291,7 @@ def generate_token( try: decoded_session_id = base64.b64decode(sub_session_id_bytes_padded, b("-_")) parts = decoded_session_id.decode("utf-8").split(u("~")) - except Exception as e: + except Exception: raise OpenTokException( u("Cannot generate token, the session_id {0} was not valid").format( session_id @@ -300,6 +304,29 @@ def generate_token( ).format(session_id, self.api_key) ) + if use_jwt: + payload = {} + payload['iss'] = self.api_key + payload['ist'] = 'project' + payload['iat'] = now + payload["exp"] = expire_time + payload['nonce'] = random.randint(0, 999999) + payload['role'] = role.value + payload['scope'] = 'session.connect' + payload['session_id'] = session_id + if initial_layout_class_list: + payload['initial_layout_class_list'] = ( + initial_layout_class_list_serialized + ) + if data: + payload['connection_data'] = data + + headers = {'alg': 'HS256', 'typ': 'JWT'} + + token = encode(payload, self.api_secret, algorithm="HS256", headers=headers) + + return f'Bearer {token}' + data_params = dict( session_id=session_id, create_time=now, @@ -470,7 +497,7 @@ def create_session( try: logger.debug( "POST to %r with params %r, headers %r, proxies %r", - self.endpoints.session_url(), + self.endpoints.get_session_url(), options, self.get_headers(), self.proxies, @@ -654,7 +681,7 @@ def start_archive( logger.debug( "POST to %r with params %r, headers %r, proxies %r", - self.endpoints.archive_url(), + self.endpoints.get_archive_url(), json.dumps(payload), self.get_json_headers(), self.proxies, @@ -701,7 +728,7 @@ def stop_archive(self, archive_id): """ logger.debug( "POST to %r with headers %r, proxies %r", - self.endpoints.archive_url(archive_id) + "/stop", + self.endpoints.get_archive_url(archive_id) + "/stop", self.get_json_headers(), self.proxies, ) @@ -736,7 +763,7 @@ def delete_archive(self, archive_id): """ logger.debug( "DELETE to %r with headers %r, proxies %r", - self.endpoints.archive_url(archive_id), + self.endpoints.get_archive_url(archive_id), self.get_json_headers(), self.proxies, ) @@ -766,7 +793,7 @@ def get_archive(self, archive_id): """ logger.debug( "GET to %r with headers %r, proxies %r", - self.endpoints.archive_url(archive_id), + self.endpoints.get_archive_url(archive_id), self.get_json_headers(), self.proxies, ) @@ -959,7 +986,7 @@ def send_signal(self, session_id, payload, connection_id=None): """ logger.debug( "POST to %r with params %r, headers %r, proxies %r", - self.endpoints.signaling_url(session_id, connection_id), + self.endpoints.get_signaling_url(session_id, connection_id), json.dumps(payload), self.get_json_headers(), self.proxies, @@ -1456,7 +1483,7 @@ def start_broadcast(self, session_id, options, stream_mode=BroadcastStreamModes. payload.update(options) - endpoint = self.endpoints.broadcast_url() + endpoint = self.endpoints.get_broadcast_url() logger.debug( "POST to %r with params %r, headers %r, proxies %r", @@ -1500,7 +1527,7 @@ def stop_broadcast(self, broadcast_id): projectId, createdAt, updatedAt and resolution """ - endpoint = self.endpoints.broadcast_url(broadcast_id, stop=True) + endpoint = self.endpoints.get_broadcast_url(broadcast_id, stop=True) logger.debug( "POST to %r with headers %r, proxies %r", @@ -1639,7 +1666,7 @@ def get_broadcast(self, broadcast_id): projectId, createdAt, updatedAt, resolution, broadcastUrls and status """ - endpoint = self.endpoints.broadcast_url(broadcast_id) + endpoint = self.endpoints.get_broadcast_url(broadcast_id) logger.debug( "GET to %r with headers %r, proxies %r", @@ -1697,7 +1724,7 @@ def set_broadcast_layout( if stylesheet is not None: payload["stylesheet"] = stylesheet - endpoint = self.endpoints.broadcast_url(broadcast_id, layout=True) + endpoint = self.endpoints.get_broadcast_url(broadcast_id, layout=True) logger.debug( "PUT to %r with params %r, headers %r, proxies %r", diff --git a/sample/HelloWorld/helloworld.py b/sample/HelloWorld/helloworld.py index 7dc4073..a6afa35 100644 --- a/sample/HelloWorld/helloworld.py +++ b/sample/HelloWorld/helloworld.py @@ -17,10 +17,8 @@ def hello(): key = api_key session_id = session.session_id - token = opentok.generate_token(session_id) - return render_template( - "index.html", api_key=key, session_id=session_id, token=token - ) + token = opentok.generate_token(session_id, use_jwt=True) + return render_template("index.html", api_key=key, session_id=session_id, token=token) if __name__ == "__main__": diff --git a/tests/helpers.py b/tests/helpers.py index 2e8180b..5cd4a91 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,26 +1,32 @@ -from six import text_type, u, b, PY3 +from six import u, PY3 from six.moves.urllib.parse import parse_qs import base64 import hmac import hashlib +from jwt import decode -def token_decoder(token): +def token_decoder(token: str, secret: str = None): token_data = {} - # remove sentinal - encoded = token[4:] - decoded = base64.b64decode(encoded.encode("utf-8")) - # decode the bytes object back to unicode with utf-8 encoding - if PY3: - decoded = decoded.decode() - parts = decoded.split(u(":")) - for decoded_part in iter(parts): - token_data.update(parse_qs(decoded_part)) - # TODO: probably a more elegent way - for k in iter(token_data): - token_data[k] = token_data[k][0] - token_data[u("data_string")] = parts[1] - return token_data + if token.startswith("T1=="): + encoded = token[4:] + + # decode the token from base64 + decoded = base64.b64decode(encoded.encode("utf-8")) + # decode the bytes object back to unicode with utf-8 encoding + if PY3: + decoded = decoded.decode() + parts = decoded.split(u(":")) + for decoded_part in iter(parts): + token_data.update(parse_qs(decoded_part)) + # TODO: probably a more elegant way + for k in iter(token_data): + token_data[k] = token_data[k][0] + token_data[u("data_string")] = parts[1] + return token_data + + encoded = token.replace('Bearer ', '').strip() + return decode(encoded, secret, algorithms='HS256') def token_signature_validator(token, secret): diff --git a/tests/test_http_options.py b/tests/test_http_options.py index 8b7b3d9..94d5516 100644 --- a/tests/test_http_options.py +++ b/tests/test_http_options.py @@ -27,6 +27,7 @@ def setUp(self): def tearDown(self): httpretty.disable() + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") def test_timeout(self): with pytest.raises(OpenTokException): opentok = Client(self.api_key, self.api_secret, timeout=1) diff --git a/tests/test_session.py b/tests/test_session.py index 959fa40..4d2738d 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,8 +1,8 @@ import unittest -from six import text_type, u, b, PY2, PY3 +from six import text_type, u from opentok import Client, Session, Roles, MediaModes -from .helpers import token_decoder, token_signature_validator +from .helpers import token_decoder class SessionTest(unittest.TestCase): @@ -20,8 +20,7 @@ def test_generate_token(self): ) token = session.generate_token() assert isinstance(token, text_type) - assert token_decoder(token)[u("session_id")] == self.session_id - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("session_id")] == self.session_id def test_generate_role_token(self): session = Session( @@ -29,6 +28,5 @@ def test_generate_role_token(self): ) token = session.generate_token(role=Roles.moderator) assert isinstance(token, text_type) - assert token_decoder(token)[u("session_id")] == self.session_id - assert token_decoder(token)[u("role")] == u("moderator") - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("session_id")] == self.session_id + assert token_decoder(token, self.api_secret)[u("role")] == u("moderator") diff --git a/tests/test_token_generation.py b/tests/test_token_generation.py index 28d68bf..07e943a 100644 --- a/tests/test_token_generation.py +++ b/tests/test_token_generation.py @@ -20,62 +20,56 @@ def setUp(self): ) self.opentok = Client(self.api_key, self.api_secret) - def test_generate_plain_token(self): - token = self.opentok.generate_token(self.session_id) + def test_generate_plain_token_t1(self): + token = self.opentok.generate_token(self.session_id, use_jwt=False) assert isinstance(token, text_type) assert token_decoder(token)[u("session_id")] == self.session_id assert token_signature_validator(token, self.api_secret) + def test_generate_plain_token_jwt(self): + token = self.opentok.generate_token(self.session_id) + assert isinstance(token, text_type) + assert token_decoder(token, self.api_secret)[u("session_id")] == self.session_id + def test_generate_role_token(self): token = self.opentok.generate_token(self.session_id, Roles.moderator) assert isinstance(token, text_type) - assert token_decoder(token)[u("role")] == Roles.moderator.value - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("role")] == Roles.moderator.value token = self.opentok.generate_token(self.session_id, role=Roles.moderator) assert isinstance(token, text_type) - assert token_decoder(token)[u("role")] == Roles.moderator.value - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("role")] == Roles.moderator.value token = self.opentok.generate_token(self.session_id, Roles.publisher_only) - assert token_decoder(token)["role"] == Roles.publisher_only.value - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)["role"] == Roles.publisher_only.value def test_generate_expires_token(self): # an integer is a valid argument expire_time = int(time.time()) + 100 token = self.opentok.generate_token(self.session_id, expire_time=expire_time) assert isinstance(token, text_type) - assert token_decoder(token)[u("expire_time")] == text_type(expire_time) - assert token_signature_validator(token, self.api_secret) + print(token_decoder(token, self.api_secret)) + assert token_decoder(token, self.api_secret)[u("exp")] == expire_time # anything that can be coerced into an integer is also valid expire_time = text_type(int(time.time()) + 100) token = self.opentok.generate_token(self.session_id, expire_time=expire_time) assert isinstance(token, text_type) - assert token_decoder(token)[u("expire_time")] == expire_time - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("exp")] == int(expire_time) # a datetime object is also valid - if PY2: - expire_time = datetime.datetime.fromtimestamp( - time.time(), pytz.UTC - ) + datetime.timedelta(days=1) - if PY3: - expire_time = datetime.datetime.fromtimestamp( - time.time(), datetime.timezone.utc - ) + datetime.timedelta(days=1) + expire_time = datetime.datetime.fromtimestamp( + time.time(), datetime.timezone.utc + ) + datetime.timedelta(days=1) token = self.opentok.generate_token(self.session_id, expire_time=expire_time) assert isinstance(token, text_type) - assert token_decoder(token)[u("expire_time")] == text_type( - calendar.timegm(expire_time.utctimetuple()) + assert token_decoder(token, self.api_secret)[u("exp")] == calendar.timegm( + expire_time.utctimetuple() ) - assert token_signature_validator(token, self.api_secret) def test_generate_data_token(self): data = u("name=Johnny") token = self.opentok.generate_token(self.session_id, data=data) assert isinstance(token, text_type) - assert token_decoder(token)[u("connection_data")] == data - assert token_signature_validator(token, self.api_secret) + assert token_decoder(token, self.api_secret)[u("connection_data")] == data def test_generate_initial_layout_class_list(self): initial_layout_class_list = [u("focus"), u("small")] @@ -84,15 +78,15 @@ def test_generate_initial_layout_class_list(self): ) assert isinstance(token, text_type) assert sorted( - token_decoder(token)[u("initial_layout_class_list")].split(u(" ")) + token_decoder(token, self.api_secret)[u("initial_layout_class_list")].split( + u(" ") + ) ) == sorted(initial_layout_class_list) - assert token_signature_validator(token, self.api_secret) def test_generate_no_data_token(self): token = self.opentok.generate_token(self.session_id) assert isinstance(token, text_type) - assert u("connection_data") not in token_decoder(token) - assert token_signature_validator(token, self.api_secret) + assert u("connection_data") not in token_decoder(token, self.api_secret) def test_does_not_generate_token_without_params(self): with pytest.raises(TypeError):