✨ Spotify OAuth & Presence
This commit is contained in:
		
							
								
								
									
										255
									
								
								DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								DysonNetwork.Pass/Auth/OpenId/SpotifyOidcService.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,255 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using DysonNetwork.Shared.Cache;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Pass.Auth.OpenId;
 | 
			
		||||
 | 
			
		||||
public class SpotifyOidcService(
 | 
			
		||||
    IConfiguration configuration,
 | 
			
		||||
    IHttpClientFactory httpClientFactory,
 | 
			
		||||
    AppDatabase db,
 | 
			
		||||
    AuthService auth,
 | 
			
		||||
    ICacheService cache
 | 
			
		||||
)
 | 
			
		||||
    : OidcService(configuration, httpClientFactory, db, auth, cache)
 | 
			
		||||
{
 | 
			
		||||
    public override string ProviderName => "Spotify";
 | 
			
		||||
    protected override string DiscoveryEndpoint => ""; // Spotify doesn't have a standard OIDC discovery endpoint
 | 
			
		||||
    protected override string ConfigSectionName => "Spotify";
 | 
			
		||||
 | 
			
		||||
    public override Task<string> GetAuthorizationUrlAsync(string state, string nonce)
 | 
			
		||||
    {
 | 
			
		||||
        return Task.FromResult(GetAuthorizationUrl(state, nonce));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override string GetAuthorizationUrl(string state, string nonce)
 | 
			
		||||
    {
 | 
			
		||||
        var config = GetProviderConfig();
 | 
			
		||||
        var queryParams = new Dictionary<string, string>
 | 
			
		||||
        {
 | 
			
		||||
            { "client_id", config.ClientId },
 | 
			
		||||
            { "redirect_uri", config.RedirectUri },
 | 
			
		||||
            { "response_type", "code" },
 | 
			
		||||
            { "scope", "user-read-private user-read-current-playing user-read-email" },
 | 
			
		||||
            { "state", state },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
 | 
			
		||||
        return $"https://accounts.spotify.com/authorize?{queryString}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
 | 
			
		||||
    {
 | 
			
		||||
        return Task.FromResult(new OidcDiscoveryDocument
 | 
			
		||||
        {
 | 
			
		||||
            AuthorizationEndpoint = "https://accounts.spotify.com/authorize",
 | 
			
		||||
            TokenEndpoint = "https://accounts.spotify.com/api/token",
 | 
			
		||||
            UserinfoEndpoint = "https://api.spotify.com/v1/me",
 | 
			
		||||
            JwksUri = null
 | 
			
		||||
        })!;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
 | 
			
		||||
    {
 | 
			
		||||
        var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
 | 
			
		||||
        if (tokenResponse?.AccessToken == null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Failed to obtain access token from Spotify");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
 | 
			
		||||
 | 
			
		||||
        userInfo.AccessToken = tokenResponse.AccessToken;
 | 
			
		||||
        userInfo.RefreshToken = tokenResponse.RefreshToken;
 | 
			
		||||
 | 
			
		||||
        return userInfo;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
 | 
			
		||||
        string? codeVerifier = null)
 | 
			
		||||
    {
 | 
			
		||||
        var config = GetProviderConfig();
 | 
			
		||||
        var client = HttpClientFactory.CreateClient();
 | 
			
		||||
 | 
			
		||||
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
 | 
			
		||||
        {
 | 
			
		||||
            { "client_id", config.ClientId },
 | 
			
		||||
            { "client_secret", config.ClientSecret },
 | 
			
		||||
            { "grant_type", "authorization_code" },
 | 
			
		||||
            { "code", code },
 | 
			
		||||
            { "redirect_uri", config.RedirectUri },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var response = await client.PostAsync("https://accounts.spotify.com/api/token", content);
 | 
			
		||||
        response.EnsureSuccessStatusCode();
 | 
			
		||||
 | 
			
		||||
        return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Refreshes an access token using the refresh token
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public async Task<OidcTokenResponse?> RefreshTokenAsync(string refreshToken)
 | 
			
		||||
    {
 | 
			
		||||
        var config = GetProviderConfig();
 | 
			
		||||
        var client = HttpClientFactory.CreateClient();
 | 
			
		||||
 | 
			
		||||
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
 | 
			
		||||
        {
 | 
			
		||||
            { "client_id", config.ClientId },
 | 
			
		||||
            { "client_secret", config.ClientSecret },
 | 
			
		||||
            { "grant_type", "refresh_token" },
 | 
			
		||||
            { "refresh_token", refreshToken },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var response = await client.PostAsync("https://accounts.spotify.com/api/token", content);
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            return null; // Refresh failed
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Gets a valid access token, refreshing if necessary
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public async Task<string?> GetValidAccessTokenAsync(string refreshToken, string? currentAccessToken = null)
 | 
			
		||||
    {
 | 
			
		||||
        // If we don't have a current token, we need to refresh
 | 
			
		||||
        if (string.IsNullOrEmpty(currentAccessToken))
 | 
			
		||||
        {
 | 
			
		||||
            var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
			
		||||
            return refreshedTokens?.AccessToken;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Test if the current token is still valid by making a lightweight API call
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var client = HttpClientFactory.CreateClient();
 | 
			
		||||
            var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
 | 
			
		||||
            request.Headers.Add("Authorization", $"Bearer {currentAccessToken}");
 | 
			
		||||
 | 
			
		||||
            var response = await client.SendAsync(request);
 | 
			
		||||
            if (response.IsSuccessStatusCode)
 | 
			
		||||
            {
 | 
			
		||||
                // Token is still valid
 | 
			
		||||
                return currentAccessToken;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // If unauthorized, try to refresh
 | 
			
		||||
            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 | 
			
		||||
            {
 | 
			
		||||
                var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
			
		||||
                return refreshedTokens?.AccessToken;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            // On any error, try to refresh
 | 
			
		||||
            var refreshedTokens = await RefreshTokenAsync(refreshToken);
 | 
			
		||||
            return refreshedTokens?.AccessToken;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Current token is invalid and refresh failed
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
 | 
			
		||||
    {
 | 
			
		||||
        var client = HttpClientFactory.CreateClient();
 | 
			
		||||
        var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me");
 | 
			
		||||
        request.Headers.Add("Authorization", $"Bearer {accessToken}");
 | 
			
		||||
 | 
			
		||||
        var response = await client.SendAsync(request);
 | 
			
		||||
        response.EnsureSuccessStatusCode();
 | 
			
		||||
 | 
			
		||||
        var json = await response.Content.ReadAsStringAsync();
 | 
			
		||||
        var spotifyUser = JsonDocument.Parse(json).RootElement;
 | 
			
		||||
 | 
			
		||||
        var userId = spotifyUser.GetProperty("id").GetString() ?? "";
 | 
			
		||||
        var email = spotifyUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null;
 | 
			
		||||
 | 
			
		||||
        // Get display name - prefer display_name, then fallback to id
 | 
			
		||||
        var displayName = spotifyUser.TryGetProperty("display_name", out var displayNameElement)
 | 
			
		||||
            ? displayNameElement.GetString() ?? ""
 | 
			
		||||
            : userId;
 | 
			
		||||
 | 
			
		||||
        // Get profile picture - take the first image URL if available
 | 
			
		||||
        string? profilePictureUrl = null;
 | 
			
		||||
        if (spotifyUser.TryGetProperty("images", out var imagesElement) && imagesElement.ValueKind == JsonValueKind.Array)
 | 
			
		||||
        {
 | 
			
		||||
            var images = imagesElement.EnumerateArray().ToList();
 | 
			
		||||
            if (images.Count > 0)
 | 
			
		||||
            {
 | 
			
		||||
                profilePictureUrl = images[0].TryGetProperty("url", out var urlElement)
 | 
			
		||||
                    ? urlElement.GetString()
 | 
			
		||||
                    : null;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new OidcUserInfo
 | 
			
		||||
        {
 | 
			
		||||
            UserId = userId,
 | 
			
		||||
            Email = email,
 | 
			
		||||
            DisplayName = displayName,
 | 
			
		||||
            PreferredUsername = userId, // Spotify doesn't have a separate username field like some platforms
 | 
			
		||||
            ProfilePictureUrl = profilePictureUrl,
 | 
			
		||||
            Provider = ProviderName
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// Gets the user's currently playing track
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public async Task<string?> GetCurrentlyPlayingAsync(string refreshToken, string? currentAccessToken = null)
 | 
			
		||||
    {
 | 
			
		||||
        var validToken = await GetValidAccessTokenAsync(refreshToken, currentAccessToken);
 | 
			
		||||
        if (string.IsNullOrEmpty(validToken))
 | 
			
		||||
        {
 | 
			
		||||
            return null; // Couldn't get a valid token
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var client = HttpClientFactory.CreateClient();
 | 
			
		||||
        var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing");
 | 
			
		||||
        request.Headers.Add("Authorization", $"Bearer {validToken}");
 | 
			
		||||
 | 
			
		||||
        var response = await client.SendAsync(request);
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
 | 
			
		||||
            {
 | 
			
		||||
                // 204 No Content means nothing is currently playing
 | 
			
		||||
                return "{}";
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Try one more time with a fresh token if it failed with unauthorized
 | 
			
		||||
            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 | 
			
		||||
            {
 | 
			
		||||
                var freshToken = await RefreshTokenAsync(refreshToken);
 | 
			
		||||
                if (freshToken?.AccessToken != null)
 | 
			
		||||
                {
 | 
			
		||||
                    request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", freshToken.AccessToken);
 | 
			
		||||
                    response = await client.SendAsync(request);
 | 
			
		||||
                    if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
 | 
			
		||||
                    {
 | 
			
		||||
                        return "{}";
 | 
			
		||||
                    }
 | 
			
		||||
                    if (!response.IsSuccessStatusCode)
 | 
			
		||||
                    {
 | 
			
		||||
                        return null;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    return null;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                return null;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await response.Content.ReadAsStringAsync();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user