How to implement Authorization Code with PKCE for Spotify
Asked Answered
B

3

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);
Boisterous answered 6/12, 2020 at 15:50 Comment(8)
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.Adroit
@Adroit 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.Boisterous
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);Adroit
Maybe using a dict instead of List<KeyValuePair<string, string>>?Adroit
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 whatBoisterous
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 ;).Adroit
No that's unfortunately the same conclusion I had, but Base64 encoding alone does not do the trick :/Boisterous
I was able to do that, look at the answer.Adroit
A
18

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.

Adroit answered 9/12, 2020 at 16:2 Comment(3)
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!Boisterous
It was certainly a "combo", without Regex.Replace I had 400 response too.Adroit
Yes, I think you're right. Thanks again for your help :)Boisterous
M
9

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

}
Maltese answered 29/1, 2022 at 2:55 Comment(3)
0 is missing in unreservedCharactersIntorsion
Thanks for the catch on the missing zero. It has been corrected.Maltese
This seems to work fantastically. For others, you will need the Microsoft.IdentityModel.Tokens NuGet package for the use of Base64UrlEncoder to use this.Erhart
S
3

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('/', '_');
        }
Sextan answered 19/4, 2023 at 12:26 Comment(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.Marilumarilyn

© 2022 - 2024 — McMap. All rights reserved.