'How to implement Authorization Code with PKCE for Spotify
Edit: To clarify, getting the authorization code works as expected. It is purely the step of exchanging the authorization code for tokens that fails.
I am trying to implement the authorization code with PKCE flow for authenticating with the spotify API. I know there are libraries out there for this, but I really want to implement it myself. The flow I am talking about is this: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce I am able to craft the link to redirect the user to the consent page and get a hold of the authorization code. However, when I try to exchange this code for tokens, I get a 400 Bad Request with the message "invalid client_secret". This leads me to believe that Spotify assumes I am trying to use the regular Authorization Code flow, as the client secret is not a part of the PKCE flow at all. I suspect I am encoding the code_verifier or the code_challenge wrong. I found this answer on SO (How to calculate PCKE's code_verifier?) and translated it to C#, yielding identical results for the Base64 encoded hash, but it still doesn't work.
My code for generating the code_verifier and code_challenge is below, as well as the code making the request to exchange the code.
CodeVerifier:
private string GenerateNonce()
{
const string chars = "abcdefghijklmnopqrstuvwxyz123456789";
var random = new Random();
var nonce = new char[100];
for (int i = 0; i < nonce.Length; i++)
{
nonce[i] = chars[random.Next(chars.Length)];
}
return new string(nonce);
}
CodeChallenge:
private string GenerateCodeChallenge(string codeVerifier)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
return Convert.ToBase64String(hash).Replace("+/", "-_").Replace("=", "");
}
Exchange token:
var parameters = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_id", ClientId ),
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("code", authCode),
new KeyValuePair<string, string>("redirect_uri", "http://localhost:5000"),
new KeyValuePair<string, string>("code_verifier", codeVerifier)
};
var content = new FormUrlEncodedContent(parameters );
var response = await HttpClient.PostAsync($"https://accounts.spotify.com/api/token", content);
Solution 1:[1]
Here is a refactor of GenerateNonce (now GenerateCodeVerifier) and GenerateCodeChallenge that complies with the rfc-7636 standard integrated into a class that can either be instantiated or used for its static methods.
/// <summary>
/// Provides a randomly generating PKCE code verifier and it's corresponding code challenge.
/// </summary>
public class Pkce
{
/// <summary>
/// The randomly generating PKCE code verifier.
/// </summary>
public string CodeVerifier;
/// <summary>
/// Corresponding PKCE code challenge.
/// </summary>
public string CodeChallenge;
/// <summary>
/// Initializes a new instance of the Pkce class.
/// </summary>
/// <param name="size">The size of the code verifier (43 - 128 charters).</param>
public Pkce(uint size = 128)
{
CodeVerifier = GenerateCodeVerifier(size);
CodeChallenge = GenerateCodeChallenge(CodeVerifier);
}
/// <summary>
/// Generates a code_verifier based on rfc-7636.
/// </summary>
/// <param name="size">The size of the code verifier (43 - 128 charters).</param>
/// <returns>A code verifier.</returns>
/// <remarks>
/// code_verifier = high-entropy cryptographic random STRING using the
/// unreserved characters[A - Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
/// from Section 2.3 of[RFC3986], with a minimum length of 43 characters
/// and a maximum length of 128 characters.
///
/// ABNF for "code_verifier" is as follows.
///
/// code-verifier = 43*128unreserved
/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
/// ALPHA = %x41-5A / %x61-7A
/// DIGIT = % x30 - 39
///
/// Reference: rfc-7636 https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
///</remarks>
public static string GenerateCodeVerifier(uint size = 128)
{
if (size < 43 || size > 128)
size = 128;
const string unreservedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
Random random = new Random();
char[] highEntropyCryptograph = new char[size];
for (int i = 0; i < highEntropyCryptograph.Length; i++)
{
highEntropyCryptograph[i] = unreservedCharacters[random.Next(unreservedCharacters.Length)];
}
return new string(highEntropyCryptograph);
}
/// <summary>
/// Generates a code_challenge based on rfc-7636.
/// </summary>
/// <param name="codeVerifier">The code verifier.</param>
/// <returns>A code challenge.</returns>
/// <remarks>
/// plain
/// code_challenge = code_verifier
///
/// S256
/// code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
///
/// If the client is capable of using "S256", it MUST use "S256", as
/// "S256" is Mandatory To Implement(MTI) on the server.Clients are
/// permitted to use "plain" only if they cannot support "S256" for some
/// technical reason and know via out-of-band configuration that the
/// server supports "plain".
///
/// The plain transformation is for compatibility with existing
/// deployments and for constrained environments that can't use the S256
/// transformation.
///
/// ABNF for "code_challenge" is as follows.
///
/// code-challenge = 43 * 128unreserved
/// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
/// ALPHA = % x41 - 5A / %x61-7A
/// DIGIT = % x30 - 39
///
/// Reference: rfc-7636 https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
/// </remarks>
public static string GenerateCodeChallenge(string codeVerifier)
{
using (var sha256 = SHA256.Create())
{
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
return Base64UrlEncoder.Encode(challengeBytes);
}
}
}
For those of you into unit testing.
/// <summary>
/// Pkce unit test.
/// </summary>
/// <remarks>
/// MethodName_StateUnderTest_ExpectedBehavior
/// Arrange, Act, Assert
/// </remarks>
[TestFixture]
public class PkceUnitTests
{
[Test]
public void GenerateCodeVerifier_DefaultSize_Returns128CharacterLengthString()
{
string codeVerifier = Pkce.GenerateCodeVerifier();
Assert.That(codeVerifier.Length, Is.EqualTo(128));
}
[Test]
public void GenerateCodeVerifier_Size45_Returns45CharacterLengthString()
{
string codeVerifier = Pkce.GenerateCodeVerifier(45);
Assert.That(codeVerifier.Length, Is.EqualTo(45));
}
[Test]
public void GenerateCodeVerifier_SizeLessThan43_ReturnsDefault128CharacterLengthString()
{
string codeVerifier = Pkce.GenerateCodeVerifier(42);
Assert.That(codeVerifier.Length, Is.EqualTo(128));
}
[Test]
public void GenerateCodeVerifier_SizeGreaterThan128_ReturnsDefault128CharacterLengthString()
{
string codeVerifier = Pkce.GenerateCodeVerifier(42);
Assert.That(codeVerifier.Length, Is.EqualTo(128));
}
[Test]
public void GenerateCodeVerifier_DefaultSize_ReturnsLegalCharacterLengthString()
{
const string unreservedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
for (int x = 0; x < 1000; x++)
{
string codeVerifier = Pkce.GenerateCodeVerifier();
for (int i = 0; i < codeVerifier.Length; i++)
{
Assert.That(unreservedCharacters.IndexOf(codeVerifier[i]), Is.GreaterThan(-1));
}
}
}
[Test]
public void GenerateCodeChallenge_GivenCodeVerifier_ReturnsCorrectCodeChallenge()
{
string codeChallenge = Pkce.GenerateCodeChallenge("0t4Rep04AxvISWM3rMxGnyla2ceDT71oMzIK0iGEDgOt5.isAGW6~2WdGBUxaPYXA6R8vbSBcgSI-jeK_1yZgVfEXoFa1Ec3gPn~Anqwo4BgeXVppo.fjtU7y2cwq_wL");
Assert.That(codeChallenge, Is.EqualTo("czx06cKMDaHQdro9ITfrQ4tR5JGv9Jbj7eRG63BKHlU"));
}
[Test]
public void InstantiateClass_WithDefaultSize_Returns128CharacterLengthCodeVerifier()
{
Pkce pkce = new Pkce();
Assert.That(pkce.CodeVerifier.Length, Is.EqualTo(128));
}
[Test]
public void InstantiateClass_WithSize57_Returns57CharacterLengthCodeVerifier()
{
Pkce pkce = new Pkce(57);
Assert.That(pkce.CodeVerifier.Length, Is.EqualTo(57));
}
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 |
