Skip to main content

zad_cli/cli/
service_spotify.rs

1//! Spotify's plug-in to the generic service lifecycle.
2//!
3//! Everything Spotify-specific lives here: the OAuth 2.0 PKCE
4//! credential shape (a public client — no `client_secret`), the flags
5//! that let the operator paste in a pre-minted refresh token (or run
6//! the interactive loopback flow), and the `GET /me` call that
7//! validates a credential set. The generic plumbing (flag parsing,
8//! path resolution, JSON envelopes, human banners, keychain I/O
9//! sequencing) lives in `src/cli/lifecycle.rs` and is shared with
10//! every other service.
11//!
12//! See `docs/services.md#adding-a-new-service` for the full recipe.
13
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use crate::cli::DialoguerExt;
18use async_trait::async_trait;
19use clap::Args;
20use dialoguer::{Confirm, Input, theme::ColorfulTheme};
21
22use crate::cli::lifecycle::{
23    CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
24    resolve_scopes,
25};
26use zad::config::{ProjectConfig, SpotifyServiceCfg};
27use zad::error::{Result, ZadError};
28use zad::oauth::{LoopbackConfig, RedirectScheme, RefreshTokenStore, run_loopback_flow};
29use zad::secrets::{self, Scope};
30use zad::service::spotify::{AUTH_URL, SpotifyHttp, TOKEN_URL, spotify_scopes_for};
31
32const DEFAULT_SCOPES: &[&str] = &[
33    "search",
34    "playlists.read",
35    "playlists.write",
36    "library.read",
37];
38const ALL_SCOPES: &[&str] = &[
39    "search",
40    "playlists.read",
41    "playlists.write",
42    "library.read",
43    "library.write",
44];
45
46/// URL the operator should visit to create a Spotify Developer app.
47const SPOTIFY_DASHBOARD_URL: &str = "https://developer.spotify.com/dashboard";
48
49/// Loopback callback deadline. Matches the default on
50/// [`LoopbackConfig`] but spelled out here so the create flow can
51/// print it to the user up front.
52const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
53
54// ---------------------------------------------------------------------------
55// credential shape
56// ---------------------------------------------------------------------------
57
58/// Spotify's credential shape — OAuth 2.0 "Authorization Code with
59/// PKCE" public client: just `client_id` + a long-lived `refresh_token`.
60/// No client secret is issued or accepted by Spotify for PKCE clients.
61/// Both pieces are persisted in the OS keychain; the access token is
62/// re-minted at each CLI invocation.
63pub struct SpotifySecrets {
64    pub client_id: String,
65    pub refresh_token: String,
66}
67
68// ---------------------------------------------------------------------------
69// `zad service create spotify` args
70// ---------------------------------------------------------------------------
71
72#[derive(Debug, Args)]
73pub struct CreateArgs {
74    #[command(flatten)]
75    pub base: CreateArgsBase,
76    #[command(flatten)]
77    pub scopes: ScopesArg,
78
79    /// OAuth 2.0 client ID from the Spotify Developer Dashboard. Not
80    /// strictly secret, but zad still stores it in the keychain for
81    /// co-location with the refresh token.
82    #[arg(long)]
83    pub client_id: Option<String>,
84
85    /// Read `--client-id` from this environment variable instead.
86    #[arg(long, conflicts_with = "client_id")]
87    pub client_id_env: Option<String>,
88
89    /// Pre-minted OAuth refresh token. When provided, zad skips the
90    /// browser loopback and stores the token verbatim. Useful for CI
91    /// and for operators who already minted one out-of-band.
92    #[arg(long, conflicts_with = "refresh_token_env")]
93    pub refresh_token: Option<String>,
94
95    /// Read `--refresh-token` from this environment variable instead.
96    #[arg(long, conflicts_with = "refresh_token")]
97    pub refresh_token_env: Option<String>,
98
99    /// Optional default playlist for verbs that omit `--playlist`.
100    /// Accepts a Spotify playlist ID, a `spotify:playlist:<id>` URI,
101    /// or a directory alias.
102    #[arg(long)]
103    pub default_playlist: Option<String>,
104}
105
106impl CreateArgsLike for CreateArgs {
107    fn base(&self) -> &CreateArgsBase {
108        &self.base
109    }
110}
111
112// ---------------------------------------------------------------------------
113// the trait impl — the entire spotify-specific lifecycle surface
114// ---------------------------------------------------------------------------
115
116pub struct SpotifyLifecycle;
117
118#[async_trait]
119impl LifecycleService for SpotifyLifecycle {
120    const NAME: &'static str = "spotify";
121    const DISPLAY: &'static str = "Spotify";
122    type Cfg = SpotifyServiceCfg;
123    type Secrets = SpotifySecrets;
124
125    fn enable_in_project(cfg: &mut ProjectConfig) {
126        cfg.enable_spotify();
127    }
128
129    fn disable_in_project(cfg: &mut ProjectConfig) {
130        cfg.disable_spotify();
131    }
132
133    async fn validate(_cfg: &SpotifyServiceCfg, creds: &mut SpotifySecrets) -> Result<String> {
134        // Spotify's PKCE flow rotates the refresh token on every
135        // `/api/token` call. The validate ping forces exactly such a
136        // call, so we wire a tiny capture store that funnels any
137        // rotation back into `creds.refresh_token` — the lifecycle
138        // driver then writes the rotated value to the keychain via
139        // `store_secrets`. Without this, the user would land in the
140        // exact bug spotifai reported: keychain holds the
141        // pre-rotation token, Spotify revokes it after the grace
142        // window, the next runtime call fails with `invalid_grant`.
143        let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
144        let store = Arc::new(CaptureRefreshToken(captured.clone()));
145        let http = SpotifyHttp::with_store(
146            creds.client_id.clone(),
147            creds.refresh_token.clone(),
148            std::collections::BTreeSet::new(),
149            std::path::PathBuf::new(),
150            Some(store),
151        );
152        let me = http.me().await?;
153        if let Some(rotated) = captured.lock().unwrap().take() {
154            creds.refresh_token = rotated;
155        }
156        Ok(me.display_name.unwrap_or(me.id))
157    }
158
159    fn store_secrets(creds: &SpotifySecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
160        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
161        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
162        secrets::store(&client_id_acct, &creds.client_id)?;
163        secrets::store(&refresh_acct, &creds.refresh_token)?;
164        Ok(vec![
165            SecretRef {
166                label: "client id",
167                account: client_id_acct,
168                present: true,
169            },
170            SecretRef {
171                label: "refresh token",
172                account: refresh_acct,
173                present: true,
174            },
175        ])
176    }
177
178    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
179        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
180        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
181        secrets::delete(&client_id_acct)?;
182        secrets::delete(&refresh_acct)?;
183        Ok(vec![
184            SecretRef {
185                label: "client id",
186                account: client_id_acct,
187                present: false,
188            },
189            SecretRef {
190                label: "refresh token",
191                account: refresh_acct,
192                present: false,
193            },
194        ])
195    }
196
197    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
198        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
199        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
200        let client_id_present = secrets::load(&client_id_acct)?.is_some();
201        let refresh_present = secrets::load(&refresh_acct)?.is_some();
202        Ok(vec![
203            SecretRef {
204                label: "client id",
205                account: client_id_acct,
206                present: client_id_present,
207            },
208            SecretRef {
209                label: "refresh token",
210                account: refresh_acct,
211                present: refresh_present,
212            },
213        ])
214    }
215
216    fn load_secrets(scope: Scope<'_>) -> Result<Option<SpotifySecrets>> {
217        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
218        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
219        let (Some(id), Some(refresh)) = (
220            secrets::load(&client_id_acct)?,
221            secrets::load(&refresh_acct)?,
222        ) else {
223            return Ok(None);
224        };
225        Ok(Some(SpotifySecrets {
226            client_id: id,
227            refresh_token: refresh,
228        }))
229    }
230
231    fn cfg_human(cfg: &SpotifyServiceCfg) -> Vec<(&'static str, String)> {
232        let mut out = vec![];
233        if let Some(p) = &cfg.default_playlist {
234            out.push(("playlist", p.clone()));
235        }
236        out
237    }
238
239    fn cfg_json(cfg: &SpotifyServiceCfg) -> serde_json::Value {
240        serde_json::json!({
241            "default_playlist": cfg.default_playlist,
242        })
243    }
244
245    fn scopes_of(cfg: &SpotifyServiceCfg) -> &[String] {
246        &cfg.scopes
247    }
248
249    fn post_create_hint(_cfg: &SpotifyServiceCfg) -> Option<String> {
250        None
251    }
252}
253
254#[async_trait]
255impl CliLifecycle for SpotifyLifecycle {
256    type CreateArgs = CreateArgs;
257
258    async fn resolve(
259        args: &CreateArgs,
260        non_interactive: bool,
261    ) -> Result<(SpotifyServiceCfg, SpotifySecrets)> {
262        let open_browser = !args.base.no_browser;
263
264        let scopes = resolve_scopes(
265            args.scopes.scopes.as_deref(),
266            DEFAULT_SCOPES,
267            ALL_SCOPES,
268            non_interactive,
269        )?;
270
271        let client_id = resolve_client_id(
272            args.client_id.as_deref(),
273            args.client_id_env.as_deref(),
274            open_browser,
275            non_interactive,
276        )?;
277
278        let refresh_token = if let Some(v) = args.refresh_token.clone() {
279            v
280        } else if let Some(env) = args.refresh_token_env.as_deref() {
281            std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
282        } else {
283            resolve_refresh_via_loopback(&client_id, &scopes, open_browser, non_interactive).await?
284        };
285
286        Ok((
287            SpotifyServiceCfg {
288                scopes,
289                default_playlist: args.default_playlist.clone(),
290            },
291            SpotifySecrets {
292                client_id,
293                refresh_token,
294            },
295        ))
296    }
297}
298
299// ---------------------------------------------------------------------------
300// prompt helpers
301// ---------------------------------------------------------------------------
302
303fn theme() -> ColorfulTheme {
304    ColorfulTheme::default()
305}
306
307fn resolve_client_id(
308    flag: Option<&str>,
309    env_flag: Option<&str>,
310    open_browser: bool,
311    non_interactive: bool,
312) -> Result<String> {
313    if let Some(env) = env_flag {
314        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
315    }
316    if let Some(v) = flag {
317        return Ok(v.to_string());
318    }
319    if non_interactive {
320        return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
321    }
322
323    println!();
324    println!("Spotify uses OAuth 2.0 (PKCE public client). You need a Spotify app:");
325    println!("  1. Open the Spotify Developer Dashboard:");
326    println!("       {SPOTIFY_DASHBOARD_URL}");
327    println!("  2. Click \"Create app\". Name and description are arbitrary.");
328    println!("  3. Under \"Redirect URIs\", add `https://127.0.0.1` and save.");
329    println!("     (Spotify dropped HTTP for OAuth redirects — zad terminates TLS on the loopback");
330    println!("     listener with a per-session self-signed cert; your browser will show a");
331    println!(
332        "     \"connection not private\" warning the first time you authorize — click through it.)"
333    );
334    println!("  4. Copy the Client ID from the app's Settings page back here.");
335    println!("     (Spotify also shows a Client Secret — you do NOT need it for PKCE.)");
336    if open_browser {
337        let _ = open::that(SPOTIFY_DASHBOARD_URL);
338    }
339
340    let v: String = Input::with_theme(&theme())
341        .with_prompt("Spotify Client ID")
342        .interact_text()
343        .into_zad()?;
344    Ok(v.trim().to_string())
345}
346
347/// Interactive browser-based loopback flow for the refresh token.
348/// Called only when the user didn't pass `--refresh-token` /
349/// `--refresh-token-env`. Bails in non-interactive mode.
350async fn resolve_refresh_via_loopback(
351    client_id: &str,
352    zad_scopes: &[String],
353    open_browser: bool,
354    non_interactive: bool,
355) -> Result<String> {
356    if non_interactive {
357        return Err(ZadError::MissingRequired(
358            "--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
359        ));
360    }
361
362    println!();
363    println!("No refresh token provided — starting the browser OAuth flow.");
364    println!(
365        "Make sure your Spotify app's \"Redirect URIs\" list includes `https://127.0.0.1` \
366         (Spotify no longer accepts http://; the loopback listener picks a random port and \
367         Spotify accepts any port on 127.0.0.1 once the host is registered). zad terminates \
368         TLS on the loopback with a per-session self-signed cert, so your browser will show a \
369         \"connection not private\" warning — click through it to finish authorization."
370    );
371    let want = Confirm::with_theme(&theme())
372        .with_prompt("Continue with the browser flow?")
373        .default(true)
374        .interact()
375        .into_zad()?;
376    if !want {
377        return Err(ZadError::Invalid(
378            "browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
379        ));
380    }
381
382    let provider_scopes = spotify_scopes_for(zad_scopes);
383    let cfg = LoopbackConfig {
384        service_name: "spotify",
385        display_name: "Spotify",
386        auth_url: AUTH_URL.to_string(),
387        token_url: TOKEN_URL.to_string(),
388        client_id: client_id.to_string(),
389        client_secret: None,
390        scopes: provider_scopes,
391        // `show_dialog=true` forces Spotify to re-prompt for consent
392        // even if the user previously authorized this app — without
393        // it, a second `create` run silently re-uses the existing
394        // grant and we never see a refresh token.
395        extra_auth_params: vec![("show_dialog".into(), "true".into())],
396        timeout: LOOPBACK_TIMEOUT,
397        // Spotify deprecated `http://` redirect URIs (loopback
398        // included). zad terminates TLS in-process with a self-signed
399        // cert; the browser shows a one-time warning the operator
400        // clicks through.
401        redirect_scheme: RedirectScheme::Https,
402    };
403    let tokens = run_loopback_flow(&cfg, open_browser).await?;
404    tokens.refresh_token.ok_or_else(|| ZadError::Service {
405        name: "spotify",
406        message: "Spotify did not return a refresh token. Re-run \
407                  `zad service create spotify` to retry the consent flow."
408            .into(),
409    })
410}
411
412/// `RefreshTokenStore` impl that captures a rotated refresh token
413/// into a shared cell instead of writing it anywhere. Used by
414/// `SpotifyLifecycle::validate` so the lifecycle driver can take the
415/// rotated value and persist it via `store_secrets` rather than
416/// hard-coding a keychain write inside validate (the keychain slot
417/// isn't yet authoritative at create time — `store_secrets` is the
418/// single writer).
419struct CaptureRefreshToken(Arc<Mutex<Option<String>>>);
420
421impl RefreshTokenStore for CaptureRefreshToken {
422    fn store(&self, refresh_token: &str) -> Result<()> {
423        *self.0.lock().unwrap() = Some(refresh_token.to_string());
424        Ok(())
425    }
426}