spotipy-dev / spotipy

A light weight Python library for the Spotify Web API
http://spotipy.readthedocs.org
MIT License
5.04k stars 959 forks source link

Overriding the auth-manager when I have obtained access_token and refresh_token outside of spotipy #555

Open peey opened 4 years ago

peey commented 4 years ago

Is it possible for me to set access_token and refresh_token to spotipy (or even just access_token) without spotipy having to worry about the authentication flow?

I have taken care of the authentication flow in a different part of the app but still would like to be able to use spotipy as an API wrapper

peey commented 4 years ago

I went through the code, and this is supported, but actually it isn't documented. Maybe we should add this to documentation as well.

A MWE:

import spotipy
from spotipy.oauth2 import SpotifyOAuth

# let's say an object "keys" contains all the secret config keys
auth = SpotifyOAuth(client_id=keys['clientId'], client_secret=keys['clientSecret'], redirect_uri=keys['redirectUri'])
tok = auth.refresh_access_token(keys['refreshToken'])
# TODO compare refresh token of the tok above with your saved refresh token and see if you need to update in case of mismatch

spotify = spotipy.Spotify()
spotify.set_auth(tok['access_token'])

After this, you can at least query for as long as the access token is valid. I don't know if that can also be automated (by passing SpotifyOAuth object as one of the params maybe?)

peey commented 4 years ago

Another way this can be done (with automated refreshing) is via a custom auth manager, I'd suggest adding this to the examples as well

class CustomAuthManager:
    def __init__(self, keys):
        self.auth = SpotifyOAuth(client_id=keys['clientId'], client_secret=keys['clientSecret'], redirect_uri=keys['redirectUri'])
        self.refresh_token = keys['refreshToken']
        self.current_token = None

    def get_access_token(self):
        import datetime
        now = datetime.datetime.now()

        # if no token or token about to expire soon
        if not self.current_token or self.current_token['expires_at'] > now.timestamp() + 60:
            self.current_token = self.auth.refresh_access_token(self.refresh_token)

        return self.current_token['access_token']

spotify = spotipy.Spotify(auth_manager=CustomAuthManager(keys))
andyd0 commented 4 years ago

@peey did you have issues with refresh_access_token? I just tried your code but eventually get_access_token fails because of the unexpected asDict argument

andyd0 commented 4 years ago

I ended up getting the token using Spotify's web api tutorial and reduced your get_access_token method to just return that token. This worked for my purposes.

My guess is that the overriding of get_access_token is not working correctly due to recent changes of adding as_dict as an argument. if i remember correctly, it fails at the client's _auth_headers

i'm fine with refreshing manually when I need it but fyi for anyone else that sees this request and want to use it

kwakubiney commented 3 years ago

@andyd0 @peey I tried to implement your solution but then I keep getting an error http status: 403, code:-1 - https://api.spotify.com/v1/me/tracks?limit=40&offset=0: Insufficient client scope, reason: None. My guess is that the scope doesn't get passed to the OAuth object. This is the code

oauth = SpotifyOAuth(
    redirect_uri="http://127.0.0.1:8000/accounts/spotify/login/callback/",
    scope='user-library-read',
    cache_handler = cache_handler)

auth manager to handle auth

    class CustomAuthManager():
    def __init__(self, keys, scope):
    self.auth = oauth
    self.refresh_token = keys["refresh_token"]
    self.access_token = None
    self.scope =  None

    def get_access_token(self):
        return SocialToken.objects.get(account__provider = "spotify")
     spotify_auth = Spotify(auth_manager=CustomAuthManager(keys, 
 scope=keys["scope"]))

