Skip to main content

omni_dev/gmail/
import.rs

1//! Imports a Google Cloud OAuth2 client id/secret from `client_secret.json`.
2//!
3//! Reads the file the Cloud Console hands out directly, so the secret never
4//! has to transit a shell, an env var, or an agent's context on its way into
5//! `~/.omni-dev/settings.json`.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, Context, Result};
11use serde::Deserialize;
12
13use crate::gmail::account::ResolvedAccount;
14use crate::gmail::auth::{self, GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET};
15use crate::utils::env::{EnvSource, SystemEnv};
16use crate::utils::secret::Secret;
17use crate::utils::settings::{active_profile_from, Settings};
18
19/// Environment variable naming an explicit `client_secret.json` path,
20/// consulted by [`discover_client_secret_file`] when no `PATH` argument is
21/// given.
22pub const GMAIL_CLIENT_SECRET_FILE: &str = "GMAIL_CLIENT_SECRET_FILE";
23
24/// A parsed, not-yet-persisted OAuth2 client id/secret.
25#[derive(Debug, Clone)]
26pub struct ImportedClientCredentials {
27    /// OAuth2 client id (not secret).
28    pub client_id: String,
29    /// OAuth2 client secret (redacted in `Debug` output).
30    pub client_secret: Secret,
31}
32
33/// The result of a successful import — enough for the CLI layer to report
34/// what happened without ever handling the secret itself.
35#[derive(Debug, Clone)]
36pub struct ImportOutcome {
37    /// The `client_secret.json` path that was used.
38    pub path: PathBuf,
39    /// The imported client id (safe to print).
40    pub client_id: String,
41}
42
43/// Discovers, parses, and saves a `client_secret.json`'s client id/secret to
44/// `~/.omni-dev/settings.json`.
45pub fn import_client_credentials(explicit: Option<&Path>) -> Result<ImportOutcome> {
46    import_client_credentials_to(
47        &Settings::get_settings_path()?,
48        active_profile_from(&SystemEnv).as_deref(),
49        &SystemEnv,
50        dirs::home_dir().as_deref(),
51        explicit,
52    )
53}
54
55/// [`import_client_credentials`], writing to an explicit settings-file path
56/// and reading from an injected [`EnvSource`]/home directory — the test seam
57/// so discovery and persistence are exercised without touching `HOME` or the
58/// process environment.
59pub(crate) fn import_client_credentials_to(
60    settings_path: &Path,
61    profile: Option<&str>,
62    env: &impl EnvSource,
63    home: Option<&Path>,
64    explicit: Option<&Path>,
65) -> Result<ImportOutcome> {
66    let path = discover_client_secret_file(env, home, explicit)?;
67    let credentials = parse_client_secret_file(&path)?;
68    save_client_credentials_to(settings_path, profile, &credentials)?;
69    Ok(ImportOutcome {
70        path,
71        client_id: credentials.client_id,
72    })
73}
74
75/// [`import_client_credentials`], but honoring the named-account resolution
76/// added by issue #1500.
77///
78/// `explicit_account` is the already-resolved `--account`/
79/// [`crate::gmail::account::GMAIL_ACCOUNT_ENV`] override, if any — resolved
80/// via `auth::resolve_for_write`, so an explicit name need not already be
81/// configured (this is how a new account is created); `explicit_path` is
82/// the `client_secret.json` path argument, resolved exactly as in
83/// [`import_client_credentials`].
84pub fn import_client_credentials_for(
85    explicit_account: Option<&str>,
86    explicit_path: Option<&Path>,
87) -> Result<ImportOutcome> {
88    let path = discover_client_secret_file(&SystemEnv, dirs::home_dir().as_deref(), explicit_path)?;
89    let credentials = parse_client_secret_file(&path)?;
90
91    let settings = Settings::load().unwrap_or_default();
92    match auth::resolve_for_write(&settings.gmail, explicit_account)? {
93        ResolvedAccount::Legacy => save_client_credentials_to(
94            &Settings::get_settings_path()?,
95            active_profile_from(&SystemEnv).as_deref(),
96            &credentials,
97        )?,
98        ResolvedAccount::Named(name) => Settings::upsert_gmail_account(
99            &Settings::get_settings_path()?,
100            &name,
101            &[
102                (
103                    "client_id",
104                    serde_json::Value::String(credentials.client_id.clone()),
105                ),
106                (
107                    "client_secret",
108                    serde_json::Value::String(
109                        credentials.client_secret.expose_secret().to_string(),
110                    ),
111                ),
112            ],
113        )?,
114    }
115
116    Ok(ImportOutcome {
117        path,
118        client_id: credentials.client_id,
119    })
120}
121
122/// Finds a `client_secret.json` by trying, in order: an explicit path, the
123/// `GMAIL_CLIENT_SECRET_FILE` environment variable, `~/.config/gws/client_secret.json`,
124/// and the most-recently-modified
125/// `~/Downloads/client_secret_*.apps.googleusercontent.com.json` (the Cloud
126/// Console's default download filename).
127///
128/// An explicit path or env var that names a file that doesn't exist is a
129/// hard error, not a silent fall-through — the user named it directly.
130pub(crate) fn discover_client_secret_file(
131    env: &impl EnvSource,
132    home: Option<&Path>,
133    explicit: Option<&Path>,
134) -> Result<PathBuf> {
135    if let Some(path) = explicit {
136        return if path.exists() {
137            Ok(path.to_path_buf())
138        } else {
139            Err(anyhow!("{} does not exist", path.display()))
140        };
141    }
142
143    if let Some(raw) = env.var(GMAIL_CLIENT_SECRET_FILE) {
144        let path = PathBuf::from(&raw);
145        return if path.exists() {
146            Ok(path)
147        } else {
148            Err(anyhow!(
149                "GMAIL_CLIENT_SECRET_FILE is set to {} but that file does not exist",
150                path.display()
151            ))
152        };
153    }
154
155    if let Some(home) = home {
156        let gws_path = home.join(".config").join("gws").join("client_secret.json");
157        if gws_path.exists() {
158            return Ok(gws_path);
159        }
160
161        if let Some(found) = find_downloaded_client_secret(&home.join("Downloads")) {
162            return Ok(found);
163        }
164    }
165
166    Err(anyhow!(
167        "No client_secret.json found. Tried $GMAIL_CLIENT_SECRET_FILE, \
168         ~/.config/gws/client_secret.json, and \
169         ~/Downloads/client_secret_*.apps.googleusercontent.com.json.\n\
170         Pass an explicit path instead: `omni-dev gmail auth import <PATH>` \
171         (see docs/gmail.md)."
172    ))
173}
174
175/// Scans `dir` for `client_secret_*.apps.googleusercontent.com.json` —
176/// Google Cloud Console's default download filename — and returns the
177/// most-recently-modified match, if any. No `glob` crate dependency exists
178/// in this repo, so this is a manual prefix/suffix filter over `read_dir`,
179/// matching the pattern already used elsewhere for filename-filtered
180/// directory scans (e.g. `crate::claude::context::discovery`).
181fn find_downloaded_client_secret(dir: &Path) -> Option<PathBuf> {
182    let entries = fs::read_dir(dir).ok()?;
183
184    entries
185        .flatten()
186        .filter(|entry| {
187            let name = entry.file_name();
188            let Some(name) = name.to_str() else {
189                return false;
190            };
191            name.starts_with("client_secret_") && name.ends_with(".apps.googleusercontent.com.json")
192        })
193        .filter_map(|entry| {
194            let modified = entry.metadata().ok()?.modified().ok()?;
195            Some((modified, entry.path()))
196        })
197        .max_by_key(|(modified, _)| *modified)
198        .map(|(_, path)| path)
199}
200
201/// The two client-type shapes Google's console can hand out. Deliberately
202/// keeps both optional at the top level so genuinely malformed JSON fails
203/// inside `serde_json::from_str` itself (a clear parse error), while
204/// well-formed-but-wrong-shaped JSON (neither key present) falls through to
205/// an explicit, actionable match in [`parse_client_secret_file`].
206#[derive(Debug, Deserialize)]
207struct ClientSecretFile {
208    #[serde(default)]
209    installed: Option<ClientSecretEntry>,
210    #[serde(default)]
211    web: Option<ClientSecretEntry>,
212}
213
214#[derive(Debug, Deserialize)]
215struct ClientSecretEntry {
216    client_id: String,
217    client_secret: String,
218}
219
220/// Parses a `client_secret.json` file, rejecting a "Web application" client
221/// (`web`) since Gmail login's loopback redirect requires a "Desktop app"
222/// client (`installed`) — see [ADR-0063](../../docs/adrs/adr-0063.md).
223pub(crate) fn parse_client_secret_file(path: &Path) -> Result<ImportedClientCredentials> {
224    let content =
225        fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
226    let parsed: ClientSecretFile = serde_json::from_str(&content)
227        .with_context(|| format!("{} is not valid JSON", path.display()))?;
228
229    match (parsed.installed, parsed.web) {
230        (Some(entry), _) => Ok(ImportedClientCredentials {
231            client_id: entry.client_id,
232            client_secret: Secret::new(entry.client_secret),
233        }),
234        (None, Some(_)) => Err(anyhow!(
235            "{} is a \"Web application\" OAuth client, but Gmail login needs a \"Desktop app\" \
236             client — a Web application client can't do the loopback redirect Gmail login uses \
237             (its redirect URIs must be pre-registered, port included). Create a Desktop app \
238             client in Google Cloud Console instead (see docs/gmail.md).",
239            path.display()
240        )),
241        (None, None) => Err(anyhow!(
242            "{} does not look like a Google OAuth client_secret.json (missing top-level \
243             \"installed\" or \"web\" key).",
244            path.display()
245        )),
246    }
247}
248
249/// Saves the imported client id/secret to `~/.omni-dev/settings.json` —
250/// only the two pre-login keys; `GMAIL_REFRESH_TOKEN`/`GMAIL_SCOPE` are
251/// written later by a successful `auth login`.
252fn save_client_credentials_to(
253    settings_path: &Path,
254    profile: Option<&str>,
255    credentials: &ImportedClientCredentials,
256) -> Result<()> {
257    Settings::upsert_env_vars_in(
258        settings_path,
259        profile,
260        &[
261            (GMAIL_CLIENT_ID, credentials.client_id.as_str()),
262            (
263                GMAIL_CLIENT_SECRET,
264                credentials.client_secret.expose_secret(),
265            ),
266        ],
267    )
268}
269
270#[cfg(test)]
271#[allow(clippy::unwrap_used, clippy::expect_used)]
272mod tests {
273    use super::*;
274    use crate::test_support::env::MapEnv;
275
276    fn temp_dir() -> tempfile::TempDir {
277        std::fs::create_dir_all("tmp").ok();
278        tempfile::TempDir::new_in("tmp").unwrap()
279    }
280
281    fn write_installed_json(path: &Path, client_id: &str, client_secret: &str) {
282        std::fs::write(
283            path,
284            serde_json::json!({
285                "installed": {
286                    "client_id": client_id,
287                    "client_secret": client_secret,
288                    "project_id": "test-project",
289                    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
290                    "token_uri": "https://oauth2.googleapis.com/token",
291                    "redirect_uris": ["http://localhost"],
292                }
293            })
294            .to_string(),
295        )
296        .unwrap();
297    }
298
299    fn write_web_json(path: &Path) {
300        std::fs::write(
301            path,
302            serde_json::json!({
303                "web": {
304                    "client_id": "web-id",
305                    "client_secret": "web-secret",
306                }
307            })
308            .to_string(),
309        )
310        .unwrap();
311    }
312
313    // ── parse_client_secret_file ─────────────────────────────────────
314
315    #[test]
316    fn parse_accepts_installed_client() {
317        let dir = temp_dir();
318        let path = dir.path().join("client_secret.json");
319        write_installed_json(&path, "the-id", "the-secret");
320
321        let creds = parse_client_secret_file(&path).unwrap();
322        assert_eq!(creds.client_id, "the-id");
323        assert_eq!(creds.client_secret.expose_secret(), "the-secret");
324    }
325
326    #[test]
327    fn parse_rejects_web_client_naming_desktop_app_requirement() {
328        let dir = temp_dir();
329        let path = dir.path().join("client_secret.json");
330        write_web_json(&path);
331
332        let err = parse_client_secret_file(&path).unwrap_err();
333        let msg = err.to_string();
334        assert!(msg.contains("Web application"));
335        assert!(msg.contains("Desktop app"));
336    }
337
338    #[test]
339    fn parse_rejects_malformed_json() {
340        let dir = temp_dir();
341        let path = dir.path().join("client_secret.json");
342        std::fs::write(&path, "{ not json").unwrap();
343
344        assert!(parse_client_secret_file(&path).is_err());
345    }
346
347    #[test]
348    fn parse_rejects_valid_json_with_neither_key() {
349        let dir = temp_dir();
350        let path = dir.path().join("client_secret.json");
351        std::fs::write(&path, serde_json::json!({"foo": "bar"}).to_string()).unwrap();
352
353        let err = parse_client_secret_file(&path).unwrap_err();
354        assert!(err.to_string().contains("installed"));
355    }
356
357    #[test]
358    fn parse_rejects_absent_file() {
359        let dir = temp_dir();
360        let path = dir.path().join("does-not-exist.json");
361        assert!(parse_client_secret_file(&path).is_err());
362    }
363
364    #[test]
365    fn imported_client_credentials_debug_redacts_client_secret() {
366        let creds = ImportedClientCredentials {
367            client_id: "the-id".to_string(),
368            client_secret: Secret::new("super-secret-value"),
369        };
370        let debug = format!("{creds:?}");
371        assert!(debug.contains("the-id"));
372        assert!(!debug.contains("super-secret-value"));
373    }
374
375    // ── discover_client_secret_file ──────────────────────────────────
376
377    #[test]
378    fn discover_prefers_explicit_path_over_everything() {
379        let dir = temp_dir();
380        let explicit_path = dir.path().join("explicit.json");
381        write_installed_json(&explicit_path, "id", "secret");
382        let env = MapEnv::new().with(GMAIL_CLIENT_SECRET_FILE, "/should/not/be/used.json");
383
384        let found = discover_client_secret_file(&env, None, Some(&explicit_path)).unwrap();
385        assert_eq!(found, explicit_path);
386    }
387
388    #[test]
389    fn discover_errors_when_explicit_path_does_not_exist() {
390        let env = MapEnv::new();
391        let missing = PathBuf::from("/definitely/does/not/exist.json");
392        let err = discover_client_secret_file(&env, None, Some(&missing)).unwrap_err();
393        assert!(err.to_string().contains("does not exist"));
394    }
395
396    #[test]
397    fn discover_uses_env_var_when_no_explicit_path() {
398        let dir = temp_dir();
399        let env_path = dir.path().join("from-env.json");
400        write_installed_json(&env_path, "id", "secret");
401        let env = MapEnv::new().with(GMAIL_CLIENT_SECRET_FILE, env_path.to_str().unwrap());
402
403        let found = discover_client_secret_file(&env, None, None).unwrap();
404        assert_eq!(found, env_path);
405    }
406
407    #[test]
408    fn discover_errors_when_env_var_path_does_not_exist() {
409        let env = MapEnv::new().with(GMAIL_CLIENT_SECRET_FILE, "/definitely/does/not/exist.json");
410        let err = discover_client_secret_file(&env, None, None).unwrap_err();
411        assert!(err.to_string().contains("GMAIL_CLIENT_SECRET_FILE"));
412    }
413
414    #[test]
415    fn discover_falls_back_to_gws_path() {
416        let dir = temp_dir();
417        let gws_dir = dir.path().join(".config").join("gws");
418        std::fs::create_dir_all(&gws_dir).unwrap();
419        let gws_path = gws_dir.join("client_secret.json");
420        write_installed_json(&gws_path, "id", "secret");
421        let env = MapEnv::new();
422
423        let found = discover_client_secret_file(&env, Some(dir.path()), None).unwrap();
424        assert_eq!(found, gws_path);
425    }
426
427    #[test]
428    fn discover_falls_back_to_downloads_glob() {
429        let dir = temp_dir();
430        let downloads = dir.path().join("Downloads");
431        std::fs::create_dir_all(&downloads).unwrap();
432        let dl_path = downloads.join("client_secret_123.apps.googleusercontent.com.json");
433        write_installed_json(&dl_path, "id", "secret");
434        let env = MapEnv::new();
435
436        let found = discover_client_secret_file(&env, Some(dir.path()), None).unwrap();
437        assert_eq!(found, dl_path);
438    }
439
440    #[test]
441    fn discover_downloads_glob_picks_most_recently_modified_on_multiple_matches() {
442        let dir = temp_dir();
443        let downloads = dir.path().join("Downloads");
444        std::fs::create_dir_all(&downloads).unwrap();
445
446        let older = downloads.join("client_secret_1.apps.googleusercontent.com.json");
447        write_installed_json(&older, "old-id", "old-secret");
448        let newer = downloads.join("client_secret_2.apps.googleusercontent.com.json");
449        write_installed_json(&newer, "new-id", "new-secret");
450
451        // Ensure a real mtime gap regardless of filesystem timestamp
452        // resolution.
453        let now = std::time::SystemTime::now();
454        std::fs::File::open(&older)
455            .unwrap()
456            .set_modified(now - std::time::Duration::from_secs(60))
457            .unwrap();
458        std::fs::File::open(&newer)
459            .unwrap()
460            .set_modified(now)
461            .unwrap();
462
463        let env = MapEnv::new();
464        let found = discover_client_secret_file(&env, Some(dir.path()), None).unwrap();
465        assert_eq!(found, newer);
466    }
467
468    #[test]
469    fn discover_errors_naming_all_tried_locations_when_nothing_found() {
470        let dir = temp_dir();
471        let env = MapEnv::new();
472        let err = discover_client_secret_file(&env, Some(dir.path()), None).unwrap_err();
473        let msg = err.to_string();
474        assert!(msg.contains("GMAIL_CLIENT_SECRET_FILE"));
475        assert!(msg.contains("gws"));
476        assert!(msg.contains("Downloads"));
477    }
478
479    // ── import_client_credentials_to ─────────────────────────────────
480
481    #[test]
482    fn import_writes_only_client_id_and_secret() {
483        let dir = temp_dir();
484        let source_path = dir.path().join("client_secret.json");
485        write_installed_json(&source_path, "imported-id", "imported-secret");
486        let settings_path = dir.path().join("settings.json");
487        let env = MapEnv::new();
488
489        let outcome =
490            import_client_credentials_to(&settings_path, None, &env, None, Some(&source_path))
491                .unwrap();
492
493        assert_eq!(outcome.client_id, "imported-id");
494        assert_eq!(outcome.path, source_path);
495
496        let saved = std::fs::read_to_string(&settings_path).unwrap();
497        assert!(saved.contains("imported-id"));
498        assert!(saved.contains("imported-secret"));
499        assert!(!saved.contains("GMAIL_REFRESH_TOKEN"));
500        assert!(!saved.contains("GMAIL_SCOPE"));
501    }
502
503    #[test]
504    fn import_rejects_web_client_and_leaves_settings_untouched() {
505        let dir = temp_dir();
506        let source_path = dir.path().join("client_secret.json");
507        write_web_json(&source_path);
508        let settings_path = dir.path().join("settings.json");
509        let env = MapEnv::new();
510
511        let err =
512            import_client_credentials_to(&settings_path, None, &env, None, Some(&source_path))
513                .unwrap_err();
514
515        assert!(err.to_string().contains("Desktop app"));
516        assert!(!settings_path.exists());
517    }
518
519    #[test]
520    fn import_targets_the_active_profile_env_map() {
521        let dir = temp_dir();
522        let source_path = dir.path().join("client_secret.json");
523        write_installed_json(&source_path, "profile-id", "profile-secret");
524        let settings_path = dir.path().join("settings.json");
525        let env = MapEnv::new();
526
527        import_client_credentials_to(&settings_path, Some("work"), &env, None, Some(&source_path))
528            .unwrap();
529
530        let saved = std::fs::read_to_string(&settings_path).unwrap();
531        let value: serde_json::Value = serde_json::from_str(&saved).unwrap();
532        assert_eq!(
533            value["profiles"]["work"]["env"]["GMAIL_CLIENT_ID"],
534            "profile-id"
535        );
536    }
537
538    // ── import_client_credentials_for (issue #1500) ─────────────────────
539    //
540    // These exercise the production wrapper, which resolves the settings
541    // path from `HOME` (like `import_client_credentials` itself), so they
542    // redirect it via `EnvGuard`. Passing an explicit `client_secret.json`
543    // path bypasses discovery entirely, so no env/home mocking is needed
544    // for that half.
545
546    #[test]
547    fn import_for_named_account_writes_gmail_accounts_not_env() {
548        let guard = crate::gmail::test_support::EnvGuard::take();
549        let dir = guard.clear_credentials();
550        let settings_path = dir.path().join(".omni-dev").join("settings.json");
551        Settings::upsert_gmail_account(
552            &settings_path,
553            "work",
554            &[(
555                "client_id",
556                serde_json::Value::String("placeholder".to_string()),
557            )],
558        )
559        .unwrap();
560
561        let secret_dir = temp_dir();
562        let secret_path = secret_dir.path().join("client_secret.json");
563        write_installed_json(&secret_path, "the-id", "the-secret");
564
565        let outcome = import_client_credentials_for(Some("work"), Some(&secret_path)).unwrap();
566        assert_eq!(outcome.client_id, "the-id");
567
568        let val: serde_json::Value =
569            serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
570        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "the-id");
571        assert_eq!(
572            val["gmail"]["accounts"]["work"]["client_secret"],
573            "the-secret"
574        );
575        assert!(val.get("env").is_none());
576    }
577
578    #[test]
579    fn import_for_creates_brand_new_named_account_without_prior_validation() {
580        let guard = crate::gmail::test_support::EnvGuard::take();
581        let dir = guard.clear_credentials();
582        let settings_path = dir.path().join(".omni-dev").join("settings.json");
583        // No account named "fresh" exists yet — import must still succeed,
584        // since import is how a new named account is created.
585
586        let secret_dir = temp_dir();
587        let secret_path = secret_dir.path().join("client_secret.json");
588        write_installed_json(&secret_path, "the-id", "the-secret");
589
590        import_client_credentials_for(Some("fresh"), Some(&secret_path)).unwrap();
591
592        let val: serde_json::Value =
593            serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
594        assert_eq!(val["gmail"]["accounts"]["fresh"]["client_id"], "the-id");
595    }
596
597    #[test]
598    fn import_for_legacy_when_no_account_given_and_none_configured() {
599        let guard = crate::gmail::test_support::EnvGuard::take();
600        let dir = guard.clear_credentials();
601        let settings_path = dir.path().join(".omni-dev").join("settings.json");
602
603        let secret_dir = temp_dir();
604        let secret_path = secret_dir.path().join("client_secret.json");
605        write_installed_json(&secret_path, "the-id", "the-secret");
606
607        import_client_credentials_for(None, Some(&secret_path)).unwrap();
608
609        let val: serde_json::Value =
610            serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap();
611        assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "the-id");
612        assert!(val.get("gmail").is_none());
613    }
614}