Skip to main content

zad_cli/cli/
service_gcal.rs

1//! Google Calendar's plug-in to the generic service lifecycle.
2//!
3//! Everything Google-specific lives here: the OAuth 2.0 three-field
4//! credential shape, the flags that let the user paste in a
5//! pre-minted refresh token (or run the interactive loopback flow),
6//! and the token → `userinfo` call that validates a credential set.
7//! The generic plumbing (flag parsing, path resolution, JSON
8//! envelopes, human banners, keychain I/O sequencing) lives in
9//! `src/cli/lifecycle.rs` and is shared with every other service.
10//!
11//! See `docs/services.md#adding-a-new-service` for the full recipe.
12
13use std::time::Duration;
14
15use crate::cli::DialoguerExt;
16use async_trait::async_trait;
17use clap::Args;
18use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme};
19
20use crate::cli::lifecycle::{
21    CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
22    resolve_scopes,
23};
24use zad::config::{GcalServiceCfg, ProjectConfig};
25use zad::error::{Result, ZadError};
26use zad::oauth::{LoopbackConfig, RedirectScheme, run_loopback_flow};
27use zad::secrets::{self, Scope};
28use zad::service::gcal::{AUTH_URL, GcalHttp, TOKEN_URL};
29
30const DEFAULT_SCOPES: &[&str] = &["calendars.read", "events.read", "events.write"];
31const ALL_SCOPES: &[&str] = &[
32    "calendars.read",
33    "events.read",
34    "events.write",
35    "events.invite",
36    "events.remind",
37];
38
39/// URL the user should open to create a Google Cloud OAuth client.
40/// Printed during interactive create so the operator doesn't have to
41/// google around for it.
42const GCP_CREDENTIALS_URL: &str = "https://console.cloud.google.com/apis/credentials";
43
44/// Loopback callback deadline. Matches the default on
45/// [`LoopbackConfig`] but spelled out here so the create flow can
46/// print it to the user up front.
47const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
48
49// ---------------------------------------------------------------------------
50// credential shape
51// ---------------------------------------------------------------------------
52
53/// Google Calendar's credential shape — OAuth 2.0 "Desktop app":
54/// `client_id` + `client_secret` + a long-lived `refresh_token`. All
55/// three are persisted in the OS keychain; the access token is
56/// re-minted at each CLI invocation.
57pub struct GcalSecrets {
58    pub client_id: String,
59    pub client_secret: String,
60    pub refresh_token: String,
61}
62
63// ---------------------------------------------------------------------------
64// `zad service create gcal` args
65// ---------------------------------------------------------------------------
66
67#[derive(Debug, Args)]
68pub struct CreateArgs {
69    #[command(flatten)]
70    pub base: CreateArgsBase,
71    #[command(flatten)]
72    pub scopes: ScopesArg,
73
74    /// OAuth 2.0 client ID from Google Cloud Console (Desktop app
75    /// type). Not a secret, but zad still stores it in the keychain
76    /// for co-location with the other OAuth fields.
77    #[arg(long)]
78    pub client_id: Option<String>,
79
80    /// Read `--client-id` from this environment variable instead.
81    #[arg(long, conflicts_with = "client_id")]
82    pub client_id_env: Option<String>,
83
84    /// OAuth 2.0 client secret issued alongside `--client-id`. Google
85    /// calls this a "secret" even for Desktop-app clients; we treat
86    /// it as one.
87    #[arg(long, conflicts_with = "client_secret_env")]
88    pub client_secret: Option<String>,
89
90    /// Read `--client-secret` from this environment variable instead.
91    #[arg(long, conflicts_with = "client_secret")]
92    pub client_secret_env: Option<String>,
93
94    /// Pre-minted OAuth refresh token. When provided, zad skips the
95    /// browser loopback and stores the token verbatim. Useful for CI
96    /// and for operators who already minted one via Google's OAuth
97    /// Playground.
98    #[arg(long, conflicts_with = "refresh_token_env")]
99    pub refresh_token: Option<String>,
100
101    /// Read `--refresh-token` from this environment variable instead.
102    #[arg(long, conflicts_with = "refresh_token")]
103    pub refresh_token_env: Option<String>,
104
105    /// Optional default calendar ID (`primary`, an email, or an
106    /// alias). Runtime verbs that omit `--calendar` will use this.
107    #[arg(long)]
108    pub default_calendar: Option<String>,
109
110    /// The authenticated user's primary email. Normally captured from
111    /// Google's userinfo endpoint during `validate` — pass this only
112    /// to pre-seed the value (non-interactive / testing).
113    #[arg(long)]
114    pub self_email: Option<String>,
115}
116
117impl CreateArgsLike for CreateArgs {
118    fn base(&self) -> &CreateArgsBase {
119        &self.base
120    }
121}
122
123// ---------------------------------------------------------------------------
124// the trait impl — the entire gcal-specific lifecycle surface
125// ---------------------------------------------------------------------------
126
127pub struct GcalLifecycle;
128
129#[async_trait]
130impl LifecycleService for GcalLifecycle {
131    const NAME: &'static str = "gcal";
132    const DISPLAY: &'static str = "Google Calendar";
133    type Cfg = GcalServiceCfg;
134    type Secrets = GcalSecrets;
135
136    fn enable_in_project(cfg: &mut ProjectConfig) {
137        cfg.enable_gcal();
138    }
139
140    fn disable_in_project(cfg: &mut ProjectConfig) {
141        cfg.disable_gcal();
142    }
143
144    async fn validate(_cfg: &GcalServiceCfg, creds: &mut GcalSecrets) -> Result<String> {
145        let http = GcalHttp::unscoped(
146            creds.client_id.clone(),
147            creds.client_secret.clone(),
148            creds.refresh_token.clone(),
149        );
150        let info = http.userinfo().await?;
151        let email = info.email.unwrap_or_else(|| "<unknown>".into());
152        // Light sanity probe — confirms the access token can actually
153        // read the calendar API, not just userinfo.
154        http.probe_calendar_list().await?;
155        Ok(email)
156    }
157
158    fn store_secrets(creds: &GcalSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
159        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
160        let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
161        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
162        secrets::store(&client_id_acct, &creds.client_id)?;
163        secrets::store(&client_secret_acct, &creds.client_secret)?;
164        secrets::store(&refresh_acct, &creds.refresh_token)?;
165        Ok(vec![
166            SecretRef {
167                label: "client id",
168                account: client_id_acct,
169                present: true,
170            },
171            SecretRef {
172                label: "client secret",
173                account: client_secret_acct,
174                present: true,
175            },
176            SecretRef {
177                label: "refresh token",
178                account: refresh_acct,
179                present: true,
180            },
181        ])
182    }
183
184    fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
185        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
186        let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
187        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
188        secrets::delete(&client_id_acct)?;
189        secrets::delete(&client_secret_acct)?;
190        secrets::delete(&refresh_acct)?;
191        Ok(vec![
192            SecretRef {
193                label: "client id",
194                account: client_id_acct,
195                present: false,
196            },
197            SecretRef {
198                label: "client secret",
199                account: client_secret_acct,
200                present: false,
201            },
202            SecretRef {
203                label: "refresh token",
204                account: refresh_acct,
205                present: false,
206            },
207        ])
208    }
209
210    fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
211        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
212        let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
213        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
214        let client_id_present = secrets::load(&client_id_acct)?.is_some();
215        let client_secret_present = secrets::load(&client_secret_acct)?.is_some();
216        let refresh_present = secrets::load(&refresh_acct)?.is_some();
217        Ok(vec![
218            SecretRef {
219                label: "client id",
220                account: client_id_acct,
221                present: client_id_present,
222            },
223            SecretRef {
224                label: "client secret",
225                account: client_secret_acct,
226                present: client_secret_present,
227            },
228            SecretRef {
229                label: "refresh token",
230                account: refresh_acct,
231                present: refresh_present,
232            },
233        ])
234    }
235
236    fn load_secrets(scope: Scope<'_>) -> Result<Option<GcalSecrets>> {
237        let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
238        let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
239        let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
240        let (Some(id), Some(secret), Some(refresh)) = (
241            secrets::load(&client_id_acct)?,
242            secrets::load(&client_secret_acct)?,
243            secrets::load(&refresh_acct)?,
244        ) else {
245            return Ok(None);
246        };
247        Ok(Some(GcalSecrets {
248            client_id: id,
249            client_secret: secret,
250            refresh_token: refresh,
251        }))
252    }
253
254    fn cfg_human(cfg: &GcalServiceCfg) -> Vec<(&'static str, String)> {
255        let mut out = vec![];
256        if let Some(c) = &cfg.default_calendar {
257            out.push(("calendar", c.clone()));
258        }
259        if let Some(e) = &cfg.self_email {
260            out.push(("email", e.clone()));
261        }
262        out
263    }
264
265    fn cfg_json(cfg: &GcalServiceCfg) -> serde_json::Value {
266        serde_json::json!({
267            "default_calendar": cfg.default_calendar,
268            "self_email": cfg.self_email,
269        })
270    }
271
272    fn scopes_of(cfg: &GcalServiceCfg) -> &[String] {
273        &cfg.scopes
274    }
275
276    fn post_create_hint(_cfg: &GcalServiceCfg) -> Option<String> {
277        // `create` succeeded → tokens are stored. No follow-up URL
278        // needed; the banner already tells the operator to run
279        // `zad service enable gcal` next.
280        None
281    }
282}
283
284#[async_trait]
285impl CliLifecycle for GcalLifecycle {
286    type CreateArgs = CreateArgs;
287
288    async fn resolve(
289        args: &CreateArgs,
290        non_interactive: bool,
291    ) -> Result<(GcalServiceCfg, GcalSecrets)> {
292        let open_browser = !args.base.no_browser;
293
294        let scopes = resolve_scopes(
295            args.scopes.scopes.as_deref(),
296            DEFAULT_SCOPES,
297            ALL_SCOPES,
298            non_interactive,
299        )?;
300
301        let client_id = resolve_client_id(
302            args.client_id.as_deref(),
303            args.client_id_env.as_deref(),
304            open_browser,
305            non_interactive,
306        )?;
307
308        let client_secret = resolve_client_secret(
309            args.client_secret.as_deref(),
310            args.client_secret_env.as_deref(),
311            non_interactive,
312        )?;
313
314        let refresh_token = if let Some(v) = args.refresh_token.clone() {
315            v
316        } else if let Some(env) = args.refresh_token_env.as_deref() {
317            std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
318        } else {
319            resolve_refresh_via_loopback(
320                &client_id,
321                &client_secret,
322                &scopes,
323                open_browser,
324                non_interactive,
325            )
326            .await?
327        };
328
329        Ok((
330            GcalServiceCfg {
331                scopes,
332                default_calendar: args.default_calendar.clone(),
333                self_email: args.self_email.clone(),
334            },
335            GcalSecrets {
336                client_id,
337                client_secret,
338                refresh_token,
339            },
340        ))
341    }
342}
343
344// ---------------------------------------------------------------------------
345// prompt helpers
346// ---------------------------------------------------------------------------
347
348fn theme() -> ColorfulTheme {
349    ColorfulTheme::default()
350}
351
352fn resolve_client_id(
353    flag: Option<&str>,
354    env_flag: Option<&str>,
355    open_browser: bool,
356    non_interactive: bool,
357) -> Result<String> {
358    if let Some(env) = env_flag {
359        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
360    }
361    if let Some(v) = flag {
362        return Ok(v.to_string());
363    }
364    if non_interactive {
365        return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
366    }
367
368    println!();
369    println!("Google Calendar uses OAuth 2.0. You need a Google Cloud OAuth client:");
370    println!("  1. Open the Google Cloud Console credentials page:");
371    println!("       {GCP_CREDENTIALS_URL}");
372    println!("  2. Create an OAuth client of type \"Desktop app\".");
373    println!("  3. Enable the \"Google Calendar API\" under APIs & Services → Library.");
374    println!("  4. Copy the Client ID and Client Secret back here.");
375    if open_browser {
376        let _ = open::that(GCP_CREDENTIALS_URL);
377    }
378
379    let v: String = Input::with_theme(&theme())
380        .with_prompt("Google OAuth Client ID")
381        .interact_text()
382        .into_zad()?;
383    Ok(v.trim().to_string())
384}
385
386fn resolve_client_secret(
387    flag: Option<&str>,
388    env_flag: Option<&str>,
389    non_interactive: bool,
390) -> Result<String> {
391    if let Some(env) = env_flag {
392        return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
393    }
394    if let Some(v) = flag {
395        return Ok(v.to_string());
396    }
397    if non_interactive {
398        return Err(ZadError::MissingRequired(
399            "--client-secret or --client-secret-env",
400        ));
401    }
402
403    let v = Password::with_theme(&theme())
404        .with_prompt("Google OAuth Client Secret")
405        .interact()
406        .into_zad()?;
407    Ok(v)
408}
409
410/// Interactive browser-based loopback flow for the refresh token.
411/// Called only when the user didn't pass `--refresh-token` /
412/// `--refresh-token-env`. Bails in non-interactive mode.
413async fn resolve_refresh_via_loopback(
414    client_id: &str,
415    client_secret: &str,
416    zad_scopes: &[String],
417    open_browser: bool,
418    non_interactive: bool,
419) -> Result<String> {
420    if non_interactive {
421        return Err(ZadError::MissingRequired(
422            "--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
423        ));
424    }
425
426    println!();
427    println!("No refresh token provided — starting the browser OAuth flow.");
428    println!(
429        "Make sure the OAuth client you created in Google Cloud Console is of type \"Desktop app\"."
430    );
431    let want = Confirm::with_theme(&theme())
432        .with_prompt("Continue with the browser flow?")
433        .default(true)
434        .interact()
435        .into_zad()?;
436    if !want {
437        return Err(ZadError::Invalid(
438            "browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
439        ));
440    }
441
442    let google_scopes = google_scopes_for(zad_scopes);
443    let cfg = LoopbackConfig {
444        service_name: "gcal",
445        display_name: "Google Calendar",
446        auth_url: AUTH_URL.to_string(),
447        token_url: TOKEN_URL.to_string(),
448        client_id: client_id.to_string(),
449        client_secret: Some(client_secret.to_string()),
450        scopes: google_scopes,
451        extra_auth_params: vec![
452            // Google needs `access_type=offline` to issue a refresh
453            // token at all, and `prompt=consent` to re-issue one on
454            // any subsequent authorization (without it, a second run
455            // silently succeeds with only an access token).
456            ("access_type".into(), "offline".into()),
457            ("prompt".into(), "consent".into()),
458            // `include_granted_scopes=true` lets Google carry over
459            // previously granted scopes so a narrower second request
460            // doesn't drop capabilities already consented to.
461            ("include_granted_scopes".into(), "true".into()),
462        ],
463        timeout: LOOPBACK_TIMEOUT,
464        redirect_scheme: RedirectScheme::Http,
465    };
466    let tokens = run_loopback_flow(&cfg, open_browser).await?;
467    tokens.refresh_token.ok_or_else(|| ZadError::Service {
468        name: "gcal",
469        message: "Google did not return a refresh token. Check that the consent screen \
470                  granted access and that the OAuth client is type 'Desktop app'. \
471                  Re-run `zad service create gcal` to retry."
472            .into(),
473    })
474}
475
476/// Compute the minimal set of Google OAuth scopes to request, given
477/// the zad-level scopes the operator declared. We keep the consent
478/// screen as narrow as possible.
479///
480/// Note that the OpenID Connect `openid email` scopes are always
481/// requested so `userinfo` can populate `self_email` during validate.
482pub fn google_scopes_for(zad_scopes: &[String]) -> Vec<String> {
483    let mut out: Vec<String> = vec!["openid".into(), "email".into()];
484    let has = |s: &str| zad_scopes.iter().any(|z| z == s);
485
486    if has("events.write")
487        || has("events.invite")
488        || has("events.remind")
489        || (!has("calendars.read") && !has("events.read"))
490    {
491        // Any write — or no explicit scope at all — gets the rw events
492        // scope, which also admits reading events.
493        out.push("https://www.googleapis.com/auth/calendar.events".into());
494    } else if has("events.read") {
495        out.push("https://www.googleapis.com/auth/calendar.events.readonly".into());
496    }
497
498    if has("calendars.read") && !has("events.write") {
499        // calendarList endpoint needs the calendarlist scope; when
500        // `events.write` is set we already have the broader
501        // `calendar.events` scope, which covers listing the user's
502        // calendar list too.
503        out.push("https://www.googleapis.com/auth/calendar.calendarlist.readonly".into());
504    }
505
506    out.sort();
507    out.dedup();
508    out
509}