1use 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
19pub const GMAIL_CLIENT_SECRET_FILE: &str = "GMAIL_CLIENT_SECRET_FILE";
23
24#[derive(Debug, Clone)]
26pub struct ImportedClientCredentials {
27 pub client_id: String,
29 pub client_secret: Secret,
31}
32
33#[derive(Debug, Clone)]
36pub struct ImportOutcome {
37 pub path: PathBuf,
39 pub client_id: String,
41}
42
43pub 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
55pub(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
75pub 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
122pub(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
175fn 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#[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
220pub(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
249fn 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 #[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 #[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 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 #[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 #[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 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}