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.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using OtpNet;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
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
|
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
|
||||||
/// algorithmically generated "Made For You" playlists.
|
/// algorithmically generated "Made For You" playlists.
|
||||||
|
///
|
||||||
|
/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyApiClient : IDisposable
|
public class SpotifyApiClient : IDisposable
|
||||||
{
|
{
|
||||||
@@ -24,17 +28,26 @@ public class SpotifyApiClient : IDisposable
|
|||||||
private readonly SpotifyApiSettings _settings;
|
private readonly SpotifyApiSettings _settings;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly HttpClient _webApiClient;
|
private readonly HttpClient _webApiClient;
|
||||||
|
private readonly CookieContainer _cookieContainer;
|
||||||
|
|
||||||
// Spotify API endpoints
|
// Spotify API endpoints
|
||||||
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
||||||
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/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)
|
// Web API access token (obtained via session cookie)
|
||||||
private string? _webAccessToken;
|
private string? _webAccessToken;
|
||||||
private DateTime _webTokenExpiry = DateTime.MinValue;
|
private DateTime _webTokenExpiry = DateTime.MinValue;
|
||||||
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||||
|
|
||||||
|
// Cached TOTP secrets
|
||||||
|
private TotpSecret? _cachedTotpSecret;
|
||||||
|
private DateTime _totpSecretFetchedAt = DateTime.MinValue;
|
||||||
|
|
||||||
public SpotifyApiClient(
|
public SpotifyApiClient(
|
||||||
ILogger<SpotifyApiClient> logger,
|
ILogger<SpotifyApiClient> logger,
|
||||||
IOptions<SpotifyApiSettings> settings)
|
IOptions<SpotifyApiSettings> settings)
|
||||||
@@ -50,17 +63,18 @@ public class SpotifyApiClient : IDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Client for web API (requires session cookie)
|
// Client for web API (requires session cookie)
|
||||||
|
_cookieContainer = new CookieContainer();
|
||||||
var handler = new HttpClientHandler
|
var handler = new HttpClientHandler
|
||||||
{
|
{
|
||||||
UseCookies = true,
|
UseCookies = true,
|
||||||
CookieContainer = new CookieContainer()
|
CookieContainer = _cookieContainer
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
{
|
{
|
||||||
handler.CookieContainer.Add(
|
_cookieContainer.SetCookies(
|
||||||
new Uri("https://open.spotify.com"),
|
new Uri(SpotifyBaseUrl),
|
||||||
new Cookie("sp_dc", _settings.SessionCookie));
|
$"sp_dc={_settings.SessionCookie}");
|
||||||
}
|
}
|
||||||
|
|
||||||
_webApiClient = new HttpClient(handler)
|
_webApiClient = new HttpClient(handler)
|
||||||
@@ -69,15 +83,15 @@ public class SpotifyApiClient : IDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Common headers for web API
|
// 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", "application/json");
|
||||||
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en");
|
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");
|
||||||
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
|
_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>
|
/// <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.
|
/// This token can be used for both the official API and web API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -97,11 +111,33 @@ public class SpotifyApiClient : IDisposable
|
|||||||
return _webAccessToken;
|
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
|
// Fetch TOTP secrets if needed
|
||||||
// No need to manually add Cookie header - HttpClient will handle it
|
var totpSecret = await GetTotpSecretAsync(cancellationToken);
|
||||||
var response = await _webApiClient.GetAsync(TokenEndpoint, 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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -111,18 +147,25 @@ public class SpotifyApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
using var doc = JsonDocument.Parse(json);
|
var tokenResponse = JsonSerializer.Deserialize<SpotifyTokenResponse>(json);
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
if (root.TryGetProperty("accessToken", out var tokenElement))
|
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||||
{
|
{
|
||||||
_webAccessToken = tokenElement.GetString();
|
_logger.LogError("No access token in Spotify response: {Json}", json);
|
||||||
|
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
|
// Token typically expires in 1 hour, but we'll refresh early
|
||||||
if (root.TryGetProperty("accessTokenExpirationTimestampMs", out var expiryElement))
|
if (tokenResponse.ExpirationTimestampMs > 0)
|
||||||
{
|
{
|
||||||
var expiryMs = expiryElement.GetInt64();
|
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime;
|
||||||
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime;
|
|
||||||
// Refresh 5 minutes early
|
// Refresh 5 minutes early
|
||||||
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||||
}
|
}
|
||||||
@@ -131,13 +174,10 @@ public class SpotifyApiClient : IDisposable
|
|||||||
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}", _webTokenExpiry);
|
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}",
|
||||||
|
_webTokenExpiry, tokenResponse.IsAnonymous);
|
||||||
return _webAccessToken;
|
return _webAccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogError("No access token in Spotify response");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting Spotify web access token");
|
_logger.LogError(ex, "Error getting Spotify web access token");
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// Fetches a playlist with all its tracks from Spotify.
|
/// 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.
|
/// 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())
|
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)
|
// Check if name matches (case-insensitive)
|
||||||
if (name.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
playlists.Add(new SpotifyPlaylist
|
playlists.Add(new SpotifyPlaylist
|
||||||
{
|
{
|
||||||
SpotifyId = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||||
Name = name,
|
Name = itemName,
|
||||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||||
tracks.TryGetProperty("total", out var total)
|
tracks.TryGetProperty("total", out var total)
|
||||||
@@ -535,4 +668,29 @@ public class SpotifyApiClient : IDisposable
|
|||||||
_webApiClient.Dispose();
|
_webApiClient.Dispose();
|
||||||
_tokenLock.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>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<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="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||||
|
|||||||
Reference in New Issue
Block a user