Skip to content

Commit 06f4fba

Browse files
committed
Add MyPlexJWTAuth class
1 parent 9a4d64a commit 06f4fba

File tree

2 files changed

+305
-1
lines changed

2 files changed

+305
-1
lines changed

plexapi/myplex.py

Lines changed: 299 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
# -*- coding: utf-8 -*-
22
import copy
3+
import hashlib
34
import html
45
import threading
56
import time
7+
from datetime import datetime, timedelta
68
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
79

810
import requests
911

12+
try:
13+
import cryptography
14+
from cryptography.hazmat.primitives import serialization
15+
from cryptography.hazmat.primitives.asymmetric import ed25519
16+
except ImportError: # pragma: no cover
17+
cryptography = None
18+
19+
try:
20+
import jwt
21+
except ImportError: # pragma: no cover
22+
jwt = None
23+
1024
from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER,
1125
log, logfilter, utils)
1226
from plexapi.base import PlexObject, cached_data_property
@@ -1686,7 +1700,7 @@ class MyPlexPinLogin:
16861700
16871701
Parameters:
16881702
session (requests.Session, optional): Use your own session object if you want to
1689-
cache the http responses from PMS
1703+
cache the http responses from Plex.
16901704
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
16911705
headers (dict): A dict of X-Plex headers to send with requests.
16921706
oauth (bool): True to use Plex OAuth instead of PIN login.
@@ -1897,6 +1911,290 @@ def _query(self, url, method=None, headers=None, **kwargs):
18971911
return utils.parseXMLString(response.text)
18981912

18991913

