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