diff --git a/.changeset/fix-issue-168-readonly-scope.md b/.changeset/fix-issue-168-readonly-scope.md new file mode 100644 index 00000000..c6e349e9 --- /dev/null +++ b/.changeset/fix-issue-168-readonly-scope.md @@ -0,0 +1,5 @@ +--- +'gws': patch +--- + +fix(auth): robustly enforce readonly scopes by injecting prompt=consent diff --git a/src/auth_commands.rs b/src/auth_commands.rs index f51ba6dd..1b979099 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -178,6 +178,7 @@ pub async fn run_login(args: &[String]) -> Result<(), GwsError> { /// Optionally includes `login_hint` in the URL for account pre-selection. struct CliFlowDelegate { login_hint: Option, + force_consent: bool, } impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelegate { @@ -189,7 +190,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega { Box::pin(async move { // Inject login_hint into the OAuth URL if we have one - let display_url = if let Some(ref hint) = self.login_hint { + let mut display_url = if let Some(ref hint) = self.login_hint { let encoded: String = percent_encoding::percent_encode( hint.as_bytes(), percent_encoding::NON_ALPHANUMERIC, @@ -203,6 +204,29 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } else { url.to_string() }; + + // Inject prompt=consent if requested (for readonly enforcement) + if self.force_consent { + if let Some(pos) = display_url.find("prompt=") { + let end_pos = display_url[pos..] + .find('&') + .map(|i| pos + i) + .unwrap_or(display_url.len()); + let current_prompt = &display_url[pos + 7..end_pos]; + if !current_prompt.split("%20").any(|p| p == "consent") { + let new_prompt = if current_prompt.is_empty() { + "consent".to_string() + } else { + format!("{}%20consent", current_prompt) + }; + display_url.replace_range(pos + 7..end_pos, &new_prompt); + } + } else if display_url.contains('?') { + display_url.push_str("&prompt=consent"); + } else { + display_url.push_str("?prompt=consent"); + } + } eprintln!("Open this URL in your browser to authenticate:\n"); eprintln!(" {display_url}\n"); Ok(String::new()) @@ -296,6 +320,8 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; } + let is_readonly = args.iter().any(|a| a == "--readonly"); + let auth = yup_oauth2::InstalledFlowAuthenticator::builder( secret, yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, @@ -304,7 +330,10 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { temp_path.clone(), ))) .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token - .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) + .flow_delegate(Box::new(CliFlowDelegate { + login_hint: None, + force_consent: is_readonly, + })) .build() .await .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;