From ee90c4b63b46425a60c3b261347d5e8ab1ab03fe Mon Sep 17 00:00:00 2001 From: Isaac Lins Date: Wed, 26 Nov 2025 13:58:06 +0100 Subject: [PATCH 1/4] fix(oauth): use RequestBody auth type for Spotify PKCE token refresh - Add AuthType::RequestBody to OAuth client for PKCE compliance - Spotify requires client_id in request body (not Basic auth header) for PKCE flow - Add OAUTH_AUTH_REQUIRED command to handle expired session gracefully - Improve error logging for OAuth refresh failures - WebApi client now emits re-auth command when token refresh fails --- psst-core/src/oauth.rs | 30 ++++- psst-gui/src/cmd.rs | 1 + psst-gui/src/controller/keybinds.rs | 195 ++++++++++++++++++++++++++++ psst-gui/src/delegate.rs | 5 + psst-gui/src/main.rs | 4 +- psst-gui/src/webapi/client.rs | 64 ++++++--- 6 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 psst-gui/src/controller/keybinds.rs diff --git a/psst-core/src/oauth.rs b/psst-core/src/oauth.rs index 7f8bdb32..226b75d5 100644 --- a/psst-core/src/oauth.rs +++ b/psst-core/src/oauth.rs @@ -1,7 +1,8 @@ use crate::error::Error; use oauth2::{ - basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken, - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl, + basic::BasicClient, reqwest::http_client, AuthType, AuthUrl, AuthorizationCode, ClientId, + CsrfToken, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, + TokenResponse, TokenUrl, }; use std::{ io::{BufRead, BufReader, Write}, @@ -152,11 +153,13 @@ fn create_spotify_oauth_client(redirect_port: u16) -> BasicClient { BasicClient::new( ClientId::new(crate::session::access_token::CLIENT_ID.to_string()), - None, + None, // No client secret for PKCE flow AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), ) .set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL")) + // Use RequestBody auth type to include client_id in the body (required for PKCE) + .set_auth_type(AuthType::RequestBody) } pub fn generate_auth_url(redirect_port: u16) -> (String, PkceCodeVerifier) { @@ -204,14 +207,29 @@ fn get_scopes() -> Vec { /// Refresh an access token using a stored refresh token. Returns the new access token and /// an optional new refresh token if Spotify rotates it. pub fn refresh_access_token(refresh_token: &str) -> Result<(String, Option), Error> { - // Reuse the same OAuth client configuration; redirect URI is irrelevant for refresh flow. - let client = create_spotify_oauth_client(0); + // For PKCE flow, Spotify requires client_id in the request body (not Basic auth header). + // We create a minimal client just for refresh - redirect URI is not needed for refresh flow. + let client = BasicClient::new( + ClientId::new(crate::session::access_token::CLIENT_ID.to_string()), + None, // No client secret for PKCE + AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), + Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), + ) + // Use RequestBody auth type to include client_id in the body instead of Authorization header + .set_auth_type(AuthType::RequestBody); + log::debug!("Attempting OAuth token refresh with client_id: {}", crate::session::access_token::CLIENT_ID); + let token_response = client .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string())) .request(http_client) - .map_err(|e| Error::OAuthError(format!("Failed to refresh token: {e}")))?; + .map_err(|e| { + // Log detailed error for debugging + log::error!("OAuth refresh failed: {:?}", e); + Error::OAuthError(format!("Failed to refresh token: {e}")) + })?; + log::info!("OAuth token refresh successful"); let access = token_response.access_token().secret().to_string(); let refresh = token_response .refresh_token() diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 6d3eefd6..52640774 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -33,6 +33,7 @@ pub const FIND_IN_SAVED_TRACKS: Selector = Selector::new("find-in-saved-tr // Session pub const SESSION_CONNECT: Selector = Selector::new("app.session-connect"); pub const LOG_OUT: Selector = Selector::new("app.log-out"); +pub const OAUTH_AUTH_REQUIRED: Selector = Selector::new("app.oauth-auth-required"); // Navigation pub const NAVIGATE: Selector