Added PKCE stuff: Initial request & refresh + PKCEUtil for generating verifiers

This commit is contained in:
Jonas Dellinger 2020-08-06 18:48:49 +00:00
parent b084524814
commit 9d22c04764
10 changed files with 334 additions and 5 deletions

View File

@ -0,0 +1,69 @@
---
id: pkce
title: PKCE
---
> The authorization code flow with PKCE is the best option for mobile and desktop applications where it is unsafe to store your client secret. It provides your app with an access token that can be refreshed. For further information about this flow, see IETF RFC-7636.
## Generating Challenge & Verifier
For every authentation request, a verify code and its challenge code needs to be generated. The class `PKCEUtil` can be used to generate those, either with random generated or self supplied values:
```csharp
// Generates a secure random verifier of length 100 and its challenge
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)
var (verifier, challenge) = PKCEUtil.GenerateCodes("YourSecureRandomString");
```
## Generating Login URI
Like most auth flows, you'll need to redirect your user to spotify's servers so he is able to grant access to your application:
```csharp
// Make sure "http://localhost:5000/callback" is in your applications redirect URIs!
var loginRequest = new LoginRequest(
new Uri("http://localhost:5000/callback"),
"YourClientId",
LoginRequest.ResponseType.Code
)
{
CodeChallengeMethod = "S256",
CodeChallenge = challenge,
Scope = new[] { Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative }
};
var uri = loginRequest.ToUri();
// Redirect user to uri via your favorite web-server
```
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`:
```csharp
// This method should be called from your web-server when the user visits "http://localhost:5000/callback"
public Task GetCallback(string code)
{
// Note that we use the verifier calculated above!
var response = await new OAuthClient().RequestToken(
new PKCETokenRequest("ClientId", code, "http://localhost:5000", verifier)
);
var spotify = new SpotifyClient(response.AccessToken);
// Also important for later: response.RefreshToken
}
```
With PKCE you can also refresh tokens once they're expired:
```csharp
var response = await new OAuthClient().RequestToken(
new PKCETokenRefreshRequest("ClientId", oldResponse.RefreshToken)
);
var spotify = new SpotifyClient(response.AccessToken);
```

View File

@ -25,6 +25,7 @@ module.exports = {
'client_credentials',
'implicit_grant',
'authorization_code',
'pkce',
'token_swap'
]
},

View File

@ -10,7 +10,7 @@ namespace SpotifyAPI.Web.Tests
[Test]
public void Base64UrlDecode_Works()
{
var encoded = "SGVsbG9Xb3JsZA==";
var encoded = "SGVsbG9Xb3JsZA";
Assert.AreEqual("HelloWorld", Encoding.UTF8.GetString(Base64Util.UrlDecode(encoded)));
}
@ -20,7 +20,7 @@ namespace SpotifyAPI.Web.Tests
{
var decoded = "HelloWorld";
Assert.AreEqual("SGVsbG9Xb3JsZA==", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded)));
Assert.AreEqual("SGVsbG9Xb3JsZA", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded)));
}
[Test]

View File

@ -15,6 +15,32 @@ namespace SpotifyAPI.Web
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062")]
public OAuthClient(SpotifyClientConfig config) : base(ValidateConfig(config)) { }
/// <summary>
/// Requests a new token using pkce flow
/// </summary>
/// <param name="request">The request-model which contains required and optional parameters.</param>
/// <remarks>
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
/// </remarks>
/// <returns></returns>1
public Task<PKCETokenResponse> RequestToken(PKCETokenRequest request)
{
return RequestToken(request, API);
}
/// <summary>
/// Refreshes a token using pkce flow
/// </summary>
/// <param name="request">The request-model which contains required and optional parameters.</param>
/// <remarks>
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
/// </remarks>
/// <returns></returns>1
public Task<PKCETokenResponse> RequestToken(PKCETokenRefreshRequest request)
{
return RequestToken(request, API);
}
/// <summary>
/// Requests a new token using client_ids and client_secrets.
/// If the token is expired, simply call the funtion again to get a new token
@ -81,6 +107,38 @@ namespace SpotifyAPI.Web
return RequestToken(request, API);
}
public static Task<PKCETokenResponse> RequestToken(PKCETokenRequest request, IAPIConnector apiConnector)
{
Ensure.ArgumentNotNull(request, nameof(request));
Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector));
var form = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_id", request.ClientId),
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("code", request.Code),
new KeyValuePair<string, string>("redirect_uri", request.RedirectUri.ToString()),
new KeyValuePair<string, string>("code_verifier", request.CodeVerifier),
};
return SendOAuthRequest<PKCETokenResponse>(apiConnector, form, null, null);
}
public static Task<PKCETokenResponse> RequestToken(PKCETokenRefreshRequest request, IAPIConnector apiConnector)
{
Ensure.ArgumentNotNull(request, nameof(request));
Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector));
var form = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_id", request.ClientId),
new KeyValuePair<string, string>("grant_type", "refresh_token"),
new KeyValuePair<string, string>("refresh_token", request.RefreshToken),
};
return SendOAuthRequest<PKCETokenResponse>(apiConnector, form, null, null);
}
public static Task<AuthorizationCodeRefreshResponse> RequestToken(
TokenSwapRefreshRequest request, IAPIConnector apiConnector
)
@ -169,8 +227,8 @@ namespace SpotifyAPI.Web
private static Task<T> SendOAuthRequest<T>(
IAPIConnector apiConnector,
List<KeyValuePair<string, string>> form,
string clientId,
string clientSecret)
string? clientId,
string? clientSecret)
{
var headers = BuildAuthHeader(clientId, clientSecret);
#pragma warning disable CA2000
@ -178,8 +236,13 @@ namespace SpotifyAPI.Web
#pragma warning restore CA2000
}
private static Dictionary<string, string> BuildAuthHeader(string clientId, string clientSecret)
private static Dictionary<string, string> BuildAuthHeader(string? clientId, string? clientSecret)
{
if (clientId == null || clientSecret == null)
{
return new Dictionary<string, string>();
}
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
return new Dictionary<string, string>
{

View File

@ -24,6 +24,8 @@ namespace SpotifyAPI.Web
public string? State { get; set; }
public ICollection<string>? Scope { get; set; }
public bool? ShowDialog { get; set; }
public string? CodeChallengeMethod { get; set; }
public string? CodeChallenge { get; set; }
public Uri ToUri()
{
@ -43,6 +45,14 @@ namespace SpotifyAPI.Web
{
builder.Append($"&show_dialog={ShowDialog.Value}");
}
if (CodeChallenge != null)
{
builder.Append($"&code_challenge={CodeChallenge}");
}
if (CodeChallengeMethod != null)
{
builder.Append($"&code_challenge_method={CodeChallengeMethod}");
}
return new Uri(builder.ToString());
}

View File

@ -0,0 +1,33 @@
using System;
namespace SpotifyAPI.Web
{
public class PKCETokenRefreshRequest
{
/// <summary>
/// Request model for refreshing a access token via PKCE Token
/// </summary>
/// <param name="clientId">The Client ID of your Spotify Application (See Spotify Dev Dashboard).</param>
/// <param name="refreshToken">The received refresh token. Expires after one refresh</param>
public PKCETokenRefreshRequest(string clientId, string refreshToken)
{
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
Ensure.ArgumentNotNullOrEmptyString(refreshToken, nameof(refreshToken));
ClientId = clientId;
RefreshToken = refreshToken;
}
/// <summary>
/// The Client ID of your Spotify Application (See Spotify Dev Dashboard).
/// </summary>
/// <value></value>
public string ClientId { get; }
/// <summary>
/// The received refresh token.
/// </summary>
/// <value></value>
public string RefreshToken { get; }
}
}

View File

@ -0,0 +1,55 @@
using System;
namespace SpotifyAPI.Web
{
public class PKCETokenRequest
{
/// <summary>
///
/// </summary>
/// <param name="clientId">The Client ID of your Spotify Application (See Spotify Dev Dashboard).</param>
/// <param name="code">The code received from the spotify response.</param>
/// <param name="redirectUri">The redirectUri which was used to initiate the authentication.</param>
/// <param name="codeVerifier">
/// The value of this parameter must match the value of the code_verifier
/// that your app generated in step 1.
/// </param>
public PKCETokenRequest(string clientId, string code, Uri redirectUri, string codeVerifier)
{
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
Ensure.ArgumentNotNullOrEmptyString(code, nameof(code));
Ensure.ArgumentNotNullOrEmptyString(codeVerifier, nameof(codeVerifier));
Ensure.ArgumentNotNull(redirectUri, nameof(redirectUri));
ClientId = clientId;
CodeVerifier = codeVerifier;
Code = code;
RedirectUri = redirectUri;
}
/// <summary>
/// The Client ID of your Spotify Application (See Spotify Dev Dashboard).
/// </summary>
/// <value></value>
public string ClientId { get; }
/// <summary>
/// The value of this parameter must match the value of the code_verifier
/// that your app generated in step 1.
/// </summary>
/// <value></value>
public string CodeVerifier { get; }
/// <summary>
/// The code received from the spotify response.
/// </summary>
/// <value></value>
public string Code { get; }
/// <summary>
/// The redirectUri which was used to initiate the authentication.
/// </summary>
/// <value></value>
public Uri RedirectUri { get; }
}
}

View File

@ -0,0 +1,21 @@
using System;
namespace SpotifyAPI.Web
{
public class PKCETokenResponse
{
public string AccessToken { get; set; } = default!;
public string TokenType { get; set; } = default!;
public int ExpiresIn { get; set; }
public string Scope { get; set; } = default!;
public string RefreshToken { get; set; } = default!;
/// <summary>
/// Auto-Initalized to UTC Now
/// </summary>
/// <value></value>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsExpired { get => CreatedAt.AddSeconds(ExpiresIn) <= DateTime.UtcNow; }
}
}

View File

@ -37,6 +37,10 @@ namespace SpotifyAPI.Web
{
buffer[i] = '_';
}
else if (ch == '=')
{
return new string(buffer, startIndex: 0, length: i);
}
}
return new string(buffer, startIndex: 0, length: numBase64Chars);

View File

@ -0,0 +1,73 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace SpotifyAPI.Web
{
public static class PKCEUtil
{
private const int VERIFY_MIN_LENGTH = 43;
private const int VERIFY_MAX_LENGTH = 128;
private const int VERIFY_DEFAULT_LENGTH = 100;
/// <summary>
/// Generate a verifier and challenge pair using RNGCryptoServiceProvider
/// </summary>
/// <param name="length">The length of the generated verifier</param>
/// <returns></returns>
public static (string verifier, string challenge) GenerateCodes(int length = VERIFY_DEFAULT_LENGTH)
{
if (length < VERIFY_MIN_LENGTH || length > VERIFY_MAX_LENGTH)
{
throw new ArgumentException(
$"length must be between {VERIFY_MIN_LENGTH} and {VERIFY_MAX_LENGTH}",
nameof(length)
);
}
var verifier = GenerateRandomURLSafeString(length);
return GenerateCodes(verifier);
}
/// <summary>
/// Return the paseed verifier and its challenge
/// </summary>
/// <param name="verifier">A secure random generated verifier</param>
/// <returns></returns>
public static (string verifier, string challenge) GenerateCodes(string verifier)
{
Ensure.ArgumentNotNull(verifier, nameof(verifier));
if (verifier.Length < VERIFY_MIN_LENGTH || verifier.Length > VERIFY_MAX_LENGTH)
{
throw new ArgumentException(
$"length must be between {VERIFY_MIN_LENGTH} and {VERIFY_MAX_LENGTH}",
nameof(verifier)
);
}
var challenge = Base64Util.UrlEncode(ComputeSHA256(verifier));
return (verifier, challenge);
}
private static string GenerateRandomURLSafeString(int length)
{
using (var rng = new RNGCryptoServiceProvider())
{
var bit_count = length * 6;
var byte_count = (bit_count + 7) / 8; // rounded up
var bytes = new byte[byte_count];
rng.GetBytes(bytes);
return Base64Util.UrlEncode(bytes);
}
}
private static byte[] ComputeSHA256(string value)
{
using (var hash = SHA256.Create())
{
return hash.ComputeHash(Encoding.UTF8.GetBytes(value));
}
}
}
}