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";
73pub const SCOPE_FILE: &str = "https://www.googleapis.com/auth/drive.file";
81pub const SCOPE_FULL: &str = "https://www.googleapis.com/auth/drive";
88
89const CALLBACK_TIMEOUT: Duration = Duration::from_secs(120);
91const REFRESH_SKEW: TimeDelta = TimeDelta::seconds(60);
94const MAX_EXPIRES_IN_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub struct DriveGrantedScopes {
114 pub readonly: bool,
116 pub metadata: bool,
119 pub file: bool,
121 pub full: bool,
123}
124
125impl DriveGrantedScopes {
126 pub const READONLY: Self = Self {
128 readonly: true,
129 metadata: false,
130 file: false,
131 full: false,
132 };
133 pub const METADATA: Self = Self {
136 readonly: true,
137 metadata: true,
138 file: false,
139 full: false,
140 };
141
142 #[must_use]
149 pub fn as_str(self) -> String {
150 [
151 self.readonly.then_some(SCOPE_READONLY),
152 self.metadata.then_some(SCOPE_METADATA),
153 self.file.then_some(SCOPE_FILE),
154 self.full.then_some(SCOPE_FULL),
155 ]
156 .into_iter()
157 .flatten()
158 .collect::<Vec<_>>()
159 .join(" ")
160 }
161
162 #[must_use]
171 pub fn from_granted(granted: &str) -> Option<Self> {
172 let tokens: Vec<&str> = granted.split_whitespace().collect();
173 let scopes = Self {
174 readonly: tokens.contains(&SCOPE_READONLY),
175 metadata: tokens.contains(&SCOPE_METADATA),
176 file: tokens.contains(&SCOPE_FILE),
177 full: tokens.contains(&SCOPE_FULL),
178 };
179 (scopes != Self::default()).then_some(scopes)
180 }
181
182 #[must_use]
185 pub fn allows_rename_move(self) -> bool {
186 self.metadata
187 }
188
189 #[must_use]
192 pub fn allows_create_or_upload(self) -> bool {
193 self.file || self.full
194 }
195
196 #[must_use]
200 pub fn allows_edit_arbitrary_content(self) -> bool {
201 self.full
202 }
203
204 #[must_use]
206 pub fn allows_write(self) -> bool {
207 self.metadata || self.file || self.full
208 }
209}
210
211#[derive(Debug, Clone)]
213pub struct DriveCredentials {
214 pub client_id: String,
217 pub client_secret: Secret,
219 pub refresh_token: Secret,
221 pub scope: DriveGrantedScopes,
223}
224
225#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
227pub struct DriveAuthStatus {
228 pub has_client_id: bool,
230 pub has_client_secret: bool,
232 pub has_refresh_token: bool,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub scope: Option<String>,
237}
238
239pub(crate) fn resolve(drive: &DriveSettings, explicit: Option<&str>) -> Result<ResolvedAccount> {
244 let explicit = fold_explicit(explicit);
245 account::resolve_account(&SystemEnv, drive, explicit.as_deref())
246}
247
248pub(crate) fn resolve_for_write(
252 drive: &DriveSettings,
253 explicit: Option<&str>,
254) -> Result<ResolvedAccount> {
255 let explicit = fold_explicit(explicit);
256 account::resolve_account_for_write(&SystemEnv, drive, explicit.as_deref())
257}
258
259fn fold_explicit(explicit: Option<&str>) -> Option<String> {
263 explicit
264 .map(str::to_string)
265 .or_else(|| account::active_drive_account_from(&SystemEnv))
266}
267
268pub(crate) fn resolve_browser_config_for(
276 drive: &DriveSettings,
277 explicit: Option<&str>,
278) -> Result<BrowserConfig> {
279 match resolve_for_write(drive, explicit)? {
280 ResolvedAccount::Unconfigured => Ok(BrowserConfig::default()),
281 ResolvedAccount::Named(name) => build_browser_config(
282 drive.accounts.get(&name),
283 chrome_profile::resolve_launch_command,
284 ),
285 }
286}
287
288fn build_browser_config(
301 account: Option<&DriveAccountSettings>,
302 resolve_chrome_profile: impl FnOnce(&str) -> Option<Vec<String>>,
303) -> Result<BrowserConfig> {
304 let Some(account) = account else {
305 return Ok(BrowserConfig::default());
306 };
307
308 if let Some(command) = account
309 .browser_command
310 .as_deref()
311 .map(str::trim)
312 .filter(|command| !command.is_empty())
313 {
314 return Ok(BrowserConfig {
315 launch: BrowserLaunch::Command(split_browser_command("browser_command", command)?),
316 ..BrowserConfig::default()
317 });
318 }
319
320 if account.chrome_profile_from_email {
321 if let Some(email) = account.email_address.as_deref() {
322 if let Some(args) = resolve_chrome_profile(email) {
323 return Ok(BrowserConfig {
324 launch: BrowserLaunch::Command(args),
325 ..BrowserConfig::default()
326 });
327 }
328 } else {
329 tracing::info!(
330 "chrome_profile_from_email is set but email_address is not; \
331 falling back to the default browser"
332 );
333 }
334 }
335
336 Ok(BrowserConfig::default())
337}
338
339pub fn load_credentials() -> Result<DriveCredentials> {
343 load_credentials_with(&crate::utils::settings::SettingsEnv::load())
344}
345
346pub(crate) fn load_credentials_for(explicit: Option<&str>) -> Result<DriveCredentials> {
356 let settings = Settings::load().unwrap_or_default();
357 match resolve(&settings.drive, explicit)? {
358 ResolvedAccount::Unconfigured => {
359 let profile = active_profile_from(&SystemEnv);
360 load_credentials_with(&crate::utils::settings::SettingsEnv::from_settings(
361 settings,
362 profile.as_deref(),
363 ))
364 }
365 ResolvedAccount::Named(name) => load_named_credentials(&settings.drive, &name),
366 }
367}
368
369fn load_named_credentials(drive: &DriveSettings, name: &str) -> Result<DriveCredentials> {
373 let account = drive
374 .accounts
375 .get(name)
376 .ok_or(DriveError::CredentialsNotFound)?;
377 let client_id = account
378 .client_id
379 .clone()
380 .ok_or(DriveError::CredentialsNotFound)?;
381 let client_secret = account
382 .client_secret
383 .clone()
384 .ok_or(DriveError::CredentialsNotFound)?;
385 let refresh_token = account
386 .refresh_token
387 .clone()
388 .ok_or(DriveError::CredentialsNotFound)?;
389 let scope = account
390 .scope
391 .as_deref()
392 .and_then(DriveGrantedScopes::from_granted)
393 .unwrap_or_default();
394
395 Ok(DriveCredentials {
396 client_id,
397 client_secret: client_secret.into(),
398 refresh_token: refresh_token.into(),
399 scope,
400 })
401}
402
403pub(crate) fn load_credentials_with(
409 env: &impl crate::utils::env::EnvSource,
410) -> Result<DriveCredentials> {
411 let client_id = env
412 .var(DRIVE_CLIENT_ID)
413 .ok_or(DriveError::CredentialsNotFound)?;
414 let client_secret = env
415 .var(DRIVE_CLIENT_SECRET)
416 .ok_or(DriveError::CredentialsNotFound)?;
417 let refresh_token = env
418 .var(DRIVE_REFRESH_TOKEN)
419 .ok_or(DriveError::CredentialsNotFound)?;
420 let scope = env
429 .var(DRIVE_SCOPE)
430 .and_then(|s| DriveGrantedScopes::from_granted(&s))
431 .unwrap_or_default();
432
433 Ok(DriveCredentials {
434 client_id,
435 client_secret: client_secret.into(),
436 refresh_token: refresh_token.into(),
437 scope,
438 })
439}
440
441pub fn status() -> DriveAuthStatus {
446 status_with(&crate::utils::settings::SettingsEnv::load())
447}
448
449pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> DriveAuthStatus {
451 DriveAuthStatus {
452 has_client_id: env.var(DRIVE_CLIENT_ID).is_some(),
453 has_client_secret: env.var(DRIVE_CLIENT_SECRET).is_some(),
454 has_refresh_token: env.var(DRIVE_REFRESH_TOKEN).is_some(),
455 scope: env.var(DRIVE_SCOPE),
456 }
457}
458
459#[cfg(feature = "mcp")]
470pub(crate) fn status_for(explicit: Option<&str>) -> Result<DriveAuthStatus> {
471 let settings = Settings::load().unwrap_or_default();
472 match resolve(&settings.drive, explicit)? {
473 ResolvedAccount::Unconfigured => {
474 let profile = active_profile_from(&SystemEnv);
475 Ok(status_with(
476 &crate::utils::settings::SettingsEnv::from_settings(settings, profile.as_deref()),
477 ))
478 }
479 ResolvedAccount::Named(name) => Ok(status_from_named(&settings.drive, &name)),
480 }
481}
482
483#[cfg(feature = "mcp")]
489fn status_from_named(drive: &DriveSettings, name: &str) -> DriveAuthStatus {
490 let account = drive.accounts.get(name);
491 DriveAuthStatus {
492 has_client_id: account.is_some_and(|a| a.client_id.is_some()),
493 has_client_secret: account.is_some_and(|a| a.client_secret.is_some()),
494 has_refresh_token: account.is_some_and(|a| a.refresh_token.is_some()),
495 scope: account.and_then(|a| a.scope.clone()),
496 }
497}
498
499pub(crate) fn record_account_email(name: &str, email: &str) -> Result<()> {
505 let settings = Settings::load().unwrap_or_default();
506 if settings
507 .drive
508 .accounts
509 .get(name)
510 .is_some_and(|account| account.email_address.is_some())
511 {
512 return Ok(());
513 }
514 Settings::upsert_drive_account(
515 &Settings::get_settings_path()?,
516 name,
517 &[(
518 "email_address",
519 serde_json::Value::String(email.to_string()),
520 )],
521 )
522}
523
524pub fn save_credentials(credentials: &DriveCredentials) -> Result<()> {
529 save_credentials_to(
530 &Settings::get_settings_path()?,
531 active_profile_from(&SystemEnv).as_deref(),
532 credentials,
533 )
534}
535
536pub(crate) fn save_credentials_to(
539 settings_path: &Path,
540 profile: Option<&str>,
541 credentials: &DriveCredentials,
542) -> Result<()> {
543 let scope_str = credentials.scope.as_str();
544 Settings::upsert_env_vars_in(
545 settings_path,
546 profile,
547 &[
548 (DRIVE_CLIENT_ID, credentials.client_id.as_str()),
549 (
550 DRIVE_CLIENT_SECRET,
551 credentials.client_secret.expose_secret(),
552 ),
553 (
554 DRIVE_REFRESH_TOKEN,
555 credentials.refresh_token.expose_secret(),
556 ),
557 (DRIVE_SCOPE, scope_str.as_str()),
558 ],
559 )
560}
561
562fn named_account_vars(credentials: &DriveCredentials) -> [(&str, serde_json::Value); 4] {
566 [
567 (
568 "client_id",
569 serde_json::Value::String(credentials.client_id.clone()),
570 ),
571 (
572 "client_secret",
573 serde_json::Value::String(credentials.client_secret.expose_secret().to_string()),
574 ),
575 (
576 "refresh_token",
577 serde_json::Value::String(credentials.refresh_token.expose_secret().to_string()),
578 ),
579 (
580 "scope",
581 serde_json::Value::String(credentials.scope.as_str()),
582 ),
583 ]
584}
585
586pub fn remove_credentials() -> Result<bool> {
592 remove_credentials_at(
593 &Settings::get_settings_path()?,
594 active_profile_from(&SystemEnv).as_deref(),
595 )
596}
597
598pub(crate) fn remove_credentials_at(settings_path: &Path, profile: Option<&str>) -> Result<bool> {
601 Settings::remove_env_vars_in(
602 settings_path,
603 profile,
604 &[
605 DRIVE_CLIENT_ID,
606 DRIVE_CLIENT_SECRET,
607 DRIVE_REFRESH_TOKEN,
608 DRIVE_SCOPE,
609 ],
610 )
611}
612
613pub(crate) fn remove_credentials_for(explicit: Option<&str>) -> Result<bool> {
618 let settings = Settings::load().unwrap_or_default();
619 match resolve(&settings.drive, explicit)? {
620 ResolvedAccount::Unconfigured => remove_credentials_at(
621 &Settings::get_settings_path()?,
622 active_profile_from(&SystemEnv).as_deref(),
623 ),
624 ResolvedAccount::Named(name) => {
625 Settings::remove_drive_account(&Settings::get_settings_path()?, &name)
626 }
627 }
628}
629
630#[derive(Clone, Debug, Default)]
641pub enum BrowserLaunch {
642 #[default]
644 Auto,
645 Command(Vec<String>),
649 Manual,
652}
653
654#[derive(Clone, Debug)]
656pub struct BrowserConfig {
657 pub launch: BrowserLaunch,
659 pub callback_addr: IpAddr,
661 pub callback_port: u16,
663}
664
665impl Default for BrowserConfig {
666 fn default() -> Self {
667 Self {
668 launch: BrowserLaunch::Auto,
669 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
670 callback_port: 0,
671 }
672 }
673}
674
675#[allow(clippy::literal_string_with_formatting_args)]
678fn open_browser(launch: &BrowserLaunch, url: &str) -> Result<()> {
679 match launch {
680 BrowserLaunch::Manual => {
681 tracing::info!("Open this URL in a browser to sign in to Drive:\n{url}");
682 Ok(())
683 }
684 BrowserLaunch::Command(args) => {
685 let mut parts = args.iter();
686 let program = parts
687 .next()
688 .ok_or_else(|| DriveError::InvalidBrowserCommand("empty browser command".into()))?;
689 let mut command = Command::new(program);
690 let mut placed = false;
691 for arg in parts {
692 if arg.contains("{url}") {
693 command.arg(arg.replace("{url}", url));
694 placed = true;
695 } else {
696 command.arg(arg);
697 }
698 }
699 if !placed {
700 command.arg(url);
701 }
702 spawn_detached(command)
703 }
704 BrowserLaunch::Auto => {
705 let program = if cfg!(target_os = "macos") {
706 "open"
707 } else if cfg!(target_os = "windows") {
708 "explorer"
709 } else {
710 "xdg-open"
711 };
712 let mut command = Command::new(program);
713 command.arg(url);
714 spawn_detached(command)
715 }
716 }
717}
718
719fn spawn_detached(mut command: Command) -> Result<()> {
721 command
722 .stdin(Stdio::null())
723 .stdout(Stdio::null())
724 .stderr(Stdio::null())
725 .spawn()
726 .map(|_| ())
727 .context("Failed to launch the browser")
728}
729
730struct PendingLogin {
735 state: String,
736 code_verifier: String,
737}
738
739fn generate_pending_login() -> PendingLogin {
740 PendingLogin {
741 state: crate::browser::auth::generate_token(),
742 code_verifier: crate::browser::auth::generate_token(),
743 }
744}
745
746fn code_challenge(code_verifier: &str) -> String {
749 let digest = Sha256::digest(code_verifier.as_bytes());
750 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
751}
752
753fn build_authorization_url(
754 client_id: &str,
755 redirect_uri: &str,
756 scope: DriveGrantedScopes,
757 state: &str,
758 code_challenge: &str,
759) -> Result<Url> {
760 let mut url =
761 Url::parse(AUTHORIZATION_ENDPOINT).context("Invalid Drive authorization endpoint")?;
762 let requested_scope = scope.as_str();
767 url.query_pairs_mut()
768 .append_pair("client_id", client_id)
769 .append_pair("redirect_uri", redirect_uri)
770 .append_pair("response_type", "code")
771 .append_pair("scope", &requested_scope)
772 .append_pair("state", state)
773 .append_pair("code_challenge", code_challenge)
774 .append_pair("code_challenge_method", "S256")
775 .append_pair("access_type", "offline")
776 .append_pair("prompt", "consent");
780 Ok(url)
781}
782
783#[derive(Debug)]
788pub(crate) struct CallbackResult {
789 code: Option<String>,
790 state: Option<String>,
791 error: Option<String>,
792 error_description: Option<String>,
793}
794
795pub(crate) async fn bind_callback_listener(browser: &BrowserConfig) -> Result<(TcpListener, u16)> {
799 let listener = TcpListener::bind(SocketAddr::new(
800 browser.callback_addr,
801 browser.callback_port,
802 ))
803 .await
804 .context("Failed to start the local OAuth callback listener")?;
805 let port = listener
806 .local_addr()
807 .context("Failed to read the callback listener's port")?
808 .port();
809 Ok((listener, port))
810}
811
812pub(crate) async fn wait_for_callback(listener: TcpListener) -> Result<CallbackResult> {
815 wait_for_callback_with_timeout(listener, CALLBACK_TIMEOUT).await
816}
817
818pub(crate) async fn wait_for_callback_with_timeout(
825 listener: TcpListener,
826 timeout: Duration,
827) -> Result<CallbackResult> {
828 let (mut stream, _addr) = tokio::time::timeout(timeout, listener.accept())
829 .await
830 .map_err(|_| DriveError::CallbackTimeout(timeout.as_secs()))?
831 .context("Failed to accept the browser's callback connection")?;
832
833 let mut buf = vec![0u8; 8192];
834 let n = stream
835 .read(&mut buf)
836 .await
837 .context("Failed to read the callback request")?;
838 let request = String::from_utf8_lossy(&buf[..n]);
839
840 let result = parse_callback(&request).ok_or(DriveError::MalformedCallback)?;
841 tracing::info!("Drive OAuth callback received");
842
843 let body = if result.error.is_some() {
844 "<html><body>Sign-in failed. You can close this tab and check the terminal.</body></html>"
845 } else {
846 "<html><body>Drive sign-in complete. You can close this tab.</body></html>"
847 };
848 let response =
849 format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n{body}");
850 let _ = stream.write_all(response.as_bytes()).await;
851 let _ = stream.flush().await;
852
853 Ok(result)
854}
855
856fn parse_callback(request: &str) -> Option<CallbackResult> {
859 let first_line = request.lines().next()?;
860 let path = first_line.split_whitespace().nth(1)?; let query = path.split_once('?')?.1;
862
863 let mut result = CallbackResult {
864 code: None,
865 state: None,
866 error: None,
867 error_description: None,
868 };
869 for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
870 match key.as_ref() {
871 "code" => result.code = Some(value.into_owned()),
872 "state" => result.state = Some(value.into_owned()),
873 "error" => result.error = Some(value.into_owned()),
874 "error_description" => result.error_description = Some(value.into_owned()),
875 _ => {}
876 }
877 }
878 Some(result)
879}
880
881#[derive(Debug, Deserialize)]
884struct TokenResponse {
885 access_token: String,
886 #[serde(default)]
887 refresh_token: Option<String>,
888 expires_in: i64,
889 #[serde(default)]
890 scope: Option<String>,
891}
892
893#[derive(Debug, Deserialize)]
894struct TokenErrorResponse {
895 error: String,
896 #[serde(default)]
897 error_description: Option<String>,
898}
899
900async fn exchange_code_for_tokens(
901 http: &reqwest::Client,
902 token_endpoint: &str,
903 client_id: &str,
904 client_secret: &str,
905 code: &str,
906 code_verifier: &str,
907 redirect_uri: &str,
908) -> Result<TokenResponse> {
909 let params = [
910 ("grant_type", "authorization_code"),
911 ("code", code),
912 ("client_id", client_id),
913 ("client_secret", client_secret),
914 ("redirect_uri", redirect_uri),
915 ("code_verifier", code_verifier),
916 ];
917 post_token_request(http, token_endpoint, ¶ms, GrantContext::CodeExchange).await
918}
919
920async fn refresh_access_token(
921 http: &reqwest::Client,
922 token_endpoint: &str,
923 client_id: &str,
924 client_secret: &str,
925 refresh_token: &str,
926) -> Result<TokenResponse> {
927 let params = [
928 ("grant_type", "refresh_token"),
929 ("refresh_token", refresh_token),
930 ("client_id", client_id),
931 ("client_secret", client_secret),
932 ];
933 post_token_request(http, token_endpoint, ¶ms, GrantContext::Refresh).await
934}
935
936async fn post_token_request(
940 http: &reqwest::Client,
941 token_endpoint: &str,
942 params: &[(&str, &str)],
943 context: GrantContext,
944) -> Result<TokenResponse> {
945 let started = std::time::Instant::now();
946 let result = http.post(token_endpoint).form(params).send().await;
947 request_log::record_http_result("drive", "POST", token_endpoint, started, &result);
948 let response = result.context("Failed to send token request to Google")?;
949
950 if !response.status().is_success() {
951 let body = response.text().await.unwrap_or_default();
952 if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
953 if err.error == "invalid_grant" {
954 return Err(DriveError::InvalidGrant(context).into());
955 }
956 return Err(anyhow::anyhow!(
957 "Google token endpoint rejected the request: {} ({})",
958 err.error,
959 err.error_description.unwrap_or_default()
960 ));
961 }
962 return Err(anyhow::anyhow!(
963 "Google token endpoint returned an unparsable error body: {body}"
964 ));
965 }
966
967 response
968 .json::<TokenResponse>()
969 .await
970 .context("Failed to parse Google's token response")
971}
972
973struct TokenState {
977 access_token: Secret,
978 expires_at: DateTime<Utc>,
979}
980
981pub struct DriveSession {
991 http: reqwest::Client,
992 client_id: String,
993 client_secret: Secret,
994 refresh_token: Secret,
995 token_endpoint: String,
996 state: tokio::sync::Mutex<TokenState>,
997}
998
999impl DriveSession {
1000 pub(crate) fn new(http: reqwest::Client, credentials: &DriveCredentials) -> Self {
1002 Self::new_with_token_endpoint(http, credentials, TOKEN_ENDPOINT)
1003 }
1004
1005 pub(crate) fn new_with_token_endpoint(
1008 http: reqwest::Client,
1009 credentials: &DriveCredentials,
1010 token_endpoint: &str,
1011 ) -> Self {
1012 Self {
1013 http,
1014 client_id: credentials.client_id.clone(),
1015 client_secret: credentials.client_secret.clone(),
1016 refresh_token: credentials.refresh_token.clone(),
1017 token_endpoint: token_endpoint.to_string(),
1018 state: tokio::sync::Mutex::new(TokenState {
1019 access_token: Secret::new(""),
1020 expires_at: DateTime::<Utc>::MIN_UTC,
1024 }),
1025 }
1026 }
1027
1028 pub(crate) async fn access_token(&self) -> Result<Secret> {
1031 let mut state = self.state.lock().await;
1032 if Utc::now() + REFRESH_SKEW >= state.expires_at {
1033 self.refresh_locked(&mut state).await?;
1034 }
1035 Ok(state.access_token.clone())
1036 }
1037
1038 pub(crate) async fn force_refresh(&self, observed: &Secret) -> Result<Secret> {
1043 let mut state = self.state.lock().await;
1044 if state.access_token != *observed {
1045 return Ok(state.access_token.clone());
1046 }
1047 self.refresh_locked(&mut state).await?;
1048 Ok(state.access_token.clone())
1049 }
1050
1051 async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
1052 let response = refresh_access_token(
1053 &self.http,
1054 &self.token_endpoint,
1055 &self.client_id,
1056 self.client_secret.expose_secret(),
1057 self.refresh_token.expose_secret(),
1058 )
1059 .await?;
1060 state.access_token = response.access_token.into();
1061 let expires_in = response.expires_in.clamp(0, MAX_EXPIRES_IN_SECONDS);
1062 state.expires_at = Utc::now() + TimeDelta::seconds(expires_in);
1063 Ok(())
1064 }
1065}
1066
1067pub async fn login(
1072 client_id: &str,
1073 client_secret: &Secret,
1074 scope: DriveGrantedScopes,
1075 browser: &BrowserConfig,
1076) -> Result<DriveAuthStatus> {
1077 login_to(
1078 &Settings::get_settings_path()?,
1079 active_profile_from(&SystemEnv).as_deref(),
1080 client_id,
1081 client_secret,
1082 scope,
1083 browser,
1084 TOKEN_ENDPOINT,
1085 )
1086 .await
1087}
1088
1089pub(crate) async fn login_to(
1092 settings_path: &Path,
1093 profile: Option<&str>,
1094 client_id: &str,
1095 client_secret: &Secret,
1096 scope: DriveGrantedScopes,
1097 browser: &BrowserConfig,
1098 token_endpoint: &str,
1099) -> Result<DriveAuthStatus> {
1100 let credentials =
1101 run_login_flow(client_id, client_secret, scope, browser, token_endpoint).await?;
1102 save_credentials_to(settings_path, profile, &credentials)?;
1103 Ok(status_from_credentials(&credentials))
1104}
1105
1106pub(crate) async fn login_for(
1114 explicit: Option<&str>,
1115 client_id: &str,
1116 client_secret: &Secret,
1117 scope: DriveGrantedScopes,
1118 browser: &BrowserConfig,
1119) -> Result<DriveAuthStatus> {
1120 let settings = Settings::load().unwrap_or_default();
1121 match resolve_for_write(&settings.drive, explicit)? {
1122 ResolvedAccount::Unconfigured => {
1123 login_to(
1124 &Settings::get_settings_path()?,
1125 active_profile_from(&SystemEnv).as_deref(),
1126 client_id,
1127 client_secret,
1128 scope,
1129 browser,
1130 TOKEN_ENDPOINT,
1131 )
1132 .await
1133 }
1134 ResolvedAccount::Named(name) => {
1135 let credentials =
1136 run_login_flow(client_id, client_secret, scope, browser, TOKEN_ENDPOINT).await?;
1137 Settings::upsert_drive_account(
1138 &Settings::get_settings_path()?,
1139 &name,
1140 &named_account_vars(&credentials),
1141 )?;
1142 Ok(status_from_credentials(&credentials))
1143 }
1144 }
1145}
1146
1147async fn run_login_flow(
1152 client_id: &str,
1153 client_secret: &Secret,
1154 scope: DriveGrantedScopes,
1155 browser: &BrowserConfig,
1156 token_endpoint: &str,
1157) -> Result<DriveCredentials> {
1158 let (listener, port) = bind_callback_listener(browser).await?;
1159 let redirect_uri = format!("http://127.0.0.1:{port}");
1160
1161 let pending = generate_pending_login();
1162 let challenge = code_challenge(&pending.code_verifier);
1163 let auth_url =
1164 build_authorization_url(client_id, &redirect_uri, scope, &pending.state, &challenge)?;
1165 open_browser(&browser.launch, auth_url.as_str())?;
1166
1167 let callback = wait_for_callback(listener).await?;
1168 if let Some(error) = callback.error {
1169 return Err(DriveError::authorization_denied(
1170 &error,
1171 callback.error_description.as_deref(),
1172 )
1173 .into());
1174 }
1175 let (Some(code), Some(returned_state)) = (callback.code, callback.state) else {
1176 return Err(DriveError::MalformedCallback.into());
1177 };
1178 if returned_state != pending.state {
1183 return Err(DriveError::StateMismatch.into());
1184 }
1185
1186 let http = reqwest::Client::builder()
1187 .connect_timeout(crate::utils::http::connect_timeout())
1188 .read_timeout(crate::utils::http::read_timeout())
1189 .build()
1190 .context("Failed to build HTTP client")?;
1191 let tokens = exchange_code_for_tokens(
1192 &http,
1193 token_endpoint,
1194 client_id,
1195 client_secret.expose_secret(),
1196 &code,
1197 &pending.code_verifier,
1198 &redirect_uri,
1199 )
1200 .await?;
1201 let refresh_token = tokens
1202 .refresh_token
1203 .ok_or(DriveError::MalformedTokenResponse("refresh_token"))?;
1204 let granted_raw = tokens.scope.unwrap_or_default();
1205 let granted_scope = DriveGrantedScopes::from_granted(&granted_raw).ok_or_else(|| {
1206 let received = if granted_raw.trim().is_empty() {
1207 "none".to_string()
1208 } else {
1209 granted_raw
1210 .split_whitespace()
1211 .collect::<Vec<_>>()
1212 .join(", ")
1213 };
1214 DriveError::NoScopeGranted(received)
1215 })?;
1216
1217 Ok(DriveCredentials {
1218 client_id: client_id.to_string(),
1219 client_secret: client_secret.clone(),
1220 refresh_token: refresh_token.into(),
1221 scope: granted_scope,
1222 })
1223}
1224
1225fn status_from_credentials(credentials: &DriveCredentials) -> DriveAuthStatus {
1228 DriveAuthStatus {
1229 has_client_id: true,
1230 has_client_secret: true,
1231 has_refresh_token: true,
1232 scope: Some(credentials.scope.as_str()),
1233 }
1234}
1235
1236#[cfg(test)]
1237#[allow(clippy::unwrap_used, clippy::expect_used)]
1238mod tests {
1239 use std::fs;
1240 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1241 use std::sync::Arc;
1242
1243 use super::*;
1244
1245 #[test]
1250 fn from_granted_sets_each_flag_independently_order_agnostic() {
1251 let scope =
1252 DriveGrantedScopes::from_granted(&format!("{SCOPE_FULL} {SCOPE_READONLY}")).unwrap();
1253 assert!(scope.readonly);
1254 assert!(!scope.metadata);
1255 assert!(!scope.file);
1256 assert!(scope.full);
1257 }
1258
1259 #[test]
1260 fn from_granted_recognizes_all_four_scopes_together() {
1261 let granted = format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}");
1262 let scope = DriveGrantedScopes::from_granted(&granted).unwrap();
1263 assert!(scope.readonly);
1264 assert!(scope.metadata);
1265 assert!(scope.file);
1266 assert!(scope.full);
1267 }
1268
1269 #[test]
1270 fn from_granted_returns_none_when_no_drive_scope_present() {
1271 assert_eq!(
1272 DriveGrantedScopes::from_granted("openid email profile"),
1273 None
1274 );
1275 assert_eq!(DriveGrantedScopes::from_granted(""), None);
1276 }
1277
1278 #[test]
1279 fn as_str_round_trips_through_from_granted() {
1280 let scope = DriveGrantedScopes {
1281 readonly: true,
1282 metadata: false,
1283 file: true,
1284 full: false,
1285 };
1286 let round_tripped = DriveGrantedScopes::from_granted(&scope.as_str()).unwrap();
1287 assert_eq!(scope, round_tripped);
1288 }
1289
1290 #[test]
1291 fn as_str_orders_scopes_readonly_metadata_file_full() {
1292 let scope = DriveGrantedScopes {
1293 readonly: true,
1294 metadata: true,
1295 file: true,
1296 full: true,
1297 };
1298 assert_eq!(
1299 scope.as_str(),
1300 format!("{SCOPE_READONLY} {SCOPE_METADATA} {SCOPE_FILE} {SCOPE_FULL}")
1301 );
1302 }
1303
1304 #[test]
1305 fn allows_predicates_reflect_the_capability_each_names() {
1306 assert!(!DriveGrantedScopes::READONLY.allows_write());
1307 assert!(!DriveGrantedScopes::READONLY.allows_rename_move());
1308 assert!(!DriveGrantedScopes::READONLY.allows_create_or_upload());
1309 assert!(!DriveGrantedScopes::READONLY.allows_edit_arbitrary_content());
1310
1311 assert!(DriveGrantedScopes::METADATA.allows_write());
1312 assert!(DriveGrantedScopes::METADATA.allows_rename_move());
1313 assert!(!DriveGrantedScopes::METADATA.allows_create_or_upload());
1314
1315 let file_only = DriveGrantedScopes {
1316 readonly: true,
1317 metadata: false,
1318 file: true,
1319 full: false,
1320 };
1321 assert!(file_only.allows_create_or_upload());
1322 assert!(
1323 !file_only.allows_edit_arbitrary_content(),
1324 "drive.file alone cannot edit an arbitrary pre-existing file"
1325 );
1326
1327 let full_only = DriveGrantedScopes {
1328 readonly: true,
1329 metadata: false,
1330 file: false,
1331 full: true,
1332 };
1333 assert!(full_only.allows_create_or_upload());
1334 assert!(full_only.allows_edit_arbitrary_content());
1335 }
1336
1337 #[test]
1338 fn code_challenge_matches_rfc_7636_test_vector() {
1339 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
1341 let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
1342 assert_eq!(code_challenge(verifier), expected);
1343 }
1344
1345 #[test]
1346 fn code_challenge_output_is_url_safe_no_padding() {
1347 let challenge = code_challenge("some-verifier-value");
1348 assert!(!challenge.contains('+'));
1349 assert!(!challenge.contains('/'));
1350 assert!(!challenge.contains('='));
1351 }
1352
1353 #[test]
1354 fn generate_pending_login_state_and_verifier_are_distinct_and_rfc_compliant_length() {
1355 let pending = generate_pending_login();
1356 assert_ne!(pending.state, pending.code_verifier);
1357 assert!(pending.code_verifier.len() >= 43 && pending.code_verifier.len() <= 128);
1358 assert!(pending
1359 .code_verifier
1360 .chars()
1361 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
1362 }
1363
1364 #[test]
1365 fn build_authorization_url_includes_pkce_state_and_offline_consent_params() {
1366 let url = build_authorization_url(
1367 "client-123",
1368 "http://127.0.0.1:5555",
1369 DriveGrantedScopes::READONLY,
1370 "state-abc",
1371 "challenge-xyz",
1372 )
1373 .unwrap();
1374 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1375 assert_eq!(query.get("client_id").unwrap(), "client-123");
1376 assert_eq!(query.get("redirect_uri").unwrap(), "http://127.0.0.1:5555");
1377 assert_eq!(query.get("response_type").unwrap(), "code");
1378 assert_eq!(query.get("scope").unwrap(), SCOPE_READONLY);
1379 assert_eq!(query.get("state").unwrap(), "state-abc");
1380 assert_eq!(query.get("code_challenge").unwrap(), "challenge-xyz");
1381 assert_eq!(query.get("code_challenge_method").unwrap(), "S256");
1382 assert_eq!(query.get("access_type").unwrap(), "offline");
1383 assert_eq!(query.get("prompt").unwrap(), "consent");
1384 }
1385
1386 #[test]
1387 fn build_authorization_url_uses_additive_scope_when_metadata_requested() {
1388 let url = build_authorization_url(
1389 "client-123",
1390 "http://127.0.0.1:5555",
1391 DriveGrantedScopes::METADATA,
1392 "state-abc",
1393 "challenge-xyz",
1394 )
1395 .unwrap();
1396 let query: std::collections::HashMap<_, _> = url.query_pairs().collect();
1397 assert_eq!(
1398 query.get("scope").unwrap(),
1399 &format!("{SCOPE_READONLY} {SCOPE_METADATA}")
1400 );
1401 }
1402
1403 #[test]
1404 fn parse_callback_extracts_code_and_state() {
1405 let request = "GET /?code=abc123&state=xyz789 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
1406 let result = parse_callback(request).unwrap();
1407 assert_eq!(result.code.as_deref(), Some("abc123"));
1408 assert_eq!(result.state.as_deref(), Some("xyz789"));
1409 assert!(result.error.is_none());
1410 }
1411
1412 #[test]
1413 fn parse_callback_extracts_error_and_error_description() {
1414 let request =
1415 "GET /?error=access_denied&error_description=user+declined&state=xyz HTTP/1.1\r\n\r\n";
1416 let result = parse_callback(request).unwrap();
1417 assert_eq!(result.error.as_deref(), Some("access_denied"));
1418 assert_eq!(result.error_description.as_deref(), Some("user declined"));
1419 }
1420
1421 #[test]
1422 fn parse_callback_missing_query_string_is_none() {
1423 assert!(parse_callback("GET / HTTP/1.1\r\n\r\n").is_none());
1424 assert!(parse_callback("garbage").is_none());
1425 }
1426
1427 #[test]
1428 fn parse_callback_ignores_unrecognized_query_keys() {
1429 let request = "GET /?code=abc&state=xyz&foo=bar HTTP/1.1\r\n\r\n";
1430 let result = parse_callback(request).unwrap();
1431 assert_eq!(result.code.as_deref(), Some("abc"));
1432 assert_eq!(result.state.as_deref(), Some("xyz"));
1433 }
1434
1435 #[test]
1436 fn open_browser_manual_logs_and_succeeds() {
1437 assert!(open_browser(&BrowserLaunch::Manual, "https://example/auth").is_ok());
1438 }
1439
1440 #[test]
1441 fn open_browser_command_substitutes_url_placeholder() {
1442 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--url={url}".to_string()]);
1443 assert!(open_browser(&launch, "https://example/auth").is_ok());
1444 }
1445
1446 #[test]
1447 fn open_browser_command_appends_url_when_no_placeholder() {
1448 let launch = BrowserLaunch::Command(vec!["true".to_string()]);
1449 assert!(open_browser(&launch, "https://example/auth").is_ok());
1450 }
1451
1452 #[test]
1453 fn open_browser_command_passes_through_args_without_the_placeholder() {
1454 let launch = BrowserLaunch::Command(vec!["true".to_string(), "--verbose".to_string()]);
1458 assert!(open_browser(&launch, "https://example/auth").is_ok());
1459 }
1460
1461 #[test]
1462 fn open_browser_command_rejects_empty_args() {
1463 let launch = BrowserLaunch::Command(vec![]);
1464 let err = open_browser(&launch, "u").unwrap_err();
1465 assert!(err.to_string().contains("empty browser command"));
1466 }
1467
1468 #[test]
1471 fn named_account_vars_maps_credentials_to_json_string_values() {
1472 let credentials = DriveCredentials {
1473 client_id: "client-1".to_string(),
1474 client_secret: Secret::new("secret-1"),
1475 refresh_token: Secret::new("refresh-1"),
1476 scope: DriveGrantedScopes::READONLY,
1477 };
1478 assert_eq!(
1479 named_account_vars(&credentials),
1480 [
1481 (
1482 "client_id",
1483 serde_json::Value::String("client-1".to_string())
1484 ),
1485 (
1486 "client_secret",
1487 serde_json::Value::String("secret-1".to_string())
1488 ),
1489 (
1490 "refresh_token",
1491 serde_json::Value::String("refresh-1".to_string())
1492 ),
1493 (
1494 "scope",
1495 serde_json::Value::String(SCOPE_READONLY.to_string())
1496 ),
1497 ]
1498 );
1499 }
1500
1501 fn assert_is_auto(config: BrowserConfig) {
1504 assert!(matches!(config.launch, BrowserLaunch::Auto));
1505 }
1506
1507 #[test]
1508 fn build_browser_config_defaults_to_auto_with_no_account() {
1509 assert_is_auto(build_browser_config(None, |_| panic!("must not be called")).unwrap());
1510 }
1511
1512 #[test]
1513 fn build_browser_config_defaults_to_auto_with_no_opt_in() {
1514 let account = DriveAccountSettings {
1515 email_address: Some("alice@example.com".to_string()),
1516 ..DriveAccountSettings::default()
1517 };
1518 assert_is_auto(
1521 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1522 );
1523 }
1524
1525 #[test]
1526 fn build_browser_config_uses_browser_command_verbatim() {
1527 let account = DriveAccountSettings {
1528 browser_command: Some("chrome --new-window {url}".to_string()),
1529 ..DriveAccountSettings::default()
1530 };
1531 let config =
1532 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1533 assert!(matches!(
1534 config.launch,
1535 BrowserLaunch::Command(args) if args == vec!["chrome", "--new-window", "{url}"]
1536 ));
1537 }
1538
1539 #[test]
1540 fn build_browser_config_browser_command_wins_over_chrome_profile_from_email() {
1541 let account = DriveAccountSettings {
1542 browser_command: Some("chrome {url}".to_string()),
1543 chrome_profile_from_email: true,
1544 email_address: Some("alice@example.com".to_string()),
1545 ..DriveAccountSettings::default()
1546 };
1547 let config =
1548 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap();
1549 assert!(matches!(config.launch, BrowserLaunch::Command(_)));
1550 }
1551
1552 #[test]
1553 fn build_browser_config_rejects_a_malformed_browser_command() {
1554 let account = DriveAccountSettings {
1555 browser_command: Some("chrome \"--flag".to_string()),
1556 ..DriveAccountSettings::default()
1557 };
1558 let err =
1559 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap_err();
1560 assert!(err.to_string().contains("browser_command"));
1561 }
1562
1563 #[test]
1564 fn build_browser_config_resolves_the_chrome_profile_when_opted_in() {
1565 let account = DriveAccountSettings {
1566 chrome_profile_from_email: true,
1567 email_address: Some("alice@example.com".to_string()),
1568 ..DriveAccountSettings::default()
1569 };
1570 let config = build_browser_config(Some(&account), |email| {
1571 assert_eq!(email, "alice@example.com");
1572 Some(vec!["chrome-stub".to_string(), "{url}".to_string()])
1573 })
1574 .unwrap();
1575 assert!(matches!(
1576 config.launch,
1577 BrowserLaunch::Command(args) if args == vec!["chrome-stub", "{url}"]
1578 ));
1579 }
1580
1581 #[test]
1582 fn build_browser_config_falls_back_to_auto_when_chrome_resolution_fails() {
1583 let account = DriveAccountSettings {
1584 chrome_profile_from_email: true,
1585 email_address: Some("alice@example.com".to_string()),
1586 ..DriveAccountSettings::default()
1587 };
1588 assert_is_auto(build_browser_config(Some(&account), |_| None).unwrap());
1589 }
1590
1591 #[test]
1592 fn build_browser_config_is_auto_when_opted_in_but_no_email_address() {
1593 let account = DriveAccountSettings {
1594 chrome_profile_from_email: true,
1595 ..DriveAccountSettings::default()
1596 };
1597 assert_is_auto(
1598 build_browser_config(Some(&account), |_| panic!("must not be called")).unwrap(),
1599 );
1600 }
1601
1602 #[test]
1605 fn resolve_browser_config_for_unconfigured_account_defaults_to_auto() {
1606 let guard = crate::drive::test_support::EnvGuard::take();
1607 let _dir = guard.clear_credentials();
1608 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
1609 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
1610 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
1611
1612 let drive = DriveSettings::default();
1613 assert_is_auto(resolve_browser_config_for(&drive, None).unwrap());
1614 }
1615
1616 #[test]
1617 fn resolve_browser_config_for_named_account_without_chrome_opt_in_defaults_to_auto() {
1618 let guard = crate::drive::test_support::EnvGuard::take();
1619 let _dir = guard.clear_credentials();
1620
1621 let mut drive = DriveSettings::default();
1622 drive.accounts.insert(
1623 "work".to_string(),
1624 DriveAccountSettings {
1625 email_address: Some("alice@example.com".to_string()),
1626 ..DriveAccountSettings::default()
1627 },
1628 );
1629
1630 assert_is_auto(resolve_browser_config_for(&drive, Some("work")).unwrap());
1633 }
1634
1635 #[tokio::test]
1638 async fn wait_for_callback_times_out_when_nothing_connects() {
1639 let browser = BrowserConfig::default();
1640 let (listener, _port) = bind_callback_listener(&browser).await.unwrap();
1641 let err = wait_for_callback_with_timeout(listener, Duration::from_millis(50))
1642 .await
1643 .unwrap_err();
1644 assert!(matches!(
1645 err.downcast_ref::<DriveError>(),
1646 Some(DriveError::CallbackTimeout(_))
1647 ));
1648 }
1649
1650 #[tokio::test]
1651 async fn wait_for_callback_reads_a_real_connection() {
1652 let browser = BrowserConfig::default();
1653 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1654
1655 let client = tokio::spawn(async move {
1656 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1657 .await
1658 .unwrap();
1659 stream
1660 .write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\n\r\n")
1661 .await
1662 .unwrap();
1663 });
1664
1665 let result = wait_for_callback(listener).await.unwrap();
1666 client.await.unwrap();
1667 assert_eq!(result.code.as_deref(), Some("abc"));
1668 assert_eq!(result.state.as_deref(), Some("xyz"));
1669 }
1670
1671 #[tokio::test]
1672 async fn wait_for_callback_malformed_request_line_is_malformed_callback() {
1673 let browser = BrowserConfig::default();
1674 let (listener, port) = bind_callback_listener(&browser).await.unwrap();
1675
1676 let client = tokio::spawn(async move {
1677 let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
1678 .await
1679 .unwrap();
1680 stream.write_all(b"not an http request").await.unwrap();
1681 });
1682
1683 let err = wait_for_callback(listener).await.unwrap_err();
1684 client.await.unwrap();
1685 assert!(matches!(
1686 err.downcast_ref::<DriveError>(),
1687 Some(DriveError::MalformedCallback)
1688 ));
1689 }
1690
1691 #[tokio::test]
1694 async fn exchange_code_for_tokens_posts_expected_form_body() {
1695 let server = wiremock::MockServer::start().await;
1696 wiremock::Mock::given(wiremock::matchers::method("POST"))
1697 .and(wiremock::matchers::path("/token"))
1698 .and(wiremock::matchers::body_string_contains(
1699 "grant_type=authorization_code",
1700 ))
1701 .and(wiremock::matchers::body_string_contains(
1702 "code_verifier=verifier-1",
1703 ))
1704 .and(wiremock::matchers::body_string_contains(
1705 "redirect_uri=http%3A%2F%2F127.0.0.1%3A9999",
1706 ))
1707 .respond_with(
1708 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1709 "access_token": "at-1",
1710 "refresh_token": "rt-1",
1711 "expires_in": 3600,
1712 "scope": SCOPE_READONLY,
1713 })),
1714 )
1715 .expect(1)
1716 .mount(&server)
1717 .await;
1718
1719 let http = reqwest::Client::new();
1720 let token_endpoint = format!("{}/token", server.uri());
1721 let response = exchange_code_for_tokens(
1722 &http,
1723 &token_endpoint,
1724 "client-1",
1725 "secret-1",
1726 "code-1",
1727 "verifier-1",
1728 "http://127.0.0.1:9999",
1729 )
1730 .await
1731 .unwrap();
1732 assert_eq!(response.access_token, "at-1");
1733 assert_eq!(response.refresh_token.as_deref(), Some("rt-1"));
1734 }
1735
1736 #[tokio::test]
1737 async fn exchange_code_for_tokens_maps_invalid_grant_to_pkce_flavored_message() {
1738 let server = wiremock::MockServer::start().await;
1739 wiremock::Mock::given(wiremock::matchers::method("POST"))
1740 .respond_with(
1741 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
1742 "error": "invalid_grant",
1743 "error_description": "Bad Request",
1744 })),
1745 )
1746 .mount(&server)
1747 .await;
1748
1749 let http = reqwest::Client::new();
1750 let err = exchange_code_for_tokens(
1751 &http,
1752 &server.uri(),
1753 "c",
1754 "s",
1755 "code",
1756 "verifier",
1757 "http://127.0.0.1:1",
1758 )
1759 .await
1760 .unwrap_err();
1761 assert!(err.to_string().contains("PKCE"));
1762 }
1763
1764 async fn connect_and_send(port: u16, request_line: &[u8]) {
1784 let mut stream = loop {
1785 match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
1786 Ok(stream) => break stream,
1787 Err(_) => tokio::time::sleep(Duration::from_millis(2)).await,
1788 }
1789 };
1790 stream.write_all(request_line).await.unwrap();
1791 }
1792
1793 fn reserve_free_port() -> u16 {
1794 std::net::TcpListener::bind("127.0.0.1:0")
1795 .unwrap()
1796 .local_addr()
1797 .unwrap()
1798 .port()
1799 }
1800
1801 fn is_callback_bind_conflict(err: &anyhow::Error) -> bool {
1805 err.to_string()
1806 .contains("Failed to start the local OAuth callback listener")
1807 }
1808
1809 const PORT_RETRY_ATTEMPTS: u32 = 5;
1810
1811 async fn run_with_port_retry<F, Fut>(mut attempt: F) -> Result<DriveAuthStatus>
1816 where
1817 F: FnMut(u16) -> Fut,
1818 Fut: std::future::Future<Output = Result<DriveAuthStatus>>,
1819 {
1820 for remaining in (0..PORT_RETRY_ATTEMPTS).rev() {
1821 let result = attempt(reserve_free_port()).await;
1822 let is_retryable_conflict =
1823 matches!(&result, Err(err) if remaining > 0 && is_callback_bind_conflict(err));
1824 if !is_retryable_conflict {
1825 return result;
1826 }
1827 }
1828 unreachable!("loop always returns on its last iteration")
1829 }
1830
1831 async fn finish_connector(
1836 connector: tokio::task::JoinHandle<()>,
1837 result: &Result<DriveAuthStatus>,
1838 ) {
1839 match result {
1840 Err(err) if is_callback_bind_conflict(err) => connector.abort(),
1841 _ => connector.await.unwrap(),
1842 }
1843 }
1844
1845 async fn wait_for_captured_url(path: &Path) -> String {
1849 loop {
1850 if let Ok(contents) = std::fs::read_to_string(path) {
1851 if !contents.is_empty() {
1852 return contents;
1853 }
1854 }
1855 tokio::time::sleep(Duration::from_millis(2)).await;
1856 }
1857 }
1858
1859 async fn run_login_to_expect_err(request_line: &'static [u8]) -> anyhow::Error {
1866 std::fs::create_dir_all("tmp").ok();
1867 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1868 let settings_path = temp_dir.path().join("settings.json");
1869
1870 let result = run_with_port_retry(|port| {
1871 let settings_path = settings_path.clone();
1872 async move {
1873 let browser = BrowserConfig {
1874 launch: BrowserLaunch::Manual,
1875 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
1876 callback_port: port,
1877 };
1878 let connector = tokio::spawn(connect_and_send(port, request_line));
1879
1880 let result = login_to(
1881 &settings_path,
1882 None,
1883 "client-id",
1884 &Secret::new("client-secret"),
1885 DriveGrantedScopes::READONLY,
1886 &browser,
1887 "http://127.0.0.1:1/token", )
1889 .await;
1890
1891 finish_connector(connector, &result).await;
1892 result
1893 }
1894 })
1895 .await;
1896
1897 let err = result.unwrap_err();
1898 assert!(!settings_path.exists());
1899 err
1900 }
1901
1902 #[tokio::test]
1903 async fn run_with_port_retry_retries_after_a_callback_bind_conflict_then_succeeds() {
1904 let attempts = AtomicU32::new(0);
1905
1906 let status = run_with_port_retry(|_port| {
1907 let attempt_no = attempts.fetch_add(1, Ordering::SeqCst);
1908 async move {
1909 if attempt_no == 0 {
1910 Err(anyhow::anyhow!(
1911 "Failed to start the local OAuth callback listener: address in use"
1912 ))
1913 } else {
1914 Ok(DriveAuthStatus {
1915 has_client_id: true,
1916 has_client_secret: true,
1917 has_refresh_token: true,
1918 scope: None,
1919 })
1920 }
1921 }
1922 })
1923 .await
1924 .unwrap();
1925
1926 assert_eq!(attempts.load(Ordering::SeqCst), 2);
1927 assert!(status.has_client_id);
1928 }
1929
1930 #[tokio::test]
1931 async fn run_with_port_retry_does_not_retry_a_non_conflict_error() {
1932 let attempts = AtomicU32::new(0);
1933
1934 let result = run_with_port_retry(|_port| {
1935 attempts.fetch_add(1, Ordering::SeqCst);
1936 async move { Err(anyhow::anyhow!("some other failure")) }
1937 })
1938 .await;
1939
1940 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1941 assert!(result.is_err());
1942 }
1943
1944 #[tokio::test]
1945 async fn finish_connector_aborts_when_login_to_lost_the_port_race() {
1946 let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
1947 let connector = tokio::spawn(async move {
1948 tokio::time::sleep(Duration::from_secs(3600)).await;
1949 let _ = tx.send(());
1950 });
1951 let result: Result<DriveAuthStatus> = Err(anyhow::anyhow!(
1952 "Failed to start the local OAuth callback listener: address in use"
1953 ));
1954
1955 tokio::time::timeout(Duration::from_secs(5), finish_connector(connector, &result))
1956 .await
1957 .expect("finish_connector must not wait for an aborted connector");
1958
1959 assert!(
1960 rx.try_recv().is_err(),
1961 "connector must have been aborted, not run to completion"
1962 );
1963 }
1964
1965 #[tokio::test]
1966 async fn finish_connector_awaits_connector_when_login_to_succeeds() {
1967 let ran = Arc::new(AtomicBool::new(false));
1968 let ran_clone = ran.clone();
1969 let connector = tokio::spawn(async move {
1970 ran_clone.store(true, Ordering::SeqCst);
1971 });
1972 let result = Ok(DriveAuthStatus {
1973 has_client_id: true,
1974 has_client_secret: true,
1975 has_refresh_token: true,
1976 scope: None,
1977 });
1978
1979 finish_connector(connector, &result).await;
1980
1981 assert!(ran.load(Ordering::SeqCst));
1982 }
1983
1984 #[tokio::test]
1985 async fn wait_for_captured_url_polls_until_content_is_written() {
1986 std::fs::create_dir_all("tmp").ok();
1987 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
1988 let path = temp_dir.path().join("captured-url.txt");
1989
1990 let write_path = path.clone();
1991 tokio::spawn(async move {
1992 tokio::time::sleep(Duration::from_millis(10)).await;
1993 std::fs::write(&write_path, "").unwrap();
1994 tokio::time::sleep(Duration::from_millis(10)).await;
1995 std::fs::write(&write_path, "https://example.com/authorize").unwrap();
1996 });
1997
1998 let contents = wait_for_captured_url(&path).await;
1999 assert_eq!(contents, "https://example.com/authorize");
2000 }
2001
2002 #[tokio::test]
2003 async fn login_to_rejects_a_callback_with_mismatched_state() {
2004 let err =
2005 run_login_to_expect_err(b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n").await;
2006
2007 assert!(matches!(
2008 err.downcast_ref::<DriveError>(),
2009 Some(DriveError::StateMismatch)
2010 ));
2011 }
2012
2013 #[tokio::test]
2014 async fn login_to_surfaces_access_denied_from_the_callback() {
2015 let err = run_login_to_expect_err(
2016 b"GET /?error=access_denied&error_description=user+declined HTTP/1.1\r\n\r\n",
2017 )
2018 .await;
2019
2020 match err.downcast_ref::<DriveError>() {
2021 Some(DriveError::AuthorizationDenied(message)) => {
2022 assert!(message.contains("access_denied"));
2023 assert!(message.contains("user declined"));
2024 }
2025 other => panic!("expected AuthorizationDenied, got {other:?}"),
2026 }
2027 }
2028
2029 #[tokio::test]
2030 async fn login_to_rejects_a_callback_missing_code_and_state() {
2031 let err = run_login_to_expect_err(b"GET /?foo=bar HTTP/1.1\r\n\r\n").await;
2032
2033 assert!(matches!(
2034 err.downcast_ref::<DriveError>(),
2035 Some(DriveError::MalformedCallback)
2036 ));
2037 }
2038
2039 #[tokio::test]
2040 async fn login_to_completes_full_success_flow_and_persists_credentials() {
2041 std::fs::create_dir_all("tmp").ok();
2050 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2051 let capture_path = temp_dir.path().join("captured-url.txt");
2052 let settings_path = temp_dir.path().join("settings.json");
2053
2054 let server = wiremock::MockServer::start().await;
2055 wiremock::Mock::given(wiremock::matchers::method("POST"))
2056 .and(wiremock::matchers::path("/token"))
2057 .respond_with(
2058 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2059 "access_token": "at-1",
2060 "refresh_token": "rt-1",
2061 "expires_in": 3600,
2062 "scope": SCOPE_READONLY,
2063 })),
2064 )
2065 .expect(1)
2066 .mount(&server)
2067 .await;
2068
2069 let status = run_with_port_retry(|port| {
2070 let capture_path = capture_path.clone();
2071 let settings_path = settings_path.clone();
2072 let token_endpoint = format!("{}/token", server.uri());
2073 async move {
2074 let browser = BrowserConfig {
2075 launch: BrowserLaunch::Command(vec![
2076 "/bin/sh".to_string(),
2077 "-c".to_string(),
2078 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2079 "{url}".to_string(),
2080 ]),
2081 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2082 callback_port: port,
2083 };
2084
2085 let connector = tokio::spawn(async move {
2086 let auth_url = wait_for_captured_url(&capture_path).await;
2087 let parsed = Url::parse(&auth_url).unwrap();
2088 let state = parsed
2089 .query_pairs()
2090 .find(|(k, _)| k == "state")
2091 .map(|(_, v)| v.into_owned())
2092 .expect("authorization URL must carry a state param");
2093 connect_and_send(
2094 port,
2095 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2096 )
2097 .await;
2098 });
2099
2100 let result = login_to(
2101 &settings_path,
2102 None,
2103 "client-id",
2104 &Secret::new("client-secret"),
2105 DriveGrantedScopes::READONLY,
2106 &browser,
2107 &token_endpoint,
2108 )
2109 .await;
2110
2111 finish_connector(connector, &result).await;
2112 result
2113 }
2114 })
2115 .await
2116 .unwrap();
2117
2118 assert!(status.has_client_id);
2119 assert!(status.has_client_secret);
2120 assert!(status.has_refresh_token);
2121 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2122
2123 let saved = std::fs::read_to_string(&settings_path).unwrap();
2124 assert!(saved.contains("rt-1"));
2125 assert!(saved.contains("client-id"));
2126 }
2127
2128 async fn run_login_to_with_token_response(
2135 token_response_body: serde_json::Value,
2136 ) -> (Result<DriveAuthStatus>, std::path::PathBuf) {
2137 std::fs::create_dir_all("tmp").ok();
2138 let temp_dir = tempfile::TempDir::new_in("tmp").unwrap();
2139 let capture_path = temp_dir.path().join("captured-url.txt");
2140 let settings_path = temp_dir.path().join("settings.json");
2141
2142 let server = wiremock::MockServer::start().await;
2143 wiremock::Mock::given(wiremock::matchers::method("POST"))
2144 .and(wiremock::matchers::path("/token"))
2145 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&token_response_body))
2146 .expect(1)
2147 .mount(&server)
2148 .await;
2149
2150 let result = run_with_port_retry(|port| {
2151 let capture_path = capture_path.clone();
2152 let settings_path = settings_path.clone();
2153 let token_endpoint = format!("{}/token", server.uri());
2154 async move {
2155 let browser = BrowserConfig {
2156 launch: BrowserLaunch::Command(vec![
2157 "/bin/sh".to_string(),
2158 "-c".to_string(),
2159 format!("printf '%s' \"$0\" > '{}'", capture_path.display()),
2160 "{url}".to_string(),
2161 ]),
2162 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2163 callback_port: port,
2164 };
2165
2166 let connector = tokio::spawn(async move {
2167 let auth_url = wait_for_captured_url(&capture_path).await;
2168 let parsed = Url::parse(&auth_url).unwrap();
2169 let state = parsed
2170 .query_pairs()
2171 .find(|(k, _)| k == "state")
2172 .map(|(_, v)| v.into_owned())
2173 .expect("authorization URL must carry a state param");
2174 connect_and_send(
2175 port,
2176 format!("GET /?code=auth-code&state={state} HTTP/1.1\r\n\r\n").as_bytes(),
2177 )
2178 .await;
2179 });
2180
2181 let result = login_to(
2182 &settings_path,
2183 None,
2184 "client-id",
2185 &Secret::new("client-secret"),
2186 DriveGrantedScopes::READONLY,
2187 &browser,
2188 &token_endpoint,
2189 )
2190 .await;
2191
2192 finish_connector(connector, &result).await;
2193 result
2194 }
2195 })
2196 .await;
2197
2198 (result, settings_path)
2199 }
2200
2201 #[tokio::test]
2202 async fn login_to_rejects_a_grant_with_no_drive_scope() {
2203 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2204 "access_token": "at-1",
2205 "refresh_token": "rt-1",
2206 "expires_in": 3600,
2207 "scope": "openid email profile",
2208 }))
2209 .await;
2210
2211 let err = result.unwrap_err();
2212 assert!(matches!(
2213 err.downcast_ref::<DriveError>(),
2214 Some(DriveError::NoScopeGranted(received)) if received == "openid, email, profile"
2215 ));
2216 assert!(!settings_path.exists());
2217 }
2218
2219 #[tokio::test]
2220 async fn login_to_rejects_a_grant_with_missing_scope_field() {
2221 let (result, settings_path) = run_login_to_with_token_response(serde_json::json!({
2222 "access_token": "at-1",
2223 "refresh_token": "rt-1",
2224 "expires_in": 3600,
2225 }))
2226 .await;
2227
2228 let err = result.unwrap_err();
2229 assert!(matches!(
2230 err.downcast_ref::<DriveError>(),
2231 Some(DriveError::NoScopeGranted(received)) if received == "none"
2232 ));
2233 assert!(!settings_path.exists());
2234 }
2235
2236 #[tokio::test]
2249 async fn login_for_unconfigured_account_rejects_a_callback_with_mismatched_state() {
2250 let guard = crate::drive::test_support::EnvGuard::take();
2251 let dir = guard.clear_credentials();
2252 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2253
2254 let result = run_with_port_retry(|port| async move {
2255 let browser = BrowserConfig {
2256 launch: BrowserLaunch::Manual,
2257 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2258 callback_port: port,
2259 };
2260 let connector = tokio::spawn(connect_and_send(
2261 port,
2262 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2263 ));
2264
2265 let result = login_for(
2266 None,
2267 "client-id",
2268 &Secret::new("client-secret"),
2269 DriveGrantedScopes::READONLY,
2270 &browser,
2271 )
2272 .await;
2273
2274 finish_connector(connector, &result).await;
2275 result
2276 })
2277 .await;
2278
2279 let err = result.unwrap_err();
2280 assert!(matches!(
2281 err.downcast_ref::<DriveError>(),
2282 Some(DriveError::StateMismatch)
2283 ));
2284 assert!(!settings_path.exists());
2285 }
2286
2287 #[tokio::test]
2288 async fn login_for_named_account_rejects_a_callback_with_mismatched_state() {
2289 let guard = crate::drive::test_support::EnvGuard::take();
2290 let dir = guard.clear_credentials();
2291 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2292
2293 let result = run_with_port_retry(|port| async move {
2294 let browser = BrowserConfig {
2295 launch: BrowserLaunch::Manual,
2296 callback_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
2297 callback_port: port,
2298 };
2299 let connector = tokio::spawn(connect_and_send(
2300 port,
2301 b"GET /?code=abc&state=the-wrong-state HTTP/1.1\r\n\r\n",
2302 ));
2303
2304 let result = login_for(
2305 Some("work"),
2306 "client-id",
2307 &Secret::new("client-secret"),
2308 DriveGrantedScopes::READONLY,
2309 &browser,
2310 )
2311 .await;
2312
2313 finish_connector(connector, &result).await;
2314 result
2315 })
2316 .await;
2317
2318 let err = result.unwrap_err();
2319 assert!(matches!(
2320 err.downcast_ref::<DriveError>(),
2321 Some(DriveError::StateMismatch)
2322 ));
2323 assert!(!settings_path.exists());
2324 }
2325
2326 #[tokio::test]
2327 async fn refresh_access_token_posts_grant_type_refresh_token_and_parses_expires_in() {
2328 let server = wiremock::MockServer::start().await;
2329 wiremock::Mock::given(wiremock::matchers::method("POST"))
2330 .and(wiremock::matchers::body_string_contains(
2331 "grant_type=refresh_token",
2332 ))
2333 .respond_with(
2334 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2335 "access_token": "at-2",
2336 "expires_in": 1800,
2337 })),
2338 )
2339 .expect(1)
2340 .mount(&server)
2341 .await;
2342
2343 let http = reqwest::Client::new();
2344 let response = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2345 .await
2346 .unwrap();
2347 assert_eq!(response.access_token, "at-2");
2348 assert_eq!(response.expires_in, 1800);
2349 }
2350
2351 #[tokio::test]
2352 async fn refresh_access_token_maps_invalid_grant_to_testing_mode_message() {
2353 let server = wiremock::MockServer::start().await;
2354 wiremock::Mock::given(wiremock::matchers::method("POST"))
2355 .respond_with(
2356 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
2357 "error": "invalid_grant",
2358 })),
2359 )
2360 .mount(&server)
2361 .await;
2362
2363 let http = reqwest::Client::new();
2364 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2365 .await
2366 .unwrap_err();
2367 let msg = err.to_string();
2368 assert!(msg.contains("7 days"));
2369 assert!(msg.contains("Testing"));
2370 }
2371
2372 #[tokio::test]
2373 async fn refresh_access_token_falls_back_to_raw_body_when_error_is_unparsable() {
2374 let server = wiremock::MockServer::start().await;
2375 wiremock::Mock::given(wiremock::matchers::method("POST"))
2376 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("not json"))
2377 .mount(&server)
2378 .await;
2379
2380 let http = reqwest::Client::new();
2381 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt-1")
2382 .await
2383 .unwrap_err();
2384 let msg = err.to_string();
2385 assert!(msg.contains("unparsable error body"));
2386 assert!(msg.contains("not json"));
2387 }
2388
2389 #[tokio::test]
2390 async fn token_request_propagates_network_errors() {
2391 let http = reqwest::Client::new();
2392 let err = refresh_access_token(&http, "http://127.0.0.1:1", "c", "s", "rt")
2393 .await
2394 .unwrap_err();
2395 assert!(err.to_string().contains("Failed to send token request"));
2396 }
2397
2398 #[tokio::test]
2399 async fn token_request_errors_on_unparsable_response_body() {
2400 let server = wiremock::MockServer::start().await;
2401 wiremock::Mock::given(wiremock::matchers::method("POST"))
2402 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2403 .mount(&server)
2404 .await;
2405
2406 let http = reqwest::Client::new();
2407 let err = refresh_access_token(&http, &server.uri(), "c", "s", "rt")
2408 .await
2409 .unwrap_err();
2410 assert!(err.to_string().contains("Failed to parse"));
2411 }
2412
2413 fn test_credentials() -> DriveCredentials {
2416 DriveCredentials {
2417 client_id: "client-1".to_string(),
2418 client_secret: "secret-1".into(),
2419 refresh_token: "refresh-1".into(),
2420 scope: DriveGrantedScopes::READONLY,
2421 }
2422 }
2423
2424 #[tokio::test]
2425 async fn access_token_refreshes_on_first_call() {
2426 let server = wiremock::MockServer::start().await;
2427 wiremock::Mock::given(wiremock::matchers::method("POST"))
2428 .respond_with(
2429 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2430 "access_token": "at-first",
2431 "expires_in": 3600,
2432 })),
2433 )
2434 .expect(1)
2435 .mount(&server)
2436 .await;
2437
2438 let session = DriveSession::new_with_token_endpoint(
2439 reqwest::Client::new(),
2440 &test_credentials(),
2441 &server.uri(),
2442 );
2443 let token = session.access_token().await.unwrap();
2444 assert_eq!(token.expose_secret(), "at-first");
2445 }
2446
2447 #[tokio::test]
2448 async fn access_token_reuses_cached_token_within_skew_window() {
2449 let server = wiremock::MockServer::start().await;
2450 wiremock::Mock::given(wiremock::matchers::method("POST"))
2451 .respond_with(
2452 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2453 "access_token": "at-cached",
2454 "expires_in": 3600,
2455 })),
2456 )
2457 .expect(1)
2458 .mount(&server)
2459 .await;
2460
2461 let session = DriveSession::new_with_token_endpoint(
2462 reqwest::Client::new(),
2463 &test_credentials(),
2464 &server.uri(),
2465 );
2466 let first = session.access_token().await.unwrap();
2467 let second = session.access_token().await.unwrap();
2468 assert_eq!(first, second);
2469 }
2470
2471 #[tokio::test]
2472 async fn access_token_proactively_refreshes_when_within_skew_window() {
2473 let server = wiremock::MockServer::start().await;
2474 wiremock::Mock::given(wiremock::matchers::method("POST"))
2475 .respond_with(
2476 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2477 "access_token": "at-short",
2478 "expires_in": 30,
2480 })),
2481 )
2482 .expect(2)
2483 .mount(&server)
2484 .await;
2485
2486 let session = DriveSession::new_with_token_endpoint(
2487 reqwest::Client::new(),
2488 &test_credentials(),
2489 &server.uri(),
2490 );
2491 session.access_token().await.unwrap();
2492 session.access_token().await.unwrap();
2493 }
2494
2495 #[tokio::test]
2496 async fn access_token_refresh_clamps_overflowing_expires_in_without_panicking() {
2497 let server = wiremock::MockServer::start().await;
2498 wiremock::Mock::given(wiremock::matchers::method("POST"))
2499 .respond_with(
2500 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2501 "access_token": "at-overflow",
2502 "expires_in": i64::MAX,
2505 })),
2506 )
2507 .expect(1)
2508 .mount(&server)
2509 .await;
2510
2511 let session = DriveSession::new_with_token_endpoint(
2512 reqwest::Client::new(),
2513 &test_credentials(),
2514 &server.uri(),
2515 );
2516 let token = session.access_token().await.unwrap();
2517 assert_eq!(token.expose_secret(), "at-overflow");
2518 }
2519
2520 #[tokio::test]
2521 async fn access_token_refresh_clamps_negative_expires_in_to_immediately_expired() {
2522 let server = wiremock::MockServer::start().await;
2523 wiremock::Mock::given(wiremock::matchers::method("POST"))
2524 .respond_with(
2525 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2526 "access_token": "at-negative",
2527 "expires_in": -3600,
2528 })),
2529 )
2530 .expect(2)
2533 .mount(&server)
2534 .await;
2535
2536 let session = DriveSession::new_with_token_endpoint(
2537 reqwest::Client::new(),
2538 &test_credentials(),
2539 &server.uri(),
2540 );
2541 session.access_token().await.unwrap();
2542 session.access_token().await.unwrap();
2543 }
2544
2545 #[tokio::test]
2546 async fn force_refresh_concurrent_callers_do_not_stampede() {
2547 let server = wiremock::MockServer::start().await;
2548 wiremock::Mock::given(wiremock::matchers::method("POST"))
2549 .respond_with(
2550 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2551 "access_token": "at-bootstrap",
2552 "expires_in": 3600,
2553 })),
2554 )
2555 .up_to_n_times(1)
2556 .with_priority(1)
2557 .mount(&server)
2558 .await;
2559 wiremock::Mock::given(wiremock::matchers::method("POST"))
2560 .respond_with(
2561 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2562 "access_token": "at-refreshed",
2563 "expires_in": 3600,
2564 })),
2565 )
2566 .expect(1)
2567 .with_priority(2)
2568 .mount(&server)
2569 .await;
2570
2571 let session = DriveSession::new_with_token_endpoint(
2572 reqwest::Client::new(),
2573 &test_credentials(),
2574 &server.uri(),
2575 );
2576 let bootstrapped = session.access_token().await.unwrap();
2577 assert_eq!(bootstrapped.expose_secret(), "at-bootstrap");
2578
2579 let (a, b) = tokio::join!(
2580 session.force_refresh(&bootstrapped),
2581 session.force_refresh(&bootstrapped)
2582 );
2583 let a = a.unwrap();
2584 let b = b.unwrap();
2585 assert_eq!(a, b);
2586 assert_eq!(a.expose_secret(), "at-refreshed");
2587 }
2588
2589 #[tokio::test]
2590 async fn force_refresh_skips_network_call_when_token_already_rotated() {
2591 let server = wiremock::MockServer::start().await;
2592 wiremock::Mock::given(wiremock::matchers::method("POST"))
2593 .respond_with(
2594 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2595 "access_token": "at-a",
2596 "expires_in": 3600,
2597 })),
2598 )
2599 .up_to_n_times(1)
2600 .with_priority(1)
2601 .mount(&server)
2602 .await;
2603 wiremock::Mock::given(wiremock::matchers::method("POST"))
2604 .respond_with(
2605 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2606 "access_token": "at-b",
2607 "expires_in": 3600,
2608 })),
2609 )
2610 .expect(1)
2611 .with_priority(2)
2612 .mount(&server)
2613 .await;
2614
2615 let session = DriveSession::new_with_token_endpoint(
2616 reqwest::Client::new(),
2617 &test_credentials(),
2618 &server.uri(),
2619 );
2620 let stale = Secret::new("at-never-issued");
2621 let bootstrapped = session.access_token().await.unwrap();
2622 assert_eq!(bootstrapped.expose_secret(), "at-a");
2623
2624 let result = session.force_refresh(&stale).await.unwrap();
2627 assert_eq!(result, bootstrapped);
2628
2629 let refreshed = session.force_refresh(&bootstrapped).await.unwrap();
2631 assert_eq!(refreshed.expose_secret(), "at-b");
2632 }
2633
2634 #[test]
2637 fn drive_credentials_debug_redacts_client_secret_and_refresh_token() {
2638 let creds = DriveCredentials {
2639 client_id: "client-visible".to_string(),
2640 client_secret: "sekret-client-secret".into(),
2641 refresh_token: "sekret-refresh-token".into(),
2642 scope: DriveGrantedScopes::READONLY,
2643 };
2644 let debug = format!("{creds:?}");
2645 assert!(debug.contains("DriveCredentials"));
2646 assert!(debug.contains("client-visible"));
2647 assert!(!debug.contains("sekret-client-secret"));
2648 assert!(!debug.contains("sekret-refresh-token"));
2649 assert!(debug.contains("client_secret: <redacted>"));
2650 assert!(debug.contains("refresh_token: <redacted>"));
2651 }
2652
2653 #[test]
2654 fn drive_auth_status_yaml_serialization_contains_no_secret_values() {
2655 let env = crate::test_support::env::MapEnv::new()
2656 .with(DRIVE_CLIENT_ID, "client-id-value")
2657 .with(DRIVE_CLIENT_SECRET, "sekret-do-not-leak")
2658 .with(DRIVE_REFRESH_TOKEN, "sekret-refresh-do-not-leak")
2659 .with(DRIVE_SCOPE, SCOPE_READONLY);
2660 let status = status_with(&env);
2661 let yaml = serde_yaml::to_string(&status).unwrap();
2662 assert!(!yaml.contains("sekret-do-not-leak"));
2663 assert!(!yaml.contains("sekret-refresh-do-not-leak"));
2664 }
2665
2666 use crate::test_support::env::MapEnv;
2669
2670 #[test]
2671 fn status_reports_all_false_when_nothing_configured() {
2672 let status = status_with(&MapEnv::new());
2673 assert!(!status.has_client_id);
2674 assert!(!status.has_client_secret);
2675 assert!(!status.has_refresh_token);
2676 assert_eq!(status.scope, None);
2677 }
2678
2679 #[test]
2680 fn status_reports_scope_when_present() {
2681 let env = MapEnv::new().with(DRIVE_SCOPE, SCOPE_READONLY);
2682 let status = status_with(&env);
2683 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
2684 }
2685
2686 #[test]
2687 fn load_credentials_errors_when_client_id_missing() {
2688 let env = MapEnv::new()
2689 .with(DRIVE_CLIENT_SECRET, "s")
2690 .with(DRIVE_REFRESH_TOKEN, "r");
2691 let err = load_credentials_with(&env).unwrap_err();
2692 assert!(err.to_string().contains("not configured"));
2693 }
2694
2695 #[test]
2696 fn load_credentials_errors_when_client_secret_missing() {
2697 let env = MapEnv::new()
2698 .with(DRIVE_CLIENT_ID, "c")
2699 .with(DRIVE_REFRESH_TOKEN, "r");
2700 assert!(load_credentials_with(&env).is_err());
2701 }
2702
2703 #[test]
2704 fn load_credentials_errors_when_refresh_token_missing() {
2705 let env = MapEnv::new()
2706 .with(DRIVE_CLIENT_ID, "c")
2707 .with(DRIVE_CLIENT_SECRET, "s");
2708 assert!(load_credentials_with(&env).is_err());
2709 }
2710
2711 #[test]
2712 fn load_credentials_succeeds_with_all_three_present() {
2713 let env = MapEnv::new()
2714 .with(DRIVE_CLIENT_ID, "c")
2715 .with(DRIVE_CLIENT_SECRET, "s")
2716 .with(DRIVE_REFRESH_TOKEN, "r");
2717 let creds = load_credentials_with(&env).unwrap();
2718 assert_eq!(creds.client_id, "c");
2719 assert_eq!(creds.scope, DriveGrantedScopes::default());
2724 }
2725
2726 #[test]
2729 fn save_then_remove_round_trip() {
2730 {
2732 let temp_dir = {
2733 std::fs::create_dir_all("tmp").ok();
2734 tempfile::TempDir::new_in("tmp").unwrap()
2735 };
2736 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2737
2738 let creds = DriveCredentials {
2739 client_id: "client-1".to_string(),
2740 client_secret: "secret-1".into(),
2741 refresh_token: "refresh-1".into(),
2742 scope: DriveGrantedScopes::READONLY,
2743 };
2744 save_credentials_to(&settings_path, None, &creds).unwrap();
2745
2746 assert!(settings_path.exists());
2747 let val: serde_json::Value =
2748 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2749 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "client-1");
2750 assert_eq!(val["env"]["DRIVE_CLIENT_SECRET"], "secret-1");
2751 assert_eq!(val["env"]["DRIVE_REFRESH_TOKEN"], "refresh-1");
2752 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2753
2754 #[cfg(unix)]
2755 {
2756 use std::os::unix::fs::PermissionsExt;
2757 let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
2758 assert_eq!(mode & 0o777, 0o600);
2759 }
2760 }
2761
2762 {
2764 let temp_dir = {
2765 std::fs::create_dir_all("tmp").ok();
2766 tempfile::TempDir::new_in("tmp").unwrap()
2767 };
2768 let omni_dir = temp_dir.path().join(".omni-dev");
2769 fs::create_dir_all(&omni_dir).unwrap();
2770 let settings_path = omni_dir.join("settings.json");
2771 fs::write(
2772 &settings_path,
2773 r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
2774 )
2775 .unwrap();
2776
2777 let creds = DriveCredentials {
2778 client_id: "client-2".to_string(),
2779 client_secret: "secret-2".into(),
2780 refresh_token: "refresh-2".into(),
2781 scope: DriveGrantedScopes::READONLY,
2782 };
2783 save_credentials_to(&settings_path, None, &creds).unwrap();
2784
2785 let val: serde_json::Value =
2786 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2787 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2788 assert_eq!(val["extra"], true);
2789 assert_eq!(val["env"]["DRIVE_SCOPE"], SCOPE_READONLY);
2790 }
2791
2792 {
2794 let temp_dir = {
2795 std::fs::create_dir_all("tmp").ok();
2796 tempfile::TempDir::new_in("tmp").unwrap()
2797 };
2798 let omni_dir = temp_dir.path().join(".omni-dev");
2799 fs::create_dir_all(&omni_dir).unwrap();
2800 let settings_path = omni_dir.join("settings.json");
2801 fs::write(
2802 &settings_path,
2803 r#"{"env": {
2804 "DRIVE_CLIENT_ID": "a",
2805 "DRIVE_CLIENT_SECRET": "b",
2806 "DRIVE_REFRESH_TOKEN": "c",
2807 "DRIVE_SCOPE": "d",
2808 "OTHER_KEY": "keep"
2809 }}"#,
2810 )
2811 .unwrap();
2812
2813 let removed = remove_credentials_at(&settings_path, None).unwrap();
2814 assert!(removed);
2815
2816 let val: serde_json::Value =
2817 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2818 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2819 assert!(val["env"].get("DRIVE_CLIENT_SECRET").is_none());
2820 assert!(val["env"].get("DRIVE_REFRESH_TOKEN").is_none());
2821 assert!(val["env"].get("DRIVE_SCOPE").is_none());
2822 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2823 }
2824
2825 {
2827 let temp_dir = {
2828 std::fs::create_dir_all("tmp").ok();
2829 tempfile::TempDir::new_in("tmp").unwrap()
2830 };
2831 let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
2832 let removed = remove_credentials_at(&settings_path, None).unwrap();
2833 assert!(!removed);
2834 }
2835 }
2836
2837 #[test]
2839 fn save_then_remove_round_trip_in_profile() {
2840 let temp_dir = {
2841 std::fs::create_dir_all("tmp").ok();
2842 tempfile::TempDir::new_in("tmp").unwrap()
2843 };
2844 let omni_dir = temp_dir.path().join(".omni-dev");
2845 fs::create_dir_all(&omni_dir).unwrap();
2846 let settings_path = omni_dir.join("settings.json");
2847 fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
2848
2849 let creds = DriveCredentials {
2850 client_id: "client-p".to_string(),
2851 client_secret: "secret-p".into(),
2852 refresh_token: "refresh-p".into(),
2853 scope: DriveGrantedScopes::READONLY,
2854 };
2855 save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
2856
2857 let val: serde_json::Value =
2858 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2859 assert_eq!(
2860 val["profiles"]["work"]["env"]["DRIVE_CLIENT_ID"],
2861 "client-p"
2862 );
2863 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2864 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2865
2866 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2867 assert!(removed);
2868 let val: serde_json::Value =
2869 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2870 assert!(val["profiles"]["work"]["env"]
2871 .get("DRIVE_CLIENT_ID")
2872 .is_none());
2873
2874 let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
2875 assert!(!removed);
2876 }
2877
2878 #[test]
2882 fn save_and_remove_credentials_resolve_default_settings_path() {
2883 let guard = crate::drive::test_support::EnvGuard::take();
2884 let dir = guard.clear_credentials();
2885
2886 let creds = DriveCredentials {
2887 client_id: "wrapper-client".to_string(),
2888 client_secret: "wrapper-secret".into(),
2889 refresh_token: "wrapper-refresh".into(),
2890 scope: DriveGrantedScopes::READONLY,
2891 };
2892 save_credentials(&creds).unwrap();
2893
2894 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2895 let val: serde_json::Value =
2896 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2897 assert_eq!(val["env"]["DRIVE_CLIENT_ID"], "wrapper-client");
2898
2899 assert!(remove_credentials().unwrap());
2900 let val: serde_json::Value =
2901 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
2902 assert!(val["env"].get("DRIVE_CLIENT_ID").is_none());
2903 }
2904
2905 #[test]
2912 fn load_credentials_for_named_reads_from_drive_accounts() {
2913 let guard = crate::drive::test_support::EnvGuard::take();
2914 let dir = guard.clear_credentials();
2915 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2916 Settings::upsert_drive_account(
2917 &settings_path,
2918 "work",
2919 &[
2920 (
2921 "client_id",
2922 serde_json::Value::String("work-id".to_string()),
2923 ),
2924 (
2925 "client_secret",
2926 serde_json::Value::String("work-secret".to_string()),
2927 ),
2928 (
2929 "refresh_token",
2930 serde_json::Value::String("work-refresh".to_string()),
2931 ),
2932 (
2933 "scope",
2934 serde_json::Value::String(SCOPE_READONLY.to_string()),
2935 ),
2936 ],
2937 )
2938 .unwrap();
2939
2940 let creds = load_credentials_for(Some("work")).unwrap();
2941 assert_eq!(creds.client_id, "work-id");
2942 assert_eq!(creds.client_secret.expose_secret(), "work-secret");
2943 assert_eq!(creds.refresh_token.expose_secret(), "work-refresh");
2944 assert_eq!(creds.scope, DriveGrantedScopes::READONLY);
2945 }
2946
2947 #[test]
2948 fn load_credentials_for_unknown_named_account_errors() {
2949 let guard = crate::drive::test_support::EnvGuard::take();
2950 let dir = guard.clear_credentials();
2951 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2952 Settings::upsert_drive_account(
2953 &settings_path,
2954 "work",
2955 &[(
2956 "client_id",
2957 serde_json::Value::String("work-id".to_string()),
2958 )],
2959 )
2960 .unwrap();
2961
2962 let err = load_credentials_for(Some("bogus")).unwrap_err();
2963 assert!(err.to_string().contains("unknown Drive account 'bogus'"));
2964 }
2965
2966 #[test]
2967 fn load_credentials_for_falls_back_to_env_when_accounts_empty() {
2968 let guard = crate::drive::test_support::EnvGuard::take();
2969 let _dir = guard.clear_credentials();
2970 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
2971 std::env::set_var(DRIVE_CLIENT_SECRET, "literal-secret");
2972 std::env::set_var(DRIVE_REFRESH_TOKEN, "literal-refresh");
2973
2974 let creds = load_credentials_for(None).unwrap();
2975 assert_eq!(creds.client_id, "literal-id");
2976 }
2977
2978 #[test]
2979 fn load_credentials_for_none_honors_ambient_account_env_var() {
2980 let guard = crate::drive::test_support::EnvGuard::take();
2981 let dir = guard.clear_credentials();
2982 let settings_path = dir.path().join(".omni-dev").join("settings.json");
2983 Settings::upsert_drive_account(
2984 &settings_path,
2985 "work",
2986 &[
2987 (
2988 "client_id",
2989 serde_json::Value::String("work-id".to_string()),
2990 ),
2991 (
2992 "client_secret",
2993 serde_json::Value::String("work-secret".to_string()),
2994 ),
2995 (
2996 "refresh_token",
2997 serde_json::Value::String("work-refresh".to_string()),
2998 ),
2999 ],
3000 )
3001 .unwrap();
3002 std::env::set_var(account::DRIVE_ACCOUNT_ENV, "work");
3003
3004 let creds = load_credentials_for(None).unwrap();
3005 assert_eq!(creds.client_id, "work-id");
3006 }
3007
3008 #[test]
3009 fn remove_credentials_for_named_removes_whole_account() {
3010 let guard = crate::drive::test_support::EnvGuard::take();
3011 let dir = guard.clear_credentials();
3012 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3013 Settings::upsert_drive_account(
3014 &settings_path,
3015 "work",
3016 &[("client_id", serde_json::Value::String("id".to_string()))],
3017 )
3018 .unwrap();
3019
3020 assert!(remove_credentials_for(Some("work")).unwrap());
3021 let val: serde_json::Value =
3022 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3023 assert!(val["drive"]["accounts"].get("work").is_none());
3024 }
3025
3026 #[cfg(feature = "mcp")]
3027 #[test]
3028 fn status_for_named_reports_presence_from_account() {
3029 let guard = crate::drive::test_support::EnvGuard::take();
3030 let dir = guard.clear_credentials();
3031 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3032 Settings::upsert_drive_account(
3033 &settings_path,
3034 "work",
3035 &[
3036 ("client_id", serde_json::Value::String("id".to_string())),
3037 (
3038 "scope",
3039 serde_json::Value::String(SCOPE_READONLY.to_string()),
3040 ),
3041 ],
3042 )
3043 .unwrap();
3044
3045 let status = status_for(Some("work")).unwrap();
3046 assert!(status.has_client_id);
3047 assert!(!status.has_client_secret);
3048 assert!(!status.has_refresh_token);
3049 assert_eq!(status.scope.as_deref(), Some(SCOPE_READONLY));
3050 }
3051
3052 #[cfg(feature = "mcp")]
3053 #[test]
3054 fn status_for_unconfigured_matches_status_with_when_accounts_empty() {
3055 let guard = crate::drive::test_support::EnvGuard::take();
3056 let _dir = guard.clear_credentials();
3057 std::env::set_var(DRIVE_CLIENT_ID, "literal-id");
3058
3059 let status = status_for(None).unwrap();
3060 assert!(status.has_client_id);
3061 assert!(!status.has_refresh_token);
3062 }
3063
3064 #[test]
3065 fn record_account_email_writes_email_address_only() {
3066 let guard = crate::drive::test_support::EnvGuard::take();
3067 let dir = guard.clear_credentials();
3068 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3069 Settings::upsert_drive_account(
3070 &settings_path,
3071 "work",
3072 &[("client_id", serde_json::Value::String("id".to_string()))],
3073 )
3074 .unwrap();
3075
3076 record_account_email("work", "alice@work.com").unwrap();
3077
3078 let val: serde_json::Value =
3079 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3080 assert_eq!(
3081 val["drive"]["accounts"]["work"]["email_address"],
3082 "alice@work.com"
3083 );
3084 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3085 }
3086
3087 #[test]
3088 fn record_account_email_does_not_overwrite_an_existing_value() {
3089 let guard = crate::drive::test_support::EnvGuard::take();
3090 let dir = guard.clear_credentials();
3091 let settings_path = dir.path().join(".omni-dev").join("settings.json");
3092 Settings::upsert_drive_account(
3093 &settings_path,
3094 "work",
3095 &[(
3096 "email_address",
3097 serde_json::Value::String("manually-set@work.com".to_string()),
3098 )],
3099 )
3100 .unwrap();
3101
3102 record_account_email("work", "alice@work.com").unwrap();
3103
3104 let val: serde_json::Value =
3105 serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
3106 assert_eq!(
3107 val["drive"]["accounts"]["work"]["email_address"],
3108 "manually-set@work.com"
3109 );
3110 }
3111
3112 #[test]
3121 fn accounts_empty_load_remove_byte_identical_via_direct_and_for_wrappers() {
3122 let guard = crate::drive::test_support::EnvGuard::take();
3123 let creds = DriveCredentials {
3124 client_id: "id".to_string(),
3125 client_secret: "secret".into(),
3126 refresh_token: "refresh".into(),
3127 scope: DriveGrantedScopes::READONLY,
3128 };
3129
3130 let dir_direct = guard.clear_credentials();
3131 save_credentials(&creds).unwrap();
3132 let direct_written =
3133 fs::read_to_string(dir_direct.path().join(".omni-dev").join("settings.json")).unwrap();
3134 let direct_loaded = load_credentials().unwrap();
3135 let direct_removed = remove_credentials().unwrap();
3136
3137 let dir_for = guard.clear_credentials();
3138 save_credentials(&creds).unwrap();
3139 let for_written =
3140 fs::read_to_string(dir_for.path().join(".omni-dev").join("settings.json")).unwrap();
3141 let for_loaded = load_credentials_for(None).unwrap();
3142 let for_removed = remove_credentials_for(None).unwrap();
3143
3144 assert_eq!(direct_written, for_written);
3145 assert_eq!(direct_loaded.client_id, for_loaded.client_id);
3146 assert_eq!(
3147 direct_loaded.client_secret.expose_secret(),
3148 for_loaded.client_secret.expose_secret()
3149 );
3150 assert_eq!(direct_loaded.scope, for_loaded.scope);
3151 assert_eq!(direct_removed, for_removed);
3152 }
3153}