spotify / ios-sdk

Spotify SDK for iOS
https://developer.spotify.com/documentation/ios/
660 stars 185 forks source link

Strategy for continuing an active session from stored values #129

Open hspinks opened 5 years ago

hspinks commented 5 years ago

Is there a good way to continue an active session from stored values? I'm storing the raw values of accessToken, refreshToken, expirationDate, and scope in a persistent storage, and I would love to use those to create a new SPTSession object that an SPTSessionManager can then manage.

E.g.:

SPTSessionManager *sessionManager = configuredSessionManager; // configured elsewhere
sessionManager.session = [[SPTSession alloc] init];
sessionManager.session.accessToken = @"stored-access-token";
sessionManager.session.refreshToken = @"stored-refresh-token";
// ... etc

// later
[sessionManager renewSession];

Or better yet:

SPTSessionManager *sessionManager = configuredSessionManager; // configured elsewhere
[sessionManager rehydrateSessionFromAccessToken:@"stored-access-token"
                                refreshToken:@"stored-refresh-token"
                                expirationDate:storedExpirationDate
                                scope:storedScope];

The goal here would be to continue an existing user's session without forcing them to go through the authentication flow again even if we've lost the SPTSessionManager that originated the session. Any help / suggestions here would be appreciated!

kkarayannis commented 5 years ago

Does this question refer to the Streaming SDK instead of this one? Please note that the Streaming SDK is deprecated and no longer supported.

hspinks commented 5 years ago

@kkarayannis no, this is for this remote SDK... I'm trying to use the SPTSessionManager and SPTSession classes in this SDK

heffo42 commented 5 years ago

Looking to solve the same problem. There seems to be very little tools to help with sessions persistence. I might just have to scrap using the whole session manager and manually deal with the token swapping.

paleksandrs commented 4 years ago

@hspinks did you find any solution for this problem? It is a shame that such a big company like Spotify can't provide solution for such a trivial thing.

paleksandrs commented 4 years ago

@kkarayannis any update?

frangulyan commented 4 years ago

I would also be interested in the reply. Our app will potentially run for several hours with playing music while the user is away from the iPhone, I can't imagine that every 60 minutes the playback will be stopped and the user needs to do the app switch manually in order for the music to continue the playback. That will be a showstopper for us.

frangulyan commented 4 years ago

@kkarayannis please let us know about the flow

paleksandrs commented 4 years ago

Besides that the Spotify iOS SDK is developed terribly, Spotify developers are so incompetent that they don't even bother to answer. Probably because they don't want to admit it :D

@frangulyan we initialise session using Spotify SDK. After that if we need to refresh access token we use our own implementation by calling our refresh access token endpoints. I also created similar issue https://github.com/spotify/ios-sdk/issues/180. Hope that helps.

frangulyan commented 4 years ago

@paleksandrs for the last 2 days I was experimenting with the sample app they have. Our app requirements are simple:

After playing with the sample app that spotify provides I came to similar solution like you mentioned - initialize with Spotify SDK and then refresh myself. I noticed the following:

  1. AppRemote has no reference to refresh token (not in APIs at least), which means it is not smart enough to refresh the access token itself. Which means that after 1 hour if you try to pause music then it will fail with "access token not valid" error (interestingly, the debugger shows that appRemote was still "connected" in that case). Although AppRemote is initialized with SPTConfiguration which has all the swap links and I was hoping that it will be smart enough to refresh the tokens. By the way, this happens randomly, in some cases even after 1 hour the App Remote is still connected normally, play/pause commands are working fine and no swap was called on my server. Have no idea how it works. The official documentation gives no comments on refresh strategies or guaranties that it will handle itself.

  2. Session Manager gives more control over the process, but still not smart enough to call refresh once the expiration date is close. That means that every time I need to do a lot of checks on both app remote and session manager (session is nil, token expiration, app remote connected, error received in any of the delegates, playerAPI is nil, etc) and if any of them fails then I need to refresh the tokens using our backend using sessionManager.renewSession().

  3. The documentation doesn't mention anything about how long the codes/tokens can be stored. I assume that, like normally, refresh tokens can be persisted for later use. I assume that authorization code can also be stored, although not sure.

