1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
11use std::path::Path;
12use std::process::{Command, Stdio};
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use base64::Engine as _;
17use chrono::{DateTime, TimeDelta, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::TcpListener;
22use url::Url;
23
24use crate::gmail::account::{self, ResolvedAccount};
25use crate::gmail::chrome_profile;
26use crate::gmail::error::{GmailError, GrantContext};
27use crate::request_log;
28use crate::utils::browser_command::split_browser_command;
29use crate::utils::env::SystemEnv;
30use crate::utils::secret::Secret;
31use crate::utils::settings::{active_profile_from, GmailAccountSettings, GmailSettings, Settings};
32
33pub const GMAIL_CLIENT_ID: &str = "GMAIL_CLIENT_ID";
36pub const GMAIL_CLIENT_SECRET: &str = "GMAIL_CLIENT_SECRET";
39pub const GMAIL_REFRESH_TOKEN: &str = "GMAIL_REFRESH_TOKEN";
41pub const GMAIL_SCOPE: &str = "GMAIL_SCOPE";
43pub const GMAIL_API_URL: &str = "GMAIL_API_URL";
55
56const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
58const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
60pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/gmail.readonly";
62pub const SCOPE_MODIFY: &str = "https://www.googleapis.com/auth/gmail.modify";
64
65const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
67const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
70const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum GmailScope {
80 #[default]
83 ReadOnly,
84 Modify,
87}
88
89impl GmailScope {
90 #[must_use]
92 pub fn as_str(self) -> &'static str {
93 match self {
94 Self::ReadOnly => SCOPE_READONLY,
95 Self::Modify => SCOPE_MODIFY,
96 }
97 }
98
99 #[must_use]
108 pub fn from_granted(granted: &str) -> Option<Self> {
109 let tokens: Vec<&str> = granted.split_whitespace().collect();
110 if tokens.contains(&SCOPE_MODIFY) {
111 Some(Self::Modify)
112 } else if tokens.contains(&SCOPE_READONLY) {
113 Some(Self::ReadOnly)
114 } else {
115 None
116 }
117 }
118
119 #[must_use]
121 pub fn allows_modify(self) -> bool {
122 matches!(self, Self::Modify)
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct GmailCredentials {
129 pub client_id: String,
132 pub client_secret: Secret,
134 pub refresh_token: Secret,
136 pub scope: GmailScope,
138}
139
140#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
142pub struct GmailAuthStatus {
143 pub has_client_id: bool,
145 pub has_client_secret: bool,
147 pub has_refresh_token: bool,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub scope: Option<String>,
152}
153
154pub(crate) fn resolve(gmail: &GmailSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
160 let explicit = fold_explicit(explicit);
161 account::resolve_account(&SystemEnv, gmail, explicit.as_deref())
162}
163
164pub(crate) fn resolve_for_write(
169 gmail: &GmailSettings,
170 explicit: Option<&str>,
171) -> Result<ResolvedAccount> {
172 let explicit = fold_explicit(explicit);
173 account::resolve_account_for_write(&SystemEnv, gmail, explicit.as_deref())
174}
175
176fn fold_explicit(explicit: Option<&str>) -> Option<String> {
180 explicit
181 .map(str::to_string)
182 .or_else(|| account::active_gmail_account_from(&SystemEnv))
183}
184
185pub(crate) fn resolve_browser_config_for(
193 gmail: &GmailSettings,
194 explicit: Option<&str>,
195) -> Result<BrowserConfig> {
196 match resolve_for_write(gmail, explicit)? {
197 ResolvedAccount::Legacy => Ok(BrowserConfig::default()),
198 ResolvedAccount::Named(name) => build_browser_config(
199 gmail.accounts.get(&name),
200 chrome_profile::resolve_launch_command,
201 ),
202 }
203}
204
205fn build_browser_config(
218 account: Option<&GmailAccountSettings>,
219 resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
220) -> Result<BrowserConfig> {
221 let Some(account) = account else {
222 return Ok(BrowserConfig::default());
223 };
224
225 if let Some(command) = account
226 .browser_command
227 .as_deref()
228 .map(str::trim)
229 .filter(|command| !command.is_empty())
230 {
231 return Ok(BrowserConfig {
232 launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
233 ..BrowserConfig::default()
234 });
235 }
236
237 if account.chrome_profile_from_email {
238 if let Some(email) = account.email_address.as_deref() {
239 if let Some(args) = resolve_chrome_profile(email) {
240 return Ok(BrowserConfig {
241 launch: BrowserLaunch::Command(args),
242 ..BrowserConfig::default()
243 });
244 }
245 } else {
246 tracing::info!(
247 "chrome_profile_from_email is set but email_address is not; \
248 falling back to the default browser"
249 );
250 }
251 }
252
253 Ok(BrowserConfig::default())
254}
255
256pub fn load_credentials() -> Result<GmailCredentials> {
260 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
261}
262
263pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<GmailCredentials> {
270 let settings = Settings::load().unwrap_or_default();
271 match resolve(&settings.gmail, explicit)? {
272 ResolvedAccount::Legacy => {
273 let profile = active_profile_from(&SystemEnv);
274 load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
275 settings,
276 profile.as_deref(),
277 ))
278 }
279 ResolvedAccount::Named(name) => load_named_credentials(&settings.gmail, &name),
280 }
281}
282
283fn load_named_credentials(gmail: &GmailSettings, name: &str) -> Result<GmailCredentials> {
287 let account = gmail
288 .accounts
289 .get(name)
290 .ok_or(GmailError::CredentialsNotFound)?;
291 let client_id = account
292 .client_id
293 .clone()
294 .ok_or(GmailError::CredentialsNotFound)?;
295 let client_secret = account
296 .client_secret
297 .clone()
298 .ok_or(GmailError::CredentialsNotFound)?;
299 let refresh_token = account
300 .refresh_token
301 .clone()
302 .ok_or(GmailError::CredentialsNotFound)?;
303 let scope = account
304 .scope
305 .as_deref()
306 .and_then(GmailScope::from_granted)
307 .unwrap_or_default();
308
309 Ok(GmailCredentials {
310 client_id,
311 client_secret: client_secret.into(),
312 refresh_token: refresh_token.into(),
313 scope,
314 })
315}
316
317pub(crate) fn load_credentials_legacy() -> Result<GmailCredentials> {
322 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
323}
324
325pub(crate) fn load_credentials_with(
331 env: &impl crate::utils::env::EnvSource,
332) -> Result<GmailCredentials> {
333 let client_id = env
334 .var(GMAIL_CLIENT_ID)
335 .ok_or(GmailError::CredentialsNotFound)?;
336 let client_secret = env
337 .var(GMAIL_CLIENT_SECRET)
338 .ok_or(GmailError::CredentialsNotFound)?;
339 let refresh_token = env
340 .var(GMAIL_REFRESH_TOKEN)
341 .ok_or(GmailError::CredentialsNotFound)?;
342 let scope = env
348 .var(GMAIL_SCOPE)
349 .and_then(|s| GmailScope::from_granted(&s))
350 .unwrap_or_default();
351
352 Ok(GmailCredentials {
353 client_id,
354 client_secret: client_secret.into(),
355 refresh_token: refresh_token.into(),
356 scope,
357 })
358}
359
360pub fn status() -> GmailAuthStatus {
365 status_with(&crate::utils::settings::SettingsEnv::load())
366}
367
368pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> GmailAuthStatus {
370 GmailAuthStatus {
371 has_client_id: env.var(GMAIL_CLIENT_ID).is_some(),
372 has_client_secret: env.var(GMAIL_CLIENT_SECRET).is_some(),
373 has_refresh_token: env.var(GMAIL_REFRESH_TOKEN).is_some(),
374 scope: env.var(GMAIL_SCOPE),
375 }
376}
377
378#[cfg(feature = "mcp")]
389pub(crate) fn status_for(explicit: Option<&str>) -> Result<GmailAuthStatus> {
390 let settings = Settings::load().unwrap_or_default();
391 match resolve(&settings.gmail, explicit)? {
392 ResolvedAccount::Legacy => {
393 let profile = active_profile_from(&SystemEnv);
394 Ok(status_with(
395 &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
396 ))
397 }
398 ResolvedAccount::Named(name) => Ok(status_from_named(&settings.gmail, &name)),
399 }
400}
401
402#[cfg(feature = "mcp")]
408fn status_from_named(gmail: &GmailSettings, name: &str) -> GmailAuthStatus {
409 let account = gmail.accounts.get(name);
410 GmailAuthStatus {
411 has_client_id: account.is_some_and(|a| a.client_id.is_some()),
412 has_client_secret: account.is_some_and(|a| a.client_secret.is_some()),
413 has_refresh_token: account.is_some_and(|a| a.refresh_token.is_some()),
414 scope: account.and_then(|a| a.scope.clone()),
415 }
416}
417
418pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
424 let settings = Settings::load().unwrap_or_default();
425 if settings
426 .gmail
427 .accounts
428 .get(name)
429 .is_some_and(|account| account.email_address.is_some())
430 {
431 return Ok(());
432 }
433 Settings::upsert_gmail_account(
434 &Settings::get_settings_path()?,
435 name,
436 &[(
437 "email_address",
438 serde_json::Value::String(email.to_string()),
439 )],
440 )
441}
442
443pub fn save_credentials(credentials: &GmailCredentials) -> Result<()> {
448 save_credentials_to(
449 &Settings::get_settings_path()?,
450 active_profile_from(&SystemEnv).as_deref(),
451 credentials,
452 )
453}
454
455pub(crate) fn save_credentials_to(
458 settings_path: &Path,
459 profile: Option<&str>,
460 credentials: &GmailCredentials,
461) -> Result<()> {
462 Settings::upsert_env_vars_in(
463 settings_path,
464 profile,
465 &[
466 (GMAIL_CLIENT_ID, credentials.client_id.as_str()),
467 (
468 GMAIL_CLIENT_SECRET,
469 credentials.client_secret.expose_secret(),
470 ),
471 (
472 GMAIL_REFRESH_TOKEN,
473 credentials.refresh_token.expose_secret(),
474 ),
475 (GMAIL_SCOPE, credentials.scope.as_str()),
476 ],
477 )
478}
479
480fn named_account_vars(credentials: &GmailCredentials) -> [(&str, serde_json::Value); 4] {
484 [
485 (
486 "client_id",
487 serde_json::Value::String(credentials.client_id.clone()),
488 ),
489 (
490 "client_secret",
491 serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
492 ),
493 (
494 "refresh_token",
495 serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
496 ),
497 (
498 "scope",
499 serde_json::Value::String(credentials.scope.as_str().to_string()),
500 ),
501 ]
502}
503
504pub fn remove_credentials() -> Result<bool> {
510 remove_credentials_at(
511 &Settings::get_settings_path()?,
512 active_profile_from(&SystemEnv).as_deref(),
513 )
514}
515
516pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
519 Settings::remove_env_vars_in(
520 settings_path,
521 profile,
522 &[
523 GMAIL_CLIENT_ID,
524 GMAIL_CLIENT_SECRET,
525 GMAIL_REFRESH_TOKEN,
526 GMAIL_SCOPE,
527 ],
528 )
529}
530
531pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
536 let settings = Settings::load().unwrap_or_default();
537 match resolve(&settings.gmail, explicit)? {
538 ResolvedAccount::Legacy => remove_credentials_at(
539 &Settings::get_settings_path()?,
540 active_profile_from(&SystemEnv).as_deref(),
541 ),
542 ResolvedAccount::Named(name) => {
543 Settings::remove_gmail_account(&Settings::get_settings_path()?, &name)
544 }
545 }
546}
547
548#[derive(Clone, Debug, Default)]
557pub enum BrowserLaunch {
558 #[default]
560 Auto,
561 Command(Vec<String>),
565 Manual,
568}
569
570#[derive(Clone, Debug)]
572pub struct BrowserConfig {
573 pub launch: BrowserLaunch,
575 pub callback_addr: IpAddr,
577 pub callback_port: u16,
579}
580
581impl Default for BrowserConfig {
582 fn default() -> Self {
583 Self {
584 launch: BrowserLaunch::Auto,
585 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
586 callback_port: 0,
587 }
588 }
589}
590
591#[allow(clippy::literal_string_with_formatting_args)]
594fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
595 match launch {
596 BrowserLaunch::Manual => {
597 tracing::info!("Open this URL in a browser to sign in to Gmail:\n{url}");
598 Ok(())
599 }
600 BrowserLaunch::Command(args) => {
601 let mut parts = args.iter();
602 let program = parts
603 .next()
604 .ok_or_else(|| GmailError::InvalidBrowserCommand("empty browser command".into()))?;
605 let mut command = Command::new(program);
606 let mut placed = false;
607 for arg in parts {
608 if arg.contains("{url}") {
609 command.arg(arg.replace("{url}", url));
610 placed = true;
611 } else {
612 command.arg(arg);
613 }
614 }
615 if !placed {
616 command.arg(url);
617 }
618 spawn_detached(command)
619 }
620 BrowserLaunch::Auto => {
621 let program = if cfg!(target_os = "macos") {
622 "open"
623 } else if cfg!(target_os = "windows") {
624 "explorer"
625 } else {
626 "xdg-open"
627 };
628 let mut command = Command::new(program);
629 command.arg(url);
630 spawn_detached(command)
631 }
632 }
633}
634
635fn spawn_detached(mut command: Command) -> Result<()> {
637 command
638 .stdin(Stdio::null())
639 .stdout(Stdio::null())
640 .stderr(Stdio::null())
641 .spawn()
642 .map(|_| ())
643 .context("Failed to launch the browser")
644}
645
646struct PendingLogin {
651 state: String,
652 code_verifier: String,
653}
654
655fn generate_pending_login() -> PendingLogin {
656 PendingLogin {
657 state: crate::browser::auth::generate_token(),
658 code_verifier: crate::browser::auth::generate_token(),
659 }
660}
661
662fn code_challenge(code_verifier: &str) -> String {
665 let digest = Sha256::digest(code_verifier.as_bytes());
666 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
667}
668
669fn build_authorization_url(
670 client_id: &str,
671 redirect_uri: &str,
672 scope: GmailScope,
673 state: &str,
674 code_challenge: &str,
675) -> Result<Url> {
676 let mut url =
677 Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Gmail authorization endpoint")?;
678 url.query_pairs_mut()
679 .append_pair("client_id", client_id)
680 .append_pair("redirect_uri", redirect_uri)
681 .append_pair("response_type", "code")
682 .append_pair("scope", scope.as_str())
683 .append_pair("state", state)
684 .append_pair("code_challenge", code_challenge)
685 .append_pair("code_challenge_method", "S256")
686 .append_pair("access_type", "offline")
687 .append_pair("prompt", "consent");
691 Ok(url)
692}
693
694#[derive(Debug)]
699pub(crate) struct CallbackResult {
700 code: Option<String>,
701 state: Option<String>,
702 error: Option<String>,
703 error_description: Option<String>,
704}
705
706pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
710 let listener = TcpListener::bind(SocketAddr::new(
711 browser.callback_addr,
712 browser.callback_port,
713 ))
714 .await
715 .context("Failed to start the local OAuth callback listener")?;
716 let port = listener
717 .local_addr()
718 .context("Failed to read the callback listener's port")?
719 .port();
720 Ok((listener, port))
721}
722
723pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
726 wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
727}
728
729pub(crate) async fn wait_for_callback_with_timeout(
736 listener: TcpListener,
737 timeout: Duration,
738) -> Result<CallbackResult> {
739 let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
740 .await
741 .map_err(|_| GmailError::CallbackTimeout(timeout.as_secs()))?
742 .context("Failed to accept the browser's callback connection")?;
743
744 let mut buf = vec![0u8; 8192];
745 let n = stream
746 .read(&mut buf)
747 .await
748 .context("Failed to read the callback request")?;
749 let request = String::from_utf8_lossy(&buf[..n]);
750
751 let result = parse_callback(&request).ok_or(GmailError::MalformedCallback)?;
752 tracing::info!("Gmail OAuth callback received");
753
754 let body = if result.error.is_some() {
755 "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
756 } else {
757 "<html><body>Gmail sign-in complete. You can close this tab.</body></html>"
758 };
759 let response =
760 format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
761 let _ = stream.write_all(response.as_bytes()).await;
762 let _ = stream.flush().await;
763
764 Ok(result)
765}
766
767fn parse_callback(request: &str) -> Option<CallbackResult> {
770 let first_line = request.lines().next()?;
771 let path = first_line.split_whitespace().nth(1)?; let query = path.split_once('?')?.1;
773
774 let mut result = CallbackResult {
775 code: None,
776 state: None,
777 error: None,
778 error_description: None,
779 };
780 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
781 match key.as_ref() {
782 "code" => result.code = Some(value.into_owned()),
783 "state" => result.state = Some(value.into_owned()),
784 "error" => result.error = Some(value.into_owned()),
785 "error_description" => result.error_description = Some(value.into_owned()),
786 _ => {}
787 }
788 }
789 Some(result)
790}
791
792#[derive(Debug, Deserialize)]
795struct TokenResponse {
796 access_token: String,
797 #[serde(default)]
798 refresh_token: Option<String>,
799 expires_in: i64,
800 #[serde(default)]
801 scope: Option<String>,
802}
803
804#[derive(Debug, Deserialize)]
805struct TokenErrorResponse {
806 error: String,
807 #[serde(default)]
808 error_description: Option<String>,
809}
810
811async fn exchange_code_for_tokens(
812 http: &reqwest::Client,
813 token_endpoint: &str,
814 client_id: &str,
815 client_secret: &str,
816 code: &str,
817 code_verifier: &str,
818 redirect_uri: &str,
819) -> Result<TokenResponse> {
820 let params = [
821 ("grant_type", "authorization_code"),
822 ("code", code),
823 ("client_id", client_id),
824 ("client_secret", client_secret),
825 ("redirect_uri", redirect_uri),
826 ("code_verifier", code_verifier),
827 ];
828 post_token_request(http, token_endpoint, ¶ms, GrantContext::CodeExchange).await
829}
830
831async fn refresh_access_token(
832 http: &reqwest::Client,
833 token_endpoint: &str,
834 client_id: &str,
835 client_secret: &str,
836 refresh_token: &str,
837) -> Result<TokenResponse> {
838 let params = [
839 ("grant_type", "refresh_token"),
840 ("refresh_token", refresh_token),
841 ("client_id", client_id),
842 ("client_secret", client_secret),
843 ];
844 post_token_request(http, token_endpoint, ¶ms, GrantContext::Refresh).await
845}
846
847async fn post_token_request(
851 http: &reqwest::Client,
852 token_endpoint: &str,
853 params: &[(&str, &str)],
854 context: GrantContext,
855) -> Result<TokenResponse> {
856 let started = std::time::Instant::now();
857 let result = http.post(token_endpoint).form(params).send().await;
858 request_log::record_http_result("gmail", "POST", token_endpoint, started, &result);
859 let response = result.context("Failed to send token request to Google")?;
860
861 if !response.status().is_success() {
862 let body = response.text().await.unwrap_or_default();
863 if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
864 if err.error == "invalid_grant" {
865 return Err(GmailError::InvalidGrant(context).into());
866 }
867 return Err(anyhow::anyhow!(
868 "Google token endpoint rejected the request: {} ({})",
869 err.error,
870 err.error_description.unwrap_or_default()
871 ));
872 }
873 return Err(anyhow::anyhow!(
874 "Google token endpoint returned an unparsable error body: {body}"
875 ));
876 }
877
878 response
879 .json::<TokenResponse>()
880 .await
881 .context("Failed to parse Google's token response")
882}
883
884struct TokenState {
888 access_token: Secret,
889 expires_at: DateTime<Utc>,
890}
891
892pub struct GmailSession {
905 http: reqwest::Client,
906 client_id: String,
907 client_secret: Secret,
908 refresh_token: Secret,
909 token_endpoint: String,
910 state: tokio::sync::Mutex<TokenState>,
911}
912
913impl GmailSession {
914 pub(crate) fn new(http: reqwest::Client, credentials: &GmailCredentials) -> Self {
916 Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
917 }
918
919 pub(crate) fn new_with_token_endpoint(
922 http: reqwest::Client,
923 credentials: &GmailCredentials,
924 token_endpoint: &str,
925 ) -> Self {
926 Self {
927 http,
928 client_id: credentials.client_id.clone(),
929 client_secret: credentials.client_secret.clone(),
930 refresh_token: credentials.refresh_token.clone(),
931 token_endpoint: token_endpoint.to_string(),
932 state: tokio::sync::Mutex::new(TokenState {
933 access_token: Secret::new(""),
934 expires_at: DateTime::<Utc>::MIN_UTC,
938 }),
939 }
940 }
941
942 pub(crate) async fn access_token(&self) -> Result<Secret> {
945 let mut state = self.state.lock().await;
946 if Utc::now() + REFRESH_SKEW >= state.expires_at {
947 self.refresh_locked(&mut state).await?;
948 }
949 Ok(state.access_token.clone())
950 }
951
952 pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
957 let mut state = self.state.lock().await;
958 if state.access_token != *observed {
959 return Ok(state.access_token.clone());
960 }
961 self.refresh_locked(&mut state).await?;
962 Ok(state.access_token.clone())
963 }
964
965 async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
966 let response = refresh_access_token(
967 &self.http,
968 &self.token_endpoint,
969 &self.client_id,
970 self.client_secret.expose_secret(),
971 self.refresh_token.expose_secret(),
972 )
973 .await?;
974 state.access_token = response.access_token.into();
975 let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
976 state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
977 Ok(())
978 }
979}
980
981pub async fn login(
986 client_id: &str,
987 client_secret: &Secret,
988 scope: GmailScope,
989 browser: &BrowserConfig,
990) -> Result<GmailAuthStatus> {
991 login_to(
992 &Settings::get_settings_path()?,
993 active_profile_from(&SystemEnv).as_deref(),
994 client_id,
995 client_secret,
996 scope,
997 browser,
998 TOKEN_ENDPOINT,
999 )
1000 .await
1001}
1002
1003pub(crate) async fn login_to(
1006 settings_path: &Path,
1007 profile: Option<&str>,
1008 client_id: &str,
1009 client_secret: &Secret,
1010 scope: GmailScope,
1011 browser: &BrowserConfig,
1012 token_endpoint: &str,
1013) -> Result<GmailAuthStatus> {
1014 let credentials =
1015 run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1016 save_credentials_to(settings_path, profile, &credentials)?;
1017 Ok(status_from_credentials(&credentials))
1018}
1019
1020pub(crate) async fn login_for(
1028 explicit: Option<&str>,
1029 client_id: &str,
1030 client_secret: &Secret,
1031 scope: GmailScope,
1032 browser: &BrowserConfig,
1033) -> Result<GmailAuthStatus> {
1034 let settings = Settings::load().unwrap_or_default();
1035 match resolve_for_write(&settings.gmail, explicit)? {
1036 ResolvedAccount::Legacy => {
1037 login_to(
1038 &Settings::get_settings_path()?,
1039 active_profile_from(&SystemEnv).as_deref(),
1040 client_id,
1041 client_secret,
1042 scope,
1043 browser,
1044 TOKEN_ENDPOINT,
1045 )
1046 .await
1047 }
1048 ResolvedAccount::Named(name) => {
1049 let credentials =
1050 run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1051 Settings::upsert_gmail_account(
1052 &Settings::get_settings_path()?,
1053 &name,
1054 &named_account_vars(&credentials),
1055 )?;
1056 Ok(status_from_credentials(&credentials))
1057 }
1058 }
1059}
1060
1061async fn run_login_flow(
1066 client_id: &str,
1067 client_secret: &Secret,
1068 scope: GmailScope,
1069 browser: &BrowserConfig,
1070 token_endpoint: &str,
1071) -> Result<GmailCredentials> {
1072 let (listener, port) = bind_callback_listener(browser).await?;
1073 let redirect_uri = format!("http://127.0.0.1:{port}");
1074
1075 let pending = generate_pending_login();
1076 let challenge = code_challenge(&pending.code_verifier);
1077 let auth_url =
1078 build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1079 open_browser(&browser.launch, auth_url.as_str())?;
1080
1081 let callback = wait_for_callback(listener).await?;
1082 if let Some(error) = callback.error {
1083 return Err(GmailError::authorization_denied(
1084 &error,
1085 callback.error_description.as_deref(),
1086 )
1087 .into());
1088 }
1089 let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1090 return Err(GmailError::MalformedCallback.into());
1091 };
1092 if returned_state != pending.state {
1097 return Err(GmailError::StateMismatch.into());
1098 }
1099
1100 let http = reqwest::Client::builder()
1101 .connect_timeout(crate::utils::http::connect_timeout())
1102 .read_timeout(crate::utils::http::read_timeout())
1103 .build()
1104 .context("Failed to build HTTP client")?;
1105 let tokens = exchange_code_for_tokens(
1106 &http,
1107 token_endpoint,
1108 client_id,
1109 client_secret.expose_secret(),
1110 &code,
1111 &pending.code_verifier,
1112 &redirect_uri,
1113 )
1114 .await?;
1115 let refresh_token = tokens
1116 .refresh_token
1117 .ok_or(GmailError::MalformedTokenResponse("refresh_token"))?;
1118 let granted_raw = tokens.scope.unwrap_or_default();
1119 let granted_scope = GmailScope::from_granted(&granted_raw).ok_or_else(|| {
1120 let received = if granted_raw.trim().is_empty() {
1121 "none".to_string()
1122 } else {
1123 granted_raw
1124 .split_whitespace()
1125 .collect::<Vec<_>>()
1126 .join(", ")
1127 };
1128 GmailError::NoGmailScopeGranted(received)
1129 })?;
1130
1131 Ok(GmailCredentials {
1132 client_id: client_id.to_string(),
1133 client_secret: client_secret.clone(),
1134 refresh_token: refresh_token.into(),
1135 scope: granted_scope,
1136 })
1137}
1138
1139fn status_from_credentials(credentials: &GmailCredentials) -> GmailAuthStatus {
1142 GmailAuthStatus {
1143 has_client_id: true,
1144 has_client_secret: true,
1145 has_refresh_token: true,
1146 scope: Some(credentials.scope.as_str().to_string()),
1147 }
1148}
1149
1150#[cfg(test)]
1151#[allow(clippy::unwrap_used, clippy::expect_used)]
1152mod tests {
1153 use std::fs;
1154 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1155 use std::sync::Arc;
1156
1157 use super::*;
1158
1159 #[test]
1162 fn scope_as_str_matches_google_scope_strings() {
1163 assert_eq!(GmailScope::ReadOnly.as_str(), SCOPE_READONLY);
1164 assert_eq!(GmailScope::Modify.as_str(), SCOPE_MODIFY);
1165 }
1166
1167 #[test]
1168 fn scope_from_granted_detects_modify_anywhere_in_the_list() {
1169 assert_eq!(
1170 GmailScope::from_granted(&format!("{SCOPE_READONLY} {SCOPE_MODIFY}")),
1171 Some(GmailScope::Modify)
1172 );
1173 }
1174
1175 #[test]
1176 fn scope_from_granted_detects_readonly_with_no_modify() {
1177 assert_eq!(
1178 GmailScope::from_granted(SCOPE_READONLY),
1179 Some(GmailScope::ReadOnly)
1180 );
1181 }
1182
1183 #[test]
1184 fn scope_from_granted_is_none_when_no_gmail_scope_present() {
1185 assert_eq!(GmailScope::from_granted("openid email profile"), None);
1186 }
1187
1188 #[test]
1189 fn scope_from_granted_is_none_for_empty_string() {
1190 assert_eq!(GmailScope::from_granted(""), None);
1191 }
1192
1193 #[test]
1194 fn scope_allows_modify() {
1195 assert!(!GmailScope::ReadOnly.allows_modify());
1196 assert!(GmailScope::Modify.allows_modify());
1197 }
1198
1199 #[test]
1200 fn code_challenge_matches_rfc_7636_test_vector() {
1201 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1203 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1204 assert_eq!(code_challenge(verifier), expected);
1205 }
1206
1207 #[test]
1208 fn code_challenge_output_is_url_safe_no_padding() {
1209 let challenge = code_challenge("some-verifier-value");
1210 assert!(!challenge.contains('+'));
1211 assert!(!challenge.contains('/'));
1212 assert!(!challenge.contains('='));
1213 }
1214
1215 #[test]
1216 fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1217 let pending = generate_pending_login();
1218 assert_ne!(pending.state, pending.code_verifier);
1219 assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1220 assert!(pending
1221 .code_verifier
1222 .chars()
1223 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1224 }
1225
1226 #[test]
1227 fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1228 let url = build_authorization_url(
1229 "client-123",
1230 "http://127.0.0.1:5555",
1231 GmailScope::ReadOnly,
1232 "state-abc",
1233 "challenge-xyz",
1234 )
1235 .unwrap();
1236 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1237 assert_eq!(query.get("client_id").unwrap(), "client-123");
1238 assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1239 assert_eq!(query.get("response_type").unwrap(), "code");
1240 assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1241 assert_eq!(query.get("state").unwrap(), "state-abc");
1242 assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1243 assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1244 assert_eq!(query.get("access_type").unwrap(), "offline");
1245 assert_eq!(query.get("prompt").unwrap(), "consent");
1246 }
1247
1248 #[test]
1249 fn build_authorization_url_uses_modify_scope_when_requested() {
1250 let url = build_authorization_url("c", "http://127.0.0.1:1", GmailScope::Modify, "s", "ch")
1251 .unwrap();
1252 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1253 assert_eq!(query.get("scope").unwrap(), SCOPE_MODIFY);
1254 }
1255
1256 #[test]
1257 fn parse_callback_extracts_code_and_state() {
1258 let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1259 let result = parse_callback(request).unwrap();
1260 assert_eq!(result.code.as_deref(), Some("abc123"));
1261 assert_eq!(result.state.as_deref(), Some("xyz789"));
1262 assert!(result.error.is_none());
1263 }
1264
1265 #[test]
1266 fn parse_callback_extracts_error_and_error_description() {
1267 let request =
1268 "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1269 let result = parse_callback(request).unwrap();
1270 assert_eq!(result.error.as_deref(), Some("access_denied"));
1271 assert_eq!(result.error_description.as_deref(), Some("user declined"));
1272 }
1273
1274 #[test]
1275 fn parse_callback_missing_query_string_is_none() {
1276 assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1277 assert!(parse_callback("garbage").is_none());
1278 }
1279
1280 #[test]
1281 fn parse_callback_ignores_unrecognized_query_keys() {
1282 let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1283 let result = parse_callback(request).unwrap();
1284 assert_eq!(result.code.as_deref(), Some("abc"));
1285 assert_eq!(result.state.as_deref(), Some("xyz"));
1286 }
1287
1288 #[test]
1289 fn open_browser_manual_logs_and_succeeds() {
1290 assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1291 }
1292
1293 #[test]
1294 fn open_browser_command_substitutes_url_placeholder() {
1295 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1296 assert!(open_browser(&launch, "https://example/auth").is_ok());
1297 }
1298
1299 #[test]
1300 fn open_browser_command_appends_url_when_no_placeholder() {
1301 let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1302 assert!(open_browser(&launch, "https://example/auth").is_ok());
1303 }
1304
1305 #[test]
1306 fn open_browser_command_passes_through_args_without_the_placeholder() {
1307 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1311 assert!(open_browser(&launch, "https://example/auth").is_ok());
1312 }
1313
1314 #[test]
1315 fn open_browser_command_rejects_empty_args() {
1316 let launch = BrowserLaunch::Command(vec![]);
1317 let err = open_browser(&launch, "u").unwrap_err();
1318 assert!(err.to_string().contains("empty browser command"));
1319 }
1320
1321 fn assert_is_auto(config: BrowserConfig) {
1324 assert!(matches!(config.launch, BrowserLaunch::Auto));
1325 }
1326
1327 #[test]
1328 fn build_browser_config_defaults_to_auto_with_no_account() {
1329 assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1330 }
1331
1332 #[test]
1333 fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1334 let account = GmailAccountSettings {
1335 email_address: Some("alice@example.com".to_string()),
1336 ..GmailAccountSettings::default()
1337 };
1338 assert_is_auto(
1341 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1342 );
1343 }
1344
1345 #[test]
1346 fn build_browser_config_uses_browser_command_verbatim() {
1347 let account = GmailAccountSettings {
1348 browser_command: Some("chrome --new-window {url}".to_string()),
1349 ..GmailAccountSettings::default()
1350 };
1351 let config =
1352 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1353 assert!(matches!(
1354 config.launch,
1355 BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1356 ));
1357 }
1358
1359 #[test]
1360 fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1361 let account = GmailAccountSettings {
1362 browser_command: Some("chrome {url}".to_string()),
1363 chrome_profile_from_email: true,
1364 email_address: Some("alice@example.com".to_string()),
1365 ..GmailAccountSettings::default()
1366 };
1367 let config =
1368 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1369 assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1370 }
1371
1372 #[test]
1373 fn build_browser_config_rejects_a_malformed_browser_command() {
1374 let account = GmailAccountSettings {
1375 browser_command: Some("chrome \"--flag".to_string()),
1376 ..GmailAccountSettings::default()
1377 };
1378 let err =
1379 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1380 assert!(err.to_string().contains("browser_command"));
1381 }
1382
1383 #[test]
1384 fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1385 let account = GmailAccountSettings {
1386 chrome_profile_from_email: true,
1387 email_address: Some("alice@example.com".to_string()),
1388 ..GmailAccountSettings::default()
1389 };
1390 let config = build_browser_config(Some(&account), |email| {
1391 assert_eq!(email, "alice@example.com");
1392 Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1393 })
1394 .unwrap();
1395 assert!(matches!(
1396 config.launch,
1397 BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1398 ));
1399 }
1400
1401 #[test]
1402 fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1403 let account = GmailAccountSettings {
1404 chrome_profile_from_email: true,
1405 email_address: Some("alice@example.com".to_string()),
1406 ..GmailAccountSettings::default()
1407 };
1408 assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1409 }
1410
1411 #[test]
1412 fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1413 let account = GmailAccountSettings {
1414 chrome_profile_from_email: true,
1415 ..GmailAccountSettings::default()
1416 };
1417 assert_is_auto(
1418 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1419 );
1420 }
1421
1422 #[test]
1425 fn resolve_browser_config_for_legacy_account_defaults_to_auto() {
1426 let guard = crate::gmail::test_support::EnvGuard::take();
1427 let _dir = guard.clear_credentials();
1428 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
1429 std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
1430 std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
1431
1432 let gmail = GmailSettings::default();
1433 assert_is_auto(resolve_browser_config_for(&gmail, None).unwrap());
1434 }
1435
1436 #[test]
1437 fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1438 let guard = crate::gmail::test_support::EnvGuard::take();
1439 let _dir = guard.clear_credentials();
1440
1441 let mut gmail = GmailSettings::default();
1442 gmail.accounts.insert(
1443 "work".to_string(),
1444 GmailAccountSettings {
1445 email_address: Some("alice@example.com".to_string()),
1446 ..GmailAccountSettings::default()
1447 },
1448 );
1449
1450 assert_is_auto(resolve_browser_config_for(&gmail, Some("work")).unwrap());
1453 }
1454
1455 #[tokio::test]
1458 async fn wait_for_callback_times_out_when_nothing_connects() {
1459 let browser = BrowserConfig::default();
1460 let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1461 let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1462 .await
1463 .unwrap_err();
1464 assert!(matches!(
1465 err.downcast_ref::<GmailError>(),
1466 Some(GmailError::CallbackTimeout(_))
1467 ));
1468 }
1469
1470 #[tokio::test]
1471 async fn wait_for_callback_reads_a_real_connection() {
1472 let browser = BrowserConfig::default();
1473 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1474
1475 let client = tokio::spawn(async move {
1476 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1477 .await
1478 .unwrap();
1479 stream
1480 .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1481 .await
1482 .unwrap();
1483 });
1484
1485 let result = wait_for_callback(listener).await.unwrap();
1486 client.await.unwrap();
1487 assert_eq!(result.code.as_deref(), Some("abc"));
1488 assert_eq!(result.state.as_deref(), Some("xyz"));
1489 }
1490
1491 #[tokio::test]
1492 async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1493 let browser = BrowserConfig::default();
1494 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1495
1496 let client = tokio::spawn(async move {
1497 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1498 .await
1499 .unwrap();
1500 stream.write_all(b"not an http request").await.unwrap();
1501 });
1502
1503 let err = wait_for_callback(listener).await.unwrap_err();
1504 client.await.unwrap();
1505 assert!(matches!(
1506 err.downcast_ref::<GmailError>(),
1507 Some(GmailError::MalformedCallback)
1508 ));
1509 }
1510
1511 #[tokio::test]
1514 async fn exchange_code_for_tokens_posts_expected_form_body() {
1515 let server = wiremock::MockServer::start().await;
1516 wiremock::Mock::given(wiremock::matchers::method("POST"))
1517 .and(wiremock::matchers::path("/token"))
1518 .and(wiremock::matchers::body_string_contains(
1519 "grant_type=authorization_code",
1520 ))
1521 .and(wiremock::matchers::body_string_contains(
1522 "code_verifier=verifier-1",
1523 ))
1524 .and(wiremock::matchers::body_string_contains(
1525 "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1526 ))
1527 .respond_with(
1528 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1529 "access_token": "at-1",
1530 "refresh_token": "rt-1",
1531 "expires_in": 3600,
1532 "scope": SCOPE_READONLY,
1533 })),
1534 )
1535 .expect(1)
1536 .mount(&server)
1537 .await;
1538
1539 let http = reqwest::Client::new();
1540 let token_endpoint = format!("{}/token", server.uri());
1541 let response = exchange_code_for_tokens(
1542 &http,
1543 &token_endpoint,
1544 "client-1",
1545 "secret-1",
1546 "code-1",
1547 "verifier-1",
1548 "http://127.0.0.1:9999",
1549 )
1550 .await
1551 .unwrap();
1552 assert_eq!(response.access_token, "at-1");
1553 assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1554 }
1555
1556 #[tokio::test]
1557 async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1558 let server = wiremock::MockServer::start().await;
1559 wiremock::Mock::given(wiremock::matchers::method("POST"))
1560 .respond_with(
1561 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1562 "error": "invalid_grant",
1563 "error_description": "Bad Request",
1564 })),
1565 )
1566 .mount(&server)
1567 .await;
1568
1569 let http = reqwest::Client::new();
1570 let err = exchange_code_for_tokens(
1571 &http,
1572 &server.uri(),
1573 "c",
1574 "s",
1575 "code",
1576 "verifier",
1577 "http://127.0.0.1:1",
1578 )
1579 .await
1580 .unwrap_err();
1581 assert!(err.to_string().contains("PKCE"));
1582 }
1583
1584 async fn connect_and_send(port: u16, request_line: &[u8]) {
1603 let mut stream = loop {
1604 match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1605 Ok(stream) => break stream,
1606 Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1607 }
1608 };
1609 stream.write_all(request_line).await.unwrap();
1610 }
1611
1612 fn reserve_free_port() -> u16 {
1613 std::net::TcpListener::bind("127.0.0.1:0")
1614 .unwrap()
1615 .local_addr()
1616 .unwrap()
1617 .port()
1618 }
1619
1620 fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1624 err.to_string()
1625 .contains("Failed to start the local OAuth callback listener")
1626 }
1627
1628 const PORT_RETRY_ATTEMPTS: u32 = 5;
1629
1630 async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<GmailAuthStatus>
1635 where
1636 F: FnMut(u16) -> Fut,
1637 Fut: std::future::Future<Output = Result<GmailAuthStatus>>,
1638 {
1639 for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1640 let result = attempt(reserve_free_port()).await;
1641 let is_retryable_conflict =
1642 matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1643 if !is_retryable_conflict {
1644 return result;
1645 }
1646 }
1647 unreachable!("loop always returns on its last iteration")
1648 }
1649
1650 async fn finish_connector(
1655 connector: tokio::task::JoinHandle<()>,
1656 result: &Result<GmailAuthStatus>,
1657 ) {
1658 match result {
1659 Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1660 _ => connector.await.unwrap(),
1661 }
1662 }
1663
1664 async fn wait_for_captured_url(path: &Path) -> String {
1668 loop {
1669 if let Ok(contents) = std::fs::read_to_string(path) {
1670 if !contents.is_empty() {
1671 return contents;
1672 }
1673 }
1674 tokio::time::sleep(Duration::from_millis(2)).await;
1675 }
1676 }
1677
1678 async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1685 std::fs::create_dir_all("tmp").ok();
1686 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1687 let settings_path = temp_dir.path().join("settings.json");
1688
1689 let result = run_with_port_retry(|port| {
1690 let settings_path = settings_path.clone();
1691 async move {
1692 let browser = BrowserConfig {
1693 launch: BrowserLaunch::Manual,
1694 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1695 callback_port: port,
1696 };
1697 let connector = tokio::spawn(connect_and_send(port, request_line));
1698
1699 let result = login_to(
1700 &settings_path,
1701 None,
1702 "client-id",
1703 &Secret::new("client-secret"),
1704 GmailScope::ReadOnly,
1705 &browser,
1706 "http://127.0.0.1:1/token", )
1708 .await;
1709
1710 finish_connector(connector, &result).await;
1711 result
1712 }
1713 })
1714 .await;
1715
1716 let err = result.unwrap_err();
1717 assert!(!settings_path.exists());
1718 err
1719 }
1720
1721 #[tokio::test]
1722 async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1723 let attempts = AtomicU32::new(0);
1724
1725 let status = run_with_port_retry(|_port| {
1726 let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1727 async move {
1728 if attempt_no == 0 {
1729 Err(anyhow::anyhow!(
1730 "Failed to start the local OAuth callback listener: address in use"
1731 ))
1732 } else {
1733 Ok(GmailAuthStatus {
1734 has_client_id: true,
1735 has_client_secret: true,
1736 has_refresh_token: true,
1737 scope: None,
1738 })
1739 }
1740 }
1741 })
1742 .await
1743 .unwrap();
1744
1745 assert_eq!(attempts.load(Ordering::SeqCst), 2);
1746 assert!(status.has_client_id);
1747 }
1748
1749 #[tokio::test]
1750 async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1751 let attempts = AtomicU32::new(0);
1752
1753 let result = run_with_port_retry(|_port| {
1754 attempts.fetch_add(1, Ordering::SeqCst);
1755 async move { Err(anyhow::anyhow!("some other failure")) }
1756 })
1757 .await;
1758
1759 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1760 assert!(result.is_err());
1761 }
1762
1763 #[tokio::test]
1764 async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1765 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1766 let connector = tokio::spawn(async move {
1767 tokio::time::sleep(Duration::from_secs(3600)).await;
1768 let _ = tx.send(());
1769 });
1770 let result: Result<GmailAuthStatus> = Err(anyhow::anyhow!(
1771 "Failed to start the local OAuth callback listener: address in use"
1772 ));
1773
1774 tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1775 .await
1776 .expect("finish_connector must not wait for an aborted connector");
1777
1778 assert!(
1779 rx.try_recv().is_err(),
1780 "connector must have been aborted, not run to completion"
1781 );
1782 }
1783
1784 #[tokio::test]
1785 async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1786 let ran = Arc::new(AtomicBool::new(false));
1787 let ran_clone = ran.clone();
1788 let connector = tokio::spawn(async move {
1789 ran_clone.store(true, Ordering::SeqCst);
1790 });
1791 let result = Ok(GmailAuthStatus {
1792 has_client_id: true,
1793 has_client_secret: true,
1794 has_refresh_token: true,
1795 scope: None,
1796 });
1797
1798 finish_connector(connector, &result).await;
1799
1800 assert!(ran.load(Ordering::SeqCst));
1801 }
1802
1803 #[tokio::test]
1804 async fn wait_for_captured_url_polls_until_content_is_written() {
1805 std::fs::create_dir_all("tmp").ok();
1806 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1807 let path = temp_dir.path().join("captured-url.txt");
1808
1809 let write_path = path.clone();
1810 tokio::spawn(async move {
1811 tokio::time::sleep(Duration::from_millis(10)).await;
1812 std::fs::write(&write_path, "").unwrap();
1813 tokio::time::sleep(Duration::from_millis(10)).await;
1814 std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1815 });
1816
1817 let contents = wait_for_captured_url(&path).await;
1818 assert_eq!(contents, "https://example.com/authorize");
1819 }
1820
1821 #[tokio::test]
1822 async fn login_to_rejects_a_callback_with_mismatched_state() {
1823 let err =
1824 run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
1825
1826 assert!(matches!(
1827 err.downcast_ref::<GmailError>(),
1828 Some(GmailError::StateMismatch)
1829 ));
1830 }
1831
1832 #[tokio::test]
1833 async fn login_to_surfaces_access_denied_from_the_callback() {
1834 let err = run_login_to_expect_err(
1835 b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
1836 )
1837 .await;
1838
1839 match err.downcast_ref::<GmailError>() {
1840 Some(GmailError::AuthorizationDenied(message)) => {
1841 assert!(message.contains("access_denied"));
1842 assert!(message.contains("user declined"));
1843 }
1844 other => panic!("expected AuthorizationDenied, got {other:?}"),
1845 }
1846 }
1847
1848 #[tokio::test]
1849 async fn login_to_rejects_a_callback_missing_code_and_state() {
1850 let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
1851
1852 assert!(matches!(
1853 err.downcast_ref::<GmailError>(),
1854 Some(GmailError::MalformedCallback)
1855 ));
1856 }
1857
1858 #[tokio::test]
1859 async fn login_to_completes_full_success_flow_and_persists_credentials() {
1860 std::fs::create_dir_all("tmp").ok();
1869 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1870 let capture_path = temp_dir.path().join("captured-url.txt");
1871 let settings_path = temp_dir.path().join("settings.json");
1872
1873 let server = wiremock::MockServer::start().await;
1874 wiremock::Mock::given(wiremock::matchers::method("POST"))
1875 .and(wiremock::matchers::path("/token"))
1876 .respond_with(
1877 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1878 "access_token": "at-1",
1879 "refresh_token": "rt-1",
1880 "expires_in": 3600,
1881 "scope": SCOPE_READONLY,
1882 })),
1883 )
1884 .expect(1)
1885 .mount(&server)
1886 .await;
1887
1888 let status = run_with_port_retry(|port| {
1889 let capture_path = capture_path.clone();
1890 let settings_path = settings_path.clone();
1891 let token_endpoint = format!("{}/token", server.uri());
1892 async move {
1893 let browser = BrowserConfig {
1894 launch: BrowserLaunch::Command(vec![
1895 "/bin/sh".to_string(),
1896 "-c".to_string(),
1897 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1898 "{url}".to_string(),
1899 ]),
1900 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1901 callback_port: port,
1902 };
1903
1904 let connector = tokio::spawn(async move {
1905 let auth_url = wait_for_captured_url(&capture_path).await;
1906 let parsed = Url::parse(&auth_url).unwrap();
1907 let state = parsed
1908 .query_pairs()
1909 .find(|(k, _)| k == "state")
1910 .map(|(_, v)| v.into_owned())
1911 .expect("authorization URL must carry a state param");
1912 connect_and_send(
1913 port,
1914 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1915 )
1916 .await;
1917 });
1918
1919 let result = login_to(
1920 &settings_path,
1921 None,
1922 "client-id",
1923 &Secret::new("client-secret"),
1924 GmailScope::ReadOnly,
1925 &browser,
1926 &token_endpoint,
1927 )
1928 .await;
1929
1930 finish_connector(connector, &result).await;
1931 result
1932 }
1933 })
1934 .await
1935 .unwrap();
1936
1937 assert!(status.has_client_id);
1938 assert!(status.has_client_secret);
1939 assert!(status.has_refresh_token);
1940 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
1941
1942 let saved = std::fs::read_to_string(&settings_path).unwrap();
1943 assert!(saved.contains("rt-1"));
1944 assert!(saved.contains("client-id"));
1945 }
1946
1947 async fn run_login_to_with_token_response(
1954 token_response_body: serde_json::Value,
1955 ) -> (Result<GmailAuthStatus>, std::path::PathBuf) {
1956 std::fs::create_dir_all("tmp").ok();
1957 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1958 let capture_path = temp_dir.path().join("captured-url.txt");
1959 let settings_path = temp_dir.path().join("settings.json");
1960
1961 let server = wiremock::MockServer::start().await;
1962 wiremock::Mock::given(wiremock::matchers::method("POST"))
1963 .and(wiremock::matchers::path("/token"))
1964 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
1965 .expect(1)
1966 .mount(&server)
1967 .await;
1968
1969 let result = run_with_port_retry(|port| {
1970 let capture_path = capture_path.clone();
1971 let settings_path = settings_path.clone();
1972 let token_endpoint = format!("{}/token", server.uri());
1973 async move {
1974 let browser = BrowserConfig {
1975 launch: BrowserLaunch::Command(vec![
1976 "/bin/sh".to_string(),
1977 "-c".to_string(),
1978 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
1979 "{url}".to_string(),
1980 ]),
1981 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1982 callback_port: port,
1983 };
1984
1985 let connector = tokio::spawn(async move {
1986 let auth_url = wait_for_captured_url(&capture_path).await;
1987 let parsed = Url::parse(&auth_url).unwrap();
1988 let state = parsed
1989 .query_pairs()
1990 .find(|(k, _)| k == "state")
1991 .map(|(_, v)| v.into_owned())
1992 .expect("authorization URL must carry a state param");
1993 connect_and_send(
1994 port,
1995 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
1996 )
1997 .await;
1998 });
1999
2000 let result = login_to(
2001 &settings_path,
2002 None,
2003 "client-id",
2004 &Secret::new("client-secret"),
2005 GmailScope::ReadOnly,
2006 &browser,
2007 &token_endpoint,
2008 )
2009 .await;
2010
2011 finish_connector(connector, &result).await;
2012 result
2013 }
2014 })
2015 .await;
2016
2017 (result, settings_path)
2018 }
2019
2020 #[tokio::test]
2021 async fn login_to_rejects_a_grant_with_no_gmail_scope() {
2022 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2023 "access_token": "at-1",
2024 "refresh_token": "rt-1",
2025 "expires_in": 3600,
2026 "scope": "openid email profile",
2027 }))
2028 .await;
2029
2030 let err = result.unwrap_err();
2031 assert!(matches!(
2032 err.downcast_ref::<GmailError>(),
2033 Some(GmailError::NoGmailScopeGranted(received)) if received == "openid, email, profile"
2034 ));
2035 assert!(!settings_path.exists());
2036 }
2037
2038 #[tokio::test]
2039 async fn login_to_rejects_a_grant_with_missing_scope_field() {
2040 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2041 "access_token": "at-1",
2042 "refresh_token": "rt-1",
2043 "expires_in": 3600,
2044 }))
2045 .await;
2046
2047 let err = result.unwrap_err();
2048 assert!(matches!(
2049 err.downcast_ref::<GmailError>(),
2050 Some(GmailError::NoGmailScopeGranted(received)) if received == "none"
2051 ));
2052 assert!(!settings_path.exists());
2053 }
2054
2055 #[tokio::test]
2056 async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2057 let server = wiremock::MockServer::start().await;
2058 wiremock::Mock::given(wiremock::matchers::method("POST"))
2059 .and(wiremock::matchers::body_string_contains(
2060 "grant_type=refresh_token",
2061 ))
2062 .respond_with(
2063 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2064 "access_token": "at-2",
2065 "expires_in": 1800,
2066 })),
2067 )
2068 .expect(1)
2069 .mount(&server)
2070 .await;
2071
2072 let http = reqwest::Client::new();
2073 let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2074 .await
2075 .unwrap();
2076 assert_eq!(response.access_token, "at-2");
2077 assert_eq!(response.expires_in, 1800);
2078 }
2079
2080 #[tokio::test]
2081 async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2082 let server = wiremock::MockServer::start().await;
2083 wiremock::Mock::given(wiremock::matchers::method("POST"))
2084 .respond_with(
2085 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2086 "error": "invalid_grant",
2087 })),
2088 )
2089 .mount(&server)
2090 .await;
2091
2092 let http = reqwest::Client::new();
2093 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2094 .await
2095 .unwrap_err();
2096 let msg = err.to_string();
2097 assert!(msg.contains("7 days"));
2098 assert!(msg.contains("Testing"));
2099 }
2100
2101 #[tokio::test]
2102 async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2103 let server = wiremock::MockServer::start().await;
2104 wiremock::Mock::given(wiremock::matchers::method("POST"))
2105 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2106 .mount(&server)
2107 .await;
2108
2109 let http = reqwest::Client::new();
2110 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2111 .await
2112 .unwrap_err();
2113 let msg = err.to_string();
2114 assert!(msg.contains("unparsable error body"));
2115 assert!(msg.contains("not json"));
2116 }
2117
2118 #[tokio::test]
2119 async fn token_request_propagates_network_errors() {
2120 let http = reqwest::Client::new();
2121 let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2122 .await
2123 .unwrap_err();
2124 assert!(err.to_string().contains("Failed to send token request"));
2125 }
2126
2127 #[tokio::test]
2128 async fn token_request_errors_on_unparsable_response_body() {
2129 let server = wiremock::MockServer::start().await;
2130 wiremock::Mock::given(wiremock::matchers::method("POST"))
2131 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2132 .mount(&server)
2133 .await;
2134
2135 let http = reqwest::Client::new();
2136 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2137 .await
2138 .unwrap_err();
2139 assert!(err.to_string().contains("Failed to parse"));
2140 }
2141
2142 fn test_credentials() -> GmailCredentials {
2145 GmailCredentials {
2146 client_id: "client-1".to_string(),
2147 client_secret: "secret-1".into(),
2148 refresh_token: "refresh-1".into(),
2149 scope: GmailScope::ReadOnly,
2150 }
2151 }
2152
2153 #[test]
2154 fn named_account_vars_maps_credentials_to_json_string_values() {
2155 assert_eq!(
2156 named_account_vars(&test_credentials()),
2157 [
2158 (
2159 "client_id",
2160 serde_json::Value::String("client-1".to_string())
2161 ),
2162 (
2163 "client_secret",
2164 serde_json::Value::String("secret-1".to_string())
2165 ),
2166 (
2167 "refresh_token",
2168 serde_json::Value::String("refresh-1".to_string())
2169 ),
2170 (
2171 "scope",
2172 serde_json::Value::String(SCOPE_READONLY.to_string())
2173 ),
2174 ]
2175 );
2176 }
2177
2178 #[tokio::test]
2179 async fn access_token_refreshes_on_first_call() {
2180 let server = wiremock::MockServer::start().await;
2181 wiremock::Mock::given(wiremock::matchers::method("POST"))
2182 .respond_with(
2183 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2184 "access_token": "at-first",
2185 "expires_in": 3600,
2186 })),
2187 )
2188 .expect(1)
2189 .mount(&server)
2190 .await;
2191
2192 let session = GmailSession::new_with_token_endpoint(
2193 reqwest::Client::new(),
2194 &test_credentials(),
2195 &server.uri(),
2196 );
2197 let token = session.access_token().await.unwrap();
2198 assert_eq!(token.expose_secret(), "at-first");
2199 }
2200
2201 #[tokio::test]
2202 async fn access_token_reuses_cached_token_within_skew_window() {
2203 let server = wiremock::MockServer::start().await;
2204 wiremock::Mock::given(wiremock::matchers::method("POST"))
2205 .respond_with(
2206 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2207 "access_token": "at-cached",
2208 "expires_in": 3600,
2209 })),
2210 )
2211 .expect(1)
2212 .mount(&server)
2213 .await;
2214
2215 let session = GmailSession::new_with_token_endpoint(
2216 reqwest::Client::new(),
2217 &test_credentials(),
2218 &server.uri(),
2219 );
2220 let first = session.access_token().await.unwrap();
2221 let second = session.access_token().await.unwrap();
2222 assert_eq!(first, second);
2223 }
2224
2225 #[tokio::test]
2226 async fn access_token_proactively_refreshes_when_within_skew_window() {
2227 let server = wiremock::MockServer::start().await;
2228 wiremock::Mock::given(wiremock::matchers::method("POST"))
2229 .respond_with(
2230 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2231 "access_token": "at-short",
2232 "expires_in": 30,
2234 })),
2235 )
2236 .expect(2)
2237 .mount(&server)
2238 .await;
2239
2240 let session = GmailSession::new_with_token_endpoint(
2241 reqwest::Client::new(),
2242 &test_credentials(),
2243 &server.uri(),
2244 );
2245 session.access_token().await.unwrap();
2246 session.access_token().await.unwrap();
2247 }
2248
2249 #[tokio::test]
2250 async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2251 let server = wiremock::MockServer::start().await;
2252 wiremock::Mock::given(wiremock::matchers::method("POST"))
2253 .respond_with(
2254 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2255 "access_token": "at-overflow",
2256 "expires_in": i64::MAX,
2259 })),
2260 )
2261 .expect(1)
2262 .mount(&server)
2263 .await;
2264
2265 let session = GmailSession::new_with_token_endpoint(
2266 reqwest::Client::new(),
2267 &test_credentials(),
2268 &server.uri(),
2269 );
2270 let token = session.access_token().await.unwrap();
2271 assert_eq!(token.expose_secret(), "at-overflow");
2272 }
2273
2274 #[tokio::test]
2275 async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2276 let server = wiremock::MockServer::start().await;
2277 wiremock::Mock::given(wiremock::matchers::method("POST"))
2278 .respond_with(
2279 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2280 "access_token": "at-negative",
2281 "expires_in": -3600,
2282 })),
2283 )
2284 .expect(2)
2287 .mount(&server)
2288 .await;
2289
2290 let session = GmailSession::new_with_token_endpoint(
2291 reqwest::Client::new(),
2292 &test_credentials(),
2293 &server.uri(),
2294 );
2295 session.access_token().await.unwrap();
2296 session.access_token().await.unwrap();
2297 }
2298
2299 #[tokio::test]
2300 async fn force_refresh_concurrent_callers_do_not_stampede() {
2301 let server = wiremock::MockServer::start().await;
2302 wiremock::Mock::given(wiremock::matchers::method("POST"))
2303 .respond_with(
2304 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2305 "access_token": "at-bootstrap",
2306 "expires_in": 3600,
2307 })),
2308 )
2309 .up_to_n_times(1)
2310 .with_priority(1)
2311 .mount(&server)
2312 .await;
2313 wiremock::Mock::given(wiremock::matchers::method("POST"))
2314 .respond_with(
2315 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2316 "access_token": "at-refreshed",
2317 "expires_in": 3600,
2318 })),
2319 )
2320 .expect(1)
2321 .with_priority(2)
2322 .mount(&server)
2323 .await;
2324
2325 let session = GmailSession::new_with_token_endpoint(
2326 reqwest::Client::new(),
2327 &test_credentials(),
2328 &server.uri(),
2329 );
2330 let bootstrapped = session.access_token().await.unwrap();
2331 assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2332
2333 let (a, b) = tokio::join!(
2334 session.force_refresh(&bootstrapped),
2335 session.force_refresh(&bootstrapped)
2336 );
2337 let a = a.unwrap();
2338 let b = b.unwrap();
2339 assert_eq!(a, b);
2340 assert_eq!(a.expose_secret(), "at-refreshed");
2341 }
2342
2343 #[tokio::test]
2344 async fn force_refresh_skips_network_call_when_token_already_rotated() {
2345 let server = wiremock::MockServer::start().await;
2346 wiremock::Mock::given(wiremock::matchers::method("POST"))
2347 .respond_with(
2348 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2349 "access_token": "at-a",
2350 "expires_in": 3600,
2351 })),
2352 )
2353 .up_to_n_times(1)
2354 .with_priority(1)
2355 .mount(&server)
2356 .await;
2357 wiremock::Mock::given(wiremock::matchers::method("POST"))
2358 .respond_with(
2359 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2360 "access_token": "at-b",
2361 "expires_in": 3600,
2362 })),
2363 )
2364 .expect(1)
2365 .with_priority(2)
2366 .mount(&server)
2367 .await;
2368
2369 let session = GmailSession::new_with_token_endpoint(
2370 reqwest::Client::new(),
2371 &test_credentials(),
2372 &server.uri(),
2373 );
2374 let stale = Secret::new("at-never-issued");
2375 let bootstrapped = session.access_token().await.unwrap();
2376 assert_eq!(bootstrapped.expose_secret(), "at-a");
2377
2378 let result = session.force_refresh(&stale).await.unwrap();
2381 assert_eq!(result, bootstrapped);
2382
2383 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2385 assert_eq!(refreshed.expose_secret(), "at-b");
2386 }
2387
2388 #[test]
2391 fn gmail_credentials_debug_redacts_client_secret_and_refresh_token() {
2392 let creds = GmailCredentials {
2393 client_id: "client-visible".to_string(),
2394 client_secret: "sekret-client-secret".into(),
2395 refresh_token: "sekret-refresh-token".into(),
2396 scope: GmailScope::ReadOnly,
2397 };
2398 let debug = format!("{creds:?}");
2399 assert!(debug.contains("GmailCredentials"));
2400 assert!(debug.contains("client-visible"));
2401 assert!(!debug.contains("sekret-client-secret"));
2402 assert!(!debug.contains("sekret-refresh-token"));
2403 assert!(debug.contains("client_secret: <redacted>"));
2404 assert!(debug.contains("refresh_token: <redacted>"));
2405 }
2406
2407 #[test]
2408 fn gmail_auth_status_yaml_serialization_contains_no_secret_values() {
2409 let env = crate::test_support::env::MapEnv::new()
2410 .with(GMAIL_CLIENT_ID, "client-id-value")
2411 .with(GMAIL_CLIENT_SECRET, "sekret-do-not-leak")
2412 .with(GMAIL_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2413 .with(GMAIL_SCOPE, SCOPE_READONLY);
2414 let status = status_with(&env);
2415 let yaml = serde_yaml::to_string(&status).unwrap();
2416 assert!(!yaml.contains("sekret-do-not-leak"));
2417 assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2418 }
2419
2420 use crate::test_support::env::MapEnv;
2423
2424 #[test]
2425 fn status_reports_all_false_when_nothing_configured() {
2426 let status = status_with(&MapEnv::new());
2427 assert!(!status.has_client_id);
2428 assert!(!status.has_client_secret);
2429 assert!(!status.has_refresh_token);
2430 assert_eq!(status.scope, None);
2431 }
2432
2433 #[test]
2434 fn status_reports_scope_when_present() {
2435 let env = MapEnv::new().with(GMAIL_SCOPE, SCOPE_MODIFY);
2436 let status = status_with(&env);
2437 assert_eq!(status.scope.as_deref(), Some(SCOPE_MODIFY));
2438 }
2439
2440 #[test]
2441 fn load_credentials_errors_when_client_id_missing() {
2442 let env = MapEnv::new()
2443 .with(GMAIL_CLIENT_SECRET, "s")
2444 .with(GMAIL_REFRESH_TOKEN, "r");
2445 let err = load_credentials_with(&env).unwrap_err();
2446 assert!(err.to_string().contains("not configured"));
2447 }
2448
2449 #[test]
2450 fn load_credentials_errors_when_client_secret_missing() {
2451 let env = MapEnv::new()
2452 .with(GMAIL_CLIENT_ID, "c")
2453 .with(GMAIL_REFRESH_TOKEN, "r");
2454 assert!(load_credentials_with(&env).is_err());
2455 }
2456
2457 #[test]
2458 fn load_credentials_errors_when_refresh_token_missing() {
2459 let env = MapEnv::new()
2460 .with(GMAIL_CLIENT_ID, "c")
2461 .with(GMAIL_CLIENT_SECRET, "s");
2462 assert!(load_credentials_with(&env).is_err());
2463 }
2464
2465 #[test]
2466 fn load_credentials_succeeds_with_all_three_present() {
2467 let env = MapEnv::new()
2468 .with(GMAIL_CLIENT_ID, "c")
2469 .with(GMAIL_CLIENT_SECRET, "s")
2470 .with(GMAIL_REFRESH_TOKEN, "r");
2471 let creds = load_credentials_with(&env).unwrap();
2472 assert_eq!(creds.client_id, "c");
2473 assert_eq!(creds.scope, GmailScope::ReadOnly);
2474 }
2475
2476 #[test]
2479 fn save_then_remove_round_trip() {
2480 {
2482 let temp_dir = {
2483 std::fs::create_dir_all("tmp").ok();
2484 tempfile::TempDir::new_in("tmp").unwrap()
2485 };
2486 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2487
2488 let creds = GmailCredentials {
2489 client_id: "client-1".to_string(),
2490 client_secret: "secret-1".into(),
2491 refresh_token: "refresh-1".into(),
2492 scope: GmailScope::ReadOnly,
2493 };
2494 save_credentials_to(&settings_path, None, &creds).unwrap();
2495
2496 assert!(settings_path.exists());
2497 let val: serde_json::Value =
2498 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2499 assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "client-1");
2500 assert_eq!(val["env"]["GMAIL_CLIENT_SECRET"], "secret-1");
2501 assert_eq!(val["env"]["GMAIL_REFRESH_TOKEN"], "refresh-1");
2502 assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_READONLY);
2503
2504 #[cfg(unix)]
2505 {
2506 use std::os::unix::fs::PermissionsExt;
2507 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2508 assert_eq!(mode & 0o777, 0o600);
2509 }
2510 }
2511
2512 {
2514 let temp_dir = {
2515 std::fs::create_dir_all("tmp").ok();
2516 tempfile::TempDir::new_in("tmp").unwrap()
2517 };
2518 let omni_dir = temp_dir.path().join(".omni-dev");
2519 fs::create_dir_all(&omni_dir).unwrap();
2520 let settings_path = omni_dir.join("settings.json");
2521 fs::write(
2522 &settings_path,
2523 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2524 )
2525 .unwrap();
2526
2527 let creds = GmailCredentials {
2528 client_id: "client-2".to_string(),
2529 client_secret: "secret-2".into(),
2530 refresh_token: "refresh-2".into(),
2531 scope: GmailScope::Modify,
2532 };
2533 save_credentials_to(&settings_path, None, &creds).unwrap();
2534
2535 let val: serde_json::Value =
2536 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2537 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2538 assert_eq!(val["extra"], true);
2539 assert_eq!(val["env"]["GMAIL_SCOPE"], SCOPE_MODIFY);
2540 }
2541
2542 {
2544 let temp_dir = {
2545 std::fs::create_dir_all("tmp").ok();
2546 tempfile::TempDir::new_in("tmp").unwrap()
2547 };
2548 let omni_dir = temp_dir.path().join(".omni-dev");
2549 fs::create_dir_all(&omni_dir).unwrap();
2550 let settings_path = omni_dir.join("settings.json");
2551 fs::write(
2552 &settings_path,
2553 r#"{"env": {
2554 "GMAIL_CLIENT_ID": "a",
2555 "GMAIL_CLIENT_SECRET": "b",
2556 "GMAIL_REFRESH_TOKEN": "c",
2557 "GMAIL_SCOPE": "d",
2558 "OTHER_KEY": "keep"
2559 }}"#,
2560 )
2561 .unwrap();
2562
2563 let removed = remove_credentials_at(&settings_path, None).unwrap();
2564 assert!(removed);
2565
2566 let val: serde_json::Value =
2567 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2568 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2569 assert!(val["env"].get("GMAIL_CLIENT_SECRET").is_none());
2570 assert!(val["env"].get("GMAIL_REFRESH_TOKEN").is_none());
2571 assert!(val["env"].get("GMAIL_SCOPE").is_none());
2572 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2573 }
2574
2575 {
2577 let temp_dir = {
2578 std::fs::create_dir_all("tmp").ok();
2579 tempfile::TempDir::new_in("tmp").unwrap()
2580 };
2581 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2582 let removed = remove_credentials_at(&settings_path, None).unwrap();
2583 assert!(!removed);
2584 }
2585 }
2586
2587 #[test]
2589 fn save_then_remove_round_trip_in_profile() {
2590 let temp_dir = {
2591 std::fs::create_dir_all("tmp").ok();
2592 tempfile::TempDir::new_in("tmp").unwrap()
2593 };
2594 let omni_dir = temp_dir.path().join(".omni-dev");
2595 fs::create_dir_all(&omni_dir).unwrap();
2596 let settings_path = omni_dir.join("settings.json");
2597 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2598
2599 let creds = GmailCredentials {
2600 client_id: "client-p".to_string(),
2601 client_secret: "secret-p".into(),
2602 refresh_token: "refresh-p".into(),
2603 scope: GmailScope::ReadOnly,
2604 };
2605 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2606
2607 let val: serde_json::Value =
2608 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2609 assert_eq!(
2610 val["profiles"]["work"]["env"]["GMAIL_CLIENT_ID"],
2611 "client-p"
2612 );
2613 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2614 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2615
2616 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2617 assert!(removed);
2618 let val: serde_json::Value =
2619 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2620 assert!(val["profiles"]["work"]["env"]
2621 .get("GMAIL_CLIENT_ID")
2622 .is_none());
2623
2624 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2625 assert!(!removed);
2626 }
2627
2628 #[test]
2632 fn save_and_remove_credentials_resolve_default_settings_path() {
2633 let guard = crate::gmail::test_support::EnvGuard::take();
2634 let dir = guard.clear_credentials();
2635
2636 let creds = GmailCredentials {
2637 client_id: "wrapper-client".to_string(),
2638 client_secret: "wrapper-secret".into(),
2639 refresh_token: "wrapper-refresh".into(),
2640 scope: GmailScope::ReadOnly,
2641 };
2642 save_credentials(&creds).unwrap();
2643
2644 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2645 let val: serde_json::Value =
2646 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2647 assert_eq!(val["env"]["GMAIL_CLIENT_ID"], "wrapper-client");
2648
2649 assert!(remove_credentials().unwrap());
2650 let val: serde_json::Value =
2651 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2652 assert!(val["env"].get("GMAIL_CLIENT_ID").is_none());
2653 }
2654
2655 #[test]
2662 fn load_credentials_for_named_reads_from_gmail_accounts() {
2663 let guard = crate::gmail::test_support::EnvGuard::take();
2664 let dir = guard.clear_credentials();
2665 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2666 Settings::upsert_gmail_account(
2667 &settings_path,
2668 "work",
2669 &[
2670 (
2671 "client_id",
2672 serde_json::Value::String("work-id".to_string()),
2673 ),
2674 (
2675 "client_secret",
2676 serde_json::Value::String("work-secret".to_string()),
2677 ),
2678 (
2679 "refresh_token",
2680 serde_json::Value::String("work-refresh".to_string()),
2681 ),
2682 ("scope", serde_json::Value::String(SCOPE_MODIFY.to_string())),
2683 ],
2684 )
2685 .unwrap();
2686
2687 let creds = load_credentials_for(Some("work")).unwrap();
2688 assert_eq!(creds.client_id, "work-id");
2689 assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2690 assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2691 assert_eq!(creds.scope, GmailScope::Modify);
2692 }
2693
2694 #[test]
2695 fn load_credentials_for_unknown_named_account_errors() {
2696 let guard = crate::gmail::test_support::EnvGuard::take();
2697 let dir = guard.clear_credentials();
2698 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2699 Settings::upsert_gmail_account(
2700 &settings_path,
2701 "work",
2702 &[(
2703 "client_id",
2704 serde_json::Value::String("work-id".to_string()),
2705 )],
2706 )
2707 .unwrap();
2708
2709 let err = load_credentials_for(Some("bogus")).unwrap_err();
2710 assert!(err.to_string().contains("unknown Gmail account 'bogus'"));
2711 }
2712
2713 #[test]
2714 fn load_credentials_for_falls_back_to_legacy_when_accounts_empty() {
2715 let guard = crate::gmail::test_support::EnvGuard::take();
2716 let _dir = guard.clear_credentials();
2717 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2718 std::env::set_var(GMAIL_CLIENT_SECRET, "legacy-secret");
2719 std::env::set_var(GMAIL_REFRESH_TOKEN, "legacy-refresh");
2720
2721 let creds = load_credentials_for(None).unwrap();
2722 assert_eq!(creds.client_id, "legacy-id");
2723 }
2724
2725 #[test]
2726 fn load_credentials_for_none_honors_ambient_account_env_var() {
2727 let guard = crate::gmail::test_support::EnvGuard::take();
2728 let dir = guard.clear_credentials();
2729 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2730 Settings::upsert_gmail_account(
2731 &settings_path,
2732 "work",
2733 &[
2734 (
2735 "client_id",
2736 serde_json::Value::String("work-id".to_string()),
2737 ),
2738 (
2739 "client_secret",
2740 serde_json::Value::String("work-secret".to_string()),
2741 ),
2742 (
2743 "refresh_token",
2744 serde_json::Value::String("work-refresh".to_string()),
2745 ),
2746 ],
2747 )
2748 .unwrap();
2749 std::env::set_var(account::GMAIL_ACCOUNT_ENV, "work");
2750
2751 let creds = load_credentials_for(None).unwrap();
2752 assert_eq!(creds.client_id, "work-id");
2753 }
2754
2755 #[test]
2756 fn remove_credentials_for_named_removes_whole_account() {
2757 let guard = crate::gmail::test_support::EnvGuard::take();
2758 let dir = guard.clear_credentials();
2759 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2760 Settings::upsert_gmail_account(
2761 &settings_path,
2762 "work",
2763 &[("client_id", serde_json::Value::String("id".to_string()))],
2764 )
2765 .unwrap();
2766
2767 assert!(remove_credentials_for(Some("work")).unwrap());
2768 let val: serde_json::Value =
2769 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2770 assert!(val["gmail"]["accounts"].get("work").is_none());
2771 }
2772
2773 #[cfg(feature = "mcp")]
2774 #[test]
2775 fn status_for_named_reports_presence_from_account() {
2776 let guard = crate::gmail::test_support::EnvGuard::take();
2777 let dir = guard.clear_credentials();
2778 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2779 Settings::upsert_gmail_account(
2780 &settings_path,
2781 "work",
2782 &[
2783 ("client_id", serde_json::Value::String("id".to_string())),
2784 (
2785 "scope",
2786 serde_json::Value::String(SCOPE_READONLY.to_string()),
2787 ),
2788 ],
2789 )
2790 .unwrap();
2791
2792 let status = status_for(Some("work")).unwrap();
2793 assert!(status.has_client_id);
2794 assert!(!status.has_client_secret);
2795 assert!(!status.has_refresh_token);
2796 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2797 }
2798
2799 #[cfg(feature = "mcp")]
2800 #[test]
2801 fn status_for_legacy_matches_status_with_when_accounts_empty() {
2802 let guard = crate::gmail::test_support::EnvGuard::take();
2803 let _dir = guard.clear_credentials();
2804 std::env::set_var(GMAIL_CLIENT_ID, "legacy-id");
2805
2806 let status = status_for(None).unwrap();
2807 assert!(status.has_client_id);
2808 assert!(!status.has_refresh_token);
2809 }
2810
2811 #[test]
2812 fn load_credentials_legacy_ignores_active_named_account() {
2813 let guard = crate::gmail::test_support::EnvGuard::take();
2814 let dir = guard.clear_credentials();
2815 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2816
2817 Settings::set_gmail_default_account(&settings_path, Some("work")).unwrap();
2819 Settings::upsert_gmail_account(
2820 &settings_path,
2821 "work",
2822 &[
2823 (
2824 "client_id",
2825 serde_json::Value::String("named-id".to_string()),
2826 ),
2827 (
2828 "client_secret",
2829 serde_json::Value::String("named-secret".to_string()),
2830 ),
2831 (
2832 "refresh_token",
2833 serde_json::Value::String("named-refresh".to_string()),
2834 ),
2835 ],
2836 )
2837 .unwrap();
2838 Settings::upsert_env_vars(
2841 &settings_path,
2842 &[
2843 (GMAIL_CLIENT_ID, "legacy-id"),
2844 (GMAIL_CLIENT_SECRET, "legacy-secret"),
2845 (GMAIL_REFRESH_TOKEN, "legacy-refresh"),
2846 ],
2847 )
2848 .unwrap();
2849
2850 let via_resolution = load_credentials_for(None).unwrap();
2852 assert_eq!(via_resolution.client_id, "named-id");
2853
2854 let forced_legacy = load_credentials_legacy().unwrap();
2856 assert_eq!(forced_legacy.client_id, "legacy-id");
2857 }
2858
2859 #[test]
2860 fn record_account_email_writes_email_address_only() {
2861 let guard = crate::gmail::test_support::EnvGuard::take();
2862 let dir = guard.clear_credentials();
2863 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2864 Settings::upsert_gmail_account(
2865 &settings_path,
2866 "work",
2867 &[("client_id", serde_json::Value::String("id".to_string()))],
2868 )
2869 .unwrap();
2870
2871 record_account_email("work", "alice@work.com").unwrap();
2872
2873 let val: serde_json::Value =
2874 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2875 assert_eq!(
2876 val["gmail"]["accounts"]["work"]["email_address"],
2877 "alice@work.com"
2878 );
2879 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2880 }
2881
2882 #[test]
2883 fn record_account_email_does_not_overwrite_an_existing_value() {
2884 let guard = crate::gmail::test_support::EnvGuard::take();
2885 let dir = guard.clear_credentials();
2886 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2887 Settings::upsert_gmail_account(
2888 &settings_path,
2889 "work",
2890 &[(
2891 "email_address",
2892 serde_json::Value::String("manually-set@work.com".to_string()),
2893 )],
2894 )
2895 .unwrap();
2896
2897 record_account_email("work", "alice@work.com").unwrap();
2898
2899 let val: serde_json::Value =
2900 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2901 assert_eq!(
2902 val["gmail"]["accounts"]["work"]["email_address"],
2903 "manually-set@work.com"
2904 );
2905 }
2906
2907 #[test]
2916 fn zero_migration_load_remove_byte_identical_when_accounts_empty() {
2917 let guard = crate::gmail::test_support::EnvGuard::take();
2918 let creds = GmailCredentials {
2919 client_id: "id".to_string(),
2920 client_secret: "secret".into(),
2921 refresh_token: "refresh".into(),
2922 scope: GmailScope::Modify,
2923 };
2924
2925 let dir_legacy = guard.clear_credentials();
2926 save_credentials(&creds).unwrap();
2927 let legacy_written =
2928 fs::read_to_string(dir_legacy.path().join(".omni-dev").join("settings.json")).unwrap();
2929 let legacy_loaded = load_credentials().unwrap();
2930 let legacy_removed = remove_credentials().unwrap();
2931
2932 let dir_for = guard.clear_credentials();
2933 save_credentials(&creds).unwrap();
2934 let for_written =
2935 fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
2936 let for_loaded = load_credentials_for(None).unwrap();
2937 let for_removed = remove_credentials_for(None).unwrap();
2938
2939 assert_eq!(legacy_written, for_written);
2940 assert_eq!(legacy_loaded.client_id, for_loaded.client_id);
2941 assert_eq!(
2942 legacy_loaded.client_secret.expose_secret(),
2943 for_loaded.client_secret.expose_secret()
2944 );
2945 assert_eq!(legacy_loaded.scope, for_loaded.scope);
2946 assert_eq!(legacy_removed, for_removed);
2947 }
2948}