|
1 | 1 | # -*- coding: utf-8 -*- |
2 | 2 | import copy |
| 3 | +import hashlib |
3 | 4 | import html |
4 | 5 | import threading |
5 | 6 | import time |
| 7 | +from datetime import datetime, timedelta |
6 | 8 | from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit |
7 | 9 |
|
8 | 10 | import requests |
9 | 11 |
|
| 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 | + |
10 | 24 | from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, |
11 | 25 | log, logfilter, utils) |
12 | 26 | from plexapi.base import PlexObject, cached_data_property |
@@ -1686,7 +1700,7 @@ class MyPlexPinLogin: |
1686 | 1700 |
|
1687 | 1701 | Parameters: |
1688 | 1702 | 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. |
1690 | 1704 | requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). |
1691 | 1705 | headers (dict): A dict of X-Plex headers to send with requests. |
1692 | 1706 | oauth (bool): True to use Plex OAuth instead of PIN login. |
@@ -1897,6 +1911,290 @@ def _query(self, url, method=None, headers=None, **kwargs): |
1897 | 1911 | return utils.parseXMLString(response.text) |
1898 | 1912 |
|
1899 | 1913 |
|
| 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 | + |
1900 | 2198 | def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None): |
1901 | 2199 | """ Connects to the specified cls with url and token. Stores the connection |
1902 | 2200 | information to results[i] in a threadsafe way. |
|
0 commit comments