Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/fix-issue-168-readonly-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gws': patch
---

fix(auth): robustly enforce readonly scopes by injecting prompt=consent
33 changes: 31 additions & 2 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
force_consent: bool,
}

impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelegate {
Expand All @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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,
Expand All @@ -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}")))?;
Expand Down
Loading