fix: narrow default OAuth scopes to avoid restricted_client, improve non-interactive setup UX (#30)

* fix: narrow default OAuth scopes to avoid restricted_client, add --full flag, improve non-interactive setup UX

Fixes #24, #25

- DEFAULT_SCOPES now aliases MINIMAL_SCOPES (no pubsub/cloud-platform)
  which avoids Google's restricted_client 403 on unverified OAuth apps
- Add FULL_SCOPES and --full flag for users who need the broader set
- Replace cryptic 'run setup interactively' error with step-by-step
  manual OAuth console instructions including URLs, options A/B/C

* chore: add changeset

* chore: cargo fmt

* fix: refactor format! with backslash continuations to concat! macro

Address Gemini review (PR #30): replace hard-to-read backslash line
continuations in large format! macros with concat! for clearer structure:
- manual_oauth_instructions(): full step-by-step guide
- stage_configure_oauth() wizard show_message: interactive prompt text

No functional change; output text is identical.
This commit is contained in:
jpoehnelt-bot
2026-03-04 00:35:22 -07:00
committed by GitHub
parent de2787e90f
commit ee2e216c10
3 changed files with 115 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Narrow default OAuth scopes to avoid `Error 403: restricted_client` on unverified apps and add a `--full` flag for broader access (fixes #25). Replace the cryptic non-interactive setup error with actionable step-by-step OAuth console instructions (fixes #24).
+36 -2
View File
@@ -19,8 +19,37 @@ use serde_json::json;
use crate::credential_store;
use crate::error::GwsError;
/// Default scopes for login — broad Workspace access.
pub const DEFAULT_SCOPES: &[&str] = &[
/// Minimal scopes for first-run login — only core Workspace APIs that never
/// trigger Google's `restricted_client` / unverified-app block.
///
/// These are the safest scopes for unverified OAuth apps and personal Cloud
/// projects. Users can request broader access with `--scopes` or `--full`.
pub const MINIMAL_SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/presentations",
"https://www.googleapis.com/auth/tasks",
];
/// Default scopes for login. Alias for [`MINIMAL_SCOPES`] — deliberately kept
/// narrow so first-run logins succeed even with an unverified OAuth app.
///
/// Previously this included `pubsub` and `cloud-platform`, which Google marks
/// as *restricted* and blocks for unverified apps, causing `Error 403:
/// restricted_client`. Use `--scopes` to add those scopes explicitly when you
/// have a verified app or a GCP project with the APIs enabled and approved.
pub const DEFAULT_SCOPES: &[&str] = MINIMAL_SCOPES;
/// Full scopes — all common Workspace APIs plus GCP platform access.
///
/// Use `gws auth login --full` to request these. Unverified OAuth apps will
/// receive a Google consent-screen warning, and some scopes (e.g. `pubsub`,
/// `cloud-platform`) require app verification or a Workspace domain admin to
/// grant access.
pub const FULL_SCOPES: &[&str] = &[
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.modify",
@@ -72,6 +101,8 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
"Usage: gws auth <login|setup|status|export|logout>\n\n\
login Authenticate via OAuth2 (opens browser)\n\
--readonly Request read-only scopes\n\
--full Request all scopes incl. pubsub + cloud-platform\n\
(may trigger restricted_client for unverified apps)\n\
--scopes Comma-separated custom scopes\n\
setup Configure GCP project + OAuth client (requires gcloud)\n\
--project Use a specific GCP project\n\
@@ -317,6 +348,9 @@ async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String
if args.iter().any(|a| a == "--readonly") {
return READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
}
if args.iter().any(|a| a == "--full") {
return FULL_SCOPES.iter().map(|s| s.to_string()).collect();
}
// Interactive scope picker when running in a TTY
if !cfg!(test) && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
+74 -8
View File
@@ -1163,6 +1163,64 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result<SetupStage, GwsErro
Ok(SetupStage::ConfigureOauth)
}
/// Build actionable manual OAuth setup instructions for non-interactive environments.
///
/// Returned as the error message when `gws auth setup` cannot prompt interactively,
/// so users get a clear checklist instead of a cryptic "run interactively" error.
fn manual_oauth_instructions(project_id: &str) -> String {
let consent_url = if project_id.is_empty() {
"https://console.cloud.google.com/apis/credentials/consent".to_string()
} else {
format!(
"https://console.cloud.google.com/apis/credentials/consent?project={}",
project_id
)
};
let creds_url = if project_id.is_empty() {
"https://console.cloud.google.com/apis/credentials".to_string()
} else {
format!(
"https://console.cloud.google.com/apis/credentials?project={}",
project_id
)
};
format!(
concat!(
"OAuth client creation requires manual setup in the Google Cloud Console.\n\n",
"Follow these steps:\n\n",
"1. Configure the OAuth consent screen (if not already done):\n",
" {consent_url}\n",
" → User Type: External\n",
" → App name: gws CLI (or your preferred name)\n",
" → Support email: your Google account email\n",
" → Save and continue through all screens\n\n",
"2. Create an OAuth client ID:\n",
" {creds_url}\n",
" → Click 'Create Credentials' → 'OAuth client ID'\n",
" → Application type: Desktop app\n",
" → Name: gws CLI (or your preferred name)\n",
" → Click 'Create'\n\n",
"3. Copy the Client ID and Client Secret shown in the dialog.\n\n",
"4. Provide the credentials to gws using one of these methods:\n\n",
" Option A — Environment variables (recommended for CI/scripts):\n",
" export GOOGLE_WORKSPACE_CLI_CLIENT_ID=\"<your-client-id>\"\n",
" export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=\"<your-client-secret>\"\n",
" gws auth login\n\n",
" Option B — Download the JSON file:\n",
" Download 'client_secret_*.json' from the Cloud Console dialog\n",
" and save it to: ~/.config/gws/client_secret.json\n",
" Then run: gws auth login\n\n",
" Option C — Re-run setup interactively (recommended for first-time setup):\n",
" gws auth setup\n\n",
"Note: The redirect URI used by gws is http://localhost (auto-negotiated port).\n",
"Desktop app clients do not require you to register a redirect URI manually."
),
consent_url = consent_url,
creds_url = creds_url
)
}
/// Stage 5: Configure OAuth consent screen and collect client credentials.
async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, GwsError> {
ctx.wiz(4, StepStatus::InProgress("Configuring...".into()));
@@ -1175,9 +1233,9 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, Gws
StepStatus::InProgress("Waiting for manual input...".into()),
);
if !ctx.interactive {
return Err(GwsError::Validation(
"Cannot automate OAuth client creation. Please run setup interactively.".to_string(),
));
return Err(GwsError::Validation(manual_oauth_instructions(
&ctx.project_id,
)));
}
let (cid_result, csecret_result) = if let Some(ref mut w) = ctx.wizard {
@@ -1186,11 +1244,19 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, Gws
.and_then(|s| serde_json::from_str(&s).ok());
w.show_message(&format!(
"Go to: https://console.cloud.google.com/apis/credentials/consent?project={}\n\
Ensure 'External' consent screen is configured. Then,\n\
Go to: https://console.cloud.google.com/apis/credentials?project={}\n\
Click 'Create Credentials' -> 'OAuth client ID' -> 'Desktop app'",
ctx.project_id, ctx.project_id
concat!(
"Manual OAuth client setup required.\n\n",
"Step A — Consent screen (if not configured):\n",
"https://console.cloud.google.com/apis/credentials/consent?project={project}\n",
"→ User Type: External, then save through all screens.\n\n",
"Step B — Create an OAuth client:\n",
"https://console.cloud.google.com/apis/credentials?project={project}\n",
"→ 'Create Credentials' → 'OAuth client ID'\n",
"→ Application type: Desktop app\n",
"→ Redirect URI: http://localhost (auto-negotiated; no manual entry needed)\n\n",
"Copy the Client ID and Client Secret from the dialog, then paste them below."
),
project = ctx.project_id
))
.ok();