Let me know if you have any comment / experience you can share on the points above, that would be very helpful.

paleksandrs commented 4 years ago

We didn't need to use AppRemote, but I believe you would need to reset access token on it. sessionManager.renewSession() didn't work for us, because after calling that method it removed refresh token from sessionManager.session so the next call to refresh access token would fail. Also SPTSession doesn't have any init and all properties are read only, so that means that you can't store your refresh token or reset it on SPTSession.

frangulyan commented 4 years ago

@paleksandrs thanks for your help!

I guess I will do the following - initially use sessionManager to get the authorization code and refresh token using Spotify's login UI, and then persist it in the app myself. From that point on I will do everything "manually" with our backend - sending both refresh token and authorization code to get a new access token, if needed. On backend I will first try with refresh token - refresh flow - if I get an error then I will try with authorization code - swap flow (maybe this step doesn't make sense if refresh token doesn't work, have no idea). If both don't work then I will tell back the mobile app to initiate another login using Spotify's sessionManager SDK since the user might have revoked the access to my app.

Regarding appRemote - it seems to handle the permissions itself and successfully keep connection for several hours - it might have built-in auth under the hood. However, sometimes it can fail, that's why there is a possibility to set access token on it. With each operation I will just check if everything is fine (a bunch of checks I mentioned in the comment above), if not - get a new access token from our backend, if that fails too - show Spotify SDK's login UI.

Hope this helps the others, would be amazing if Spotify could give some hints or update docs with more details. Their Web API seems to be really good and well thought, I hope that mobile SDK's will catch up soon, at least on the level of clarifying stuff.

jholmes12 commented 4 years ago

Hey everyone, I just stumbled across this looking for the same solution as well.

When trying to re-initiate a new session (when a user opens the app again) it seems there will have to be a hard app-switch involved to restore the session and fetch new tokens.

I was hoping to have a way to just pass in the refresh_token in the configs to the sessionManager, that way it would use them without having to switch over to Spotify first.

Like you all, after the initial auth process I ended up just checking and refreshing them manually when needed on the backend if expired instead of using the session manager.

frangulyan commented 4 years ago

@jholmes12 the hard app-switch is only needed if:

If I oversimplify my codes, removing different states and checks, I ended up using the following high-level logic:

MySpotifyManager.getTokens { tokens in
        guard let tokens = tokens else {
            // complete with failure
            return
        }

        self.appRemote.connectionParameters.accessToken = tokens.accessToken

        SPTAppRemote.checkIfSpotifyAppIsActive { isActive in
            if isActive {
                // this will call SPTAppRemoteDelegate functions, you should complete with success or failure depending on the invoked delegate functions
                self.appRemote.connect()
            } else {
                // this will NEVER call SPTAppRemoteDelegate, you need to wait till AppDelegate's application(_ app, open url) is called after the call below and from there, if no error given, you do the same as in the above 'if' branch - need to call appRemote.connect() and wait for SPTAppRemoteDelegate functions to be called to complete with success or failure
                self.appRemote.authorizeAndPlayURI(initialTrackId)
            }
        }
    }

MySpotifyManager.getTokens is doing the full logic of token fetching, I call it the Session Manager phase - checking if refresh token is cached, using session manager to get auth code if not (will do the app switch in-between), calling backend to get tokens based on auth code or currently saved tokens, handling errors from backend (which might happen if the cached refresh token is invalidated for some reason), and so on. If this function returns nil then it's bad, if not - I'm going to the next phase, the App Remote phase, which is shown in the closure - setting the access token and trying to connect the app remote (starting the Spotify app before that, if needed).

jholmes12 commented 4 years ago

