Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/9df09438f1eb.md
Original file line number Diff line number Diff line change
@@ -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).
38 changes: 36 additions & 2 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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\
Expand Down Expand Up @@ -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()) {
Expand Down
82 changes: 74 additions & 8 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -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 {
Expand All @@ -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();

Expand Down
Loading