brim-borium / spotify_sdk

Flutter Package to connect the spotify sdk
https://pub.dev/packages/spotify_sdk
Apache License 2.0
143 stars 81 forks source link

Web SDK can take a custom OAuth token handler function #116

Closed nzoschke closed 2 years ago

nzoschke commented 3 years ago

Before, a Web Playback SDK developer could only use the internal in PKCE auth and refresh flow, which seems to always require an auth prompt.

Now, a developer can pass in a custom getOAuthToken function which allows them to customize the auth and refresh flow.

The use case is for Spotify apps that use the standard Authorization Code flow and manage access and refresh tokens with a server side component.

Fixes #112

TODO:

nzoschke commented 3 years ago

I'm splitting up #114 into smaller parts. This addresses No 2 from https://github.com/brim-borium/spotify_sdk/pull/114#issuecomment-866832623

  1. can this library be enhanced to take a custom token callback to connectToSpotifyRemote for a scenarios where you already have a valid token and perform the refresh with a custom server side component?

I tested that the standard usage of this library still works with:

{
    var scope = [
      'app-remote-control',
      'playlist-modify-public',
      'playlist-read-collaborative',
      'playlist-read-private',
      'streaming',
      'user-library-modify',
      'user-library-read',
      'user-modify-playback-state',
      'user-read-currently-playing',
      'user-read-email',
      'user-read-playback-state',
      'user-read-private',
      'user-top-read',
    ].join(',');

    var token = await SpotifySdk.getAuthenticationToken(
      clientId: clientID,
      redirectUrl: redirectURI,
      scope: scope,
    );

    await SpotifySdk.connectToSpotifyRemote(
      clientId: clientID,
      redirectUrl: redirectURI,
      accessToken: token,
      scope: scope,
    );
  }

I also tested that my custom token handler works with:

