Merge pull request #1708 from rmcrackan/rmcrackan/1704-mkb79import

fix bug with importing mkb79 auth with website_cookies: null vs empty
This commit is contained in:
rmcrackan
2026-03-31 13:22:33 -04:00
committed by GitHub
2 changed files with 142 additions and 4 deletions

View File

@@ -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<string, string?>? WebsiteCookies
{
get => _websiteCookies?.ToObject<Dictionary<string, string?>>();
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<Mkb79Auth>(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);
}
/// <summary>
/// audible-cli expects <c>website_cookies</c> as JSON null when empty (not <c>{}</c>) and a PEM
/// <c>device_private_key</c> with standard 64-character base64 lines and newline separators.
/// </summary>
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<string>();
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<Account> 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

View File

@@ -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<JObject>? 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<string>()!;
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));
}
}