diff --git a/spotify-backup.py b/spotify-backup.py index f272564..1a19d24 100755 --- a/spotify-backup.py +++ b/spotify-backup.py @@ -13,10 +13,28 @@ import urllib.parse import urllib.request import webbrowser +import secrets +import string +import base64 +import hashlib logging.basicConfig(level=20, datefmt='%I:%M:%S', format='[%(asctime)s] %(message)s') +def generate_pkce_pair(length: int = 64) -> tuple[str, str]: + # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow#code-verifier + chars = string.ascii_letters + string.digits + code_verifier = ''.join(secrets.choice(chars) for _ in range(length)) + + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + code_challenge = ( + base64.urlsafe_b64encode(digest) + .rstrip(b"=") + .decode("ascii") + ) + + return code_verifier, code_challenge + class SpotifyAPI: # Requires an OAuth token. @@ -64,11 +82,15 @@ def list(self, url, params={}): # Pops open a browser window for a user to log in and authorize API access. @staticmethod def authorize(client_id, scope): + code_verifier, code_challenge = generate_pkce_pair() + url = 'https://accounts.spotify.com/authorize?' + urllib.parse.urlencode({ - 'response_type': 'token', + 'response_type': 'code', 'client_id': client_id, 'scope': scope, - 'redirect_uri': 'http://127.0.0.1:{}/redirect'.format(SpotifyAPI._SERVER_PORT) + 'redirect_uri': 'http://127.0.0.1:{}/redirect'.format(SpotifyAPI._SERVER_PORT), + 'code_challenge_method': 'S256', + 'code_challenge': code_challenge, }) logging.info(f'Logging in (click if it doesn\'t open automatically): {url}') webbrowser.open(url) @@ -79,7 +101,19 @@ def authorize(client_id, scope): while True: server.handle_request() except SpotifyAPI._Authorization as auth: - return SpotifyAPI(auth.access_token) + # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow#request-an-access-token + body = bytes(urllib.parse.urlencode({ + 'grant_type': 'authorization_code', + 'code': auth.access_code, + 'redirect_uri': 'http://127.0.0.1:{}/redirect'.format(SpotifyAPI._SERVER_PORT), + 'client_id': client_id, + 'code_verifier': code_verifier, + }), encoding='utf-8') + req = urllib.request.Request("https://accounts.spotify.com/api/token", data=body) + req.add_header('content-type', 'application/x-www-form-urlencoded') + res = urllib.request.urlopen(req) + reader = codecs.getreader('utf-8') + return SpotifyAPI(json.load(reader(res))['access_token']) # The port that the local server listens on. Don't change this, # as Spotify only will redirect to certain predefined URLs. @@ -95,25 +129,17 @@ def handle_error(self, request, client_address): class _AuthorizationHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): - # The Spotify API has redirected here, but access_token is hidden in the URL fragment. - # Read it using JavaScript and send it to /token as an actual query string... - if self.path.startswith('/redirect'): - self.send_response(200) - self.send_header('Content-Type', 'text/html') - self.end_headers() - self.wfile.write(b'') - # Read access_token and use an exception to kill the server listening... - elif self.path.startswith('/token?'): + if self.path.startswith('/redirect?code'): self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() self.wfile.write(b'Thanks! You may now close this window.') - access_token = re.search('access_token=([^&]*)', self.path).group(1) - logging.info(f'Received access token from Spotify: {access_token}') - raise SpotifyAPI._Authorization(access_token) - + code = re.search('code=([^&]*)', self.path).group(1) + logging.info(f'Received access code from Spotify: {code}') + + raise SpotifyAPI._Authorization(code) else: self.send_error(404) @@ -122,8 +148,8 @@ def log_message(self, format, *args): pass class _Authorization(Exception): - def __init__(self, access_token): - self.access_token = access_token + def __init__(self, access_code): + self.access_code = access_code def main():