@frangulyan thanks so much for taking the time to let me know how you are handling that, it all is starting to become more clear now for sure! This sounds like a good way to handle it. It took a little trial and error to account for all the situations of spotify being active or not, or the app remote being connected, but overall definitely making more sense. Appreciate it!

raysarebest commented 4 years ago

At the risk of the Spotify developers breaking this method in later versions, too, I found a way to persist and recreate SPTSession objects between launches. At first, I noticed that SPTSession is declared to conform to NSSecureCoding, and it seemed to properly encode its contents with NSKeyedArchiver, though decoding it with NSKeyedUnarchiver always returned nil.

While -[SPTSession init] is marked as NS_UNAVAILABLE, +[SPTSession new] still functions just fine. Furthermore, while all of SPTSession's property accessors are readonly, setting their value via setValue:forKey: also works. Using these methods, I made this category:

SPTSession+Archiving.h

@interface SPTSession (Archiving)
/// @brief Stores the data for the current session in @c +[NSUserDefaults @c standardUserDefaults]
- (void)archive;
/**
 @brief Creates a new @c SPTSession using the data that's stored in @c +[NSUserDefaults @c standardUserDefaults]
 @return If there is session data stored in @c +[NSUserDefaults @c standardUserDefaults], a new @c SPTSession with configured with that data. If there is no or incomplete data, @c nil
 */
+ (nullable SPTSession *)archivedSession;
/**
 @brief Removes any session data stored in @c +[NSUserDefaults @c standardUserDefaults]
 @discussion Consider calling this method when the user intends to log out of Spotify
 */
+ (void)removeArchivedSession;
@end

SPTSession+Archiving.m

#import "SPTSession+Archiving.h"

/// @brief Key constants for @c NSUserDefaults to correspond to the properties of @c SPTSession
typedef NSString * SPTSessionArchivingKey NS_TYPED_ENUM;

/// @brief Key constant for @c NSUserDefaults to correspond to the session's @c accessToken
static const SPTSessionArchivingKey SPTSessionArchivingKeyAccessToken = @"SPTSessionArchivingKeyAccessToken";
/// @brief Key constant for @c NSUserDefaults to correspond to the session's @c refreshToken
static const SPTSessionArchivingKey SPTSessionArchivingKeyRefreshToken = @"SPTSessionArchivingKeyRefreshToken";
/// @brief Key constant for @c NSUserDefaults to correspond to the session's @c expirationDate
static const SPTSessionArchivingKey SPTSessionArchivingKeyExpirationDate = @"SPTSessionArchivingKeyExpirationDate";
/// @brief Key constant for @c NSUserDefaults to correspond to the session's @c scope
static const SPTSessionArchivingKey SPTSessionArchivingKeyScope = @"SPTSessionArchivingKeyScope";

@implementation SPTSession (Archiving)
- (void)archive {
    [NSUserDefaults.standardUserDefaults setObject:self.accessToken forKey:SPTSessionArchivingKeyAccessToken];
    [NSUserDefaults.standardUserDefaults setObject:self.refreshToken forKey:SPTSessionArchivingKeyRefreshToken];
    [NSUserDefaults.standardUserDefaults setInteger:self.expirationDate.timeIntervalSinceReferenceDate forKey:SPTSessionArchivingKeyExpirationDate];
    [NSUserDefaults.standardUserDefaults setInteger:self.scope forKey:SPTSessionArchivingKeyScope];
}

+ (nullable SPTSession *)archivedSession {
    NSString * const storedAccessToken = [NSUserDefaults.standardUserDefaults stringForKey:SPTSessionArchivingKeyAccessToken];
    NSString * const storedRefreshToken = [NSUserDefaults.standardUserDefaults stringForKey:SPTSessionArchivingKeyRefreshToken];
    NSDate * const storedExpirationDate = [NSDate dateWithTimeIntervalSinceReferenceDate:[NSUserDefaults.standardUserDefaults integerForKey:SPTSessionArchivingKeyExpirationDate]];

    if (storedAccessToken && storedRefreshToken && storedExpirationDate) {
        SPTSession * const storedSession = [SPTSession new];

        [storedSession setValue:storedAccessToken forKey:NSStringFromSelector(@selector(accessToken))];
        [storedSession setValue:storedRefreshToken forKey:NSStringFromSelector(@selector(refreshToken))];
        [storedSession setValue:storedExpirationDate forKey:NSStringFromSelector(@selector(expirationDate))];
        [storedSession setValue:@([NSUserDefaults.standardUserDefaults integerForKey:SPTSessionArchivingKeyScope]) forKey:NSStringFromSelector(@selector(scope))];

        return storedSession;
    } else {
        return nil;
    }
}