Traceback (most recent call last):
File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
packages\spotipy\client.py", line 244, in _internal_call
response.raise_for_status()
File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
packages\requests\models.py", line 943, in raise_for_status
raise HTTPError(http_error_msg, response=self)

 During handling of the above exception (403 Client Error: Forbidden for url: 
 https://api.spotify.com/v1/me/tracks?limit=40&offset=0), another exception occurred:
  File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
  packages\django\core\handlers\exception.py", line 47, in inner
   response = get_response(request)
   File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
  packages\django\core\handlers\base.py", line 179, in _get_response
  response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\Sparison\views.py", line 44, in 
  liked
  liked = spotify_auth.current_user_saved_tracks(limit=40)['items']
  File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
  packages\spotipy\client.py", line 1225, in current_user_saved_tracks
  return self._get("me/tracks", limit=limit, offset=offset, market=market)
  File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
  packages\spotipy\client.py", line 290, in _get
  return self._internal_call("GET", url, payload, kwargs)
  File "C:\Users\Kwaku Biney\Desktop\sparison-1\project\venv\lib\site- 
  packages\spotipy\client.py", line 260, in _internal_call
  raise SpotifyException(

  Exception Type: SpotifyException at /liked/
  Exception Value: http status: 403, code:-1 - https://api.spotify.com/v1/me/tracks? 
  limit=40&offset=0:
  Insufficient client scope, reason: None

@Peter-Schorn

Peter-Schorn commented 3 years ago

It's impossible for me to determine the issue if you don't post the code that implements the authorization process. Why don't you just use the built in auth managers?

@peey Your CustomAuthManager is completely pointless and should never be used. Just use SpotifyOAuth directly instead. It already automatically refreshes the access token when necessary for you.

peey commented 3 years ago

Sorry guys, I only used this library for a weekend project and that's when I posted the issue and the workaround. I haven't used this library since (haven't even run the code much), so I won't be able to help more here.

With the above disclaimer in place, @Peter-Schorn the most I can say is that I wanted to use an access_token that I obtained outside of spotipy (described more in first post of the issue), and I didn't find a way to do it at that time so I developed that code.

kwakubiney commented 3 years ago

@Peter-Schorn The thing is I already have a refresh token and access token in my database from django-allauth. I wanted a workaround where I would just use both the refresh token and access token for API calls using Spotipy without going through the auth process of Spotipy.

The SpotifyOauth defines the auth manager to be passed to a Spotify object which then makes the API calls. But how do I use the Spotify object if I already have these in my DB? I wanted to avoid using two access tokens, one for authentication to my site and one for API calls. Wanted to use just one for both. SpotifyOAuth seems to take care of refreshing the token perfectly but is there a way to pass my access token instead

This is the extra code I did not write earlier

  spotify_auth = Spotify(auth_manager=CustomAuthManager(keys, 
  scope=keys["scope"]))
  spotify_auth.current_user_saved_tracks(limit=40)['items']

seems to be failing at https://github.com/plamere/spotipy/blob/b80bfa5c52aaa1db6532fc00a3cfe11e38afd567/spotipy/client.py#L217 after https://github.com/plamere/spotipy/blob/b80bfa5c52aaa1db6532fc00a3cfe11e38afd567/spotipy/client.py#L293 is called from https://github.com/plamere/spotipy/blob/b80bfa5c52aaa1db6532fc00a3cfe11e38afd567/spotipy/client.py#L1232

Peter-Schorn commented 3 years ago

The thing is I already have a refresh token and access token in my database from django-allauth. I wanted a workaround where I would just use both the refresh token and access token for API calls using spotipy without going through the auth process of spotipy.

The builtin authorization managers don't require you to go through the authorization process again if you've already done so and have the token info stored somewhere.

The solution to your problem is to create a custom class that inherits from CacheHandler. Pass an instance of this class into the initializer for any of the auth managers (SpotifyOAuth, SpotifyPKCE, SpotifyClientCredentials). The cache handler is responsible for retrieving the authorization information and storing it whenever it changes. Use the CacheFileHandler and MemoryCacheHandler as a guide.

andyd0 commented 3 years ago

Sorry @kweku-45 - This was a one and done thing for me so I no longer remember how I handled it then.

kwakubiney commented 3 years ago

@Peter-Schorn does token_info have to be a python dictionary? can it just be a string?

I tried using this script:

 token_info = {}
 token_info["access_token"] =  SocialToken.objects.get(account__provider = "spotify").token 
 token_info["refresh_token"] =  SocialToken.objects.get(account__provider = "spotify").token_secret

Implement authentication using Cache Handler

 class SparisonCacheHandler(CacheHandler):
         def __init__(self,
             token_info=None,
             ):
         if token_info:
              self.token_info = token_info
         else:
             print("You have no token information")

      def get_cached_token(self):
           token_info = token_info
            return token_info

          def save_token_to_cache(self, token_info):
                 return token_info

          oauth = SpotifyOAuth(
          redirect_uri="http://127.0.0.1:8000/accounts/spotify/login/callback/",
          scope='user-library-read',
          cache_handler = SparisonCacheHandler(token_info = token_info) )

What happens is: I get redirected to http://127.0.0.1:8000/accounts/spotify/login/callback/?code=xxxxx when I try to make an API call then the app fails because I have already logged in the user. I was guessing I did not have to authorize the app all over again because I already have the access token??

Peter-Schorn commented 3 years ago

Please use proper indentation when posting code. Otherwise, it becomes very hard to read.

The token info must be a dictionary with the following keys:

The reason why you're getting redirected to the authorization URL is because you're missing that last two parameters, so the token info is considered invalid. How is spotipy supposed to know when to refresh the access token if it doesn't know when it expires?

Furthermore, there are major issues with your cache handler. First of all, the first line of get_cached_token doesn't make sense:

token_info = token_info

token_info is not defined in this scope.

Furthermore, in SparisonCacheHandler.__init__, self.token_info is not defined if token_info is None. Instead the initializer should look like this:

def __init__(self, token_info=None):
    self.token_info = token_info

You also need to read the docstring for CacheHandler.get_cached_token and CacheHandler.save_token_to_cache. The latter should return None; Your implementation returns the token info.

Furthermore, you need to come up with one centralized place where the token info will be stored. It doesn't make sense to store it in both the cache handler and in the SocialToken object, whatever that is. Pick one or the other. For example if you want to store the token info in the SocialToken object, then you could do something like this:

class SparisonCacheHandler(CacheHandler):

    def __init__(self):
        self.spotify_object = SocialToken.objects.get(account__provider="spotify")

    def get_cached_token(self):
        # retrieve the token info from the `SocialToken` object

        token_info = {}

        token_info["access_token"] = self.spotify_object.token
        token_info["refresh_token"] = self.spotify_object.token_secret
        token_info["expires_at"] = self.spotify_object.expires_at
        token_info["scope"] = self.spotify_object.scope

        return token_info

    def save_token_to_cache(self, token_info):
        # save the token info back to the `SocialToken` object
        # notice that we're saving the token info back to the same place that we retrieved it from
        # in `get_cached_token`; this is crucial

        self.spotify_object.token = token_info["access_token"]
        self.spotify_object.token_secret = token_info["refresh_token"]
        self.spotify_object.expires_at = token_info["expires_at"]
        self.spotify_object.scope = token_info["scope"]
kwakubiney commented 3 years ago

@Peter-Schorn Thank you for your correction. I ended up implementing like this.

    def convert_to_unix(DateTimeField):
          return DateTimeField.timestamp()

    #Implement authentication using Cache Handler

    expires_at = SocialToken.objects.get(account__provider = "spotify").expires_at
    expires_at_in_unix = convert_to_unix(expires_at)

   class SparisonCacheHandler(CacheHandler):
          def __init__(self):
                  self.spotify_object = SocialToken.objects.get(account__provider="spotify")

    def get_cached_token(self):
           token_info = {}
           token_info["access_token"] = self.spotify_object.token
           token_info["refresh_token"] = self.spotify_object.token_secret
           token_info["expires_at"] = convert_to_unix(self.spotify_object.expires_at)
           token_info["scope"] = "user-library-read"
           return token_info

    def save_token_to_cache(self, token_info):
           self.spotify_object.token = token_info["access_token"]
           self.spotify_object.token_secret = token_info["refresh_token"]

    oauth = SpotifyOAuth(
    redirect_uri="http://127.0.0.1:8000/accounts/spotify/login/callback/",
    cache_handler = SparisonCacheHandler() ,
    scope="user-library-read")

    Spotify(auth_manager=oauth).current_user_saved_tracks(limit=40)['items']

I do still get the same error from the beginning: http status: 403, code:-1 - https://api.spotify.com/v1/me/tracks?limit=40&offset=0: Insufficient client scope, reason: None

Other calls like Spotify.me() works though which then means the scope doesn't get passed to the SpotifyAuth object for some reason.

Peter-Schorn commented 3 years ago

Insufficient client scope

This means that you didn't request the correct scope(s) when going through the authorization process. Show me the code where you implement the authorization process. It's impossible for me to help you if you don't do this.

Another way you can determine if the correct scopes are being requested is to look at the URL of the page that asks you to sign in with your Spotify account. You should see "user-library-read" in the query string of the URL.

Other calls like Spotify.me() works though

These endpoints work because they don't require any scopes.

which then means the scope doesn't get passed to the SpotifyOAuth object for some reason.

No, it doesn't mean that because you're not using SpotifyOAuth to implement the authorization process (from what I understand). It means the scope is not getting passed to whatever object in django-allauth that you are using to implement the authorization process.

Also, there are still issues with your cache handler:

def save_token_to_cache(self, token_info):
   self.spotify_object.token = token_info["access_token"]
   self.spotify_object.token_secret = token_info["refresh_token"]

Why are you ignoring the expires_at and scope parameters? This won't work. You also should not be hardcoding the scope in SparisonCacheHandler.get_cached_token. This should be stored in your spotify_object.

sapristi commented 1 year ago

I have set up a spotipy client using allauth token, see here https://github.com/sapristi/lalipo/blob/master/lalipo/spotipy_cache.py for how to implement the cache and override the client, and https://github.com/sapristi/lalipo/blob/master/lalipo/spotify_helpers.py#L9 for how to use it.

Note that allauth should be configured to store the token in db.