From 543a0e0ca5c4252b8f09d80d3a158f891b323187 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:57:18 -0800 Subject: [PATCH 01/11] Fix Plex JWT signature verification --- plexapi/myplex.py | 64 +++++++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 5785e6175..f4f1e5428 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2146,23 +2146,44 @@ def _encodeClientJWT(self): headers=headers ) - def _decodePlexJWT(self): - """ Returns the decoded and verified Plex JWT using the Plex public JWK. """ - return jwt.decode( - self.jwtToken, - key=jwt.PyJWK.from_dict(self._getPlexPublicJWK()), - algorithms=['EdDSA'], - options={ - 'require': ['aud', 'iss', 'exp', 'iat', 'thumbprint'] - }, - audience=['plex.tv', self._clientIdentifier], - issuer='plex.tv', - ) + def decodePlexJWT(self, verify_signature=True): + """ Returns the decoded and verified Plex JWT using the Plex public JWK. + + Parameters: + verify_signature (bool): Set to False to skip signature verification. + """ + kwargs = { + 'jwt': self.jwtToken, + 'algorithms': ['EdDSA'], + 'options': {'verify_signature': verify_signature}, + 'audience': ['plex.tv', self._clientIdentifier], + 'issuer': 'plex.tv', + } + + if not verify_signature: + return jwt.decode(**kwargs) + + kwargs['options']['require'] = ['aud', 'iss', 'exp', 'iat', 'thumbprint'] + + for plexJWK in self._getPlexPublicJWK(): + try: + return jwt.decode( + key=jwt.PyJWK.from_dict(plexJWK), + **kwargs + ) + except jwt.InvalidSignatureError: + continue + except jwt.InvalidTokenError as e: + log.warning('Invalid Plex JWT: %s', str(e)) + raise + else: + log.warning('Plex JWT signature could not be verified with any known Plex JWKs') + raise jwt.InvalidSignatureError @property def decodedJWT(self): - """ Returns the decoded Plex JWT. """ - return self._decodePlexJWT() + """ Returns the decoded Plex JWT without any signature verification. """ + return self.decodePlexJWT(verify_signature=False) def _registerPlexDevice(self): """ Registers the public JWK with Plex. """ @@ -2185,10 +2206,10 @@ def _exchangePlexJWT(self): return data['auth_token'] def _getPlexPublicJWK(self): - """ Gets the Plex public JWK. """ + """ Gets the Plex public JWKs. """ url = f'{self.AUTH}/keys' data = self._query(url, method=self._session.get) - return data['keys'][0] + return reversed(data['keys']) def registerDevice(self): """ Registers the device with Plex using the provided token and private/public keypair. @@ -2233,15 +2254,8 @@ def verifyJWT(self, refreshWithinDays=1): the JWT invalid and in need of refresh. Default is 1 day. """ try: - decodedJWT = self.decodedJWT - except jwt.ExpiredSignatureError: - log.warning('Existing JWT has expired') - return False - except jwt.InvalidSignatureError: - log.warning('Existing JWT has invalid signature') - return False - except jwt.InvalidTokenError as e: - log.warning(f'Existing JWT is invalid: {e}') + decodedJWT = self.decodePlexJWT() + except jwt.InvalidTokenError: return False else: if decodedJWT['thumbprint'] != self._keyID: From 352a48819ea873ea3b50fcf34648c4f66b81e0a5 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:35:20 -0800 Subject: [PATCH 02/11] Fix decoding json response in MyPlexJWTLogin --- plexapi/myplex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index f4f1e5428..d074b76c0 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2443,7 +2443,7 @@ def _query(self, url, method=None, headers=None, **kwargs): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}') - if 'application/json' in response.headers.get('Content-Type', ''): + if 'application/json' in response.headers.get('Content-Type', '') and len(response.content): return response.json() return utils.parseXMLString(response.text) From 30f0ac6e625eb4e0674680666fad735135f20631 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:35:54 -0800 Subject: [PATCH 03/11] Add test for MyPlexJWTLogin --- tests/test_myplex.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 613b6efa5..89ebf2939 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import pytest from plexapi.exceptions import BadRequest, NotFound, Unauthorized -from plexapi.myplex import MyPlexInvite +from plexapi.myplex import MyPlexAccount, MyPlexInvite, MyPlexJWTLogin from . import conftest as utils from .payloads import MYPLEX_INVITE @@ -366,3 +366,29 @@ def test_myplex_geoip(account): def test_myplex_ping(account): assert account.ping() + + +def test_myplex_jwt_login(account): + jwtlogin = MyPlexJWTLogin( + token=account.authToken, + scopes=['username', 'email', 'friendly_name'] + ) + jwtlogin.generateKeypair(keyfiles=('private.key', 'public.key'), overwrite=True) + with pytest.raises(FileExistsError): + jwtlogin.generateKeypair(keyfiles=('private.key', 'public.key')) + jwtlogin.registerDevice() + jwtToken = jwtlogin.refreshJWT() + assert jwtlogin.decodePlexJWT() + assert jwtlogin.decodedJWT['user']['username'] == account.username + assert MyPlexAccount(token=jwtToken) == account + + jwtlogin = MyPlexJWTLogin( + jwtToken=jwtToken, + keypair=('private.key', 'public.key'), + scopes=['username', 'email', 'friendly_name'] + ) + assert jwtlogin.verifyJWT() + newjwtToken = jwtlogin.refreshJWT() + assert newjwtToken != jwtToken + assert jwtlogin.decodePlexJWT() + assert MyPlexAccount(token=newjwtToken) == account From a11f3512a7882633cb226ad30b777466b13eb989 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:58:23 -0800 Subject: [PATCH 04/11] Update decodePlexJWT doc string --- plexapi/myplex.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index d074b76c0..932bbcccd 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2147,10 +2147,11 @@ def _encodeClientJWT(self): ) def decodePlexJWT(self, verify_signature=True): - """ Returns the decoded and verified Plex JWT using the Plex public JWK. + """ Returns the decoded Plex JWT with optional signature verification using the Plex public JWK. Parameters: - verify_signature (bool): Set to False to skip signature verification. + verify_signature (bool): Whether to verify the JWT signature and required claims. + Defaults to True. Set to False to skip signature verification and required-claim enforcement. """ kwargs = { 'jwt': self.jwtToken, From 095eb70aaa178d5a635bcdadb07faae416367a2b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:59:18 -0800 Subject: [PATCH 05/11] Remove redundant else statement --- plexapi/myplex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 932bbcccd..e77a1b8a5 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2177,9 +2177,9 @@ def decodePlexJWT(self, verify_signature=True): except jwt.InvalidTokenError as e: log.warning('Invalid Plex JWT: %s', str(e)) raise - else: - log.warning('Plex JWT signature could not be verified with any known Plex JWKs') - raise jwt.InvalidSignatureError + + log.warning('Plex JWT signature could not be verified with any known Plex JWKs') + raise jwt.InvalidSignatureError @property def decodedJWT(self): From ceb3cece295ae5e5e6688372c63252c04d93dae8 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:02:10 -0800 Subject: [PATCH 06/11] Use tmp_path for jwt test keys --- tests/test_myplex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 89ebf2939..85c45e4bb 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -368,14 +368,14 @@ def test_myplex_ping(account): assert account.ping() -def test_myplex_jwt_login(account): +def test_myplex_jwt_login(account, tmp_path): jwtlogin = MyPlexJWTLogin( token=account.authToken, scopes=['username', 'email', 'friendly_name'] ) - jwtlogin.generateKeypair(keyfiles=('private.key', 'public.key'), overwrite=True) + jwtlogin.generateKeypair(keyfiles=(tmp_path / 'private.key', tmp_path / 'public.key'), overwrite=True) with pytest.raises(FileExistsError): - jwtlogin.generateKeypair(keyfiles=('private.key', 'public.key')) + jwtlogin.generateKeypair(keyfiles=(tmp_path / 'private.key', tmp_path / 'public.key')) jwtlogin.registerDevice() jwtToken = jwtlogin.refreshJWT() assert jwtlogin.decodePlexJWT() From 8b8a07366e8bd5e9853337718bb737af7f89470e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:19:44 -0800 Subject: [PATCH 07/11] Test invalid Plex JWK signatures --- tests/test_myplex.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 85c45e4bb..d2f6bc1e8 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import jwt + import pytest from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.myplex import MyPlexAccount, MyPlexInvite, MyPlexJWTLogin @@ -368,7 +370,7 @@ def test_myplex_ping(account): assert account.ping() -def test_myplex_jwt_login(account, tmp_path): +def test_myplex_jwt_login(account, tmp_path, monkeypatch): jwtlogin = MyPlexJWTLogin( token=account.authToken, scopes=['username', 'email', 'friendly_name'] @@ -378,7 +380,6 @@ def test_myplex_jwt_login(account, tmp_path): jwtlogin.generateKeypair(keyfiles=(tmp_path / 'private.key', tmp_path / 'public.key')) jwtlogin.registerDevice() jwtToken = jwtlogin.refreshJWT() - assert jwtlogin.decodePlexJWT() assert jwtlogin.decodedJWT['user']['username'] == account.username assert MyPlexAccount(token=jwtToken) == account @@ -390,5 +391,14 @@ def test_myplex_jwt_login(account, tmp_path): assert jwtlogin.verifyJWT() newjwtToken = jwtlogin.refreshJWT() assert newjwtToken != jwtToken - assert jwtlogin.decodePlexJWT() assert MyPlexAccount(token=newjwtToken) == account + + plexPublicJWKs = jwtlogin._getPlexPublicJWK() + invalidJWK = plexPublicJWKs[0].copy() + invalidJWK['x'] += b'0' + monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: [invalidJWK] + plexPublicJWKs) + assert jwtlogin.decodePlexJWT() + + monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: [invalidJWK]) + with pytest.raises(jwt.InvalidSignatureError): + jwtlogin.decodePlexJWT() From daf0d8da757a7324b3619cff13d3b6e6eabe0e63 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:30:48 -0800 Subject: [PATCH 08/11] Revert decodedJWT with signature verification --- plexapi/myplex.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index e77a1b8a5..e50c7fe28 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -2166,7 +2166,7 @@ def decodePlexJWT(self, verify_signature=True): kwargs['options']['require'] = ['aud', 'iss', 'exp', 'iat', 'thumbprint'] - for plexJWK in self._getPlexPublicJWK(): + for plexJWK in reversed(self._getPlexPublicJWK()): try: return jwt.decode( key=jwt.PyJWK.from_dict(plexJWK), @@ -2183,8 +2183,8 @@ def decodePlexJWT(self, verify_signature=True): @property def decodedJWT(self): - """ Returns the decoded Plex JWT without any signature verification. """ - return self.decodePlexJWT(verify_signature=False) + """ Returns the decoded Plex JWT with signature verification and required-claim enforcement. """ + return self.decodePlexJWT() def _registerPlexDevice(self): """ Registers the public JWK with Plex. """ @@ -2210,7 +2210,7 @@ def _getPlexPublicJWK(self): """ Gets the Plex public JWKs. """ url = f'{self.AUTH}/keys' data = self._query(url, method=self._session.get) - return reversed(data['keys']) + return data['keys'] def registerDevice(self): """ Registers the device with Plex using the provided token and private/public keypair. @@ -2255,7 +2255,7 @@ def verifyJWT(self, refreshWithinDays=1): the JWT invalid and in need of refresh. Default is 1 day. """ try: - decodedJWT = self.decodePlexJWT() + decodedJWT = self.decodedJWT except jwt.InvalidTokenError: return False else: From 8dd7c8065a73f268083afb4f0c798f3225ce7445 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:31:11 -0800 Subject: [PATCH 09/11] Fix test invalid JWK --- tests/test_myplex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index d2f6bc1e8..41c3d77cd 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -395,8 +395,8 @@ def test_myplex_jwt_login(account, tmp_path, monkeypatch): plexPublicJWKs = jwtlogin._getPlexPublicJWK() invalidJWK = plexPublicJWKs[0].copy() - invalidJWK['x'] += b'0' - monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: [invalidJWK] + plexPublicJWKs) + invalidJWK['x'] += 'invalid' + monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: plexPublicJWKs + [invalidJWK]) assert jwtlogin.decodePlexJWT() monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: [invalidJWK]) From 3c6cab76bb55d49c875e1873ab2db0bd813e5d1c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:46:12 -0800 Subject: [PATCH 10/11] Fix tmp_path in JWT test keys Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_myplex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 41c3d77cd..cd631f672 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -385,7 +385,7 @@ def test_myplex_jwt_login(account, tmp_path, monkeypatch): jwtlogin = MyPlexJWTLogin( jwtToken=jwtToken, - keypair=('private.key', 'public.key'), + keypair=(tmp_path / 'private.key', tmp_path / 'public.key'), scopes=['username', 'email', 'friendly_name'] ) assert jwtlogin.verifyJWT() From dce438ca2a5c2ec3df86f45375fd3e00bf49e8af Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:46:48 -0800 Subject: [PATCH 11/11] Fix self in jwt test monkeypatch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_myplex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index cd631f672..ead1d4f87 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -396,9 +396,9 @@ def test_myplex_jwt_login(account, tmp_path, monkeypatch): plexPublicJWKs = jwtlogin._getPlexPublicJWK() invalidJWK = plexPublicJWKs[0].copy() invalidJWK['x'] += 'invalid' - monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: plexPublicJWKs + [invalidJWK]) + monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda self: plexPublicJWKs + [invalidJWK]) assert jwtlogin.decodePlexJWT() - monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda: [invalidJWK]) + monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda self: [invalidJWK]) with pytest.raises(jwt.InvalidSignatureError): jwtlogin.decodePlexJWT()