Skip to content

Commit 9d22c04

Browse files
committed
Added PKCE stuff: Initial request & refresh + PKCEUtil for generating verifiers
1 parent b084524 commit 9d22c04

File tree

10 files changed

+334
-5
lines changed

10 files changed

+334
-5
lines changed

SpotifyAPI.Docs/docs/pkce.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
id: pkce
3+
title: PKCE
4+
---
5+
6+
> 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.
7+
8+
## Generating Challenge & Verifier
9+
10+
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:
11+
12+
```csharp
13+
// Generates a secure random verifier of length 100 and its challenge
14+
var (verifier, challenge) = PKCEUtil.GenerateCodes();
15+
16+
// Generates a secure random verifier of length 120 and its challenge
17+
var (verifier, challenge) = PKCEUtil.GenerateCodes(120);
18+
19+
// Returns the passed string and its challenge (Make sure it's random and is long enough)
20+
var (verifier, challenge) = PKCEUtil.GenerateCodes("YourSecureRandomString");
21+
```
22+
23+
## Generating Login URI
24+
25+
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:
26+
27+
```csharp
28+
// Make sure "http://localhost:5000/callback" is in your applications redirect URIs!
29+
var loginRequest = new LoginRequest(
30+
new Uri("http://localhost:5000/callback"),
31+
"YourClientId",
32+
LoginRequest.ResponseType.Code
33+
)
34+
{
35+
CodeChallengeMethod = "S256",
36+
CodeChallenge = challenge,
37+
Scope = new[] { Scopes.PlaylistReadPrivate, Scopes.PlaylistReadCollaborative }
38+
};
39+
var uri = loginRequest.ToUri();
40+
// Redirect user to uri via your favorite web-server
41+
```
42+
43+
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`:
44+
45+
```csharp
46+
// This method should be called from your web-server when the user visits "http://localhost:5000/callback"
47+
public Task GetCallback(string code)
48+
{
49+
// Note that we use the verifier calculated above!
50+
var response = await new OAuthClient().RequestToken(
51+
new PKCETokenRequest("ClientId", code, "http://localhost:5000", verifier)
52+
);
53+
54+
var spotify = new SpotifyClient(response.AccessToken);
55+
// Also important for later: response.RefreshToken
56+
}
57+
```
58+
59+
With PKCE you can also refresh tokens once they're expired:
60+
61+
```csharp
62+
var response = await new OAuthClient().RequestToken(
63+
new PKCETokenRefreshRequest("ClientId", oldResponse.RefreshToken)
64+
);
65+
66+
var spotify = new SpotifyClient(response.AccessToken);
67+
```
68+
69+

SpotifyAPI.Docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module.exports = {
2525
'client_credentials',
2626
'implicit_grant',
2727
'authorization_code',
28+
'pkce',
2829
'token_swap'
2930
]
3031
},

SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class Base64UtilTest
1010
[Test]
1111
public void Base64UrlDecode_Works()
1212
{
13-
var encoded = "SGVsbG9Xb3JsZA==";
13+
var encoded = "SGVsbG9Xb3JsZA";
1414

1515
Assert.AreEqual("HelloWorld", Encoding.UTF8.GetString(Base64Util.UrlDecode(encoded)));
1616
}
@@ -20,7 +20,7 @@ public void Base64UrlEncode_Works()
2020
{
2121
var decoded = "HelloWorld";
2222

23-
Assert.AreEqual("SGVsbG9Xb3JsZA==", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded)));
23+
Assert.AreEqual("SGVsbG9Xb3JsZA", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded)));
2424
}
2525

2626
[Test]

SpotifyAPI.Web/Clients/OAuthClient.cs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,32 @@ public OAuthClient(IAPIConnector apiConnector) : base(apiConnector) { }
1515
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062")]
1616
public OAuthClient(SpotifyClientConfig config) : base(ValidateConfig(config)) { }
1717

18+
/// <summary>
19+
/// Requests a new token using pkce flow
20+
/// </summary>
21+
/// <param name="request">The request-model which contains required and optional parameters.</param>
22+
/// <remarks>
23+
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
24+
/// </remarks>
25+
/// <returns></returns>1
26+
public Task<PKCETokenResponse> RequestToken(PKCETokenRequest request)
27+
{
28+
return RequestToken(request, API);
29+
}
30+
31+
/// <summary>
32+
/// Refreshes a token using pkce flow
33+
/// </summary>
34+
/// <param name="request">The request-model which contains required and optional parameters.</param>
35+
/// <remarks>
36+
/// https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
37+
/// </remarks>
38+
/// <returns></returns>1
39+
public Task<PKCETokenResponse> RequestToken(PKCETokenRefreshRequest request)
40+
{
41+
return RequestToken(request, API);
42+
}
43+
1844
/// <summary>
1945
/// Requests a new token using client_ids and client_secrets.
2046
/// If the token is expired, simply call the funtion again to get a new token
@@ -81,6 +107,38 @@ public Task<AuthorizationCodeRefreshResponse> RequestToken(TokenSwapRefreshReque
81107
return RequestToken(request, API);
82108
}
83109

110+
public static Task<PKCETokenResponse> RequestToken(PKCETokenRequest request, IAPIConnector apiConnector)
111+
{
112+
Ensure.ArgumentNotNull(request, nameof(request));
113+
Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector));
114+
115+
var form = new List<KeyValuePair<string, string>>
116+
{
117+
new KeyValuePair<string, string>("client_id", request.ClientId),
118+
new KeyValuePair<string, string>("grant_type", "authorization_code"),
119+
new KeyValuePair<string, string>("code", request.Code),
120+
new KeyValuePair<string, string>("redirect_uri", request.RedirectUri.ToString()),
121+
new KeyValuePair<string, string>("code_verifier", request.CodeVerifier),
122+
};
123+
124+
return SendOAuthRequest<PKCETokenResponse>(apiConnector, form, null, null);
125+
}
126+
127+
public static Task<PKCETokenResponse> RequestToken(PKCETokenRefreshRequest request, IAPIConnector apiConnector)
128+
{
129+
Ensure.ArgumentNotNull(request, nameof(request));
130+
Ensure.ArgumentNotNull(apiConnector, nameof(apiConnector));
131+
132+
var form = new List<KeyValuePair<string, string>>
133+
{
134+
new KeyValuePair<string, string>("client_id", request.ClientId),
135+
new KeyValuePair<string, string>("grant_type", "refresh_token"),
136+
new KeyValuePair<string, string>("refresh_token", request.RefreshToken),
137+
};
138+
139+
return SendOAuthRequest<PKCETokenResponse>(apiConnector, form, null, null);
140+
}
141+
84142
public static Task<AuthorizationCodeRefreshResponse> RequestToken(
85143
TokenSwapRefreshRequest request, IAPIConnector apiConnector
86144
)
@@ -169,17 +227,22 @@ public static Task<AuthorizationCodeTokenResponse> RequestToken(
169227
private static Task<T> SendOAuthRequest<T>(
170228
IAPIConnector apiConnector,
171229
List<KeyValuePair<string, string>> form,
172-
string clientId,
173-
string clientSecret)
230+
string? clientId,
231+
string? clientSecret)
174232
{
175233
var headers = BuildAuthHeader(clientId, clientSecret);
176234
#pragma warning disable CA2000
177235
return apiConnector.Post<T>(SpotifyUrls.OAuthToken, null, new FormUrlEncodedContent(form), headers);
178236
#pragma warning restore CA2000
179237
}
180238

181-
private static Dictionary<string, string> BuildAuthHeader(string clientId, string clientSecret)
239+
private static Dictionary<string, string> BuildAuthHeader(string? clientId, string? clientSecret)
182240
{
241+
if (clientId == null || clientSecret == null)
242+
{
243+
return new Dictionary<string, string>();
244+
}
245+
183246
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
184247
return new Dictionary<string, string>
185248
{

SpotifyAPI.Web/Models/Request/LoginRequest.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public LoginRequest(Uri redirectUri, string clientId, ResponseType responseType)
2424
public string? State { get; set; }
2525
public ICollection<string>? Scope { get; set; }
2626
public bool? ShowDialog { get; set; }
27+
public string? CodeChallengeMethod { get; set; }
28+
public string? CodeChallenge { get; set; }
2729

2830
public Uri ToUri()
2931
{
@@ -43,6 +45,14 @@ public Uri ToUri()
4345
{
4446
builder.Append($"&show_dialog={ShowDialog.Value}");
4547
}
48+
if (CodeChallenge != null)
49+
{
50+
builder.Append($"&code_challenge={CodeChallenge}");
51+
}
52+
if (CodeChallengeMethod != null)
53+
{
54+
builder.Append($"&code_challenge_method={CodeChallengeMethod}");
55+
}
4656

4757
return new Uri(builder.ToString());
4858
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
3+
namespace SpotifyAPI.Web
4+
{
5+
public class PKCETokenRefreshRequest
6+
{
7+
/// <summary>
8+
/// Request model for refreshing a access token via PKCE Token
9+
/// </summary>
10+
/// <param name="clientId">The Client ID of your Spotify Application (See Spotify Dev Dashboard).</param>
11+
/// <param name="refreshToken">The received refresh token. Expires after one refresh</param>
12+
public PKCETokenRefreshRequest(string clientId, string refreshToken)
13+
{
14+
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
15+
Ensure.ArgumentNotNullOrEmptyString(refreshToken, nameof(refreshToken));
16+
17+
ClientId = clientId;
18+
RefreshToken = refreshToken;
19+
}
20+
21+
/// <summary>
22+
/// The Client ID of your Spotify Application (See Spotify Dev Dashboard).
23+
/// </summary>
24+
/// <value></value>
25+
public string ClientId { get; }
26+
27+
/// <summary>
28+
/// The received refresh token.
29+
/// </summary>
30+
/// <value></value>
31+
public string RefreshToken { get; }
32+
}
33+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System;
2+
3+
namespace SpotifyAPI.Web
4+
{
5+
public class PKCETokenRequest
6+
{
7+
/// <summary>
8+
///
9+
/// </summary>
10+
/// <param name="clientId">The Client ID of your Spotify Application (See Spotify Dev Dashboard).</param>
11+
/// <param name="code">The code received from the spotify response.</param>
12+
/// <param name="redirectUri">The redirectUri which was used to initiate the authentication.</param>
13+
/// <param name="codeVerifier">
14+
/// The value of this parameter must match the value of the code_verifier
15+
/// that your app generated in step 1.
16+
/// </param>
17+
public PKCETokenRequest(string clientId, string code, Uri redirectUri, string codeVerifier)
18+
{
19+
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
20+
Ensure.ArgumentNotNullOrEmptyString(code, nameof(code));
21+
Ensure.ArgumentNotNullOrEmptyString(codeVerifier, nameof(codeVerifier));
22+
Ensure.ArgumentNotNull(redirectUri, nameof(redirectUri));
23+
24+
ClientId = clientId;
25+
CodeVerifier = codeVerifier;
26+
Code = code;
27+
RedirectUri = redirectUri;
28+
}
29+
30+
/// <summary>
31+
/// The Client ID of your Spotify Application (See Spotify Dev Dashboard).
32+
/// </summary>
33+
/// <value></value>
34+
public string ClientId { get; }
35+
36+
/// <summary>
37+
/// The value of this parameter must match the value of the code_verifier
38+
/// that your app generated in step 1.
39+
/// </summary>
40+
/// <value></value>
41+
public string CodeVerifier { get; }
42+
43+
/// <summary>
44+
/// The code received from the spotify response.
45+
/// </summary>
46+
/// <value></value>
47+
public string Code { get; }
48+
49+
/// <summary>
50+
/// The redirectUri which was used to initiate the authentication.
51+
/// </summary>
52+
/// <value></value>
53+
public Uri RedirectUri { get; }
54+
}
55+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
3+
namespace SpotifyAPI.Web
4+
{
5+
public class PKCETokenResponse
6+
{
7+
public string AccessToken { get; set; } = default!;
8+
public string TokenType { get; set; } = default!;
9+
public int ExpiresIn { get; set; }
10+
public string Scope { get; set; } = default!;
11+
public string RefreshToken { get; set; } = default!;
12+
13+
/// <summary>
14+
/// Auto-Initalized to UTC Now
15+
/// </summary>
16+
/// <value></value>
17+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
18+
19+
public bool IsExpired { get => CreatedAt.AddSeconds(ExpiresIn) <= DateTime.UtcNow; }
20+
}
21+
}

SpotifyAPI.Web/Util/Base64Util.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ public static string UrlEncode(byte[] input)
3737
{
3838
buffer[i] = '_';
3939
}
40+
else if (ch == '=')
41+
{
42+
return new string(buffer, startIndex: 0, length: i);
43+
}
4044
}
4145

4246
return new string(buffer, startIndex: 0, length: numBase64Chars);

0 commit comments

Comments
 (0)