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::secret_env::{secret_var, secret_var_is_set};
35use crate::utils::settings::{
36 account_secret, active_profile_from, DriveAccountSettings, DriveSettings, Settings,
37};
38
39pub const DRIVE_CLIENT_ID: &str = "DRIVE_CLIENT_ID";
42pub const DRIVE_CLIENT_SECRET: &str = "DRIVE_CLIENT_SECRET";
45pub const DRIVE_REFRESH_TOKEN: &str = "DRIVE_REFRESH_TOKEN";
47pub const DRIVE_SCOPE: &str = "DRIVE_SCOPE";
49pub const DRIVE_API_URL: &str = "DRIVE_API_URL";
61
62const AUTHORIZATION_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
65const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
67pub const SCOPE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
73pub const SCOPE_METADATA: &str = "https://www.googleapis.com/auth/drive.metadata";
76pub const SCOPE_FILE: &str = "https://www.googleapis.com/auth/drive.file";
84pub const SCOPE_FULL: &str = "https://www.googleapis.com/auth/drive";
91
92const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
94const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
97const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub struct DriveGrantedScopes {
117 pub readonly: bool,
119 pub metadata: bool,
122 pub file: bool,
124 pub full: bool,
126}
127
128impl DriveGrantedScopes {
129 pub const READONLY: Self = Self {
131 readonly: true,
132 metadata: false,
133 file: false,
134 full: false,
135 };
136 pub const METADATA: Self = Self {
139 readonly: true,
140 metadata: true,
141 file: false,
142 full: false,
143 };
144
145 #[must_use]
152 pub fn as_str(self) -> String {
153 [
154 self.readonly.then_some(SCOPE_READONLY),
155 self.metadata.then_some(SCOPE_METADATA),
156 self.file.then_some(SCOPE_FILE),
157 self.full.then_some(SCOPE_FULL),
158 ]
159 .into_iter()
160 .flatten()
161 .collect::<Vec<_>>()
162 .join(" ")
163 }
164
165 #[must_use]
181 pub fn summary(self) -> String {
182 let names: Vec<&str> = [
183 self.readonly.then_some("drive.readonly"),
184 self.metadata.then_some("drive.metadata"),
185 self.file.then_some("drive.file"),
186 self.full.then_some("drive"),
187 ]
188 .into_iter()
189 .flatten()
190 .collect();
191 if names.is_empty() {
192 return "(none)".to_string();
193 }
194 names.join(", ")
195 }
196
197 #[must_use]
206 pub fn from_granted(granted: &str) -> Option<Self> {
207 let tokens: Vec<&str> = granted.split_whitespace().collect();
208 let scopes = Self {
209 readonly: tokens.contains(&SCOPE_READONLY),
210 metadata: tokens.contains(&SCOPE_METADATA),
211 file: tokens.contains(&SCOPE_FILE),
212 full: tokens.contains(&SCOPE_FULL),
213 };
214 (scopes != Self::default()).then_some(scopes)
215 }
216
217 #[must_use]
220 pub fn allows_rename_move(self) -> bool {
221 self.metadata
222 }
223
224 #[must_use]
227 pub fn allows_create_or_upload(self) -> bool {
228 self.file || self.full
229 }
230
231 #[must_use]
235 pub fn allows_edit_arbitrary_content(self) -> bool {
236 self.full
237 }
238
239 #[must_use]
241 pub fn allows_write(self) -> bool {
242 self.metadata || self.file || self.full
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct DriveCredentials {
249 pub client_id: String,
252 pub client_secret: Secret,
254 pub refresh_token: Secret,
256 pub scope: DriveGrantedScopes,
258}
259
260#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
262pub struct DriveAuthStatus {
263 pub has_client_id: bool,
265 pub has_client_secret: bool,
267 pub has_refresh_token: bool,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub scope: Option<String>,
272}
273
274pub(crate) fn resolve(drive: &DriveSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
279 let explicit = fold_explicit(explicit);
280 account::resolve_account(&SystemEnv, drive, explicit.as_deref())
281}
282
283pub(crate) fn resolve_for_write(
287 drive: &DriveSettings,
288 explicit: Option<&str>,
289) -> Result<ResolvedAccount> {
290 let explicit = fold_explicit(explicit);
291 account::resolve_account_for_write(&SystemEnv, drive, explicit.as_deref())
292}
293
294fn fold_explicit(explicit: Option<&str>) -> Option<String> {
298 explicit
299 .map(str::to_string)
300 .or_else(|| account::active_drive_account_from(&SystemEnv))
301}
302
303pub(crate) fn resolve_browser_config_for(
311 drive: &DriveSettings,
312 explicit: Option<&str>,
313) -> Result<BrowserConfig> {
314 match resolve_for_write(drive, explicit)? {
315 ResolvedAccount::Unconfigured => Ok(BrowserConfig::default()),
316 ResolvedAccount::Named(name) => build_browser_config(
317 drive.accounts.get(&name),
318 chrome_profile::resolve_launch_command,
319 ),
320 }
321}
322
323fn build_browser_config(
336 account: Option<&DriveAccountSettings>,
337 resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
338) -> Result<BrowserConfig> {
339 let Some(account) = account else {
340 return Ok(BrowserConfig::default());
341 };
342
343 if let Some(command) = account
344 .browser_command
345 .as_deref()
346 .map(str::trim)
347 .filter(|command| !command.is_empty())
348 {
349 return Ok(BrowserConfig {
350 launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
351 ..BrowserConfig::default()
352 });
353 }
354
355 if account.chrome_profile_from_email {
356 if let Some(email) = account.email_address.as_deref() {
357 if let Some(args) = resolve_chrome_profile(email) {
358 return Ok(BrowserConfig {
359 launch: BrowserLaunch::Command(args),
360 ..BrowserConfig::default()
361 });
362 }
363 } else {
364 tracing::info!(
365 "chrome_profile_from_email is set but email_address is not; \
366 falling back to the default browser"
367 );
368 }
369 }
370
371 Ok(BrowserConfig::default())
372}
373
374pub fn load_credentials() -> Result<DriveCredentials> {
378 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
379}
380
381pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<DriveCredentials> {
391 let settings = Settings::load_or_warn_default();
392 match resolve(&settings.drive, explicit)? {
393 ResolvedAccount::Unconfigured => {
394 let profile = active_profile_from(&SystemEnv);
395 load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
396 settings,
397 profile.as_deref(),
398 ))
399 }
400 ResolvedAccount::Named(name) => load_named_credentials(&settings.drive, &name),
401 }
402}
403
404fn load_named_credentials(drive: &DriveSettings, name: &str) -> Result<DriveCredentials> {
408 let account = drive
409 .accounts
410 .get(name)
411 .ok_or(DriveError::CredentialsNotFound)?;
412 let client_id = account
413 .client_id
414 .clone()
415 .ok_or(DriveError::CredentialsNotFound)?;
416 let client_secret = account_secret(
417 "drive",
418 name,
419 "client_secret",
420 account.client_secret.as_deref(),
421 account.client_secret_file.as_deref(),
422 )?
423 .ok_or(DriveError::CredentialsNotFound)?;
424 let refresh_token = account_secret(
425 "drive",
426 name,
427 "refresh_token",
428 account.refresh_token.as_deref(),
429 account.refresh_token_file.as_deref(),
430 )?
431 .ok_or(DriveError::CredentialsNotFound)?;
432 let scope = account
433 .scope
434 .as_deref()
435 .and_then(DriveGrantedScopes::from_granted)
436 .unwrap_or_default();
437
438 Ok(DriveCredentials {
439 client_id,
440 client_secret,
441 refresh_token,
442 scope,
443 })
444}
445
446pub(crate) fn load_credentials_with(
452 env: &impl crate::utils::env::EnvSource,
453) -> Result<DriveCredentials> {
454 let client_id = env
455 .var(DRIVE_CLIENT_ID)
456 .ok_or(DriveError::CredentialsNotFound)?;
457 let client_secret =
458 secret_var(env, DRIVE_CLIENT_SECRET)?.ok_or(DriveError::CredentialsNotFound)?;
459 let refresh_token =
460 secret_var(env, DRIVE_REFRESH_TOKEN)?.ok_or(DriveError::CredentialsNotFound)?;
461 let scope = env
470 .var(DRIVE_SCOPE)
471 .and_then(|s| DriveGrantedScopes::from_granted(&s))
472 .unwrap_or_default();
473
474 Ok(DriveCredentials {
475 client_id,
476 client_secret,
477 refresh_token,
478 scope,
479 })
480}
481
482pub fn status() -> DriveAuthStatus {
487 status_with(&crate::utils::settings::SettingsEnv::load())
488}
489
490pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> DriveAuthStatus {
492 DriveAuthStatus {
493 has_client_id: env.var(DRIVE_CLIENT_ID).is_some(),
494 has_client_secret: secret_var_is_set(env, DRIVE_CLIENT_SECRET),
495 has_refresh_token: secret_var_is_set(env, DRIVE_REFRESH_TOKEN),
496 scope: env.var(DRIVE_SCOPE),
497 }
498}
499
500#[cfg(feature = "mcp")]
511pub(crate) fn status_for(explicit: Option<&str>) -> Result<DriveAuthStatus> {
512 let settings = Settings::load_or_warn_default();
513 match resolve(&settings.drive, explicit)? {
514 ResolvedAccount::Unconfigured => {
515 let profile = active_profile_from(&SystemEnv);
516 Ok(status_with(
517 &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
518 ))
519 }
520 ResolvedAccount::Named(name) => Ok(status_from_named(&settings.drive, &name)),
521 }
522}
523
524#[cfg(feature = "mcp")]
530fn status_from_named(drive: &DriveSettings, name: &str) -> DriveAuthStatus {
531 let account = drive.accounts.get(name);
532 DriveAuthStatus {
533 has_client_id: account.is_some_and(|a| a.client_id.is_some()),
534 has_client_secret: account.is_some_and(|a| {
535 crate::utils::settings::account_secret_is_set(
536 a.client_secret.as_deref(),
537 a.client_secret_file.as_deref(),
538 )
539 }),
540 has_refresh_token: account.is_some_and(|a| {
541 crate::utils::settings::account_secret_is_set(
542 a.refresh_token.as_deref(),
543 a.refresh_token_file.as_deref(),
544 )
545 }),
546 scope: account.and_then(|a| a.scope.clone()),
547 }
548}
549
550pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
556 let settings = Settings::load_or_warn_default();
557 if settings
558 .drive
559 .accounts
560 .get(name)
561 .is_some_and(|account| account.email_address.is_some())
562 {
563 return Ok(());
564 }
565 Settings::upsert_drive_account(
566 &Settings::get_settings_path()?,
567 name,
568 &[(
569 "email_address",
570 serde_json::Value::String(email.to_string()),
571 )],
572 )
573}
574
575pub fn save_credentials(credentials: &DriveCredentials) -> Result<()> {
580 save_credentials_to(
581 &Settings::get_settings_path()?,
582 active_profile_from(&SystemEnv).as_deref(),
583 credentials,
584 )
585}
586
587pub(crate) fn save_credentials_to(
590 settings_path: &Path,
591 profile: Option<&str>,
592 credentials: &DriveCredentials,
593) -> Result<()> {
594 let scope_str = credentials.scope.as_str();
595 Settings::upsert_env_vars_in(
596 settings_path,
597 profile,
598 &[
599 (DRIVE_CLIENT_ID, credentials.client_id.as_str()),
600 (
601 DRIVE_CLIENT_SECRET,
602 credentials.client_secret.expose_secret(),
603 ),
604 (
605 DRIVE_REFRESH_TOKEN,
606 credentials.refresh_token.expose_secret(),
607 ),
608 (DRIVE_SCOPE, scope_str.as_str()),
609 ],
610 )
611}
612
613fn named_account_vars(credentials: &DriveCredentials) -> [(&str, serde_json::Value); 4] {
617 [
618 (
619 "client_id",
620 serde_json::Value::String(credentials.client_id.clone()),
621 ),
622 (
623 "client_secret",
624 serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
625 ),
626 (
627 "refresh_token",
628 serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
629 ),
630 (
631 "scope",
632 serde_json::Value::String(credentials.scope.as_str()),
633 ),
634 ]
635}
636
637pub fn remove_credentials() -> Result<bool> {
643 remove_credentials_at(
644 &Settings::get_settings_path()?,
645 active_profile_from(&SystemEnv).as_deref(),
646 )
647}
648
649pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
652 Settings::remove_env_vars_in(
653 settings_path,
654 profile,
655 &[
656 DRIVE_CLIENT_ID,
657 DRIVE_CLIENT_SECRET,
658 DRIVE_REFRESH_TOKEN,
659 DRIVE_SCOPE,
660 ],
661 )
662}
663
664pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
669 let settings = Settings::load_or_warn_default();
670 match resolve(&settings.drive, explicit)? {
671 ResolvedAccount::Unconfigured => remove_credentials_at(
672 &Settings::get_settings_path()?,
673 active_profile_from(&SystemEnv).as_deref(),
674 ),
675 ResolvedAccount::Named(name) => {
676 Settings::remove_drive_account(&Settings::get_settings_path()?, &name)
677 }
678 }
679}
680
681#[derive(Clone, Debug, Default)]
692pub enum BrowserLaunch {
693 #[default]
695 Auto,
696 Command(Vec<String>),
700 Manual,
703}
704
705#[derive(Clone, Debug)]
707pub struct BrowserConfig {
708 pub launch: BrowserLaunch,
710 pub callback_addr: IpAddr,
712 pub callback_port: u16,
714}
715
716impl Default for BrowserConfig {
717 fn default() -> Self {
718 Self {
719 launch: BrowserLaunch::Auto,
720 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
721 callback_port: 0,
722 }
723 }
724}
725
726#[allow(clippy::literal_string_with_formatting_args)]
729fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
730 match launch {
731 BrowserLaunch::Manual => {
732 tracing::info!("Open this URL in a browser to sign in to Drive:\n{url}");
733 Ok(())
734 }
735 BrowserLaunch::Command(args) => {
736 let mut parts = args.iter();
737 let program = parts
738 .next()
739 .ok_or_else(|| DriveError::InvalidBrowserCommand("empty browser command".into()))?;
740 let mut command = Command::new(program);
741 let mut placed = false;
742 for arg in parts {
743 if arg.contains("{url}") {
744 command.arg(arg.replace("{url}", url));
745 placed = true;
746 } else {
747 command.arg(arg);
748 }
749 }
750 if !placed {
751 command.arg(url);
752 }
753 spawn_detached(command)
754 }
755 BrowserLaunch::Auto => {
756 let program = if cfg!(target_os = "macos") {
757 "open"
758 } else if cfg!(target_os = "windows") {
759 "explorer"
760 } else {
761 "xdg-open"
762 };
763 let mut command = Command::new(program);
764 command.arg(url);
765 spawn_detached(command)
766 }
767 }
768}
769
770fn spawn_detached(mut command: Command) -> Result<()> {
772 command
773 .stdin(Stdio::null())
774 .stdout(Stdio::null())
775 .stderr(Stdio::null())
776 .spawn()
777 .map(|_| ())
778 .context("Failed to launch the browser")
779}
780
781struct PendingLogin {
786 state: String,
787 code_verifier: String,
788}
789
790fn generate_pending_login() -> PendingLogin {
791 PendingLogin {
792 state: crate::browser::auth::generate_token(),
793 code_verifier: crate::browser::auth::generate_token(),
794 }
795}
796
797fn code_challenge(code_verifier: &str) -> String {
800 let digest = Sha256::digest(code_verifier.as_bytes());
801 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
802}
803
804fn build_authorization_url(
805 client_id: &str,
806 redirect_uri: &str,
807 scope: DriveGrantedScopes,
808 state: &str,
809 code_challenge: &str,
810) -> Result<Url> {
811 let mut url =
812 Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Drive authorization endpoint")?;
813 let requested_scope = scope.as_str();
818 url.query_pairs_mut()
819 .append_pair("client_id", client_id)
820 .append_pair("redirect_uri", redirect_uri)
821 .append_pair("response_type", "code")
822 .append_pair("scope", &requested_scope)
823 .append_pair("state", state)
824 .append_pair("code_challenge", code_challenge)
825 .append_pair("code_challenge_method", "S256")
826 .append_pair("access_type", "offline")
827 .append_pair("prompt", "consent");
831 Ok(url)
832}
833
834#[derive(Debug)]
839pub(crate) struct CallbackResult {
840 code: Option<String>,
841 state: Option<String>,
842 error: Option<String>,
843 error_description: Option<String>,
844}
845
846pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
850 let listener = TcpListener::bind(SocketAddr::new(
851 browser.callback_addr,
852 browser.callback_port,
853 ))
854 .await
855 .context("Failed to start the local OAuth callback listener")?;
856 let port = listener
857 .local_addr()
858 .context("Failed to read the callback listener's port")?
859 .port();
860 Ok((listener, port))
861}
862
863pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
866 wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
867}
868
869pub(crate) async fn wait_for_callback_with_timeout(
876 listener: TcpListener,
877 timeout: Duration,
878) -> Result<CallbackResult> {
879 let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
880 .await
881 .map_err(|_| DriveError::CallbackTimeout(timeout.as_secs()))?
882 .context("Failed to accept the browser's callback connection")?;
883
884 let mut buf = vec![0u8; 8192];
885 let n = stream
886 .read(&mut buf)
887 .await
888 .context("Failed to read the callback request")?;
889 let request = String::from_utf8_lossy(&buf[..n]);
890
891 let result = parse_callback(&request).ok_or(DriveError::MalformedCallback)?;
892 tracing::info!("Drive OAuth callback received");
893
894 let body = if result.error.is_some() {
895 "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
896 } else {
897 "<html><body>Drive sign-in complete. You can close this tab.</body></html>"
898 };
899 let response =
900 format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
901 let _ = stream.write_all(response.as_bytes()).await;
902 let _ = stream.flush().await;
903
904 Ok(result)
905}
906
907fn parse_callback(request: &str) -> Option<CallbackResult> {
910 let first_line = request.lines().next()?;
911 let path = first_line.split_whitespace().nth(1)?; let query = path.split_once('?')?.1;
913
914 let mut result = CallbackResult {
915 code: None,
916 state: None,
917 error: None,
918 error_description: None,
919 };
920 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
921 match key.as_ref() {
922 "code" => result.code = Some(value.into_owned()),
923 "state" => result.state = Some(value.into_owned()),
924 "error" => result.error = Some(value.into_owned()),
925 "error_description" => result.error_description = Some(value.into_owned()),
926 _ => {}
927 }
928 }
929 Some(result)
930}
931
932#[derive(Debug, Deserialize)]
935struct TokenResponse {
936 access_token: String,
937 #[serde(default)]
938 refresh_token: Option<String>,
939 expires_in: i64,
940 #[serde(default)]
941 scope: Option<String>,
942}
943
944#[derive(Debug, Deserialize)]
945struct TokenErrorResponse {
946 error: String,
947 #[serde(default)]
948 error_description: Option<String>,
949}
950
951async fn exchange_code_for_tokens(
952 http: &reqwest::Client,
953 token_endpoint: &str,
954 client_id: &str,
955 client_secret: &str,
956 code: &str,
957 code_verifier: &str,
958 redirect_uri: &str,
959) -> Result<TokenResponse> {
960 let params = [
961 ("grant_type", "authorization_code"),
962 ("code", code),
963 ("client_id", client_id),
964 ("client_secret", client_secret),
965 ("redirect_uri", redirect_uri),
966 ("code_verifier", code_verifier),
967 ];
968 post_token_request(http, token_endpoint, ¶ms, GrantContext::CodeExchange).await
969}
970
971async fn refresh_access_token(
972 http: &reqwest::Client,
973 token_endpoint: &str,
974 client_id: &str,
975 client_secret: &str,
976 refresh_token: &str,
977) -> Result<TokenResponse> {
978 let params = [
979 ("grant_type", "refresh_token"),
980 ("refresh_token", refresh_token),
981 ("client_id", client_id),
982 ("client_secret", client_secret),
983 ];
984 post_token_request(http, token_endpoint, ¶ms, GrantContext::Refresh).await
985}
986
987async fn post_token_request(
991 http: &reqwest::Client,
992 token_endpoint: &str,
993 params: &[(&str, &str)],
994 context: GrantContext,
995) -> Result<TokenResponse> {
996 let started = std::time::Instant::now();
997 let result = http.post(token_endpoint).form(params).send().await;
998 request_log::record_http_result("drive", "POST", token_endpoint, started, &result);
999 let response = result.context("Failed to send token request to Google")?;
1000
1001 if !response.status().is_success() {
1002 let body = response.text().await.unwrap_or_default();
1003 if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
1004 if err.error == "invalid_grant" {
1005 return Err(DriveError::InvalidGrant(context).into());
1006 }
1007 return Err(anyhow::anyhow!(
1008 "Google token endpoint rejected the request: {} ({})",
1009 err.error,
1010 err.error_description.unwrap_or_default()
1011 ));
1012 }
1013 return Err(anyhow::anyhow!(
1014 "Google token endpoint returned an unparsable error body: {body}"
1015 ));
1016 }
1017
1018 response
1019 .json::<TokenResponse>()
1020 .await
1021 .context("Failed to parse Google's token response")
1022}
1023
1024struct TokenState {
1028 access_token: Secret,
1029 expires_at: DateTime<Utc>,
1030}
1031
1032pub struct DriveSession {
1042 http: reqwest::Client,
1043 client_id: String,
1044 client_secret: Secret,
1045 refresh_token: Secret,
1046 token_endpoint: String,
1047 state: tokio::sync::Mutex<TokenState>,
1048}
1049
1050impl DriveSession {
1051 pub(crate) fn new(http: reqwest::Client, credentials: &DriveCredentials) -> Self {
1053 Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
1054 }
1055
1056 pub(crate) fn new_with_token_endpoint(
1059 http: reqwest::Client,
1060 credentials: &DriveCredentials,
1061 token_endpoint: &str,
1062 ) -> Self {
1063 Self {
1064 http,
1065 client_id: credentials.client_id.clone(),
1066 client_secret: credentials.client_secret.clone(),
1067 refresh_token: credentials.refresh_token.clone(),
1068 token_endpoint: token_endpoint.to_string(),
1069 state: tokio::sync::Mutex::new(TokenState {
1070 access_token: Secret::new(""),
1071 expires_at: DateTime::<Utc>::MIN_UTC,
1075 }),
1076 }
1077 }
1078
1079 pub(crate) async fn access_token(&self) -> Result<Secret> {
1082 let mut state = self.state.lock().await;
1083 if Utc::now() + REFRESH_SKEW >= state.expires_at {
1084 self.refresh_locked(&mut state).await?;
1085 }
1086 Ok(state.access_token.clone())
1087 }
1088
1089 pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
1094 let mut state = self.state.lock().await;
1095 if state.access_token != *observed {
1096 return Ok(state.access_token.clone());
1097 }
1098 self.refresh_locked(&mut state).await?;
1099 Ok(state.access_token.clone())
1100 }
1101
1102 async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
1103 let response = refresh_access_token(
1104 &self.http,
1105 &self.token_endpoint,
1106 &self.client_id,
1107 self.client_secret.expose_secret(),
1108 self.refresh_token.expose_secret(),
1109 )
1110 .await?;
1111 state.access_token = response.access_token.into();
1112 let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
1113 state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
1114 Ok(())
1115 }
1116}
1117
1118pub async fn login(
1123 client_id: &str,
1124 client_secret: &Secret,
1125 scope: DriveGrantedScopes,
1126 browser: &BrowserConfig,
1127) -> Result<DriveAuthStatus> {
1128 login_to(
1129 &Settings::get_settings_path()?,
1130 active_profile_from(&SystemEnv).as_deref(),
1131 client_id,
1132 client_secret,
1133 scope,
1134 browser,
1135 TOKEN_ENDPOINT,
1136 )
1137 .await
1138}
1139
1140pub(crate) async fn login_to(
1143 settings_path: &Path,
1144 profile: Option<&str>,
1145 client_id: &str,
1146 client_secret: &Secret,
1147 scope: DriveGrantedScopes,
1148 browser: &BrowserConfig,
1149 token_endpoint: &str,
1150) -> Result<DriveAuthStatus> {
1151 let credentials =
1152 run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1153 save_credentials_to(settings_path, profile, &credentials)?;
1154 Ok(status_from_credentials(&credentials))
1155}
1156
1157pub(crate) async fn login_for(
1165 explicit: Option<&str>,
1166 client_id: &str,
1167 client_secret: &Secret,
1168 scope: DriveGrantedScopes,
1169 browser: &BrowserConfig,
1170) -> Result<DriveAuthStatus> {
1171 let settings = Settings::load_or_warn_default();
1172 match resolve_for_write(&settings.drive, explicit)? {
1173 ResolvedAccount::Unconfigured => {
1174 login_to(
1175 &Settings::get_settings_path()?,
1176 active_profile_from(&SystemEnv).as_deref(),
1177 client_id,
1178 client_secret,
1179 scope,
1180 browser,
1181 TOKEN_ENDPOINT,
1182 )
1183 .await
1184 }
1185 ResolvedAccount::Named(name) => {
1186 let credentials =
1187 run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1188 Settings::upsert_drive_account(
1189 &Settings::get_settings_path()?,
1190 &name,
1191 &named_account_vars(&credentials),
1192 )?;
1193 Ok(status_from_credentials(&credentials))
1194 }
1195 }
1196}
1197
1198async fn run_login_flow(
1203 client_id: &str,
1204 client_secret: &Secret,
1205 scope: DriveGrantedScopes,
1206 browser: &BrowserConfig,
1207 token_endpoint: &str,
1208) -> Result<DriveCredentials> {
1209 let (listener, port) = bind_callback_listener(browser).await?;
1210 let redirect_uri = format!("http://127.0.0.1:{port}");
1211
1212 let pending = generate_pending_login();
1213 let challenge = code_challenge(&pending.code_verifier);
1214 let auth_url =
1215 build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1216 open_browser(&browser.launch, auth_url.as_str())?;
1217
1218 let callback = wait_for_callback(listener).await?;
1219 if let Some(error) = callback.error {
1220 return Err(DriveError::authorization_denied(
1221 &error,
1222 callback.error_description.as_deref(),
1223 )
1224 .into());
1225 }
1226 let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1227 return Err(DriveError::MalformedCallback.into());
1228 };
1229 if returned_state != pending.state {
1234 return Err(DriveError::StateMismatch.into());
1235 }
1236
1237 let http = reqwest::Client::builder()
1238 .connect_timeout(crate::utils::http::connect_timeout())
1239 .read_timeout(crate::utils::http::read_timeout())
1240 .build()
1241 .context("Failed to build HTTP client")?;
1242 let tokens = exchange_code_for_tokens(
1243 &http,
1244 token_endpoint,
1245 client_id,
1246 client_secret.expose_secret(),
1247 &code,
1248 &pending.code_verifier,
1249 &redirect_uri,
1250 )
1251 .await?;
1252 let refresh_token = tokens
1253 .refresh_token
1254 .ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
1255 let granted_raw = tokens.scope.unwrap_or_default();
1256 let granted_scope = DriveGrantedScopes::from_granted(&granted_raw).ok_or_else(|| {
1257 let received = if granted_raw.trim().is_empty() {
1258 "none".to_string()
1259 } else {
1260 granted_raw
1261 .split_whitespace()
1262 .collect::<Vec<_>>()
1263 .join(", ")
1264 };
1265 DriveError::NoScopeGranted(received)
1266 })?;
1267
1268 Ok(DriveCredentials {
1269 client_id: client_id.to_string(),
1270 client_secret: client_secret.clone(),
1271 refresh_token: refresh_token.into(),
1272 scope: granted_scope,
1273 })
1274}
1275
1276fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
1279 DriveAuthStatus {
1280 has_client_id: true,
1281 has_client_secret: true,
1282 has_refresh_token: true,
1283 scope: Some(credentials.scope.as_str()),
1284 }
1285}
1286
1287#[cfg(test)]
1288#[allow(clippy::unwrap_used, clippy::expect_used)]
1289mod tests {
1290 use std::fs;
1291 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1292 use std::sync::Arc;
1293
1294 use super::*;
1295
1296 #[test]
1301 fn from_granted_sets_each_flag_independently_order_agnostic() {
1302 let scope =
1303 DriveGrantedScopes::from_granted(&format!("{SCOPE_FULL} {SCOPE_READONLY}")).unwrap();
1304 assert!(scope.readonly);
1305 assert!(!scope.metadata);
1306 assert!(!scope.file);
1307 assert!(scope.full);
1308 }
1309
1310 #[test]
1311 fn from_granted_recognizes_all_four_scopes_together() {
1312 let granted = format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}");
1313 let scope = DriveGrantedScopes::from_granted(&granted).unwrap();
1314 assert!(scope.readonly);
1315 assert!(scope.metadata);
1316 assert!(scope.file);
1317 assert!(scope.full);
1318 }
1319
1320 #[test]
1321 fn from_granted_returns_none_when_no_drive_scope_present() {
1322 assert_eq!(
1323 DriveGrantedScopes::from_granted("openid email profile"),
1324 None
1325 );
1326 assert_eq!(DriveGrantedScopes::from_granted(""), None);
1327 }
1328
1329 #[test]
1335 fn summary_never_invents_metadata_for_a_full_scope_account() {
1336 let scope = DriveGrantedScopes {
1337 readonly: true,
1338 metadata: false,
1339 file: false,
1340 full: true,
1341 };
1342 assert_eq!(scope.summary(), "drive.readonly, drive");
1343 assert!(
1344 !scope.summary().contains("metadata"),
1345 "the account holds no metadata scope"
1346 );
1347 }
1348
1349 #[test]
1353 fn summary_matches_the_documented_write_write_file_example() {
1354 let scope = DriveGrantedScopes {
1355 readonly: true,
1356 metadata: true,
1357 file: true,
1358 full: false,
1359 };
1360 assert_eq!(
1361 scope.summary(),
1362 "drive.readonly, drive.metadata, drive.file"
1363 );
1364 }
1365
1366 #[test]
1370 fn summary_renders_exactly_the_flags_that_are_set() {
1371 for bits in 0..16u8 {
1372 let scope = DriveGrantedScopes {
1373 readonly: bits & 1 != 0,
1374 metadata: bits & 2 != 0,
1375 file: bits & 4 != 0,
1376 full: bits & 8 != 0,
1377 };
1378 let summary = scope.summary();
1379 for (set, name) in [
1380 (scope.readonly, "drive.readonly"),
1381 (scope.metadata, "drive.metadata"),
1382 (scope.file, "drive.file"),
1383 (scope.full, "drive"),
1384 ] {
1385 let present = summary.split(", ").any(|part| part == name);
1388 assert_eq!(present, set, "{name} in {summary:?} (bits {bits})");
1389 }
1390 }
1391 }
1392
1393 #[test]
1394 fn summary_of_an_empty_set_is_not_an_empty_string() {
1395 assert_eq!(DriveGrantedScopes::default().summary(), "(none)");
1396 }
1397
1398 #[test]
1401 fn summary_and_as_str_agree_on_which_scopes_are_present() {
1402 for bits in 0..16u8 {
1403 let scope = DriveGrantedScopes {
1404 readonly: bits & 1 != 0,
1405 metadata: bits & 2 != 0,
1406 file: bits & 4 != 0,
1407 full: bits & 8 != 0,
1408 };
1409 let wire_count = scope.as_str().split_whitespace().count();
1410 let short_count = if scope.summary() == "(none)" {
1411 0
1412 } else {
1413 scope.summary().split(", ").count()
1414 };
1415 assert_eq!(wire_count, short_count, "bits {bits}");
1416 }
1417 }
1418
1419 #[test]
1420 fn as_str_round_trips_through_from_granted() {
1421 let scope = DriveGrantedScopes {
1422 readonly: true,
1423 metadata: false,
1424 file: true,
1425 full: false,
1426 };
1427 let round_tripped = DriveGrantedScopes::from_granted(&scope.as_str()).unwrap();
1428 assert_eq!(scope, round_tripped);
1429 }
1430
1431 #[test]
1432 fn as_str_orders_scopes_readonly_metadata_file_full() {
1433 let scope = DriveGrantedScopes {
1434 readonly: true,
1435 metadata: true,
1436 file: true,
1437 full: true,
1438 };
1439 assert_eq!(
1440 scope.as_str(),
1441 format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}")
1442 );
1443 }
1444
1445 #[test]
1446 fn allows_predicates_reflect_the_capability_each_names() {
1447 assert!(!DriveGrantedScopes::READONLY.allows_write());
1448 assert!(!DriveGrantedScopes::READONLY.allows_rename_move());
1449 assert!(!DriveGrantedScopes::READONLY.allows_create_or_upload());
1450 assert!(!DriveGrantedScopes::READONLY.allows_edit_arbitrary_content());
1451
1452 assert!(DriveGrantedScopes::METADATA.allows_write());
1453 assert!(DriveGrantedScopes::METADATA.allows_rename_move());
1454 assert!(!DriveGrantedScopes::METADATA.allows_create_or_upload());
1455
1456 let file_only = DriveGrantedScopes {
1457 readonly: true,
1458 metadata: false,
1459 file: true,
1460 full: false,
1461 };
1462 assert!(file_only.allows_create_or_upload());
1463 assert!(
1464 !file_only.allows_edit_arbitrary_content(),
1465 "drive.file alone cannot edit an arbitrary pre-existing file"
1466 );
1467
1468 let full_only = DriveGrantedScopes {
1469 readonly: true,
1470 metadata: false,
1471 file: false,
1472 full: true,
1473 };
1474 assert!(full_only.allows_create_or_upload());
1475 assert!(full_only.allows_edit_arbitrary_content());
1476 }
1477
1478 #[test]
1479 fn code_challenge_matches_rfc_7636_test_vector() {
1480 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1482 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1483 assert_eq!(code_challenge(verifier), expected);
1484 }
1485
1486 #[test]
1487 fn code_challenge_output_is_url_safe_no_padding() {
1488 let challenge = code_challenge("some-verifier-value");
1489 assert!(!challenge.contains('+'));
1490 assert!(!challenge.contains('/'));
1491 assert!(!challenge.contains('='));
1492 }
1493
1494 #[test]
1495 fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1496 let pending = generate_pending_login();
1497 assert_ne!(pending.state, pending.code_verifier);
1498 assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1499 assert!(pending
1500 .code_verifier
1501 .chars()
1502 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1503 }
1504
1505 #[test]
1506 fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1507 let url = build_authorization_url(
1508 "client-123",
1509 "http://127.0.0.1:5555",
1510 DriveGrantedScopes::READONLY,
1511 "state-abc",
1512 "challenge-xyz",
1513 )
1514 .unwrap();
1515 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1516 assert_eq!(query.get("client_id").unwrap(), "client-123");
1517 assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1518 assert_eq!(query.get("response_type").unwrap(), "code");
1519 assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1520 assert_eq!(query.get("state").unwrap(), "state-abc");
1521 assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1522 assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1523 assert_eq!(query.get("access_type").unwrap(), "offline");
1524 assert_eq!(query.get("prompt").unwrap(), "consent");
1525 }
1526
1527 #[test]
1528 fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
1529 let url = build_authorization_url(
1530 "client-123",
1531 "http://127.0.0.1:5555",
1532 DriveGrantedScopes::METADATA,
1533 "state-abc",
1534 "challenge-xyz",
1535 )
1536 .unwrap();
1537 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1538 assert_eq!(
1539 query.get("scope").unwrap(),
1540 &format!("{SCOPE_READONLY} {SCOPE_METADATA}")
1541 );
1542 }
1543
1544 #[test]
1545 fn parse_callback_extracts_code_and_state() {
1546 let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1547 let result = parse_callback(request).unwrap();
1548 assert_eq!(result.code.as_deref(), Some("abc123"));
1549 assert_eq!(result.state.as_deref(), Some("xyz789"));
1550 assert!(result.error.is_none());
1551 }
1552
1553 #[test]
1554 fn parse_callback_extracts_error_and_error_description() {
1555 let request =
1556 "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1557 let result = parse_callback(request).unwrap();
1558 assert_eq!(result.error.as_deref(), Some("access_denied"));
1559 assert_eq!(result.error_description.as_deref(), Some("user declined"));
1560 }
1561
1562 #[test]
1563 fn parse_callback_missing_query_string_is_none() {
1564 assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1565 assert!(parse_callback("garbage").is_none());
1566 }
1567
1568 #[test]
1569 fn parse_callback_ignores_unrecognized_query_keys() {
1570 let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1571 let result = parse_callback(request).unwrap();
1572 assert_eq!(result.code.as_deref(), Some("abc"));
1573 assert_eq!(result.state.as_deref(), Some("xyz"));
1574 }
1575
1576 #[test]
1577 fn open_browser_manual_logs_and_succeeds() {
1578 assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1579 }
1580
1581 #[test]
1582 fn open_browser_command_substitutes_url_placeholder() {
1583 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1584 assert!(open_browser(&launch, "https://example/auth").is_ok());
1585 }
1586
1587 #[test]
1588 fn open_browser_command_appends_url_when_no_placeholder() {
1589 let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1590 assert!(open_browser(&launch, "https://example/auth").is_ok());
1591 }
1592
1593 #[test]
1594 fn open_browser_command_passes_through_args_without_the_placeholder() {
1595 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1599 assert!(open_browser(&launch, "https://example/auth").is_ok());
1600 }
1601
1602 #[test]
1603 fn open_browser_command_rejects_empty_args() {
1604 let launch = BrowserLaunch::Command(vec![]);
1605 let err = open_browser(&launch, "u").unwrap_err();
1606 assert!(err.to_string().contains("empty browser command"));
1607 }
1608
1609 #[test]
1612 fn named_account_vars_maps_credentials_to_json_string_values() {
1613 let credentials = DriveCredentials {
1614 client_id: "client-1".to_string(),
1615 client_secret: Secret::new("secret-1"),
1616 refresh_token: Secret::new("refresh-1"),
1617 scope: DriveGrantedScopes::READONLY,
1618 };
1619 assert_eq!(
1620 named_account_vars(&credentials),
1621 [
1622 (
1623 "client_id",
1624 serde_json::Value::String("client-1".to_string())
1625 ),
1626 (
1627 "client_secret",
1628 serde_json::Value::String("secret-1".to_string())
1629 ),
1630 (
1631 "refresh_token",
1632 serde_json::Value::String("refresh-1".to_string())
1633 ),
1634 (
1635 "scope",
1636 serde_json::Value::String(SCOPE_READONLY.to_string())
1637 ),
1638 ]
1639 );
1640 }
1641
1642 fn assert_is_auto(config: BrowserConfig) {
1645 assert!(matches!(config.launch, BrowserLaunch::Auto));
1646 }
1647
1648 #[test]
1649 fn build_browser_config_defaults_to_auto_with_no_account() {
1650 assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1651 }
1652
1653 #[test]
1654 fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1655 let account = DriveAccountSettings {
1656 email_address: Some("alice@example.com".to_string()),
1657 ..DriveAccountSettings::default()
1658 };
1659 assert_is_auto(
1662 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1663 );
1664 }
1665
1666 #[test]
1667 fn build_browser_config_uses_browser_command_verbatim() {
1668 let account = DriveAccountSettings {
1669 browser_command: Some("chrome --new-window {url}".to_string()),
1670 ..DriveAccountSettings::default()
1671 };
1672 let config =
1673 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1674 assert!(matches!(
1675 config.launch,
1676 BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1677 ));
1678 }
1679
1680 #[test]
1681 fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1682 let account = DriveAccountSettings {
1683 browser_command: Some("chrome {url}".to_string()),
1684 chrome_profile_from_email: true,
1685 email_address: Some("alice@example.com".to_string()),
1686 ..DriveAccountSettings::default()
1687 };
1688 let config =
1689 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1690 assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1691 }
1692
1693 #[test]
1694 fn build_browser_config_rejects_a_malformed_browser_command() {
1695 let account = DriveAccountSettings {
1696 browser_command: Some("chrome \"--flag".to_string()),
1697 ..DriveAccountSettings::default()
1698 };
1699 let err =
1700 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1701 assert!(err.to_string().contains("browser_command"));
1702 }
1703
1704 #[test]
1705 fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1706 let account = DriveAccountSettings {
1707 chrome_profile_from_email: true,
1708 email_address: Some("alice@example.com".to_string()),
1709 ..DriveAccountSettings::default()
1710 };
1711 let config = build_browser_config(Some(&account), |email| {
1712 assert_eq!(email, "alice@example.com");
1713 Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1714 })
1715 .unwrap();
1716 assert!(matches!(
1717 config.launch,
1718 BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1719 ));
1720 }
1721
1722 #[test]
1723 fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1724 let account = DriveAccountSettings {
1725 chrome_profile_from_email: true,
1726 email_address: Some("alice@example.com".to_string()),
1727 ..DriveAccountSettings::default()
1728 };
1729 assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1730 }
1731
1732 #[test]
1733 fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1734 let account = DriveAccountSettings {
1735 chrome_profile_from_email: true,
1736 ..DriveAccountSettings::default()
1737 };
1738 assert_is_auto(
1739 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1740 );
1741 }
1742
1743 #[test]
1746 fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
1747 let guard = crate::drive::test_support::EnvGuard::take();
1748 let _dir = guard.clear_credentials();
1749 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
1750 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
1751 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
1752
1753 let drive = DriveSettings::default();
1754 assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
1755 }
1756
1757 #[test]
1758 fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1759 let guard = crate::drive::test_support::EnvGuard::take();
1760 let _dir = guard.clear_credentials();
1761
1762 let mut drive = DriveSettings::default();
1763 drive.accounts.insert(
1764 "work".to_string(),
1765 DriveAccountSettings {
1766 email_address: Some("alice@example.com".to_string()),
1767 ..DriveAccountSettings::default()
1768 },
1769 );
1770
1771 assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
1774 }
1775
1776 #[tokio::test]
1779 async fn wait_for_callback_times_out_when_nothing_connects() {
1780 let browser = BrowserConfig::default();
1781 let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1782 let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1783 .await
1784 .unwrap_err();
1785 assert!(matches!(
1786 err.downcast_ref::<DriveError>(),
1787 Some(DriveError::CallbackTimeout(_))
1788 ));
1789 }
1790
1791 #[tokio::test]
1792 async fn wait_for_callback_reads_a_real_connection() {
1793 let browser = BrowserConfig::default();
1794 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1795
1796 let client = tokio::spawn(async move {
1797 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1798 .await
1799 .unwrap();
1800 stream
1801 .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1802 .await
1803 .unwrap();
1804 });
1805
1806 let result = wait_for_callback(listener).await.unwrap();
1807 client.await.unwrap();
1808 assert_eq!(result.code.as_deref(), Some("abc"));
1809 assert_eq!(result.state.as_deref(), Some("xyz"));
1810 }
1811
1812 #[tokio::test]
1813 async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1814 let browser = BrowserConfig::default();
1815 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1816
1817 let client = tokio::spawn(async move {
1818 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1819 .await
1820 .unwrap();
1821 stream.write_all(b"not an http request").await.unwrap();
1822 });
1823
1824 let err = wait_for_callback(listener).await.unwrap_err();
1825 client.await.unwrap();
1826 assert!(matches!(
1827 err.downcast_ref::<DriveError>(),
1828 Some(DriveError::MalformedCallback)
1829 ));
1830 }
1831
1832 #[tokio::test]
1835 async fn exchange_code_for_tokens_posts_expected_form_body() {
1836 let server = wiremock::MockServer::start().await;
1837 wiremock::Mock::given(wiremock::matchers::method("POST"))
1838 .and(wiremock::matchers::path("/token"))
1839 .and(wiremock::matchers::body_string_contains(
1840 "grant_type=authorization_code",
1841 ))
1842 .and(wiremock::matchers::body_string_contains(
1843 "code_verifier=verifier-1",
1844 ))
1845 .and(wiremock::matchers::body_string_contains(
1846 "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1847 ))
1848 .respond_with(
1849 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1850 "access_token": "at-1",
1851 "refresh_token": "rt-1",
1852 "expires_in": 3600,
1853 "scope": SCOPE_READONLY,
1854 })),
1855 )
1856 .expect(1)
1857 .mount(&server)
1858 .await;
1859
1860 let http = reqwest::Client::new();
1861 let token_endpoint = format!("{}/token", server.uri());
1862 let response = exchange_code_for_tokens(
1863 &http,
1864 &token_endpoint,
1865 "client-1",
1866 "secret-1",
1867 "code-1",
1868 "verifier-1",
1869 "http://127.0.0.1:9999",
1870 )
1871 .await
1872 .unwrap();
1873 assert_eq!(response.access_token, "at-1");
1874 assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1875 }
1876
1877 #[tokio::test]
1878 async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1879 let server = wiremock::MockServer::start().await;
1880 wiremock::Mock::given(wiremock::matchers::method("POST"))
1881 .respond_with(
1882 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1883 "error": "invalid_grant",
1884 "error_description": "Bad Request",
1885 })),
1886 )
1887 .mount(&server)
1888 .await;
1889
1890 let http = reqwest::Client::new();
1891 let err = exchange_code_for_tokens(
1892 &http,
1893 &server.uri(),
1894 "c",
1895 "s",
1896 "code",
1897 "verifier",
1898 "http://127.0.0.1:1",
1899 )
1900 .await
1901 .unwrap_err();
1902 assert!(err.to_string().contains("PKCE"));
1903 }
1904
1905 async fn connect_and_send(port: u16, request_line: &[u8]) {
1925 let mut stream = loop {
1926 match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1927 Ok(stream) => break stream,
1928 Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1929 }
1930 };
1931 stream.write_all(request_line).await.unwrap();
1932 }
1933
1934 fn reserve_free_port() -> u16 {
1935 std::net::TcpListener::bind("127.0.0.1:0")
1936 .unwrap()
1937 .local_addr()
1938 .unwrap()
1939 .port()
1940 }
1941
1942 fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1946 err.to_string()
1947 .contains("Failed to start the local OAuth callback listener")
1948 }
1949
1950 const PORT_RETRY_ATTEMPTS: u32 = 5;
1951
1952 async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
1957 where
1958 F: FnMut(u16) -> Fut,
1959 Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
1960 {
1961 for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1962 let result = attempt(reserve_free_port()).await;
1963 let is_retryable_conflict =
1964 matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1965 if !is_retryable_conflict {
1966 return result;
1967 }
1968 }
1969 unreachable!("loop always returns on its last iteration")
1970 }
1971
1972 async fn finish_connector(
1977 connector: tokio::task::JoinHandle<()>,
1978 result: &Result<DriveAuthStatus>,
1979 ) {
1980 match result {
1981 Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1982 _ => connector.await.unwrap(),
1983 }
1984 }
1985
1986 async fn wait_for_captured_url(path: &Path) -> String {
1990 loop {
1991 if let Ok(contents) = std::fs::read_to_string(path) {
1992 if !contents.is_empty() {
1993 return contents;
1994 }
1995 }
1996 tokio::time::sleep(Duration::from_millis(2)).await;
1997 }
1998 }
1999
2000 async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
2007 std::fs::create_dir_all("tmp").ok();
2008 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2009 let settings_path = temp_dir.path().join("settings.json");
2010
2011 let result = run_with_port_retry(|port| {
2012 let settings_path = settings_path.clone();
2013 async move {
2014 let browser = BrowserConfig {
2015 launch: BrowserLaunch::Manual,
2016 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2017 callback_port: port,
2018 };
2019 let connector = tokio::spawn(connect_and_send(port, request_line));
2020
2021 let result = login_to(
2022 &settings_path,
2023 None,
2024 "client-id",
2025 &Secret::new("client-secret"),
2026 DriveGrantedScopes::READONLY,
2027 &browser,
2028 "http://127.0.0.1:1/token", )
2030 .await;
2031
2032 finish_connector(connector, &result).await;
2033 result
2034 }
2035 })
2036 .await;
2037
2038 let err = result.unwrap_err();
2039 assert!(!settings_path.exists());
2040 err
2041 }
2042
2043 #[tokio::test]
2044 async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
2045 let attempts = AtomicU32::new(0);
2046
2047 let status = run_with_port_retry(|_port| {
2048 let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
2049 async move {
2050 if attempt_no == 0 {
2051 Err(anyhow::anyhow!(
2052 "Failed to start the local OAuth callback listener: address in use"
2053 ))
2054 } else {
2055 Ok(DriveAuthStatus {
2056 has_client_id: true,
2057 has_client_secret: true,
2058 has_refresh_token: true,
2059 scope: None,
2060 })
2061 }
2062 }
2063 })
2064 .await
2065 .unwrap();
2066
2067 assert_eq!(attempts.load(Ordering::SeqCst), 2);
2068 assert!(status.has_client_id);
2069 }
2070
2071 #[tokio::test]
2072 async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
2073 let attempts = AtomicU32::new(0);
2074
2075 let result = run_with_port_retry(|_port| {
2076 attempts.fetch_add(1, Ordering::SeqCst);
2077 async move { Err(anyhow::anyhow!("some other failure")) }
2078 })
2079 .await;
2080
2081 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2082 assert!(result.is_err());
2083 }
2084
2085 #[tokio::test]
2086 async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
2087 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
2088 let connector = tokio::spawn(async move {
2089 tokio::time::sleep(Duration::from_secs(3600)).await;
2090 let _ = tx.send(());
2091 });
2092 let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
2093 "Failed to start the local OAuth callback listener: address in use"
2094 ));
2095
2096 tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
2097 .await
2098 .expect("finish_connector must not wait for an aborted connector");
2099
2100 assert!(
2101 rx.try_recv().is_err(),
2102 "connector must have been aborted, not run to completion"
2103 );
2104 }
2105
2106 #[tokio::test]
2107 async fn finish_connector_awaits_connector_when_login_to_succeeds() {
2108 let ran = Arc::new(AtomicBool::new(false));
2109 let ran_clone = ran.clone();
2110 let connector = tokio::spawn(async move {
2111 ran_clone.store(true, Ordering::SeqCst);
2112 });
2113 let result = Ok(DriveAuthStatus {
2114 has_client_id: true,
2115 has_client_secret: true,
2116 has_refresh_token: true,
2117 scope: None,
2118 });
2119
2120 finish_connector(connector, &result).await;
2121
2122 assert!(ran.load(Ordering::SeqCst));
2123 }
2124
2125 #[tokio::test]
2126 async fn wait_for_captured_url_polls_until_content_is_written() {
2127 std::fs::create_dir_all("tmp").ok();
2128 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2129 let path = temp_dir.path().join("captured-url.txt");
2130
2131 let write_path = path.clone();
2132 tokio::spawn(async move {
2133 tokio::time::sleep(Duration::from_millis(10)).await;
2134 std::fs::write(&write_path, "").unwrap();
2135 tokio::time::sleep(Duration::from_millis(10)).await;
2136 std::fs::write(&write_path, "https://example.com/authorize").unwrap();
2137 });
2138
2139 let contents = wait_for_captured_url(&path).await;
2140 assert_eq!(contents, "https://example.com/authorize");
2141 }
2142
2143 #[tokio::test]
2144 async fn login_to_rejects_a_callback_with_mismatched_state() {
2145 let err =
2146 run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
2147
2148 assert!(matches!(
2149 err.downcast_ref::<DriveError>(),
2150 Some(DriveError::StateMismatch)
2151 ));
2152 }
2153
2154 #[tokio::test]
2155 async fn login_to_surfaces_access_denied_from_the_callback() {
2156 let err = run_login_to_expect_err(
2157 b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
2158 )
2159 .await;
2160
2161 match err.downcast_ref::<DriveError>() {
2162 Some(DriveError::AuthorizationDenied(message)) => {
2163 assert!(message.contains("access_denied"));
2164 assert!(message.contains("user declined"));
2165 }
2166 other => panic!("expected AuthorizationDenied, got {other:?}"),
2167 }
2168 }
2169
2170 #[tokio::test]
2171 async fn login_to_rejects_a_callback_missing_code_and_state() {
2172 let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
2173
2174 assert!(matches!(
2175 err.downcast_ref::<DriveError>(),
2176 Some(DriveError::MalformedCallback)
2177 ));
2178 }
2179
2180 #[tokio::test]
2181 async fn login_to_completes_full_success_flow_and_persists_credentials() {
2182 std::fs::create_dir_all("tmp").ok();
2191 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2192 let capture_path = temp_dir.path().join("captured-url.txt");
2193 let settings_path = temp_dir.path().join("settings.json");
2194
2195 let server = wiremock::MockServer::start().await;
2196 wiremock::Mock::given(wiremock::matchers::method("POST"))
2197 .and(wiremock::matchers::path("/token"))
2198 .respond_with(
2199 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2200 "access_token": "at-1",
2201 "refresh_token": "rt-1",
2202 "expires_in": 3600,
2203 "scope": SCOPE_READONLY,
2204 })),
2205 )
2206 .expect(1)
2207 .mount(&server)
2208 .await;
2209
2210 let status = run_with_port_retry(|port| {
2211 let capture_path = capture_path.clone();
2212 let settings_path = settings_path.clone();
2213 let token_endpoint = format!("{}/token", server.uri());
2214 async move {
2215 let browser = BrowserConfig {
2216 launch: BrowserLaunch::Command(vec![
2217 "/bin/sh".to_string(),
2218 "-c".to_string(),
2219 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2220 "{url}".to_string(),
2221 ]),
2222 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2223 callback_port: port,
2224 };
2225
2226 let connector = tokio::spawn(async move {
2227 let auth_url = wait_for_captured_url(&capture_path).await;
2228 let parsed = Url::parse(&auth_url).unwrap();
2229 let state = parsed
2230 .query_pairs()
2231 .find(|(k, _)| k == "state")
2232 .map(|(_, v)| v.into_owned())
2233 .expect("authorization URL must carry a state param");
2234 connect_and_send(
2235 port,
2236 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2237 )
2238 .await;
2239 });
2240
2241 let result = login_to(
2242 &settings_path,
2243 None,
2244 "client-id",
2245 &Secret::new("client-secret"),
2246 DriveGrantedScopes::READONLY,
2247 &browser,
2248 &token_endpoint,
2249 )
2250 .await;
2251
2252 finish_connector(connector, &result).await;
2253 result
2254 }
2255 })
2256 .await
2257 .unwrap();
2258
2259 assert!(status.has_client_id);
2260 assert!(status.has_client_secret);
2261 assert!(status.has_refresh_token);
2262 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2263
2264 let saved = std::fs::read_to_string(&settings_path).unwrap();
2265 assert!(saved.contains("rt-1"));
2266 assert!(saved.contains("client-id"));
2267 }
2268
2269 async fn run_login_to_with_token_response(
2276 token_response_body: serde_json::Value,
2277 ) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
2278 std::fs::create_dir_all("tmp").ok();
2279 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2280 let capture_path = temp_dir.path().join("captured-url.txt");
2281 let settings_path = temp_dir.path().join("settings.json");
2282
2283 let server = wiremock::MockServer::start().await;
2284 wiremock::Mock::given(wiremock::matchers::method("POST"))
2285 .and(wiremock::matchers::path("/token"))
2286 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2287 .expect(1)
2288 .mount(&server)
2289 .await;
2290
2291 let result = run_with_port_retry(|port| {
2292 let capture_path = capture_path.clone();
2293 let settings_path = settings_path.clone();
2294 let token_endpoint = format!("{}/token", server.uri());
2295 async move {
2296 let browser = BrowserConfig {
2297 launch: BrowserLaunch::Command(vec![
2298 "/bin/sh".to_string(),
2299 "-c".to_string(),
2300 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2301 "{url}".to_string(),
2302 ]),
2303 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2304 callback_port: port,
2305 };
2306
2307 let connector = tokio::spawn(async move {
2308 let auth_url = wait_for_captured_url(&capture_path).await;
2309 let parsed = Url::parse(&auth_url).unwrap();
2310 let state = parsed
2311 .query_pairs()
2312 .find(|(k, _)| k == "state")
2313 .map(|(_, v)| v.into_owned())
2314 .expect("authorization URL must carry a state param");
2315 connect_and_send(
2316 port,
2317 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2318 )
2319 .await;
2320 });
2321
2322 let result = login_to(
2323 &settings_path,
2324 None,
2325 "client-id",
2326 &Secret::new("client-secret"),
2327 DriveGrantedScopes::READONLY,
2328 &browser,
2329 &token_endpoint,
2330 )
2331 .await;
2332
2333 finish_connector(connector, &result).await;
2334 result
2335 }
2336 })
2337 .await;
2338
2339 (result, settings_path)
2340 }
2341
2342 #[tokio::test]
2343 async fn login_to_rejects_a_grant_with_no_drive_scope() {
2344 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2345 "access_token": "at-1",
2346 "refresh_token": "rt-1",
2347 "expires_in": 3600,
2348 "scope": "openid email profile",
2349 }))
2350 .await;
2351
2352 let err = result.unwrap_err();
2353 assert!(matches!(
2354 err.downcast_ref::<DriveError>(),
2355 Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
2356 ));
2357 assert!(!settings_path.exists());
2358 }
2359
2360 #[tokio::test]
2361 async fn login_to_rejects_a_grant_with_missing_scope_field() {
2362 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2363 "access_token": "at-1",
2364 "refresh_token": "rt-1",
2365 "expires_in": 3600,
2366 }))
2367 .await;
2368
2369 let err = result.unwrap_err();
2370 assert!(matches!(
2371 err.downcast_ref::<DriveError>(),
2372 Some(DriveError::NoScopeGranted(received)) if received == "none"
2373 ));
2374 assert!(!settings_path.exists());
2375 }
2376
2377 #[tokio::test]
2390 async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
2391 let guard = crate::drive::test_support::EnvGuard::take();
2392 let dir = guard.clear_credentials();
2393 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2394
2395 let result = run_with_port_retry(|port| async move {
2396 let browser = BrowserConfig {
2397 launch: BrowserLaunch::Manual,
2398 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2399 callback_port: port,
2400 };
2401 let connector = tokio::spawn(connect_and_send(
2402 port,
2403 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2404 ));
2405
2406 let result = login_for(
2407 None,
2408 "client-id",
2409 &Secret::new("client-secret"),
2410 DriveGrantedScopes::READONLY,
2411 &browser,
2412 )
2413 .await;
2414
2415 finish_connector(connector, &result).await;
2416 result
2417 })
2418 .await;
2419
2420 let err = result.unwrap_err();
2421 assert!(matches!(
2422 err.downcast_ref::<DriveError>(),
2423 Some(DriveError::StateMismatch)
2424 ));
2425 assert!(!settings_path.exists());
2426 }
2427
2428 #[tokio::test]
2429 async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
2430 let guard = crate::drive::test_support::EnvGuard::take();
2431 let dir = guard.clear_credentials();
2432 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2433
2434 let result = run_with_port_retry(|port| async move {
2435 let browser = BrowserConfig {
2436 launch: BrowserLaunch::Manual,
2437 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2438 callback_port: port,
2439 };
2440 let connector = tokio::spawn(connect_and_send(
2441 port,
2442 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2443 ));
2444
2445 let result = login_for(
2446 Some("work"),
2447 "client-id",
2448 &Secret::new("client-secret"),
2449 DriveGrantedScopes::READONLY,
2450 &browser,
2451 )
2452 .await;
2453
2454 finish_connector(connector, &result).await;
2455 result
2456 })
2457 .await;
2458
2459 let err = result.unwrap_err();
2460 assert!(matches!(
2461 err.downcast_ref::<DriveError>(),
2462 Some(DriveError::StateMismatch)
2463 ));
2464 assert!(!settings_path.exists());
2465 }
2466
2467 #[tokio::test]
2468 async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2469 let server = wiremock::MockServer::start().await;
2470 wiremock::Mock::given(wiremock::matchers::method("POST"))
2471 .and(wiremock::matchers::body_string_contains(
2472 "grant_type=refresh_token",
2473 ))
2474 .respond_with(
2475 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2476 "access_token": "at-2",
2477 "expires_in": 1800,
2478 })),
2479 )
2480 .expect(1)
2481 .mount(&server)
2482 .await;
2483
2484 let http = reqwest::Client::new();
2485 let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2486 .await
2487 .unwrap();
2488 assert_eq!(response.access_token, "at-2");
2489 assert_eq!(response.expires_in, 1800);
2490 }
2491
2492 #[tokio::test]
2493 async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2494 let server = wiremock::MockServer::start().await;
2495 wiremock::Mock::given(wiremock::matchers::method("POST"))
2496 .respond_with(
2497 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2498 "error": "invalid_grant",
2499 })),
2500 )
2501 .mount(&server)
2502 .await;
2503
2504 let http = reqwest::Client::new();
2505 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2506 .await
2507 .unwrap_err();
2508 let msg = err.to_string();
2509 assert!(msg.contains("7 days"));
2510 assert!(msg.contains("Testing"));
2511 }
2512
2513 #[tokio::test]
2514 async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2515 let server = wiremock::MockServer::start().await;
2516 wiremock::Mock::given(wiremock::matchers::method("POST"))
2517 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2518 .mount(&server)
2519 .await;
2520
2521 let http = reqwest::Client::new();
2522 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2523 .await
2524 .unwrap_err();
2525 let msg = err.to_string();
2526 assert!(msg.contains("unparsable error body"));
2527 assert!(msg.contains("not json"));
2528 }
2529
2530 #[tokio::test]
2531 async fn token_request_propagates_network_errors() {
2532 let http = reqwest::Client::new();
2533 let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2534 .await
2535 .unwrap_err();
2536 assert!(err.to_string().contains("Failed to send token request"));
2537 }
2538
2539 #[tokio::test]
2540 async fn token_request_errors_on_unparsable_response_body() {
2541 let server = wiremock::MockServer::start().await;
2542 wiremock::Mock::given(wiremock::matchers::method("POST"))
2543 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2544 .mount(&server)
2545 .await;
2546
2547 let http = reqwest::Client::new();
2548 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2549 .await
2550 .unwrap_err();
2551 assert!(err.to_string().contains("Failed to parse"));
2552 }
2553
2554 fn test_credentials() -> DriveCredentials {
2557 DriveCredentials {
2558 client_id: "client-1".to_string(),
2559 client_secret: "secret-1".into(),
2560 refresh_token: "refresh-1".into(),
2561 scope: DriveGrantedScopes::READONLY,
2562 }
2563 }
2564
2565 #[tokio::test]
2566 async fn access_token_refreshes_on_first_call() {
2567 let server = wiremock::MockServer::start().await;
2568 wiremock::Mock::given(wiremock::matchers::method("POST"))
2569 .respond_with(
2570 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2571 "access_token": "at-first",
2572 "expires_in": 3600,
2573 })),
2574 )
2575 .expect(1)
2576 .mount(&server)
2577 .await;
2578
2579 let session = DriveSession::new_with_token_endpoint(
2580 reqwest::Client::new(),
2581 &test_credentials(),
2582 &server.uri(),
2583 );
2584 let token = session.access_token().await.unwrap();
2585 assert_eq!(token.expose_secret(), "at-first");
2586 }
2587
2588 #[tokio::test]
2589 async fn access_token_reuses_cached_token_within_skew_window() {
2590 let server = wiremock::MockServer::start().await;
2591 wiremock::Mock::given(wiremock::matchers::method("POST"))
2592 .respond_with(
2593 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2594 "access_token": "at-cached",
2595 "expires_in": 3600,
2596 })),
2597 )
2598 .expect(1)
2599 .mount(&server)
2600 .await;
2601
2602 let session = DriveSession::new_with_token_endpoint(
2603 reqwest::Client::new(),
2604 &test_credentials(),
2605 &server.uri(),
2606 );
2607 let first = session.access_token().await.unwrap();
2608 let second = session.access_token().await.unwrap();
2609 assert_eq!(first, second);
2610 }
2611
2612 #[tokio::test]
2613 async fn access_token_proactively_refreshes_when_within_skew_window() {
2614 let server = wiremock::MockServer::start().await;
2615 wiremock::Mock::given(wiremock::matchers::method("POST"))
2616 .respond_with(
2617 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2618 "access_token": "at-short",
2619 "expires_in": 30,
2621 })),
2622 )
2623 .expect(2)
2624 .mount(&server)
2625 .await;
2626
2627 let session = DriveSession::new_with_token_endpoint(
2628 reqwest::Client::new(),
2629 &test_credentials(),
2630 &server.uri(),
2631 );
2632 session.access_token().await.unwrap();
2633 session.access_token().await.unwrap();
2634 }
2635
2636 #[tokio::test]
2637 async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2638 let server = wiremock::MockServer::start().await;
2639 wiremock::Mock::given(wiremock::matchers::method("POST"))
2640 .respond_with(
2641 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2642 "access_token": "at-overflow",
2643 "expires_in": i64::MAX,
2646 })),
2647 )
2648 .expect(1)
2649 .mount(&server)
2650 .await;
2651
2652 let session = DriveSession::new_with_token_endpoint(
2653 reqwest::Client::new(),
2654 &test_credentials(),
2655 &server.uri(),
2656 );
2657 let token = session.access_token().await.unwrap();
2658 assert_eq!(token.expose_secret(), "at-overflow");
2659 }
2660
2661 #[tokio::test]
2662 async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2663 let server = wiremock::MockServer::start().await;
2664 wiremock::Mock::given(wiremock::matchers::method("POST"))
2665 .respond_with(
2666 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2667 "access_token": "at-negative",
2668 "expires_in": -3600,
2669 })),
2670 )
2671 .expect(2)
2674 .mount(&server)
2675 .await;
2676
2677 let session = DriveSession::new_with_token_endpoint(
2678 reqwest::Client::new(),
2679 &test_credentials(),
2680 &server.uri(),
2681 );
2682 session.access_token().await.unwrap();
2683 session.access_token().await.unwrap();
2684 }
2685
2686 #[tokio::test]
2687 async fn force_refresh_concurrent_callers_do_not_stampede() {
2688 let server = wiremock::MockServer::start().await;
2689 wiremock::Mock::given(wiremock::matchers::method("POST"))
2690 .respond_with(
2691 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2692 "access_token": "at-bootstrap",
2693 "expires_in": 3600,
2694 })),
2695 )
2696 .up_to_n_times(1)
2697 .with_priority(1)
2698 .mount(&server)
2699 .await;
2700 wiremock::Mock::given(wiremock::matchers::method("POST"))
2701 .respond_with(
2702 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2703 "access_token": "at-refreshed",
2704 "expires_in": 3600,
2705 })),
2706 )
2707 .expect(1)
2708 .with_priority(2)
2709 .mount(&server)
2710 .await;
2711
2712 let session = DriveSession::new_with_token_endpoint(
2713 reqwest::Client::new(),
2714 &test_credentials(),
2715 &server.uri(),
2716 );
2717 let bootstrapped = session.access_token().await.unwrap();
2718 assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2719
2720 let (a, b) = tokio::join!(
2721 session.force_refresh(&bootstrapped),
2722 session.force_refresh(&bootstrapped)
2723 );
2724 let a = a.unwrap();
2725 let b = b.unwrap();
2726 assert_eq!(a, b);
2727 assert_eq!(a.expose_secret(), "at-refreshed");
2728 }
2729
2730 #[tokio::test]
2731 async fn force_refresh_skips_network_call_when_token_already_rotated() {
2732 let server = wiremock::MockServer::start().await;
2733 wiremock::Mock::given(wiremock::matchers::method("POST"))
2734 .respond_with(
2735 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2736 "access_token": "at-a",
2737 "expires_in": 3600,
2738 })),
2739 )
2740 .up_to_n_times(1)
2741 .with_priority(1)
2742 .mount(&server)
2743 .await;
2744 wiremock::Mock::given(wiremock::matchers::method("POST"))
2745 .respond_with(
2746 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2747 "access_token": "at-b",
2748 "expires_in": 3600,
2749 })),
2750 )
2751 .expect(1)
2752 .with_priority(2)
2753 .mount(&server)
2754 .await;
2755
2756 let session = DriveSession::new_with_token_endpoint(
2757 reqwest::Client::new(),
2758 &test_credentials(),
2759 &server.uri(),
2760 );
2761 let stale = Secret::new("at-never-issued");
2762 let bootstrapped = session.access_token().await.unwrap();
2763 assert_eq!(bootstrapped.expose_secret(), "at-a");
2764
2765 let result = session.force_refresh(&stale).await.unwrap();
2768 assert_eq!(result, bootstrapped);
2769
2770 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2772 assert_eq!(refreshed.expose_secret(), "at-b");
2773 }
2774
2775 #[test]
2778 fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
2779 let creds = DriveCredentials {
2780 client_id: "client-visible".to_string(),
2781 client_secret: "sekret-client-secret".into(),
2782 refresh_token: "sekret-refresh-token".into(),
2783 scope: DriveGrantedScopes::READONLY,
2784 };
2785 let debug = format!("{creds:?}");
2786 assert!(debug.contains("DriveCredentials"));
2787 assert!(debug.contains("client-visible"));
2788 assert!(!debug.contains("sekret-client-secret"));
2789 assert!(!debug.contains("sekret-refresh-token"));
2790 assert!(debug.contains("client_secret: <redacted>"));
2791 assert!(debug.contains("refresh_token: <redacted>"));
2792 }
2793
2794 #[test]
2795 fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
2796 let env = crate::test_support::env::MapEnv::new()
2797 .with(DRIVE_CLIENT_ID, "client-id-value")
2798 .with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
2799 .with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2800 .with(DRIVE_SCOPE, SCOPE_READONLY);
2801 let status = status_with(&env);
2802 let yaml = serde_yaml::to_string(&status).unwrap();
2803 assert!(!yaml.contains("sekret-do-not-leak"));
2804 assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2805 }
2806
2807 use crate::test_support::env::MapEnv;
2810
2811 #[test]
2812 fn refresh_token_can_come_from_a_file() {
2813 let (_dir, path) = crate::test_support::env::secret_file("rt-from-file\n");
2814 let env = MapEnv::new()
2815 .with(DRIVE_CLIENT_ID, "id")
2816 .with(DRIVE_CLIENT_SECRET, "secret")
2817 .with("DRIVE_REFRESH_TOKEN_FILE", &path);
2818 let creds = load_credentials_with(&env).unwrap();
2819 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
2820 assert!(status_with(&env).has_refresh_token);
2821 }
2822
2823 #[test]
2824 fn status_reports_all_false_when_nothing_configured() {
2825 let status = status_with(&MapEnv::new());
2826 assert!(!status.has_client_id);
2827 assert!(!status.has_client_secret);
2828 assert!(!status.has_refresh_token);
2829 assert_eq!(status.scope, None);
2830 }
2831
2832 #[test]
2833 fn status_reports_scope_when_present() {
2834 let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
2835 let status = status_with(&env);
2836 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2837 }
2838
2839 #[test]
2840 fn load_credentials_errors_when_client_id_missing() {
2841 let env = MapEnv::new()
2842 .with(DRIVE_CLIENT_SECRET, "s")
2843 .with(DRIVE_REFRESH_TOKEN, "r");
2844 let err = load_credentials_with(&env).unwrap_err();
2845 assert!(err.to_string().contains("not configured"));
2846 }
2847
2848 #[test]
2849 fn load_credentials_errors_when_client_secret_missing() {
2850 let env = MapEnv::new()
2851 .with(DRIVE_CLIENT_ID, "c")
2852 .with(DRIVE_REFRESH_TOKEN, "r");
2853 assert!(load_credentials_with(&env).is_err());
2854 }
2855
2856 #[test]
2857 fn load_credentials_errors_when_refresh_token_missing() {
2858 let env = MapEnv::new()
2859 .with(DRIVE_CLIENT_ID, "c")
2860 .with(DRIVE_CLIENT_SECRET, "s");
2861 assert!(load_credentials_with(&env).is_err());
2862 }
2863
2864 #[test]
2865 fn load_credentials_succeeds_with_all_three_present() {
2866 let env = MapEnv::new()
2867 .with(DRIVE_CLIENT_ID, "c")
2868 .with(DRIVE_CLIENT_SECRET, "s")
2869 .with(DRIVE_REFRESH_TOKEN, "r");
2870 let creds = load_credentials_with(&env).unwrap();
2871 assert_eq!(creds.client_id, "c");
2872 assert_eq!(creds.scope, DriveGrantedScopes::default());
2877 }
2878
2879 #[test]
2882 fn save_then_remove_round_trip() {
2883 {
2885 let temp_dir = {
2886 std::fs::create_dir_all("tmp").ok();
2887 tempfile::TempDir::new_in("tmp").unwrap()
2888 };
2889 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2890
2891 let creds = DriveCredentials {
2892 client_id: "client-1".to_string(),
2893 client_secret: "secret-1".into(),
2894 refresh_token: "refresh-1".into(),
2895 scope: DriveGrantedScopes::READONLY,
2896 };
2897 save_credentials_to(&settings_path, None, &creds).unwrap();
2898
2899 assert!(settings_path.exists());
2900 let val: serde_json::Value =
2901 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2902 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
2903 assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
2904 assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
2905 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2906
2907 #[cfg(unix)]
2908 {
2909 use std::os::unix::fs::PermissionsExt;
2910 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2911 assert_eq!(mode & 0o777, 0o600);
2912 }
2913 }
2914
2915 {
2917 let temp_dir = {
2918 std::fs::create_dir_all("tmp").ok();
2919 tempfile::TempDir::new_in("tmp").unwrap()
2920 };
2921 let omni_dir = temp_dir.path().join(".omni-dev");
2922 fs::create_dir_all(&omni_dir).unwrap();
2923 let settings_path = omni_dir.join("settings.json");
2924 fs::write(
2925 &settings_path,
2926 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2927 )
2928 .unwrap();
2929
2930 let creds = DriveCredentials {
2931 client_id: "client-2".to_string(),
2932 client_secret: "secret-2".into(),
2933 refresh_token: "refresh-2".into(),
2934 scope: DriveGrantedScopes::READONLY,
2935 };
2936 save_credentials_to(&settings_path, None, &creds).unwrap();
2937
2938 let val: serde_json::Value =
2939 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2940 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2941 assert_eq!(val["extra"], true);
2942 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2943 }
2944
2945 {
2947 let temp_dir = {
2948 std::fs::create_dir_all("tmp").ok();
2949 tempfile::TempDir::new_in("tmp").unwrap()
2950 };
2951 let omni_dir = temp_dir.path().join(".omni-dev");
2952 fs::create_dir_all(&omni_dir).unwrap();
2953 let settings_path = omni_dir.join("settings.json");
2954 fs::write(
2955 &settings_path,
2956 r#"{"env": {
2957 "DRIVE_CLIENT_ID": "a",
2958 "DRIVE_CLIENT_SECRET": "b",
2959 "DRIVE_REFRESH_TOKEN": "c",
2960 "DRIVE_SCOPE": "d",
2961 "OTHER_KEY": "keep"
2962 }}"#,
2963 )
2964 .unwrap();
2965
2966 let removed = remove_credentials_at(&settings_path, None).unwrap();
2967 assert!(removed);
2968
2969 let val: serde_json::Value =
2970 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2971 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2972 assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
2973 assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
2974 assert!(val["env"].get("DRIVE_SCOPE").is_none());
2975 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2976 }
2977
2978 {
2980 let temp_dir = {
2981 std::fs::create_dir_all("tmp").ok();
2982 tempfile::TempDir::new_in("tmp").unwrap()
2983 };
2984 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2985 let removed = remove_credentials_at(&settings_path, None).unwrap();
2986 assert!(!removed);
2987 }
2988 }
2989
2990 #[test]
2992 fn save_then_remove_round_trip_in_profile() {
2993 let temp_dir = {
2994 std::fs::create_dir_all("tmp").ok();
2995 tempfile::TempDir::new_in("tmp").unwrap()
2996 };
2997 let omni_dir = temp_dir.path().join(".omni-dev");
2998 fs::create_dir_all(&omni_dir).unwrap();
2999 let settings_path = omni_dir.join("settings.json");
3000 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
3001
3002 let creds = DriveCredentials {
3003 client_id: "client-p".to_string(),
3004 client_secret: "secret-p".into(),
3005 refresh_token: "refresh-p".into(),
3006 scope: DriveGrantedScopes::READONLY,
3007 };
3008 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
3009
3010 let val: serde_json::Value =
3011 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3012 assert_eq!(
3013 val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
3014 "client-p"
3015 );
3016 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3017 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
3018
3019 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3020 assert!(removed);
3021 let val: serde_json::Value =
3022 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3023 assert!(val["profiles"]["work"]["env"]
3024 .get("DRIVE_CLIENT_ID")
3025 .is_none());
3026
3027 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
3028 assert!(!removed);
3029 }
3030
3031 #[test]
3035 fn save_and_remove_credentials_resolve_default_settings_path() {
3036 let guard = crate::drive::test_support::EnvGuard::take();
3037 let dir = guard.clear_credentials();
3038
3039 let creds = DriveCredentials {
3040 client_id: "wrapper-client".to_string(),
3041 client_secret: "wrapper-secret".into(),
3042 refresh_token: "wrapper-refresh".into(),
3043 scope: DriveGrantedScopes::READONLY,
3044 };
3045 save_credentials(&creds).unwrap();
3046
3047 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3048 let val: serde_json::Value =
3049 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3050 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
3051
3052 assert!(remove_credentials().unwrap());
3053 let val: serde_json::Value =
3054 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3055 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
3056 }
3057
3058 fn owner_only_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
3069 let path = dir.path().join(name);
3070 std::fs::write(&path, contents).unwrap();
3071 #[cfg(unix)]
3072 {
3073 use std::os::unix::fs::PermissionsExt;
3074 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3075 }
3076 path.to_str().unwrap().to_string()
3077 }
3078
3079 fn named_settings(account: DriveAccountSettings) -> DriveSettings {
3080 DriveSettings {
3081 accounts: std::iter::once(("work".to_string(), account)).collect(),
3082 ..DriveSettings::default()
3083 }
3084 }
3085
3086 #[test]
3087 fn load_named_credentials_reads_secrets_from_their_files() {
3088 let dir = tempfile::tempdir().unwrap();
3089 let account = DriveAccountSettings {
3090 client_id: Some("id".to_string()),
3091 client_secret_file: Some(owner_only_file(&dir, "cs", "cs-from-file\n")),
3092 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt-from-file\n")),
3093 ..DriveAccountSettings::default()
3094 };
3095 let creds = load_named_credentials(&named_settings(account), "work").unwrap();
3096 assert_eq!(creds.client_secret.expose_secret(), "cs-from-file");
3097 assert_eq!(creds.refresh_token.expose_secret(), "rt-from-file");
3098 }
3099
3100 #[test]
3101 fn load_named_credentials_rejects_a_field_and_its_file_together() {
3102 let dir = tempfile::tempdir().unwrap();
3103 let account = DriveAccountSettings {
3104 client_id: Some("id".to_string()),
3105 client_secret: Some("cs".to_string()),
3106 refresh_token: Some("rt".to_string()),
3107 refresh_token_file: Some(owner_only_file(&dir, "rt", "rt\n")),
3108 ..DriveAccountSettings::default()
3109 };
3110 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3111 assert_eq!(
3112 err.to_string(),
3113 "both drive.accounts.work.refresh_token and drive.accounts.work.refresh_token_file \
3114 are set; set only one of them"
3115 );
3116 }
3117
3118 #[test]
3119 fn load_named_credentials_rejects_a_relative_secret_file() {
3120 let account = DriveAccountSettings {
3121 client_id: Some("id".to_string()),
3122 client_secret_file: Some("secrets/cs".to_string()),
3123 refresh_token: Some("rt".to_string()),
3124 ..DriveAccountSettings::default()
3125 };
3126 let err = load_named_credentials(&named_settings(account), "work").unwrap_err();
3127 assert!(
3128 err.to_string()
3129 .contains("drive.accounts.work.client_secret_file must be an absolute path"),
3130 "{err}"
3131 );
3132 }
3133
3134 #[cfg(feature = "mcp")]
3135 #[test]
3136 fn status_from_named_counts_a_secret_file_as_present() {
3137 let account = DriveAccountSettings {
3138 client_id: Some("id".to_string()),
3139 client_secret_file: Some("/never/read".to_string()),
3140 refresh_token_file: Some("/never/read".to_string()),
3141 ..DriveAccountSettings::default()
3142 };
3143 let status = status_from_named(&named_settings(account), "work");
3144 assert!(status.has_client_secret);
3145 assert!(status.has_refresh_token);
3146 }
3147
3148 #[test]
3149 fn load_credentials_for_named_reads_from_drive_accounts() {
3150 let guard = crate::drive::test_support::EnvGuard::take();
3151 let dir = guard.clear_credentials();
3152 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3153 Settings::upsert_drive_account(
3154 &settings_path,
3155 "work",
3156 &[
3157 (
3158 "client_id",
3159 serde_json::Value::String("work-id".to_string()),
3160 ),
3161 (
3162 "client_secret",
3163 serde_json::Value::String("work-secret".to_string()),
3164 ),
3165 (
3166 "refresh_token",
3167 serde_json::Value::String("work-refresh".to_string()),
3168 ),
3169 (
3170 "scope",
3171 serde_json::Value::String(SCOPE_READONLY.to_string()),
3172 ),
3173 ],
3174 )
3175 .unwrap();
3176
3177 let creds = load_credentials_for(Some("work")).unwrap();
3178 assert_eq!(creds.client_id, "work-id");
3179 assert_eq!(creds.client_secret.expose_secret(), "work-secret");
3180 assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
3181 assert_eq!(creds.scope, DriveGrantedScopes::READONLY);
3182 }
3183
3184 #[test]
3185 fn load_credentials_for_unknown_named_account_errors() {
3186 let guard = crate::drive::test_support::EnvGuard::take();
3187 let dir = guard.clear_credentials();
3188 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3189 Settings::upsert_drive_account(
3190 &settings_path,
3191 "work",
3192 &[(
3193 "client_id",
3194 serde_json::Value::String("work-id".to_string()),
3195 )],
3196 )
3197 .unwrap();
3198
3199 let err = load_credentials_for(Some("bogus")).unwrap_err();
3200 assert!(err.to_string().contains("unknown Drive account 'bogus'"));
3201 }
3202
3203 #[test]
3204 fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
3205 let guard = crate::drive::test_support::EnvGuard::take();
3206 let _dir = guard.clear_credentials();
3207 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3208 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
3209 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
3210
3211 let creds = load_credentials_for(None).unwrap();
3212 assert_eq!(creds.client_id, "literal-id");
3213 }
3214
3215 #[test]
3216 fn load_credentials_for_none_honors_ambient_account_env_var() {
3217 let guard = crate::drive::test_support::EnvGuard::take();
3218 let dir = guard.clear_credentials();
3219 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3220 Settings::upsert_drive_account(
3221 &settings_path,
3222 "work",
3223 &[
3224 (
3225 "client_id",
3226 serde_json::Value::String("work-id".to_string()),
3227 ),
3228 (
3229 "client_secret",
3230 serde_json::Value::String("work-secret".to_string()),
3231 ),
3232 (
3233 "refresh_token",
3234 serde_json::Value::String("work-refresh".to_string()),
3235 ),
3236 ],
3237 )
3238 .unwrap();
3239 std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
3240
3241 let creds = load_credentials_for(None).unwrap();
3242 assert_eq!(creds.client_id, "work-id");
3243 }
3244
3245 #[test]
3246 fn remove_credentials_for_named_removes_whole_account() {
3247 let guard = crate::drive::test_support::EnvGuard::take();
3248 let dir = guard.clear_credentials();
3249 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3250 Settings::upsert_drive_account(
3251 &settings_path,
3252 "work",
3253 &[("client_id", serde_json::Value::String("id".to_string()))],
3254 )
3255 .unwrap();
3256
3257 assert!(remove_credentials_for(Some("work")).unwrap());
3258 let val: serde_json::Value =
3259 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3260 assert!(val["drive"]["accounts"].get("work").is_none());
3261 }
3262
3263 #[cfg(feature = "mcp")]
3264 #[test]
3265 fn status_for_named_reports_presence_from_account() {
3266 let guard = crate::drive::test_support::EnvGuard::take();
3267 let dir = guard.clear_credentials();
3268 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3269 Settings::upsert_drive_account(
3270 &settings_path,
3271 "work",
3272 &[
3273 ("client_id", serde_json::Value::String("id".to_string())),
3274 (
3275 "scope",
3276 serde_json::Value::String(SCOPE_READONLY.to_string()),
3277 ),
3278 ],
3279 )
3280 .unwrap();
3281
3282 let status = status_for(Some("work")).unwrap();
3283 assert!(status.has_client_id);
3284 assert!(!status.has_client_secret);
3285 assert!(!status.has_refresh_token);
3286 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
3287 }
3288
3289 #[cfg(feature = "mcp")]
3290 #[test]
3291 fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
3292 let guard = crate::drive::test_support::EnvGuard::take();
3293 let _dir = guard.clear_credentials();
3294 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3295
3296 let status = status_for(None).unwrap();
3297 assert!(status.has_client_id);
3298 assert!(!status.has_refresh_token);
3299 }
3300
3301 #[test]
3302 fn record_account_email_writes_email_address_only() {
3303 let guard = crate::drive::test_support::EnvGuard::take();
3304 let dir = guard.clear_credentials();
3305 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3306 Settings::upsert_drive_account(
3307 &settings_path,
3308 "work",
3309 &[("client_id", serde_json::Value::String("id".to_string()))],
3310 )
3311 .unwrap();
3312
3313 record_account_email("work", "alice@work.com").unwrap();
3314
3315 let val: serde_json::Value =
3316 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3317 assert_eq!(
3318 val["drive"]["accounts"]["work"]["email_address"],
3319 "alice@work.com"
3320 );
3321 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3322 }
3323
3324 #[test]
3325 fn record_account_email_does_not_overwrite_an_existing_value() {
3326 let guard = crate::drive::test_support::EnvGuard::take();
3327 let dir = guard.clear_credentials();
3328 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3329 Settings::upsert_drive_account(
3330 &settings_path,
3331 "work",
3332 &[(
3333 "email_address",
3334 serde_json::Value::String("manually-set@work.com".to_string()),
3335 )],
3336 )
3337 .unwrap();
3338
3339 record_account_email("work", "alice@work.com").unwrap();
3340
3341 let val: serde_json::Value =
3342 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3343 assert_eq!(
3344 val["drive"]["accounts"]["work"]["email_address"],
3345 "manually-set@work.com"
3346 );
3347 }
3348
3349 #[test]
3358 fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
3359 let guard = crate::drive::test_support::EnvGuard::take();
3360 let creds = DriveCredentials {
3361 client_id: "id".to_string(),
3362 client_secret: "secret".into(),
3363 refresh_token: "refresh".into(),
3364 scope: DriveGrantedScopes::READONLY,
3365 };
3366
3367 let dir_direct = guard.clear_credentials();
3368 save_credentials(&creds).unwrap();
3369 let direct_written =
3370 fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
3371 let direct_loaded = load_credentials().unwrap();
3372 let direct_removed = remove_credentials().unwrap();
3373
3374 let dir_for = guard.clear_credentials();
3375 save_credentials(&creds).unwrap();
3376 let for_written =
3377 fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3378 let for_loaded = load_credentials_for(None).unwrap();
3379 let for_removed = remove_credentials_for(None).unwrap();
3380
3381 assert_eq!(direct_written, for_written);
3382 assert_eq!(direct_loaded.client_id, for_loaded.client_id);
3383 assert_eq!(
3384 direct_loaded.client_secret.expose_secret(),
3385 for_loaded.client_secret.expose_secret()
3386 );
3387 assert_eq!(direct_loaded.scope, for_loaded.scope);
3388 assert_eq!(direct_removed, for_removed);
3389 }
3390}