mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Implement TOTP-based Spotify authentication (like Jellyfin plugin)
- Use pre-scraped TOTP secrets from Viperinius/spotify-totp-secrets - Generate TOTP code using server time and cipher transformation - Add OTP.NET package for TOTP generation - Match the authentication flow used by jellyfin-plugin-spotify-import
This commit is contained in:
@@ -2,9 +2,11 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OtpNet;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -17,6 +19,8 @@ namespace allstarr.Services.Spotify;
|
||||
///
|
||||
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
|
||||
/// algorithmically generated "Made For You" playlists.
|
||||
///
|
||||
/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin.
|
||||
/// </summary>
|
||||
public class SpotifyApiClient : IDisposable
|
||||
{
|
||||
@@ -24,17 +28,26 @@ public class SpotifyApiClient : IDisposable
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpClient _webApiClient;
|
||||
private readonly CookieContainer _cookieContainer;
|
||||
|
||||
// Spotify API endpoints
|
||||
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
||||
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1";
|
||||
private const string TokenEndpoint = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player";
|
||||
private const string SpotifyBaseUrl = "https://open.spotify.com";
|
||||
private const string TokenEndpoint = "https://open.spotify.com/api/token";
|
||||
|
||||
// URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses)
|
||||
private const string TotpSecretsUrl = "https://raw.githubusercontent.com/Viperinius/spotify-totp-secrets/refs/heads/main/secrets.json";
|
||||
|
||||
// Web API access token (obtained via session cookie)
|
||||
private string? _webAccessToken;
|
||||
private DateTime _webTokenExpiry = DateTime.MinValue;
|
||||
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||
|
||||
// Cached TOTP secrets
|
||||
private TotpSecret? _cachedTotpSecret;
|
||||
private DateTime _totpSecretFetchedAt = DateTime.MinValue;
|
||||
|
||||
public SpotifyApiClient(
|
||||
ILogger<SpotifyApiClient> logger,
|
||||
IOptions<SpotifyApiSettings> settings)
|
||||
@@ -50,17 +63,18 @@ public class SpotifyApiClient : IDisposable
|
||||
};
|
||||
|
||||
// Client for web API (requires session cookie)
|
||||
_cookieContainer = new CookieContainer();
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer()
|
||||
CookieContainer = _cookieContainer
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
handler.CookieContainer.Add(
|
||||
new Uri("https://open.spotify.com"),
|
||||
new Cookie("sp_dc", _settings.SessionCookie));
|
||||
_cookieContainer.SetCookies(
|
||||
new Uri(SpotifyBaseUrl),
|
||||
$"sp_dc={_settings.SessionCookie}");
|
||||
}
|
||||
|
||||
_webApiClient = new HttpClient(handler)
|
||||
@@ -69,15 +83,15 @@ public class SpotifyApiClient : IDisposable
|
||||
};
|
||||
|
||||
// Common headers for web API
|
||||
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");
|
||||
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
|
||||
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.31.0");
|
||||
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an access token using the session cookie.
|
||||
/// Gets an access token using the session cookie and TOTP authentication.
|
||||
/// This token can be used for both the official API and web API.
|
||||
/// </summary>
|
||||
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||
@@ -97,11 +111,33 @@ public class SpotifyApiClient : IDisposable
|
||||
return _webAccessToken;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching new Spotify web access token");
|
||||
_logger.LogDebug("Fetching new Spotify web access token using TOTP authentication");
|
||||
|
||||
// The cookie is already set in the CookieContainer during client construction
|
||||
// No need to manually add Cookie header - HttpClient will handle it
|
||||
var response = await _webApiClient.GetAsync(TokenEndpoint, cancellationToken);
|
||||
// Fetch TOTP secrets if needed
|
||||
var totpSecret = await GetTotpSecretAsync(cancellationToken);
|
||||
if (totpSecret == null)
|
||||
{
|
||||
_logger.LogError("Failed to get TOTP secrets");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate TOTP
|
||||
var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken);
|
||||
if (totpResult == null)
|
||||
{
|
||||
_logger.LogError("Failed to generate TOTP");
|
||||
return null;
|
||||
}
|
||||
|
||||
var (otp, serverTime) = totpResult.Value;
|
||||
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Build token URL with TOTP parameters
|
||||
var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}";
|
||||
|
||||
_logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***"));
|
||||
|
||||
var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -111,32 +147,36 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var tokenResponse = JsonSerializer.Deserialize<SpotifyTokenResponse>(json);
|
||||
|
||||
if (root.TryGetProperty("accessToken", out var tokenElement))
|
||||
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||
{
|
||||
_webAccessToken = tokenElement.GetString();
|
||||
|
||||
// Token typically expires in 1 hour, but we'll refresh early
|
||||
if (root.TryGetProperty("accessTokenExpirationTimestampMs", out var expiryElement))
|
||||
{
|
||||
var expiryMs = expiryElement.GetInt64();
|
||||
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime;
|
||||
// Refresh 5 minutes early
|
||||
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||
}
|
||||
else
|
||||
{
|
||||
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}", _webTokenExpiry);
|
||||
return _webAccessToken;
|
||||
_logger.LogError("No access token in Spotify response: {Json}", json);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogError("No access token in Spotify response");
|
||||
return null;
|
||||
if (tokenResponse.IsAnonymous)
|
||||
{
|
||||
_logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid");
|
||||
}
|
||||
|
||||
_webAccessToken = tokenResponse.AccessToken;
|
||||
|
||||
// Token typically expires in 1 hour, but we'll refresh early
|
||||
if (tokenResponse.ExpirationTimestampMs > 0)
|
||||
{
|
||||
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime;
|
||||
// Refresh 5 minutes early
|
||||
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||
}
|
||||
else
|
||||
{
|
||||
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}",
|
||||
_webTokenExpiry, tokenResponse.IsAnonymous);
|
||||
return _webAccessToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -149,6 +189,99 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches TOTP secrets from the pre-scraped secrets repository.
|
||||
/// </summary>
|
||||
private async Task<TotpSecret?> GetTotpSecretAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Return cached secret if fresh (cache for 1 hour)
|
||||
if (_cachedTotpSecret != null && DateTime.UtcNow - _totpSecretFetchedAt < TimeSpan.FromHours(1))
|
||||
{
|
||||
return _cachedTotpSecret;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl);
|
||||
|
||||
var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var secrets = JsonSerializer.Deserialize<TotpSecret[]>(json);
|
||||
|
||||
if (secrets == null || secrets.Length == 0)
|
||||
{
|
||||
_logger.LogError("No TOTP secrets found in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the newest version
|
||||
_cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First();
|
||||
_totpSecretFetchedAt = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version);
|
||||
return _cachedTotpSecret;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching TOTP secrets");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a TOTP code using the secret and server time.
|
||||
/// Based on the Jellyfin plugin implementation.
|
||||
/// </summary>
|
||||
private async Task<(string Otp, long ServerTime)?> GenerateTotpAsync(TotpSecret secret, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get server time from Spotify via HEAD request
|
||||
var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl);
|
||||
var response = await _webApiClient.SendAsync(headRequest, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var serverTime = response.Headers.Date?.ToUnixTimeSeconds();
|
||||
if (serverTime == null)
|
||||
{
|
||||
_logger.LogError("No Date header in Spotify response");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute secret from cipher bytes
|
||||
// The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9)
|
||||
var cipherBytes = secret.Secret.ToArray();
|
||||
var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray();
|
||||
|
||||
// Convert to UTF-8 string representation then back to bytes for TOTP
|
||||
var transformedString = string.Join("", transformedBytes.Select(b => b.ToString()));
|
||||
var utf8Bytes = Encoding.UTF8.GetBytes(transformedString);
|
||||
|
||||
// Generate TOTP
|
||||
var totp = new Totp(utf8Bytes, step: 30, totpSize: 6);
|
||||
var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value));
|
||||
|
||||
_logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value);
|
||||
return (otp, serverTime.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating TOTP");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a playlist with all its tracks from Spotify.
|
||||
/// Uses the web API to access editorial playlists that aren't available via the official API.
|
||||
@@ -432,15 +565,15 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var name = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (name.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
||||
Name = name,
|
||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||
Name = itemName,
|
||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("total", out var total)
|
||||
@@ -535,4 +668,29 @@ public class SpotifyApiClient : IDisposable
|
||||
_webApiClient.Dispose();
|
||||
_tokenLock.Dispose();
|
||||
}
|
||||
|
||||
// Internal classes for JSON deserialization
|
||||
private class SpotifyTokenResponse
|
||||
{
|
||||
[JsonPropertyName("accessToken")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("accessTokenExpirationTimestampMs")]
|
||||
public long ExpirationTimestampMs { get; set; }
|
||||
|
||||
[JsonPropertyName("isAnonymous")]
|
||||
public bool IsAnonymous { get; set; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class TotpSecret
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("secret")]
|
||||
public List<byte> Secret { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||
|
||||
Reference in New Issue
Block a user