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