Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
88 changes: 80 additions & 8 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,69 @@ 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!(
"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."
)
}

/// 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 +1238,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 +1249,20 @@ 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
"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