{
    // get access token from server
    final res = await http.post(...);
    final token = jsonDecode(res.body)['access_token'] as String;

    SpotifySdkPlugin.getOAuthToken = () async {
      print('getOAuthToken custom cb $token');
      return token;
    };

    await SpotifySdk.connectToSpotifyRemote(
      clientId: clientID,
      redirectUrl: redirectURI,
      playerName: 'JukeLab',
      accessToken: token,
      scope: scope,
    );

Due to having a server side access / refresh token, my app loads the player with no auth prompt!

fotiDim commented 3 years ago

In general looks good. Let's also update the readme to indicate this new feature, how developers can use it and clearly state that it is "web only".

fotiDim commented 3 years ago

@nzoschke something that came to mind, if in an alternative implementation, you only passed a "refresh token" endpoint instead of a function would that be enough for you? I am thinking that on the iOS SDK there is an alternative authentication flow that works like this. Also other generic oAuth libraries that I have used work in similar way. If you set the refresh endpoint then they use it. We could potentially align the APIs.

My counter suggestion would look like this:

SpotifySdkPlugin.tokenSwapURL = '';
SpotifySdkPlugin.tokenRefreshURL = '';
nzoschke commented 3 years ago

Yes I think that would work.

My server side components were built with this token swap pattern in mind.

Support for this in the iOS SDK would be great, and I see how this could work with the web SDK for my use case too.

Here's another reference doc about the token swap:

https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/

fotiDim commented 3 years ago

I think I understand it a bit better now. The iOS SDK does a bit more magic and calls your refresh endpoint automatically. However on the web SDK you need to write that code yourself and pass it as a function.

Can you post an example of a real life token refresh function? Could it be worth including that in this library? In that case the end developer would only need to pass the refresh URL and our library would make the call.

On Tue 29. Jun 2021 at 13:40, Noah Zoschke @.***> wrote:

Yes I think that would work.

My server side components were built with this token swap pattern in mind.

Support for this in the iOS SDK would be great, and I see how this could work with the web SDK for my use case too.

Here's another reference doc about the token swap:

https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/

— You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/brim-borium/spotify_sdk/pull/116#issuecomment-870482690, or unsubscribe https://github.com/notifications/unsubscribe-auth/AARX7D35FTPX7DZGKWEE36LTVGPJPANCNFSM47IEXI6A .

nzoschke commented 3 years ago

Here is a fairly complete example. This is using:

As I do intend to support iOS for my app, I did end up following the token swap pattern as documented in:

I think this pattern makes sense for both iOS and web.

  Future permissionCheck() async {
    var apiURI = 'http://localhost:8000';
    var clientID = '<REDACTED>';
    var redirectURI = 'http://localhost:8686/callback.html';

    var scope = [
      'app-remote-control',
      'playlist-modify-public',
      'playlist-read-collaborative',
      'playlist-read-private',
      'streaming',
      'user-library-modify',
      'user-library-read',
      'user-modify-playback-state',
      'user-read-currently-playing',
      'user-read-email',
      'user-read-playback-state',
      'user-read-private',
      'user-top-read',
    ].join(',');

    final url = Uri.https('accounts.spotify.com', '/authorize', {
      'client_id': clientID,
      'redirect_uri': redirectURI,
      'response_type': 'code',
      'scope': scope,
    });

    final result = await FlutterWebAuth.authenticate(
      url: url.toString(),
      callbackUrlScheme: redirectURI,
    );

    // swap code for token
    final res = await http.post(
      Uri.parse('$apiURI/api/v1/spotify/token'),
      body: {
        'code': Uri.parse(result).queryParameters['code'],
        'redirect_uri': redirectURI,
      },
    );

    var out = jsonDecode(res.body);
    var now = (DateTime.now().millisecondsSinceEpoch / 1000).round();

    var token = SpotifyToken(
      accessToken: out['access_token'] as String,
      clientId: clientID,
      expiry: now + out['expires_in'] as int,
      refreshToken: out['refresh_token'] as String,
    );

    SpotifySdkPlugin.getOAuthToken = () async {
      // refresh token if expired
      if (token.expiry <= DateTime.now().millisecondsSinceEpoch / 1000) {
        print('getOAuthToken refresh ${token.expiry}');

        var rt = token.refreshToken;
        final res = await http.post(
          Uri.parse('$apiURI/api/v1/spotify/refresh'),
          body: {
            'refresh_token': rt,
          },
        );

        var out = jsonDecode(res.body);
        var now = (DateTime.now().millisecondsSinceEpoch / 1000).round();

        token = SpotifyToken(
          accessToken: out['access_token'] as String,
          clientId: clientID,
          expiry: now + out['expires_in'] as int,
          refreshToken: rt,
        );
      }

      print('getOAuthToken ${token.accessToken}');
      return token.accessToken;
    };

    var ok = await SpotifySdk.connectToSpotifyRemote(
      clientId: clientID,
      redirectUrl: redirectURI,
      accessToken: token.accessToken,
      scope: scope,
    );

    print('connect $ok');
  }
nzoschke commented 3 years ago

So I too could see a design that uses the token swap settings for the web SDK.

We introduce these:

SpotifySdkPlugin.tokenSwapURL = '';
SpotifySdkPlugin.tokenRefreshURL = '';

So if the token URLs are not set, it does the existing Authorization Code with PKCE auth flow and refresh.

If these are set, it does an Authorization Code (without PKCE) to get a code, then follows the well defined token swap spec (docs) to swap the code for an access token and to refresh the token.

The pros are:

The cons are:

So @fotiDim given all of the above do you have a preference between (1) this existing PR for getOAuthToken or (2) making a new PR with the token swap pattern for the Web SDK?

nzoschke commented 3 years ago

Here's a first pass at what doing the token swap stuff in the web SDK might look like. I think it's feeling good to bake this in, certainly simpler than all the stuff I had to figure out on my own to do the flow and pass the refresh function down.

One thing is I'll want to get the access token out to use with the web API client.

diff --git a/lib/spotify_sdk_web.dart b/lib/spotify_sdk_web.dart
index bd0475d..9654661 100644
--- a/lib/spotify_sdk_web.dart
+++ b/lib/spotify_sdk_web.dart
@@ -83,6 +83,17 @@ class SpotifySdkPlugin {
   static const String DEFAULT_SCOPES =
       'streaming user-read-email user-read-private';

+  static String? _tokenSwapURL;
+  static String? _tokenRefreshURL;
+
+  static set tokenSwapURL(String s) {
+    _tokenSwapURL = s;
+  }
+
+  static set tokenRefreshURL(String s) {
+    _tokenRefreshURL = s;
+  }
+
   /// constructor
   SpotifySdkPlugin(
       this.playerContextEventController,
@@ -403,19 +414,36 @@ class SpotifySdkPlugin {
   }

   /// Authenticates a new user with Spotify and stores access token.
-  Future<String> _authorizeSpotify(
-      {required String clientId,
-      required String redirectUrl,
-      required String? scopes}) async {
+  Future<String> _authorizeSpotify({
+    required String clientId,
+    required String redirectUrl,
+    required String? scopes,
+  }) async {
+    print('_authorizeSpotify $_tokenSwapURL');
     // creating auth uri
     var codeVerifier = _createCodeVerifier();
     var codeChallenge = _createCodeChallenge(codeVerifier);
     var state = _createAuthState();
-    var authorizationUri =
-        'https://accounts.spotify.com/authorize?client_id=$clientId&response_type=code&redirect_uri=$redirectUrl&code_challenge_method=S256&code_challenge=$codeChallenge&state=$state&scope=$scopes';
+
+    var params = {
+      'client_id': clientId,
+      'redirect_uri': redirectUrl,
+      'response_type': 'code',
+      'state': state,
+      'scope': scopes,
+    };
+
+    if (_tokenSwapURL == null) {
+      params['code_challenge_method'] = 'S256';
+      params['code_challenge'] = codeChallenge;
+    }
+
+    final authorizationUri =
+        Uri.https('accounts.spotify.com', 'authorize', params);

     // opening auth window
-    var authPopup = window.open(authorizationUri, 'Spotify Authorization');
+    var authPopup =
+        window.open(authorizationUri.toString(), 'Spotify Authorization');
     String? message;
     var sub = window.onMessage.listen(allowInterop((event) {
       message = event.data.toString();
@@ -461,20 +489,38 @@ class SpotifySdkPlugin {
     }
     await sub.cancel();

-    // exchange auth code for access and refresh tokens
     dynamic authResponse;
+
+    // build request to exchange auth code with PKCE for access and refresh tokens
+    var req = RequestOptions(
+      path: 'https://accounts.spotify.com/api/token',
+      method: 'POST',
+      data: {
+        'client_id': clientId,
+        'grant_type': 'authorization_code',
+        'code': parsedMessage.queryParameters['code'],
+        'redirect_uri': redirectUrl,
+        'code_verifier': codeVerifier
+      },
+      contentType: Headers.formUrlEncodedContentType,
+    );
+
+    // or build request to exchange code with token swap
+    // https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
+    if (_tokenSwapURL != null) {
+      req = RequestOptions(
+        path: _tokenSwapURL!,
+        method: 'POST',
+        data: {
+          'code': parsedMessage.queryParameters['code'],
+          'redirect_uri': redirectUrl,
+        },
+        contentType: Headers.formUrlEncodedContentType,
+      );
+    }
+
     try {
-      authResponse = (await _authDio.post(
-              'https://accounts.spotify.com/api/token',
-              data: {
-                'client_id': clientId,
-                'grant_type': 'authorization_code',
-                'code': parsedMessage.queryParameters['code'],
-                'redirect_uri': redirectUrl,
-                'code_verifier': codeVerifier
-              },
-              options: Options(contentType: Headers.formUrlEncodedContentType)))
-          .data;
+      authResponse = (await _authDio.fetch(req)).data;
     } on DioError catch (e) {
       print('Spotify auth error: ${e.response?.data}');
       rethrow;
@@ -492,15 +538,36 @@ class SpotifySdkPlugin {
   /// Refreshes the Spotify access token using the refresh token.
   Future<dynamic> _refreshSpotifyToken(
       String? clientId, String? refreshToken) async {
+    print('refresh?');
+    // build request to refresh PKCE for access and refresh tokens
+    var req = RequestOptions(
+      path: 'https://accounts.spotify.com/api/token',
+      method: 'POST',
+      data: {
+        'grant_type': 'refresh_token',
+        'refresh_token': refreshToken,
+        'client_id': clientId,
+      },
+      contentType: Headers.formUrlEncodedContentType,
+    );
+
+    // or build request to refresh code with token swap
+    // https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/
+    if (_tokenRefreshURL != null) {
+      req = RequestOptions(
+        path: _tokenRefreshURL!,
+        method: 'POST',
+        data: {
+          'refresh_token': refreshToken,
+        },
+        contentType: Headers.formUrlEncodedContentType,
+      );
+    }
+
     try {
-      return (await _authDio.post('https://accounts.spotify.com/api/token',
-              data: {
-                'grant_type': 'refresh_token',
-                'refresh_token': refreshToken,
-                'client_id': clientId,
-              },
-              options: Options(contentType: Headers.formUrlEncodedContentType)))
-          .data;
+      var d = (await _authDio.fetch(req)).data;
+      d['refresh_token'] = refreshToken;
+      return d;
     } on DioError catch (e) {
       print('Token refresh error: ${e.response?.data}');
       rethrow;
fotiDim commented 2 years ago

As I do intend to support iOS for my app, I did end up following the token swap pattern as documented in:

That is great I was about to suggest that. My current thinking is:

Given the above my vote goes for adopting tokenSwapURL and tokenRefreshURL given that we keep the introduced business logic to the absolute minimum. I will value API consistency in that case.

@brim-borium since this is a strategic decision that we need to make, do you have an opinion on this?

nzoschke commented 2 years ago

See #121 for a PR implementing the token swap pattern for web

fotiDim commented 2 years ago

Shall we close this in favour of #121?

nzoschke commented 2 years ago

Yes closing in favor of #121.