spotify_cli/spotify/
auth.rs

1use std::io::{Read, Write};
2use std::net::TcpListener;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{bail, Context};
6use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7use base64::Engine;
8use rand::RngCore;
9use reqwest::blocking::Client as HttpClient;
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12use url::Url;
13
14use crate::cache::metadata::{AuthTokenCache, ClientIdentity, Metadata};
15use crate::domain::settings::Settings;
16use crate::cache::metadata::MetadataStore;
17use crate::domain::auth::{AuthScopes, AuthStatus};
18use crate::error::Result;
19
20const ACCOUNTS_BASE: &str = "https://accounts.spotify.com";
21const API_BASE: &str = "https://api.spotify.com/v1";
22const REDIRECT_URI_DEFAULT: &str = "http://127.0.0.1:8888/callback";
23const SCOPES: &[&str] = &[
24    "user-read-playback-state",
25    "user-modify-playback-state",
26    "user-read-currently-playing",
27    "user-read-playback-position",
28    "user-top-read",
29    "user-read-recently-played",
30    "user-library-modify",
31    "user-library-read",
32    "user-read-private",
33    "user-read-email",
34    "user-follow-modify",
35    "user-follow-read",
36    "playlist-read-private",
37    "playlist-read-collaborative",
38    "playlist-modify-public",
39    "playlist-modify-private",
40];
41
42/// OAuth token data returned by Spotify.
43#[derive(Debug, Clone)]
44pub struct AuthToken {
45    pub access_token: String,
46    pub refresh_token: Option<String>,
47    pub expires_at: Option<u64>,
48    pub scopes: Option<Vec<String>>,
49}
50
51/// OAuth login and token refresh service.
52#[derive(Debug, Clone)]
53pub struct AuthService {
54    store: MetadataStore,
55}
56
57impl AuthService {
58    pub fn new(store: MetadataStore) -> Self {
59        Self { store }
60    }
61
62    pub fn login_oauth(&self, client_id: String) -> Result<()> {
63        self.login_oauth_with_redirect(client_id, REDIRECT_URI_DEFAULT)
64    }
65
66    pub fn login_oauth_with_redirect(
67        &self,
68        client_id: String,
69        redirect_uri: &str,
70    ) -> Result<()> {
71        let code_verifier = pkce_verifier();
72        let code_challenge = pkce_challenge(&code_verifier);
73        let state = oauth_state();
74        let authorize_url =
75            build_authorize_url(&client_id, redirect_uri, &state, &code_challenge)?;
76
77        println!("Open this URL to authorize: {}", authorize_url);
78        println!("Waiting for Spotify authorization...");
79
80        let code = wait_for_code(redirect_uri, &state)?;
81        let token = exchange_code(&client_id, redirect_uri, &code, &code_verifier)?;
82
83        let user_name = if should_fetch_profile() {
84            fetch_user_name(&token.access_token).ok()
85        } else {
86            None
87        };
88        let metadata = Metadata {
89            auth: Some(AuthTokenCache {
90                access_token: token.access_token,
91                refresh_token: token.refresh_token,
92                expires_at: token.expires_at,
93                granted_scopes: token.scopes,
94            }),
95            client: Some(ClientIdentity { client_id }),
96            settings: Settings {
97                user_name,
98                ..Settings::default()
99            },
100        };
101
102        self.store.save(&metadata)?;
103        Ok(())
104    }
105
106    pub fn login(&self, token: AuthToken) -> Result<()> {
107        let mut metadata = self.store.load()?;
108        let user_name = if should_fetch_profile() {
109            fetch_user_name(&token.access_token).ok()
110        } else {
111            None
112        };
113        metadata.auth = Some(AuthTokenCache {
114            access_token: token.access_token,
115            refresh_token: token.refresh_token,
116            expires_at: token.expires_at,
117            granted_scopes: token.scopes.clone(),
118        });
119        if user_name.is_some() {
120            metadata.settings.user_name = user_name;
121        }
122        self.store.save(&metadata)?;
123        Ok(())
124    }
125
126    pub fn status(&self) -> Result<AuthStatus> {
127        let metadata = self.store.load()?;
128        let Some(auth) = metadata.auth else {
129            return Ok(AuthStatus {
130                logged_in: false,
131                expires_at: None,
132            });
133        };
134
135        Ok(AuthStatus {
136            logged_in: !auth.access_token.is_empty(),
137            expires_at: auth.expires_at,
138        })
139    }
140
141    pub fn scopes(&self) -> Result<AuthScopes> {
142        let metadata = self.store.load()?;
143        let required = SCOPES.iter().map(|scope| scope.to_string()).collect::<Vec<_>>();
144        let granted = metadata
145            .auth
146            .as_ref()
147            .and_then(|auth| auth.granted_scopes.clone());
148        let missing = if let Some(granted) = granted.as_ref() {
149            required
150                .iter()
151                .filter(|scope| !granted.iter().any(|value| value == *scope))
152                .cloned()
153                .collect()
154        } else {
155            Vec::new()
156        };
157
158        Ok(AuthScopes {
159            required,
160            granted,
161            missing,
162        })
163    }
164
165    pub fn token(&self) -> Result<AuthToken> {
166        let metadata = self.store.load()?;
167        let Some(mut auth) = metadata.auth else {
168            bail!("not logged in; run `spotify auth login`");
169        };
170
171        if token_needs_refresh(auth.expires_at) {
172            if let Some(refresh) = auth.refresh_token.clone() {
173                if let Some(client) = metadata.client {
174                    let refreshed = refresh_token(&client.client_id, &refresh)?;
175                    auth.access_token = refreshed.access_token;
176                    auth.expires_at = refreshed.expires_at;
177                    if refreshed.refresh_token.is_some() {
178                        auth.refresh_token = refreshed.refresh_token;
179                    }
180                    if refreshed.scopes.is_some() {
181                        auth.granted_scopes = refreshed.scopes;
182                    }
183                    let updated = Metadata {
184                        auth: Some(auth.clone()),
185                        client: Some(client),
186                        settings: metadata.settings,
187                    };
188                    self.store.save(&updated)?;
189                }
190            }
191        }
192
193        if token_needs_refresh(auth.expires_at) {
194            bail!("token expired; run `spotify auth login`");
195        }
196
197        Ok(AuthToken {
198            access_token: auth.access_token,
199            refresh_token: auth.refresh_token,
200            expires_at: auth.expires_at,
201            scopes: auth.granted_scopes,
202        })
203    }
204
205    pub fn clear(&self) -> Result<()> {
206        let metadata = Metadata {
207            auth: None,
208            client: None,
209            settings: Settings::default(),
210        };
211        self.store.save(&metadata)?;
212        Ok(())
213    }
214
215    pub fn country(&self) -> Result<Option<String>> {
216        let metadata = self.store.load()?;
217        Ok(metadata.settings.country)
218    }
219
220    pub fn set_country(&self, country: Option<String>) -> Result<()> {
221        let mut metadata = self.store.load()?;
222        metadata.settings.country = country;
223        self.store.save(&metadata)?;
224        Ok(())
225    }
226
227    pub fn user_name(&self) -> Result<Option<String>> {
228        let metadata = self.store.load()?;
229        Ok(metadata.settings.user_name)
230    }
231
232    pub fn set_user_name(&self, user_name: Option<String>) -> Result<()> {
233        let mut metadata = self.store.load()?;
234        metadata.settings.user_name = user_name;
235        self.store.save(&metadata)?;
236        Ok(())
237    }
238
239    pub fn ensure_user_name(&self) -> Result<Option<String>> {
240        let mut metadata = self.store.load()?;
241        if metadata.settings.user_name.is_some() {
242            return Ok(metadata.settings.user_name);
243        }
244
245        if !should_fetch_profile() {
246            return Ok(None);
247        }
248
249        if let Some(auth) = metadata.auth.as_ref() {
250            if let Ok(user_name) = fetch_user_name(&auth.access_token) {
251                metadata.settings.user_name = Some(user_name.clone());
252                self.store.save(&metadata)?;
253                return Ok(Some(user_name));
254            }
255        }
256
257        Ok(None)
258    }
259}
260
261fn oauth_state() -> String {
262    let mut bytes = [0u8; 32];
263    rand::thread_rng().fill_bytes(&mut bytes);
264    let token = URL_SAFE_NO_PAD.encode(bytes);
265    format!("spotify-cli-{token}")
266}
267
268fn build_authorize_url(
269    client_id: &str,
270    redirect_uri: &str,
271    state: &str,
272    code_challenge: &str,
273) -> Result<String> {
274    let scope = SCOPES.join(" ");
275    let encoded_scope = urlencoding::encode(&scope);
276    let encoded_redirect = urlencoding::encode(redirect_uri);
277
278    Ok(format!(
279        "{ACCOUNTS_BASE}/authorize?response_type=code&client_id={client_id}&scope={encoded_scope}&redirect_uri={encoded_redirect}&state={state}&code_challenge_method=S256&code_challenge={code_challenge}"
280    ))
281}
282
283fn wait_for_code(redirect_uri: &str, expected_state: &str) -> Result<String> {
284    let url = Url::parse(redirect_uri)?;
285    if url.scheme() != "http" {
286        bail!("redirect URI must use http");
287    }
288
289    let host = url.host_str().unwrap_or("127.0.0.1");
290    let host = if host == "localhost" { "127.0.0.1" } else { host };
291    if !matches!(host, "127.0.0.1" | "::1") {
292        bail!("redirect URI must use a loopback host");
293    }
294    let port = url.port_or_known_default().unwrap_or(8888);
295    let path = url.path().to_string();
296
297    let listener = TcpListener::bind((host, port))
298        .with_context(|| format!("unable to bind redirect listener on {host}:{port}"))?;
299    let (mut stream, _) = listener
300        .accept()
301        .context("failed to accept redirect connection")?;
302
303    let mut buffer = [0u8; 4096];
304    let size = stream.read(&mut buffer)?;
305    let request = String::from_utf8_lossy(&buffer[..size]);
306    let first_line = request.lines().next().unwrap_or("");
307    let mut parts = first_line.split_whitespace();
308    let method = parts.next().unwrap_or("");
309    let target = parts.next().unwrap_or("");
310
311    if method != "GET" {
312        bail!("unexpected redirect method: {method}");
313    }
314
315    let (request_path, query) = match target.split_once('?') {
316        Some((path, query)) => (path, query),
317        None => (target, ""),
318    };
319
320    if request_path != path {
321        bail!("unexpected redirect path: {request_path}");
322    }
323
324    let params = parse_query(query);
325    let Some(state) = params.get("state") else {
326        bail!("missing state in redirect");
327    };
328
329    if state != expected_state {
330        bail!("state mismatch during login");
331    }
332
333    let Some(code) = params.get("code") else {
334        bail!("missing code in redirect");
335    };
336
337    let response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nYou can close this window.";
338    stream.write_all(response.as_bytes())?;
339    stream.flush()?;
340
341    Ok(code.to_string())
342}
343
344fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
345    let mut params = std::collections::HashMap::new();
346    for pair in query.split('&') {
347        if pair.is_empty() {
348            continue;
349        }
350        let (key, value) = match pair.split_once('=') {
351            Some((key, value)) => (key, value),
352            None => (pair, ""),
353        };
354        let key = urlencoding::decode(key).unwrap_or_else(|_| key.into());
355        let value = urlencoding::decode(value).unwrap_or_else(|_| value.into());
356        params.insert(key.to_string(), value.to_string());
357    }
358    params
359}
360
361fn exchange_code(
362    client_id: &str,
363    redirect_uri: &str,
364    code: &str,
365    code_verifier: &str,
366) -> Result<AuthToken> {
367    let client = HttpClient::builder().build()?;
368    let url = format!("{ACCOUNTS_BASE}/api/token");
369
370    let response = client
371        .post(url)
372        .form(&[
373            ("grant_type", "authorization_code"),
374            ("code", code),
375            ("redirect_uri", redirect_uri),
376            ("client_id", client_id),
377            ("code_verifier", code_verifier),
378        ])
379        .send()
380        .context("spotify token exchange failed")?;
381
382    if !response.status().is_success() {
383        bail!("spotify token exchange failed: {}", response.status());
384    }
385
386    let payload: TokenResponse = response.json()?;
387    Ok(AuthToken {
388        access_token: payload.access_token,
389        refresh_token: payload.refresh_token,
390        expires_at: Some(unix_time() + payload.expires_in),
391        scopes: payload.scope.map(parse_scopes),
392    })
393}
394
395fn refresh_token(client_id: &str, refresh_token: &str) -> Result<AuthToken> {
396    let client = HttpClient::builder().build()?;
397    let url = format!("{ACCOUNTS_BASE}/api/token");
398
399    let response = client
400        .post(url)
401        .form(&[
402            ("grant_type", "refresh_token"),
403            ("refresh_token", refresh_token),
404            ("client_id", client_id),
405        ])
406        .send()
407        .context("spotify token refresh failed")?;
408
409    if !response.status().is_success() {
410        bail!("spotify token refresh failed: {}", response.status());
411    }
412
413    let payload: TokenResponse = response.json()?;
414    Ok(AuthToken {
415        access_token: payload.access_token,
416        refresh_token: payload.refresh_token,
417        expires_at: Some(unix_time() + payload.expires_in),
418        scopes: payload.scope.map(parse_scopes),
419    })
420}
421
422#[derive(Deserialize)]
423struct TokenResponse {
424    access_token: String,
425    expires_in: u64,
426    #[serde(default)]
427    refresh_token: Option<String>,
428    #[serde(default)]
429    scope: Option<String>,
430}
431
432fn unix_time() -> u64 {
433    SystemTime::now()
434        .duration_since(UNIX_EPOCH)
435        .expect("time")
436        .as_secs()
437}
438
439fn token_needs_refresh(expires_at: Option<u64>) -> bool {
440    let Some(expires_at) = expires_at else {
441        return false;
442    };
443    unix_time().saturating_add(60) >= expires_at
444}
445
446fn should_fetch_profile() -> bool {
447    std::env::var("SPOTIFY_CLI_SKIP_PROFILE").is_err()
448}
449
450fn fetch_user_name(access_token: &str) -> Result<String> {
451    let client = HttpClient::builder().build()?;
452    let url = format!("{API_BASE}/me");
453    let response = client.get(url).bearer_auth(access_token).send()?;
454    if !response.status().is_success() {
455        bail!("spotify profile request failed: {}", response.status());
456    }
457    let payload: UserProfile = response.json()?;
458    Ok(payload
459        .display_name
460        .or(payload.id)
461        .unwrap_or_else(|| "You".to_string()))
462}
463
464fn parse_scopes(scope: String) -> Vec<String> {
465    scope
466        .split_whitespace()
467        .filter(|value| !value.is_empty())
468        .map(|value| value.to_string())
469        .collect()
470}
471
472#[derive(Deserialize)]
473struct UserProfile {
474    display_name: Option<String>,
475    id: Option<String>,
476}
477
478fn pkce_verifier() -> String {
479    let mut bytes = [0u8; 32];
480    rand::rngs::OsRng.fill_bytes(&mut bytes);
481    URL_SAFE_NO_PAD.encode(bytes)
482}
483
484fn pkce_challenge(verifier: &str) -> String {
485    let digest = Sha256::digest(verifier.as_bytes());
486    URL_SAFE_NO_PAD.encode(digest)
487}