Skip to content

Commit e7e9b8c

Browse files
JonnyWong16Copilot
andauthored
Fix Plex JWT signature verification (#1577)
* Fix Plex JWT signature verification * Fix decoding json response in MyPlexJWTLogin * Add test for MyPlexJWTLogin * Update decodePlexJWT doc string * Remove redundant else statement * Use tmp_path for jwt test keys * Test invalid Plex JWK signatures * Revert decodedJWT with signature verification * Fix test invalid JWK * Fix tmp_path in JWT test keys Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix self in jwt test monkeypatch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 09ea3c5 commit e7e9b8c

2 files changed

Lines changed: 77 additions & 26 deletions

File tree

plexapi/myplex.py

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,23 +2145,45 @@ def _encodeClientJWT(self):
21452145
headers=headers
21462146
)
21472147

2148-
def _decodePlexJWT(self):
2149-
""" Returns the decoded and verified Plex JWT using the Plex public JWK. """
2150-
return jwt.decode(
2151-
self.jwtToken,
2152-
key=jwt.PyJWK.from_dict(self._getPlexPublicJWK()),
2153-
algorithms=['EdDSA'],
2154-
options={
2155-
'require': ['aud', 'iss', 'exp', 'iat', 'thumbprint']
2156-
},
2157-
audience=['plex.tv', self._clientIdentifier],
2158-
issuer='plex.tv',
2159-
)
2148+
def decodePlexJWT(self, verify_signature=True):
2149+
""" Returns the decoded Plex JWT with optional signature verification using the Plex public JWK.
2150+
2151+
Parameters:
2152+
verify_signature (bool): Whether to verify the JWT signature and required claims.
2153+
Defaults to True. Set to False to skip signature verification and required-claim enforcement.
2154+
"""
2155+
kwargs = {
2156+
'jwt': self.jwtToken,
2157+
'algorithms': ['EdDSA'],
2158+
'options': {'verify_signature': verify_signature},
2159+
'audience': ['plex.tv', self._clientIdentifier],
2160+
'issuer': 'plex.tv',
2161+
}
2162+
2163+
if not verify_signature:
2164+
return jwt.decode(**kwargs)
2165+
2166+
kwargs['options']['require'] = ['aud', 'iss', 'exp', 'iat', 'thumbprint']
2167+
2168+
for plexJWK in reversed(self._getPlexPublicJWK()):
2169+
try:
2170+
return jwt.decode(
2171+
key=jwt.PyJWK.from_dict(plexJWK),
2172+
**kwargs
2173+
)
2174+
except jwt.InvalidSignatureError:
2175+
continue
2176+
except jwt.InvalidTokenError as e:
2177+
log.warning('Invalid Plex JWT: %s', str(e))
2178+
raise
2179+
2180+
log.warning('Plex JWT signature could not be verified with any known Plex JWKs')
2181+
raise jwt.InvalidSignatureError
21602182

21612183
@property
21622184
def decodedJWT(self):
2163-
""" Returns the decoded Plex JWT. """
2164-
return self._decodePlexJWT()
2185+
""" Returns the decoded Plex JWT with signature verification and required-claim enforcement. """
2186+
return self.decodePlexJWT()
21652187

21662188
def _registerPlexDevice(self):
21672189
""" Registers the public JWK with Plex. """
@@ -2184,10 +2206,10 @@ def _exchangePlexJWT(self):
21842206
return data['auth_token']
21852207

21862208
def _getPlexPublicJWK(self):
2187-
""" Gets the Plex public JWK. """
2209+
""" Gets the Plex public JWKs. """
21882210
url = f'{self.AUTH}/keys'
21892211
data = self._query(url, method=self._session.get)
2190-
return data['keys'][0]
2212+
return data['keys']
21912213

21922214
def registerDevice(self):
21932215
""" Registers the device with Plex using the provided token and private/public keypair.
@@ -2233,14 +2255,7 @@ def verifyJWT(self, refreshWithinDays=1):
22332255
"""
22342256
try:
22352257
decodedJWT = self.decodedJWT
2236-
except jwt.ExpiredSignatureError:
2237-
log.warning('Existing JWT has expired')
2238-
return False
2239-
except jwt.InvalidSignatureError:
2240-
log.warning('Existing JWT has invalid signature')
2241-
return False
2242-
except jwt.InvalidTokenError as e:
2243-
log.warning(f'Existing JWT is invalid: {e}')
2258+
except jwt.InvalidTokenError:
22442259
return False
22452260
else:
22462261
if decodedJWT['thumbprint'] != self._keyID:
@@ -2428,7 +2443,7 @@ def _query(self, url, method=None, headers=None, **kwargs):
24282443
codename = codes.get(response.status_code)[0]
24292444
errtext = response.text.replace('\n', ' ')
24302445
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
2431-
if 'application/json' in response.headers.get('Content-Type', ''):
2446+
if 'application/json' in response.headers.get('Content-Type', '') and len(response.content):
24322447
return response.json()
24332448
return utils.parseXMLString(response.text)
24342449

tests/test_myplex.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import jwt
2+
13
import pytest
24
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
3-
from plexapi.myplex import MyPlexInvite
5+
from plexapi.myplex import MyPlexAccount, MyPlexInvite, MyPlexJWTLogin
46

57
from . import conftest as utils
68
from .payloads import MYPLEX_INVITE
@@ -364,3 +366,37 @@ def test_myplex_geoip(account):
364366

365367
def test_myplex_ping(account):
366368
assert account.ping()
369+
370+
371+
def test_myplex_jwt_login(account, tmp_path, monkeypatch):
372+
jwtlogin = MyPlexJWTLogin(
373+
token=account.authToken,
374+
scopes=['username', 'email', 'friendly_name']
375+
)
376+
jwtlogin.generateKeypair(keyfiles=(tmp_path / 'private.key', tmp_path / 'public.key'), overwrite=True)
377+
with pytest.raises(FileExistsError):
378+
jwtlogin.generateKeypair(keyfiles=(tmp_path / 'private.key', tmp_path / 'public.key'))
379+
jwtlogin.registerDevice()
380+
jwtToken = jwtlogin.refreshJWT()
381+
assert jwtlogin.decodedJWT['user']['username'] == account.username
382+
assert MyPlexAccount(token=jwtToken) == account
383+
384+
jwtlogin = MyPlexJWTLogin(
385+
jwtToken=jwtToken,
386+
keypair=(tmp_path / 'private.key', tmp_path / 'public.key'),
387+
scopes=['username', 'email', 'friendly_name']
388+
)
389+
assert jwtlogin.verifyJWT()
390+
newjwtToken = jwtlogin.refreshJWT()
391+
assert newjwtToken != jwtToken
392+
assert MyPlexAccount(token=newjwtToken) == account
393+
394+
plexPublicJWKs = jwtlogin._getPlexPublicJWK()
395+
invalidJWK = plexPublicJWKs[0].copy()
396+
invalidJWK['x'] += 'invalid'
397+
monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda self: plexPublicJWKs + [invalidJWK])
398+
assert jwtlogin.decodePlexJWT()
399+
400+
monkeypatch.setattr(MyPlexJWTLogin, "_getPlexPublicJWK", lambda self: [invalidJWK])
401+
with pytest.raises(jwt.InvalidSignatureError):
402+
jwtlogin.decodePlexJWT()

0 commit comments

Comments
 (0)