+ (void)removeArchivedSession {
    for (SPTSessionArchivingKey key in @[SPTSessionArchivingKeyAccessToken, SPTSessionArchivingKeyRefreshToken, SPTSessionArchivingKeyExpirationDate, SPTSessionArchivingKeyScope]) {
        [NSUserDefaults.standardUserDefaults removeObjectForKey:key];
    }
}
@end
chriship commented 4 years ago

I'm also struggling with this. To test, I have taken the sample app SPTLoginSampleAppSwift. I have added functionality to save the access token and refresh token using UserDefaults. I then have a renew button which tries to renew the session like so:

sessionManager.session?.setValue(self.accessToken, forKey: "accessToken")
sessionManager.session?.setValue(self.refreshToken, forKey: "refreshToken")
sessionManager.renewSession()

If I load up my app and connect to Spotify. Then press the renew button, the renewal works. But if I close my app, reopen it and press the renew button (thus loading up the session details from UserDefaults. Authorization does not work.

Do I need to call my token swap server URL to swap to token before renewing the session? I assumed the renewSession call was taking care of that part.

Peter-Schorn commented 3 years ago

The solution to persistent storage of the session is incredibly simple: just encode and decode the SPTSession.

I've been able to successfully encode SPTSession using NSKeyedArchiver and decode it using NSKeyedUnarchiver.

Make sure you encode it in both sessionManager(manager:didInitiate:) and sessionManager(manager:didRenew:):

let sessionData: Data = try NSKeyedArchiver.archivedData(
    withRootObject: session,
    requiringSecureCoding: false
)

Then decode it later like this:

let object = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(sessionData)
let session = object as? SPTSession 

I still don't understand why the SPTSession initializer is not publicly exposed. Some clients may wish to create an instance from authorization information that was retrieved from a source other than the iOS SDK.

xabaddonx1 commented 2 years ago

I got saved sessions working but now I have a new problem... if the user logs out of Spotify and logs into another account from the Spotify app, is there any way to tell that the cached session I have is now logged into the wrong account?

Saturn-V commented 7 months ago

@xabaddonx1 how did you get this to work? Here is my ViewController, a near identical VC to the sample app SPTLoginSampleAppSwift in this repo. I am restoring the session from local storage as well as updating the appRemote's accessToken and yet, each time the session is restored I am taken to Spotify and back for the SDK to retrieve an access token. Any thoughts on what I am doing wrong here?

xmollv commented 3 months ago

The solution to persistent storage of the session is incredibly simple: just encode and decode the SPTSession.

I've been able to successfully encode SPTSession using NSKeyedArchiver and decode it using NSKeyedUnarchiver.

Make sure you encode it in both sessionManager(manager:didInitiate:) and sessionManager(manager:didRenew:):

let sessionData: Data = try NSKeyedArchiver.archivedData(
    withRootObject: session,
    requiringSecureCoding: false
)

Then decode it later like this:

let object = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(sessionData)
let session = object as? SPTSession 

I still don't understand why the SPTSession initializer is not publicly exposed. Some clients may wish to create an instance from authorization information that was retrieved from a source other than the iOS SDK.

It's unbelievable that this is not on the official documentation. The Spotify SDK is probably the weirdest SDK I ever had to deal with, I can't comprehend how badly designed it is. Thanks for sharing this, spent half a day trying to find out a solution to this.