1914+
class MyPlexJWTAuth:
1915+
""" MyPlex JWT authentication class to obtain a Plex JWT (JSON Web Token) which can be used in place of a Plex token
1916+
when creating a :class:`~plexapi.myplex.MyPlexAccount` instance.
1917+
Requires the ``PyJWT`` with ``cryptography`` packages to be installed (``pyjwt[crypto]``).
1918+
See: https://developer.plex.tv/pms/#section/API-Info/Authenticating-with-Plex
1919+
1920+
Parameters:
1921+
session (requests.Session, optional): Use your own session object if you want to
1922+
cache the http responses from Plex.
1923+
requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT).
1924+
headers (dict): A dict of X-Plex headers to send with requests.
1925+
token (str): Plex token only required to register the device initially.
1926+
jwtToken (str): Existing Plex JWT to verify or refresh.
1927+
keypair (tuple[str|bytes]): A tuple of the full file paths (str) to the ED25519 private and public key pair
1928+
or the raw keys themselves (bytes) to use for signing the JWT.
1929+
1930+
Attributes:
1931+
AUTH (str): 'https://clients.plex.tv/api/v2/auth'
1932+
SCOPES (list): List of all available scopes to request for the JWT.
1933+
jwtToken (str): The Plex JWT received after refreshing.
1934+
1935+
Example:
1936+
1937+
.. code-block:: python
1938+
1939+
from plexapi.myplex import MyPlexAccount, MyPlexJWTAuth
1940+
1941+
# Generate a new Plex JWT
1942+
jwtauth = MyPlexJWTAuth(token='2ffLuB84dqLswk9skLos')
1943+
jwtauth.generateKeypair(keyfiles=('private.key', 'public.key'))
1944+
jwtauth.registerDevice()
1945+
jwtToken = jwtauth.refreshJWT(scope=['username', 'email', 'friendly_name'])
1946+
1947+
account = MyPlexAccount(token=jwtToken)
1948+
1949+
# Refresh an existing Plex JWT
1950+
jwtauth = MyPlexJWTAuth(jwtToken=jwtToken, keypair=('private.key', 'public.key'))
1951+
if not jwtauth.verifyJWT():
1952+
jwtToken = jwtauth.refreshJWT(scope=['username', 'email', 'friendly_name'])
1953+
1954+
account = MyPlexAccount(token=jwtToken)
1955+
1956+
"""
1957+
AUTH = 'https://clients.plex.tv/api/v2/auth'
1958+
SCOPES = ['username', 'email', 'friendly_name', 'restricted', 'anonymous', 'joinedAt']
1959+
1960+
def __init__(self, session=None, requestTimeout=None, headers=None, token=None, jwtToken=None, keypair=(None, None)):
1961+
super(MyPlexJWTAuth, self).__init__()
1962+
self._session = session or requests.Session()
1963+
self._requestTimeout = requestTimeout or TIMEOUT
1964+
self.headers = headers
1965+
self._token = token
1966+
self.jwtToken = jwtToken
1967+
self._privateKey = utils.openOrRead(keypair[0]) if keypair[0] else None
1968+
self._publicKey = utils.openOrRead(keypair[1]) if keypair[1] else None
1969+
self._clientJWT = None
1970+
1971+
if not jwt:
1972+
log.warning('PyJWT package is not installed, cannot use Plex JWT login')
1973+
return
1974+
1975+
def generateKeypair(self, keyfiles=(None, None)):
1976+
""" Generates a new ED25519 private/public keypair for signing the JWT and saves them to files.
1977+
Requires the ``cryptography`` package to be installed.
1978+
1979+
Parameters:
1980+
keyfiles (tuple[str]): A tuple of the full file paths to write the private and public keypair to.
1981+
"""
1982+
if not cryptography:
1983+
log.warning('Cryptography package is not installed, cannot generate ED25519 keypair')
1984+
return
1985+
1986+
privateKey = ed25519.Ed25519PrivateKey.generate()
1987+
publicKey = privateKey.public_key()
1988+
self._privateKey = privateKey.private_bytes(
1989+
encoding=serialization.Encoding.Raw,
1990+
format=serialization.PrivateFormat.Raw,
1991+
encryption_algorithm=serialization.NoEncryption()
1992+
)
1993+
self._publicKey = publicKey.public_bytes(
1994+
encoding=serialization.Encoding.Raw,
1995+
format=serialization.PublicFormat.Raw
1996+
)
1997+
1998+
if keyfiles[0] and keyfiles[1]:
1999+
with open(keyfiles[0], 'wb') as privateFile, open(keyfiles[1], 'wb') as publicFile:
2000+
privateFile.write(self._privateKey)
2001+
publicFile.write(self._publicKey)
2002+
2003+
@cached_data_property
2004+
def _clientIdentifier(self):
2005+
""" Returns the client identifier from the headers. """
2006+
headers = self._headers()
2007+
return headers['X-Plex-Client-Identifier']
2008+
2009+
@cached_data_property
2010+
def _keyID(self):
2011+
""" Returns the key ID (thumbprint) for the ED25519 keypair. """
2012+
return hashlib.sha256(self._privateKey + self._publicKey).hexdigest()
2013+
2014+
@cached_data_property
2015+
def _privateJWK(self):
2016+
""" Returns the private JWK (JSON Web Key) for the ED25519 keypair."""
2017+
return jwt.PyJWK.from_dict({
2018+
'kty': 'OKP',
2019+
'crv': 'Ed25519',
2020+
'x': utils.base64urlEncode(self._publicKey),
2021+
'd': utils.base64urlEncode(self._privateKey),
2022+
'use': 'sig',
2023+
'alg': 'EdDSA',
2024+
'kid': self._keyID,
2025+
})
2026+
2027+
@cached_data_property
2028+
def _publicJWK(self):
2029+
""" Returns the public JWK (JSON Web Key) for the ED25519 keypair."""
2030+
return jwt.PyJWK.from_dict({
2031+
'kty': 'OKP',
2032+
'crv': 'Ed25519',
2033+
'x': utils.base64urlEncode(self._publicKey),
2034+
'use': 'sig',
2035+
'alg': 'EdDSA',
2036+
'kid': self._keyID,
2037+
})
2038+
2039+
def _encodeClientJWT(self, scope):
2040+
""" Encodes the client JWT using the private JWK.
2041+
2042+
Parameters:
2043+
scope (list[str]): List of scopes to request in the token.
2044+
"""
2045+
payload = {
2046+
'nonce': self._getPlexNonce(),
2047+
'scope': ','.join(scope),
2048+
'aud': 'plex.tv',
2049+
'iss': self._clientIdentifier,
2050+
'iat': int(datetime.now().timestamp()),
2051+
'exp': int((datetime.now() + timedelta(minutes=5)).timestamp()),
2052+
}
2053+
headers = {
2054+
'kid': self._keyID
2055+
}
2056+
self._clientJWT = jwt.encode(
2057+
payload,
2058+
key=self._privateJWK,
2059+
algorithm='EdDSA',
2060+
headers=headers
2061+
)
2062+
2063+
def _decodePlexJWT(self):
2064+
""" Decodes and verifies the Plex JWT using the Plex public JWK. """
2065+
return jwt.decode(
2066+
self.jwtToken,
2067+
key=jwt.PyJWK.from_dict(self._getPlexPublicJWK()),
2068+
algorithms=['EdDSA'],
2069+
options={
2070+
'require': ['aud', 'iss', 'exp', 'iat', 'thumbprint']
2071+
},
2072+
audience=['plex.tv', self._clientIdentifier],
2073+
issuer='plex.tv',
2074+
)
2075+
2076+
def _registerPlexDevice(self):
2077+
""" Registers the public JWK with Plex. """
2078+
url = f'{self.AUTH}/jwk'
2079+
headers = self._headers(**{'X-Plex-Token': self._token})
2080+
body = {'jwk': self._publicJWK._jwk_data}
2081+
self._query(url, method=self._session.post, headers=headers, json=body)
2082+
2083+
def _getPlexNonce(self):
2084+
""" Gets a nonce from Plex. """
2085+
url = f'{self.AUTH}/nonce'
2086+
data = self._query(url, method=self._session.get)
2087+
return data['nonce']
2088+
2089+
def _exchangePlexJWT(self):
2090+
""" Exchanges the client JWT for a Plex JWT. """
2091+
url = f'{self.AUTH}/token'
2092+
body = {'jwt': self._clientJWT}
2093+
data = self._query(url, method=self._session.post, json=body)
2094+
return data['auth_token']
2095+
2096+
def _getPlexPublicJWK(self):
2097+
""" Gets the Plex public JWK. """
2098+
url = f'{self.AUTH}/keys'
2099+
data = self._query(url, method=self._session.get)
2100+
return data['keys'][0]
2101+
2102+
def registerDevice(self):
2103+
""" Registers the device with Plex using the provided token and private/public keypair.
2104+
This must be done once before the Plex JWT can be refreshed.
2105+
2106+
Raises:
2107+
:exc:`~plexapi.exceptions.BadRequest`: when token or keypair is missing.
2108+
"""
2109+
if not self._token:
2110+
raise BadRequest('Plex token is required to register device.')
2111+
2112+
if not self._privateKey or not self._publicKey:
2113+
raise BadRequest('ED25519 private and public keys are required to register device. '
2114+
'Use generateKeypair() to generate a new keypair.')
2115+
2116+
self._registerPlexDevice()
2117+
2118+
def refreshJWT(self, scope=None):
2119+
""" Refreshes the Plex JWT using the existing private/public keypair.
2120+
2121+
Parameters:
2122+
scope (list[str], optional): List of scopes to request in the new token.
2123+
Default is all available scopes.
2124+
2125+
Returns:
2126+
str: The new Plex JWT.
2127+
2128+
Raises:
2129+
:exc:`~plexapi.exceptions.BadRequest`: when keypair is missing.
2130+
:exc:`~plexapi.exceptions.BadRequest`: when the newly obtained JWT cannot be verified.
2131+
"""
2132+
if not self._privateKey or not self._publicKey:
2133+
raise BadRequest('ED25519 private and public keys are required to refresh JWT.')
2134+
2135+
if scope is None:
2136+
scope = self.SCOPES
2137+
2138+
self._encodeClientJWT(scope)
2139+
self.jwtToken = self._exchangePlexJWT()
2140+
if self.verifyJWT():
2141+
return self.jwtToken
2142+
raise BadRequest('Failed to verify newly obtained JWT.')
2143+
2144+
def verifyJWT(self, refreshWithinDays=1):
2145+
""" Verifies the existing Plex JWT is valid and not expiring within the specified number of days.
2146+
2147+
Parameters:
2148+
refreshWithinDays (int): Number of days before expiration to consider
2149+
the JWT invalid and in need of refresh. Default is 1 day.
2150+
"""
2151+
try:
2152+
decoded_jwt = self._decodePlexJWT()
2153+
except jwt.ExpiredSignatureError:
2154+
log.warning('Existing JWT has expired')
2155+
return False
2156+
except jwt.InvalidSignatureError:
2157+
log.warning('Existing JWT has invalid signature')
2158+
return False
2159+
except jwt.InvalidTokenError as e:
2160+
log.warning(f'Existing JWT is invalid: {e}')
2161+
return False
2162+
else:
2163+
if decoded_jwt['thumbprint'] != self._keyID:
2164+
log.warning('Existing JWT was signed with a different key')
2165+
return False
2166+
elif decoded_jwt['exp'] < int((datetime.now() + timedelta(days=refreshWithinDays)).timestamp()):
2167+
log.warning(f'Existing JWT is expiring within {refreshWithinDays} day(s)')
2168+
return False
2169+
return True
2170+
2171+
@property
2172+
def decodedJWT(self):
2173+
""" Returns the decoded Plex JWT. """
2174+
return self._decodePlexJWT()
2175+
2176+
def _headers(self, **kwargs):
2177+
""" Returns dict containing base headers for all requests for Plex JWT login. """
2178+
headers = BASE_HEADERS.copy()
2179+
if self.headers:
2180+
headers.update(self.headers)
2181+
headers.update(kwargs)
2182+
headers['Accept'] = 'application/json'
2183+
return headers
2184+
2185+
def _query(self, url, method=None, headers=None, **kwargs):
2186+
method = method or self._session.get
2187+
log.debug('%s %s', method.__name__.upper(), url)
2188+
headers = headers or self._headers()
2189+
response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs)
2190+
if not response.ok: # pragma: no cover
2191+
codename = codes.get(response.status_code)[0]
2192+
errtext = response.text.replace('\n', ' ')
2193+
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
2194+
if response.text:
2195+
return response.json()
2196+
2197+
19002198
def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None):
19012199
""" Connects to the specified cls with url and token. Stores the connection
19022200
information to results[i] in a threadsafe way.

plexapi/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,10 @@ def base64str(text):
625625
return base64.b64encode(text.encode('utf-8')).decode('utf-8')
626626

627627

628+
def base64urlEncode(data: bytes) -> str:
629+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')
630+
631+
628632
def deprecated(message, stacklevel=2):
629633
def decorator(func):
630634
"""This is a decorator which can be used to mark functions
@@ -667,6 +671,8 @@ def serialize(obj):
667671

668672

669673
def openOrRead(file):
674+
if isinstance(file, bytes):
675+
return file
670676
if hasattr(file, 'read'):
671677
return file.read()
672678
with open(file, 'rb') as f:

0 commit comments

Comments
 (0)