spotify_cli/spotify/
auth.rs

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