spotify_cli/http/
auth.rs

1//! Spotify authentication endpoints.
2//!
3//! Handles token exchange and refresh via accounts.spotify.com.
4
5use thiserror::Error;
6
7use super::client::HttpClient;
8use crate::constants::SPOTIFY_AUTH_BASE_URL;
9
10/// Errors from authentication requests.
11#[derive(Debug, Error)]
12pub enum AuthError {
13    #[error("Request failed: {0}")]
14    Request(#[from] reqwest::Error),
15
16    #[error("Token exchange failed ({status}): {message}")]
17    TokenExchange { status: u16, message: String },
18}
19
20/// Spotify authentication client.
21///
22/// Handles token operations via accounts.spotify.com/api/token.
23pub struct SpotifyAuth {
24    http: HttpClient,
25    base_url: String,
26}
27
28impl SpotifyAuth {
29    /// Create a new authentication client.
30    pub fn new() -> Self {
31        Self {
32            http: HttpClient::new(),
33            base_url: SPOTIFY_AUTH_BASE_URL.to_string(),
34        }
35    }
36
37    /// Create a new authentication client with a custom base URL.
38    ///
39    /// Useful for testing with mock servers.
40    pub fn with_base_url(base_url: String) -> Self {
41        Self {
42            http: HttpClient::new(),
43            base_url,
44        }
45    }
46
47    /// Build a URL for the Spotify accounts endpoint.
48    pub fn url(path: &str) -> String {
49        format!("{}{}", SPOTIFY_AUTH_BASE_URL, path)
50    }
51
52    /// Build a URL using this client's base URL.
53    fn endpoint(&self, path: &str) -> String {
54        format!("{}{}", self.base_url, path)
55    }
56
57    /// Exchange authorization code for tokens (PKCE flow)
58    pub async fn exchange_code(
59        &self,
60        client_id: &str,
61        code: &str,
62        redirect_uri: &str,
63        code_verifier: &str,
64    ) -> Result<serde_json::Value, AuthError> {
65        let params = [
66            ("grant_type", "authorization_code"),
67            ("code", code),
68            ("redirect_uri", redirect_uri),
69            ("client_id", client_id),
70            ("code_verifier", code_verifier),
71        ];
72
73        self.token_request(&params).await
74    }
75
76    /// Refresh an access token
77    pub async fn refresh_token(
78        &self,
79        client_id: &str,
80        refresh_token: &str,
81    ) -> Result<serde_json::Value, AuthError> {
82        let params = [
83            ("grant_type", "refresh_token"),
84            ("refresh_token", refresh_token),
85            ("client_id", client_id),
86        ];
87
88        self.token_request(&params).await
89    }
90
91    async fn token_request(&self, params: &[(&str, &str)]) -> Result<serde_json::Value, AuthError> {
92        let response = self
93            .http
94            .inner()
95            .post(self.endpoint("/api/token"))
96            .form(params)
97            .send()
98            .await?;
99
100        if !response.status().is_success() {
101            let status = response.status();
102            let body = response.text().await.unwrap_or_default();
103            return Err(AuthError::TokenExchange {
104                status: status.as_u16(),
105                message: body,
106            });
107        }
108
109        let json: serde_json::Value = response.json().await?;
110        Ok(json)
111    }
112}
113
114impl Default for SpotifyAuth {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn auth_error_display() {
126        let err = AuthError::TokenExchange {
127            status: 400,
128            message: "invalid_grant".to_string(),
129        };
130        let display = format!("{}", err);
131        assert!(display.contains("400"));
132        assert!(display.contains("invalid_grant"));
133    }
134
135    #[test]
136    fn auth_error_token_exchange_status() {
137        let err = AuthError::TokenExchange {
138            status: 401,
139            message: "unauthorized".to_string(),
140        };
141        match err {
142            AuthError::TokenExchange { status, message } => {
143                assert_eq!(status, 401);
144                assert_eq!(message, "unauthorized");
145            }
146            _ => panic!("Wrong error type"),
147        }
148    }
149
150    #[test]
151    fn spotify_auth_url_building() {
152        let url = SpotifyAuth::url("/api/token");
153        assert!(url.contains("/api/token"));
154        assert!(url.starts_with("https://"));
155    }
156
157    #[test]
158    fn spotify_auth_default() {
159        let _auth = SpotifyAuth::default();
160        // Just verify it creates successfully
161    }
162
163    #[test]
164    fn spotify_auth_new() {
165        let _auth = SpotifyAuth::new();
166        // Just verify it creates successfully
167    }
168
169    #[test]
170    fn auth_error_debug() {
171        let err = AuthError::TokenExchange {
172            status: 500,
173            message: "server error".to_string(),
174        };
175        let debug = format!("{:?}", err);
176        assert!(debug.contains("TokenExchange"));
177        assert!(debug.contains("500"));
178    }
179}