From ad2101ab8c56b0fe16aef612ba10cf0dc5613c0b Mon Sep 17 00:00:00 2001 From: rmcrackan Date: Mon, 30 Mar 2026 15:10:55 -0400 Subject: [PATCH] fix bug with importing mkb79 auth with website_cookies: null vs empty --- Source/AudibleUtilities/Mkb79Auth.cs | 78 ++++++++++++++++++- .../Mkb79AuthExportTests.cs | 68 ++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs index c71acf42..709f8c47 100644 --- a/Source/AudibleUtilities/Mkb79Auth.cs +++ b/Source/AudibleUtilities/Mkb79Auth.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; namespace AudibleUtilities; @@ -53,7 +54,9 @@ public partial class Mkb79Auth : IIdentityMaintainer public Dictionary? WebsiteCookies { get => _websiteCookies?.ToObject>(); - private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); + private set => _websiteCookies = value is null || value.Count == 0 + ? null + : JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); } [JsonIgnore] @@ -123,7 +126,75 @@ public partial class Mkb79Auth => JsonConvert.DeserializeObject(json, Converter.Settings); public string ToJson() - => JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented); + { + var jo = JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)); + ApplyAudibleCliExportConventions(jo); + return jo.ToString(Formatting.Indented); + } + + /// + /// audible-cli expects website_cookies as JSON null when empty (not {}) and a PEM + /// device_private_key with standard 64-character base64 lines and newline separators. + /// + internal static void ApplyAudibleCliExportConventions(JObject jo) + { + if (jo["website_cookies"] is JObject wc && !wc.Properties().Any()) + jo["website_cookies"] = JValue.CreateNull(); + + if (jo["device_private_key"]?.Type == JTokenType.String) + { + var s = jo["device_private_key"]!.Value(); + var formatted = FormatDevicePrivateKeyForAudibleCliExport(s); + if (formatted is not null) + jo["device_private_key"] = formatted; + } + } + + private static string? FormatDevicePrivateKeyForAudibleCliExport(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return value; + + var trimmed = value.Trim(); + string payload; + if (trimmed.StartsWith(PrivateKey.REQUIRED_BEGINNING, StringComparison.Ordinal)) + { + var endIdx = trimmed.LastIndexOf(PrivateKey.REQUIRED_ENDING, StringComparison.Ordinal); + if (endIdx < PrivateKey.REQUIRED_BEGINNING.Length) + return value; + + payload = trimmed + .Substring(PrivateKey.REQUIRED_BEGINNING.Length, endIdx - PrivateKey.REQUIRED_BEGINNING.Length) + .Replace("\r", "") + .Replace("\n", "") + .Replace("\\n", "", StringComparison.Ordinal) + .Trim(); + } + else + payload = trimmed; + + if (payload.Length == 0) + return value; + + try + { + Convert.FromBase64String(payload); + } + catch (FormatException) + { + return value; + } + + var sb = new StringBuilder(); + sb.Append(PrivateKey.REQUIRED_BEGINNING).Append('\n'); + for (var i = 0; i < payload.Length; i += 64) + { + var len = Math.Min(64, payload.Length - i); + sb.Append(payload, i, len).Append('\n'); + } + sb.Append(PrivateKey.REQUIRED_ENDING).Append('\n'); + return sb.ToString(); + } public async Task ToAccountAsync() { @@ -196,8 +267,7 @@ public partial class Mkb79Auth public static class Serialize { - public static string ToJson(this Mkb79Auth self) - => JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented); + public static string ToJson(this Mkb79Auth self) => self.ToJson(); } internal static class Converter diff --git a/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs b/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs new file mode 100644 index 00000000..1ad5491b --- /dev/null +++ b/Source/_Tests/AudibleUtilities.Tests/Mkb79AuthExportTests.cs @@ -0,0 +1,68 @@ +using AssertionHelper; +using AudibleApi.Cryptography; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; + +namespace AudibleUtilities.Tests; + +[TestClass] +public class Mkb79AuthExportTests +{ + static string MinimalMkb79Json(Action? tweak = null) + { + var jo = new JObject + { + ["website_cookies"] = new JObject(), + ["adp_token"] = "a", + ["access_token"] = "b", + ["refresh_token"] = "c", + ["device_private_key"] = "d", + ["store_authentication_cookie"] = new JObject { ["cookie"] = "" }, + ["device_info"] = new JObject(), + ["customer_info"] = new JObject(), + ["expires"] = 0, + ["locale_code"] = "us", + ["with_username"] = false, + }; + tweak?.Invoke(jo); + return jo.ToString(Newtonsoft.Json.Formatting.None); + } + + [TestMethod] + public void ToJson_empty_website_cookies_is_null_not_object() + { + var auth = Mkb79Auth.FromJson(MinimalMkb79Json()); + auth.BeNotNull(); + var jo = JObject.Parse(auth.ToJson()); + Assert.AreEqual(JTokenType.Null, jo["website_cookies"]!.Type); + } + + [TestMethod] + public void ToJson_device_private_key_is_pem_with_64_char_lines() + { + var keyMaterial = Convert.ToBase64String(new byte[100]); + var singleLine = PrivateKey.REQUIRED_BEGINNING + keyMaterial + PrivateKey.REQUIRED_ENDING; + var auth = Mkb79Auth.FromJson(MinimalMkb79Json(j => + { + j["website_cookies"] = JValue.CreateNull(); + j["device_private_key"] = singleLine; + })); + auth.BeNotNull(); + var pem = JObject.Parse(auth.ToJson())["device_private_key"]!.Value()!; + var lines = pem.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); + lines[0].Should().Be(PrivateKey.REQUIRED_BEGINNING); + lines[^1].Should().Be(PrivateKey.REQUIRED_ENDING); + foreach (var body in lines.Skip(1).Take(lines.Length - 2)) + Assert.IsTrue(body.Length <= 64); + } + + [TestMethod] + public void Serialize_ToJson_matches_instance_ToJson() + { + var auth = Mkb79Auth.FromJson(MinimalMkb79Json(j => j["device_private_key"] = "AAAA")); + auth.BeNotNull(); + auth.ToJson().Should().Be(Serialize.ToJson(auth)); + } +}