10

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);
sunero4
  • 820
  • 9
  • 29
  • Hi, I think you're missing a step. Before calling /api/token you should be calling /authorize to get the code to exchange with an access token. – Michaelsoft Dec 09 '20 at 13:21
  • @Michaelsoft Sorry if that was unclear - the part where I retrieve the authorization code works fine, and I am also passing it along in the request content as can be seen in the code below the "Exchange token" header in the post. – sunero4 Dec 09 '20 at 13:34
  • Ok, sorry, I misunderstood that part. Did you tried something similar instead of PostAsync? var client = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(parameters) }; var res = await client.SendAsync(req); – Michaelsoft Dec 09 '20 at 13:59
  • Maybe using a dict instead of List>? – Michaelsoft Dec 09 '20 at 14:00
  • Thanks for the input - I have tried both things now, but unfortunately neither worked. I am almost 100% sure that it must be something related to how I encode the code_verifier or the code_challenge, but I cannot figure out what – sunero4 Dec 09 '20 at 14:45
  • Based on spotify's doc I would say that the only thing that's not returning now is the ".Replace("+/", "-_").Replace("=", "")". Spotify just says to Base64 encode the hashed verifier to get the challenge. i'm gonna try your code ;). – Michaelsoft Dec 09 '20 at 14:53
  • No that's unfortunately the same conclusion I had, but Base64 encoding alone does not do the trick :/ – sunero4 Dec 09 '20 at 14:59
  • I was able to do that, look at the answer. – Michaelsoft Dec 09 '20 at 15:57

3 Answers3

17

I reproduced code and was able to make it work. Here is a working project on github: https://github.com/michaeldisaro/TestSpotifyPkce.

The changes I made:

public class Code
{

    public static string CodeVerifier;

    public static string CodeChallenge;

    public static void Init()
    {
        CodeVerifier = GenerateNonce();
        CodeChallenge = GenerateCodeChallenge(CodeVerifier);
    }

    private static string GenerateNonce()
    {
        const string chars = "abcdefghijklmnopqrstuvwxyz123456789";
        var random = new Random();
        var nonce = new char[128];
        for (int i = 0; i < nonce.Length; i++)
        {
            nonce[i] = chars[random.Next(chars.Length)];
        }

        return new string(nonce);
    }

    private static string GenerateCodeChallenge(string codeVerifier)
    {
        using var sha256 = SHA256.Create();
        var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
        var b64Hash = Convert.ToBase64String(hash);
        var code = Regex.Replace(b64Hash, "\\+", "-");
        code = Regex.Replace(code, "\\/", "_");
        code = Regex.Replace(code, "=+$", "");
        return code;
    }

}

I call Init before redirecting to /authorize, the on the redirect url I have:

public async Task OnGet(string code,
                        string state,
                        string error)
{
    var httpClient = _httpClientFactory.CreateClient();

    var parameters = new Dictionary<string, string>
    {
        {"client_id", "*****************"},
        {"grant_type", "authorization_code"},
        {"code", code},
        {"redirect_uri", "https://localhost:5001/SpotifyResponse"},
        {"code_verifier", Code.CodeVerifier}
    };

    var urlEncodedParameters = new FormUrlEncodedContent(parameters);
    var req = new HttpRequestMessage(HttpMethod.Post, "https://accounts.spotify.com/api/token") { Content = urlEncodedParameters };
    var response = await httpClient.SendAsync(req);
    var content = response.Content;
}

Replacing the correct regex does the job. It seems the problem is the "=", only the last ones must be replaced.

The function is not complete, I just watched at content variable and there was the token inside. Take that and do whatevere you prefer.

H. Pauwelyn
  • 13,575
  • 26
  • 81
  • 144
Michaelsoft
  • 777
  • 5
  • 18
  • 1
    Dude, thank you so much for taking your time to do this. It was in fact not the code_verifier or code_challenge (or at least not only that), but after I changed the way I build the Spotify url that I redirect the user to, to the way you did it in your repo, it worked. Thank you! – sunero4 Dec 09 '20 at 19:06
  • 1
    It was certainly a "combo", without Regex.Replace I had 400 response too. – Michaelsoft Dec 10 '20 at 07:53
  • 1
    Yes, I think you're right. Thanks again for your help :) – sunero4 Dec 10 '20 at 08:50
6

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));
    }

}
Terence Golla
  • 1,018
  • 1
  • 13
  • 12
2

Another Implentation
This will give you the code_challenge and code_verifier values calculated from a random string.

        var rng = RandomNumberGenerator.Create();
        var bytes = new byte[32];
        rng.GetBytes(bytes);

        // It is recommended to use a URL-safe string as code_verifier.
        // See section 4 of RFC 7636 for more details.
        var code_verifier = Convert.ToBase64String(bytes)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');

        var code_challenge = string.Empty;
        using (var sha256 = SHA256.Create())
        {
            var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(code_verifier));
            code_challenge = Convert.ToBase64String(challengeBytes)
                .TrimEnd('=')
                .Replace('+', '-')
                .Replace('/', '_');
        }
  • 1
    As it’s currently written, your answer is unclear. Please [edit] to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Apr 22 '23 at 00:18