From f8b278715468b82e7fb31f8537e2073114f10ea9 Mon Sep 17 00:00:00 2001 From: Jonas Dellinger Date: Thu, 6 Aug 2020 13:14:45 +0200 Subject: [PATCH] Implemented util class for base64url, start of #490 --- .../UtilTests/Base64UtilTest.cs | 44 +++++++ SpotifyAPI.Web/Assembly.cs | 3 + SpotifyAPI.Web/Util/Base64Util.cs | 123 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs create mode 100644 SpotifyAPI.Web/Assembly.cs create mode 100644 SpotifyAPI.Web/Util/Base64Util.cs diff --git a/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs b/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs new file mode 100644 index 00000000..59e63405 --- /dev/null +++ b/SpotifyAPI.Web.Tests/UtilTests/Base64UtilTest.cs @@ -0,0 +1,44 @@ +using System.Text; +using NUnit.Framework; +using SpotifyAPI.Web; + +namespace SpotifyAPI.Web.Tests +{ + [TestFixture] + public class Base64UtilTest + { + [Test] + public void Base64UrlDecode_Works() + { + var encoded = "SGVsbG9Xb3JsZA=="; + + Assert.AreEqual("HelloWorld", Encoding.UTF8.GetString(Base64Util.UrlDecode(encoded))); + } + + [Test] + public void Base64UrlEncode_Works() + { + var decoded = "HelloWorld"; + + Assert.AreEqual("SGVsbG9Xb3JsZA==", Base64Util.UrlEncode(Encoding.UTF8.GetBytes(decoded))); + } + + [Test] + public void Base64UrlEncode_WorksSpecialChars() + { + var bytes = new byte[] { 0x04, 0x9f, 0x9c, 0xff, 0x3f, 0x0a }; + + // normal base64: BJ+c/z8K + Assert.AreEqual("BJ-c_z8K", Base64Util.UrlEncode(bytes)); + } + + [Test] + public void Base64UrlDecode_WorksSpecialChars() + { + var bytes = new byte[] { 0x04, 0x9f, 0x9c, 0xff, 0x3f, 0x0a }; + + // normal base64: BJ+c/z8K + Assert.AreEqual(bytes, Base64Util.UrlDecode("BJ-c_z8K")); + } + } +} diff --git a/SpotifyAPI.Web/Assembly.cs b/SpotifyAPI.Web/Assembly.cs new file mode 100644 index 00000000..6e71e55c --- /dev/null +++ b/SpotifyAPI.Web/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SpotifyAPI.Web.Tests")] diff --git a/SpotifyAPI.Web/Util/Base64Util.cs b/SpotifyAPI.Web/Util/Base64Util.cs new file mode 100644 index 00000000..1c476e22 --- /dev/null +++ b/SpotifyAPI.Web/Util/Base64Util.cs @@ -0,0 +1,123 @@ + +using System; +using System.Globalization; + +namespace SpotifyAPI.Web +{ + internal class Base64Util + { + internal const string WebEncoders_InvalidCountOffsetOrLength = "Invalid {0}, {1} or {2} length."; + internal const string WebEncoders_MalformedInput = "Malformed input: {0} is an invalid input length."; + + public static string UrlEncode(byte[] input) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + // Special-case empty input + if (input.Length == 0) + { + return string.Empty; + } + + var buffer = new char[GetArraySizeRequiredToEncode(input.Length)]; + var numBase64Chars = Convert.ToBase64CharArray(input, 0, input.Length, buffer, 0); + + return new string(buffer, startIndex: 0, length: numBase64Chars); + } + + public static byte[] UrlDecode(string input) + { + var buffer = new char[GetArraySizeRequiredToDecode(input.Length)]; + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + if (count == 0) + { + return Array.Empty(); + } + + // Assumption: input is base64url encoded without padding and contains no whitespace. + + var paddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); + var arraySizeRequired = checked(count + paddingCharsToAdd); + + // Copy input into buffer, fixing up '-' -> '+' and '_' -> '/'. + var i = 0; + for (var j = 0; i < count; i++, j++) + { + var ch = input[j]; + if (ch == '-') + { + buffer[i] = '+'; + } + else if (ch == '_') + { + buffer[i] = '/'; + } + else + { + buffer[i] = ch; + } + } + + // Add the padding characters back. + for (; paddingCharsToAdd > 0; i++, paddingCharsToAdd--) + { + buffer[i] = '='; + } + + // Decode. + // If the caller provided invalid base64 chars, they'll be caught here. + return Convert.FromBase64CharArray(buffer, 0, arraySizeRequired); + } + + private static int GetArraySizeRequiredToEncode(int count) + { + var numWholeOrPartialInputBlocks = checked(count + 2) / 3; + return checked(numWholeOrPartialInputBlocks * 4); + } + + private static int GetArraySizeRequiredToDecode(int count) + { + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (count == 0) + { + return 0; + } + + var numPaddingCharsToAdd = GetNumBase64PaddingCharsToAddForDecode(count); + + return checked(count + numPaddingCharsToAdd); + } + + private static int GetNumBase64PaddingCharsToAddForDecode(int inputLength) + { + switch (inputLength % 4) + { + case 0: + return 0; + case 2: + return 2; + case 3: + return 1; + default: + throw new FormatException( + string.Format( + CultureInfo.CurrentCulture, + WebEncoders_MalformedInput, + inputLength)); + } + } + } + + +}