modules/Core/AuthenticationManager.psm1
|
# Authentication Manager # Handles OAuth 2.0 flow, token storage, and credential management for SpotifyCLI. # --- Module State --- $script:ClientId = $null $script:ClientSecret = $null $script:RedirectUri = "http://127.0.0.1:8888/callback" $script:TokenEndpoint = "https://accounts.spotify.com/api/token" $script:Scopes = @( "user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing", "user-read-private", "playlist-read-private", "user-library-read", "user-library-modify", "user-read-recently-played", "user-top-read", "playlist-modify-public", "playlist-modify-private" ) -join " " $script:AppDataDir = Join-Path $env:APPDATA "SpotifyCLI" $script:TokenFile = Join-Path $script:AppDataDir "tokens.json" # --- Credential Setup --- function Initialize-SpotifyCredentials { <# .SYNOPSIS Load Spotify API credentials in priority order: parameters > env vars > .env file > prompt. #> param([string]$ClientId, [string]$ClientSecret) if (-not $ClientId) { $ClientId = $env:SPOTIFY_CLIENT_ID } if (-not $ClientSecret) { $ClientSecret = $env:SPOTIFY_CLIENT_SECRET } if (-not $ClientId -or -not $ClientSecret) { $envFile = Join-Path (Get-Location) ".env" if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match "^(.*?)=(.*)$") { $key = $matches[1].Trim() $val = $matches[2].Trim() if ($key -eq "SPOTIFY_CLIENT_ID" -and -not $ClientId) { $ClientId = $val } if ($key -eq "SPOTIFY_CLIENT_SECRET" -and -not $ClientSecret) { $ClientSecret = $val } } } } } if (-not $ClientId) { Write-Host "" Write-Host "Spotify API credentials required." -ForegroundColor Yellow Write-Host "Get them at: https://developer.spotify.com/dashboard" -ForegroundColor Cyan Write-Host "" $ClientId = Read-Host "Enter SPOTIFY_CLIENT_ID" } if (-not $ClientSecret) { $ClientSecret = Read-Host "Enter SPOTIFY_CLIENT_SECRET" } $script:ClientId = $ClientId $script:ClientSecret = $ClientSecret # Also set env vars so other modules that read them directly still work $env:SPOTIFY_CLIENT_ID = $ClientId $env:SPOTIFY_CLIENT_SECRET = $ClientSecret } # --- Token Storage --- function Initialize-TokenStore { if (-not (Test-Path $script:AppDataDir)) { New-Item -ItemType Directory -Path $script:AppDataDir | Out-Null } if (-not (Test-Path $script:TokenFile)) { '{}' | Set-Content -Path $script:TokenFile -Encoding UTF8 } } function Get-StoredTokens { Initialize-TokenStore try { $json = Get-Content -Path $script:TokenFile -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($json)) { return @{} } return ($json | ConvertFrom-Json) } catch { return @{} } } function Set-StoredTokens { param($Tokens) Initialize-TokenStore ($Tokens | ConvertTo-Json -Depth 5) | Set-Content -Path $script:TokenFile -Encoding UTF8 } # --- OAuth Flow --- function Start-SpotifyAuth { <# .SYNOPSIS Run the Spotify OAuth 2.0 Authorization Code flow. Opens the browser and listens on port 8888. #> if (-not $script:ClientId -or -not $script:ClientSecret) { Write-Host "Credentials not set. Call Initialize-SpotifyCredentials first." -ForegroundColor Red return $null } Write-Host "Starting Spotify authentication..." -ForegroundColor Cyan $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add(($script:RedirectUri.TrimEnd('/') + "/")) try { $listener.Start() } catch { Write-Host "" Write-Host "Authentication Setup Error" -ForegroundColor Red Write-Host "Could not start local authentication server on port 8888." -ForegroundColor Yellow Write-Host "" Write-Host "Solutions:" -ForegroundColor Green Write-Host " Run PowerShell as Administrator" -ForegroundColor White Write-Host " Make sure port 8888 is not in use" -ForegroundColor White Write-Host " Check Windows Firewall settings" -ForegroundColor White return $null } $state = [Guid]::NewGuid().ToString() $authUrl = "https://accounts.spotify.com/authorize" + "?response_type=code" + "&client_id=$($script:ClientId)" + "&redirect_uri=$($script:RedirectUri)" + "&scope=$($script:Scopes)" + "&state=$state" try { Start-Process $authUrl -ErrorAction Stop | Out-Null Write-Host "Browser opened — log in and authorize SpotifyCLI." -ForegroundColor Green } catch { Write-Host "Could not open browser automatically. Open this URL manually:" -ForegroundColor Yellow Write-Host $authUrl -ForegroundColor White } $context = $listener.GetContext() $request = $context.Request $response = $context.Response $queryParams = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query) $code = $queryParams["code"] $retState = $queryParams["state"] $authError = $queryParams["error"] $html = "<html><body style='font-family:sans-serif'><h2>Done!</h2><p>You can close this tab and return to PowerShell.</p></body></html>" $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.OutputStream.Close() $listener.Stop() if ($authError) { Write-Error "Spotify auth error: $authError"; return $null } if ($retState -ne $state) { Write-Error "State mismatch — possible CSRF."; return $null } if (-not $code) { Write-Error "No auth code received."; return $null } $body = @{ grant_type = "authorization_code" code = $code redirect_uri = $script:RedirectUri client_id = $script:ClientId client_secret = $script:ClientSecret } $tokenResp = Invoke-RestMethod -Method Post -Uri $script:TokenEndpoint -Body $body $tokens = [ordered]@{ access_token = $tokenResp.access_token token_type = $tokenResp.token_type expires_in = $tokenResp.expires_in refresh_token = $tokenResp.refresh_token obtained_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() scopes = $script:Scopes } Set-StoredTokens $tokens Write-Host "Authentication complete." -ForegroundColor Green return $tokens } # --- Token Scope Validation --- function Test-TokenScopes { param($Tokens) if (-not $Tokens.scopes) { return $false } $requiredScopes = $script:Scopes -split ' ' $tokenScopes = $Tokens.scopes -split ' ' foreach ($scope in $requiredScopes) { if ($scope -notin $tokenScopes) { return $false } } return $true } # --- Token Access (with auto-refresh and re-auth) --- function Get-SpotifyAccessToken { $tokens = Get-StoredTokens if (-not $tokens.access_token) { $tokens = Start-SpotifyAuth if (-not $tokens) { return $null } return $tokens.access_token } if (-not (Test-TokenScopes $tokens)) { Write-Host "Token needs additional permissions. Re-authenticating..." -ForegroundColor Yellow $tokens = Start-SpotifyAuth if (-not $tokens) { return $null } return $tokens.access_token } $obtained = [long]$tokens.obtained_at $expiresIn = [int]$tokens.expires_in $age = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $obtained if ($age -ge ($expiresIn - 60)) { if (-not $tokens.refresh_token) { $tokens = Start-SpotifyAuth if (-not $tokens) { return $null } return $tokens.access_token } $body = @{ grant_type = "refresh_token" refresh_token = $tokens.refresh_token client_id = $script:ClientId client_secret = $script:ClientSecret } try { $tokenResp = Invoke-RestMethod -Method Post -Uri $script:TokenEndpoint -Body $body $tokens.access_token = $tokenResp.access_token if ($tokenResp.refresh_token) { $tokens.refresh_token = $tokenResp.refresh_token } $tokens.expires_in = $tokenResp.expires_in $tokens.obtained_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() Set-StoredTokens $tokens } catch { Write-Host "Token refresh failed. Re-authenticating..." -ForegroundColor Yellow $tokens = Start-SpotifyAuth if (-not $tokens) { return $null } } } return $tokens.access_token } # --- Exports --- Export-ModuleMember -Function @( 'Initialize-SpotifyCredentials', 'Initialize-TokenStore', 'Get-StoredTokens', 'Set-StoredTokens', 'Start-SpotifyAuth', 'Get-SpotifyAccessToken' ) |