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#[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#[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}