From 24459453a60f0045818e528d2b6915f34a88a0ee Mon Sep 17 00:00:00 2001 From: Jonas Dellinger Date: Wed, 19 Aug 2020 14:03:01 +0200 Subject: [PATCH] Finished PKCE Docs & Implementation - Example.CLI.PersistentConfig now uses PKCE --- SpotifyAPI.Docs/docs/auth_introduction.md | 13 ++-- SpotifyAPI.Docs/docs/pkce.md | 22 ++++-- .../static/img/auth_comparison.png | Bin 15479 -> 16436 bytes .../Example.CLI.PersistentConfig/Program.cs | 43 +++++++----- .../Authenticators/PKCEAuthenticator.cs | 65 ++++++++++++++++++ 5 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs diff --git a/SpotifyAPI.Docs/docs/auth_introduction.md b/SpotifyAPI.Docs/docs/auth_introduction.md index 4652d7f4..dac18163 100644 --- a/SpotifyAPI.Docs/docs/auth_introduction.md +++ b/SpotifyAPI.Docs/docs/auth_introduction.md @@ -7,16 +7,17 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; Spotify does not allow unauthorized access to the api. Thus, you need an access token to make requets. This access token can be gathered via multiple schemes, all following the OAuth2 spec. Since it's important to choose the correct scheme for your usecase, make sure you have a grasp of the following terminology/docs: -* OAuth2 -* [Spotify Authorization Flows](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) +- OAuth2 +- [Spotify Authorization Flows](https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow) Since every auth flow also needs an application in the [spotify dashboard](https://developer.spotify.com/dashboard/), make sure you have the necessary values (like `Client Id` and `Client Secret`). Then, continue with the docs of the specific auth flows: -* [Client Credentials](client_credentials.md) -* [Implicit Grant](implicit_grant.md) -* [Authorization Code](authorization_code.md) -* [Token Swap](token_swap.md) +- [Client Credentials](client_credentials.md) +- [Implicit Grant](implicit_grant.md) +- [Authorization Code](authorization_code.md) +- [PKCE](pkce.md) +- [(Token Swap)](token_swap.md) auth comparison diff --git a/SpotifyAPI.Docs/docs/pkce.md b/SpotifyAPI.Docs/docs/pkce.md index 6717e4eb..206bafea 100644 --- a/SpotifyAPI.Docs/docs/pkce.md +++ b/SpotifyAPI.Docs/docs/pkce.md @@ -16,7 +16,7 @@ var (verifier, challenge) = PKCEUtil.GenerateCodes(); // Generates a secure random verifier of length 120 and its challenge var (verifier, challenge) = PKCEUtil.GenerateCodes(120); -// Returns the passed string and its challenge (Make sure it's random and is long enough) +// Returns the passed string and its challenge (Make sure it's random and long enough) var (verifier, challenge) = PKCEUtil.GenerateCodes("YourSecureRandomString"); ``` @@ -37,7 +37,7 @@ var loginRequest = new LoginRequest( Scope = new[] { Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative } }; var uri = loginRequest.ToUri(); -// Redirect user to uri via your favorite web-server +// Redirect user to uri via your favorite web-server or open a local browser window ``` When the user is redirected to the generated uri, he will have to login with his spotify account and confirm, that your application wants to access his user data. Once confirmed, he will be redirect to `http://localhost:5000/callback` and a `code` parameter is attached to the query. The redirect URI can also contain a custom protocol paired with UWP App Custom Protocol handler. This received `code` has to be exchanged for an `access_token` and `refresh_token`: @@ -47,11 +47,11 @@ When the user is redirected to the generated uri, he will have to login with his public Task GetCallback(string code) { // Note that we use the verifier calculated above! - var response = await new OAuthClient().RequestToken( + var initialResponse = await new OAuthClient().RequestToken( new PKCETokenRequest("ClientId", code, "http://localhost:5000", verifier) ); - var spotify = new SpotifyClient(response.AccessToken); + var spotify = new SpotifyClient(initialResponse.AccessToken); // Also important for later: response.RefreshToken } ``` @@ -59,11 +59,19 @@ public Task GetCallback(string code) With PKCE you can also refresh tokens once they're expired: ```csharp -var response = await new OAuthClient().RequestToken( - new PKCETokenRefreshRequest("ClientId", oldResponse.RefreshToken) +var newResponse = await new OAuthClient().RequestToken( + new PKCETokenRefreshRequest("ClientId", initialResponse.RefreshToken) ); -var spotify = new SpotifyClient(response.AccessToken); +var spotify = new SpotifyClient(newResponse.AccessToken); ``` +If you do not want to take care of manually refreshing tokens, you can use `PKCEAuthenticator`: +```csharp +var authenticator = new PKCEAuthenticator(clientId, initialResponse); + +var config = SpotifyClientConfig.CreateDefault() + .WithAuthenticator(authenticator); +var spotify = new SpotifyClient(config); +``` diff --git a/SpotifyAPI.Docs/static/img/auth_comparison.png b/SpotifyAPI.Docs/static/img/auth_comparison.png index 60553ef5b99199c977c81dddfa10fe54ac974a1d..9cfe917e2239edb06076cb8b76072f4ab36cc1f8 100644 GIT binary patch literal 16436 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 Yi&iC<9Nt@34l%%eO@q7Tx9y()KMi_GNdN!< literal 15479 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 hx&O~r#d7ZxNWZQ|h`y Console.CursorVisible = true; public static async Task Main() { - if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + // This is a bug in the SWAN Logging library, need this hack to bring back the cursor + AppDomain.CurrentDomain.ProcessExit += (sender, e) => Exiting(); + + if (string.IsNullOrEmpty(clientId)) { throw new NullReferenceException( - "Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET via environment variables before starting the program" + "Please set SPOTIFY_CLIENT_ID via environment variables before starting the program" ); } @@ -46,9 +49,9 @@ namespace Example.CLI.PersistentConfig private static async Task Start() { var json = await File.ReadAllTextAsync(CredentialsPath); - var token = JsonConvert.DeserializeObject(json); + var token = JsonConvert.DeserializeObject(json); - var authenticator = new AuthorizationCodeAuthenticator(clientId!, clientSecret!, token); + var authenticator = new PKCEAuthenticator(clientId!, token); authenticator.TokenRefreshed += (sender, token) => File.WriteAllText(CredentialsPath, JsonConvert.SerializeObject(token)); var config = SpotifyClientConfig.CreateDefault() @@ -68,12 +71,25 @@ namespace Example.CLI.PersistentConfig private static async Task StartAuthentication() { + var (verifier, challenge) = PKCEUtil.GenerateCodes(); + await _server.Start(); - _server.AuthorizationCodeReceived += OnAuthorizationCodeReceived; + _server.AuthorizationCodeReceived += async (sender, response) => + { + await _server.Stop(); + PKCETokenResponse token = await new OAuthClient().RequestToken( + new PKCETokenRequest(clientId!, response.Code, _server.BaseUri, verifier) + ); + + await File.WriteAllTextAsync(CredentialsPath, JsonConvert.SerializeObject(token)); + await Start(); + }; var request = new LoginRequest(_server.BaseUri, clientId!, LoginRequest.ResponseType.Code) { - Scope = new List { UserReadEmail, UserReadPrivate, PlaylistReadPrivate } + CodeChallenge = challenge, + CodeChallengeMethod = "S256", + Scope = new List { UserReadEmail, UserReadPrivate, PlaylistReadPrivate, PlaylistReadCollaborative } }; Uri uri = request.ToUri(); @@ -86,16 +102,5 @@ namespace Example.CLI.PersistentConfig Console.WriteLine("Unable to open URL, manually open: {0}", uri); } } - - private static async Task OnAuthorizationCodeReceived(object sender, AuthorizationCodeResponse response) - { - await _server.Stop(); - AuthorizationCodeTokenResponse token = await new OAuthClient().RequestToken( - new AuthorizationCodeTokenRequest(clientId!, clientSecret!, response.Code, _server.BaseUri) - ); - - await File.WriteAllTextAsync(CredentialsPath, JsonConvert.SerializeObject(token)); - await Start(); - } } } diff --git a/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs b/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs new file mode 100644 index 00000000..4cfb99d6 --- /dev/null +++ b/SpotifyAPI.Web/Authenticators/PKCEAuthenticator.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using SpotifyAPI.Web.Http; + +namespace SpotifyAPI.Web +{ + /// + /// This Authenticator requests new credentials token on demand and stores them into memory. + /// It is unable to query user specifc details. + /// + public class PKCEAuthenticator : IAuthenticator + { + /// + /// Initiate a new instance. The token will be refreshed once it expires. + /// The initialToken will be updated with the new values on refresh! + /// + public PKCEAuthenticator(string clientId, PKCETokenResponse initialToken) + { + Ensure.ArgumentNotNull(clientId, nameof(clientId)); + Ensure.ArgumentNotNull(initialToken, nameof(initialToken)); + + InitialToken = initialToken; + ClientId = clientId; + } + + /// + /// This event is called once a new refreshed token was aquired + /// + public event EventHandler? TokenRefreshed; + + + /// + /// The ClientID, defined in a spotify application in your Spotify Developer Dashboard + /// + public string ClientId { get; } + + /// + /// The inital token passed to the authenticator. Fields will be updated on refresh. + /// + /// + public PKCETokenResponse InitialToken { get; } + + public async Task Apply(IRequest request, IAPIConnector apiConnector) + { + Ensure.ArgumentNotNull(request, nameof(request)); + + if (InitialToken.IsExpired) + { + var tokenRequest = new PKCETokenRefreshRequest(ClientId, InitialToken.RefreshToken); + var refreshedToken = await OAuthClient.RequestToken(tokenRequest, apiConnector).ConfigureAwait(false); + + InitialToken.AccessToken = refreshedToken.AccessToken; + InitialToken.CreatedAt = refreshedToken.CreatedAt; + InitialToken.ExpiresIn = refreshedToken.ExpiresIn; + InitialToken.Scope = refreshedToken.Scope; + InitialToken.TokenType = refreshedToken.TokenType; + InitialToken.RefreshToken = refreshedToken.RefreshToken; + + TokenRefreshed?.Invoke(this, InitialToken); + } + + request.Headers["Authorization"] = $"{InitialToken.TokenType} {InitialToken.AccessToken}"; + } + } +}