Skip to content

Commit ee2e216

Browse files
fix: narrow default OAuth scopes to avoid restricted_client, improve non-interactive setup UX (npm#30)
* fix: narrow default OAuth scopes to avoid restricted_client, add --full flag, improve non-interactive setup UX Fixes npm#24, npm#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 npm#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.
1 parent de2787e commit ee2e216

3 files changed

Lines changed: 115 additions & 10 deletions

File tree

.changeset/9df09438f1eb.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
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).

src/auth_commands.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,37 @@ use serde_json::json;
1919
use crate::credential_store;
2020
use crate::error::GwsError;
2121

22-
/// Default scopes for login — broad Workspace access.
23-
pub const DEFAULT_SCOPES: &[&str] = &[
22+
/// Minimal scopes for first-run login — only core Workspace APIs that never
23+
/// trigger Google's `restricted_client` / unverified-app block.
24+
///
25+
/// These are the safest scopes for unverified OAuth apps and personal Cloud
26+
/// projects. Users can request broader access with `--scopes` or `--full`.
27+
pub const MINIMAL_SCOPES: &[&str] = &[
28+
"https://www.googleapis.com/auth/drive",
29+
"https://www.googleapis.com/auth/spreadsheets",
30+
"https://www.googleapis.com/auth/gmail.modify",
31+
"https://www.googleapis.com/auth/calendar",
32+
"https://www.googleapis.com/auth/documents",
33+
"https://www.googleapis.com/auth/presentations",
34+
"https://www.googleapis.com/auth/tasks",
35+
];
36+
37+
/// Default scopes for login. Alias for [`MINIMAL_SCOPES`] — deliberately kept
38+
/// narrow so first-run logins succeed even with an unverified OAuth app.
39+
///
40+
/// Previously this included `pubsub` and `cloud-platform`, which Google marks
41+
/// as *restricted* and blocks for unverified apps, causing `Error 403:
42+
/// restricted_client`. Use `--scopes` to add those scopes explicitly when you
43+
/// have a verified app or a GCP project with the APIs enabled and approved.
44+
pub const DEFAULT_SCOPES: &[&str] = MINIMAL_SCOPES;
45+
46+
/// Full scopes — all common Workspace APIs plus GCP platform access.
47+
///
48+
/// Use `gws auth login --full` to request these. Unverified OAuth apps will
49+
/// receive a Google consent-screen warning, and some scopes (e.g. `pubsub`,
50+
/// `cloud-platform`) require app verification or a Workspace domain admin to
51+
/// grant access.
52+
pub const FULL_SCOPES: &[&str] = &[
2453
"https://www.googleapis.com/auth/drive",
2554
"https://www.googleapis.com/auth/spreadsheets",
2655
"https://www.googleapis.com/auth/gmail.modify",
@@ -72,6 +101,8 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
72101
"Usage: gws auth <login|setup|status|export|logout>\n\n\
73102
login Authenticate via OAuth2 (opens browser)\n\
74103
--readonly Request read-only scopes\n\
104+
--full Request all scopes incl. pubsub + cloud-platform\n\
105+
(may trigger restricted_client for unverified apps)\n\
75106
--scopes Comma-separated custom scopes\n\
76107
setup Configure GCP project + OAuth client (requires gcloud)\n\
77108
--project Use a specific GCP project\n\
@@ -317,6 +348,9 @@ async fn resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec<String
317348
if args.iter().any(|a| a == "--readonly") {
318349
return READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
319350
}
351+
if args.iter().any(|a| a == "--full") {
352+
return FULL_SCOPES.iter().map(|s| s.to_string()).collect();
353+
}
320354

321355
// Interactive scope picker when running in a TTY
322356
if !cfg!(test) && std::io::IsTerminal::is_terminal(&std::io::stdin()) {

src/setup.rs

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,64 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result<SetupStage, GwsErro
11631163
Ok(SetupStage::ConfigureOauth)
11641164
}
11651165

1166+
/// Build actionable manual OAuth setup instructions for non-interactive environments.
1167+
///
1168+
/// Returned as the error message when `gws auth setup` cannot prompt interactively,
1169+
/// so users get a clear checklist instead of a cryptic "run interactively" error.
1170+
fn manual_oauth_instructions(project_id: &str) -> String {
1171+
let consent_url = if project_id.is_empty() {
1172+
"https://console.cloud.google.com/apis/credentials/consent".to_string()
1173+
} else {
1174+
format!(
1175+
"https://console.cloud.google.com/apis/credentials/consent?project={}",
1176+
project_id
1177+
)
1178+
};
1179+
let creds_url = if project_id.is_empty() {
1180+
"https://console.cloud.google.com/apis/credentials".to_string()
1181+
} else {
1182+
format!(
1183+
"https://console.cloud.google.com/apis/credentials?project={}",
1184+
project_id
1185+
)
1186+
};
1187+
1188+
format!(
1189+
concat!(
1190+
"OAuth client creation requires manual setup in the Google Cloud Console.\n\n",
1191+
"Follow these steps:\n\n",
1192+
"1. Configure the OAuth consent screen (if not already done):\n",
1193+
" {consent_url}\n",
1194+
" → User Type: External\n",
1195+
" → App name: gws CLI (or your preferred name)\n",
1196+
" → Support email: your Google account email\n",
1197+
" → Save and continue through all screens\n\n",
1198+
"2. Create an OAuth client ID:\n",
1199+
" {creds_url}\n",
1200+
" → Click 'Create Credentials' → 'OAuth client ID'\n",
1201+
" → Application type: Desktop app\n",
1202+
" → Name: gws CLI (or your preferred name)\n",
1203+
" → Click 'Create'\n\n",
1204+
"3. Copy the Client ID and Client Secret shown in the dialog.\n\n",
1205+
"4. Provide the credentials to gws using one of these methods:\n\n",
1206+
" Option A — Environment variables (recommended for CI/scripts):\n",
1207+
" export GOOGLE_WORKSPACE_CLI_CLIENT_ID=\"<your-client-id>\"\n",
1208+
" export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=\"<your-client-secret>\"\n",
1209+
" gws auth login\n\n",
1210+
" Option B — Download the JSON file:\n",
1211+
" Download 'client_secret_*.json' from the Cloud Console dialog\n",
1212+
" and save it to: ~/.config/gws/client_secret.json\n",
1213+
" Then run: gws auth login\n\n",
1214+
" Option C — Re-run setup interactively (recommended for first-time setup):\n",
1215+
" gws auth setup\n\n",
1216+
"Note: The redirect URI used by gws is http://localhost (auto-negotiated port).\n",
1217+
"Desktop app clients do not require you to register a redirect URI manually."
1218+
),
1219+
consent_url = consent_url,
1220+
creds_url = creds_url
1221+
)
1222+
}
1223+
11661224
/// Stage 5: Configure OAuth consent screen and collect client credentials.
11671225
async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, GwsError> {
11681226
ctx.wiz(4, StepStatus::InProgress("Configuring...".into()));
@@ -1175,9 +1233,9 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, Gws
11751233
StepStatus::InProgress("Waiting for manual input...".into()),
11761234
);
11771235
if !ctx.interactive {
1178-
return Err(GwsError::Validation(
1179-
"Cannot automate OAuth client creation. Please run setup interactively.".to_string(),
1180-
));
1236+
return Err(GwsError::Validation(manual_oauth_instructions(
1237+
&ctx.project_id,
1238+
)));
11811239
}
11821240

11831241
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
11861244
.and_then(|s| serde_json::from_str(&s).ok());
11871245

11881246
w.show_message(&format!(
1189-
"Go to: https://console.cloud.google.com/apis/credentials/consent?project={}\n\
1190-
Ensure 'External' consent screen is configured. Then,\n\
1191-
Go to: https://console.cloud.google.com/apis/credentials?project={}\n\
1192-
Click 'Create Credentials' -> 'OAuth client ID' -> 'Desktop app'",
1193-
ctx.project_id, ctx.project_id
1247+
concat!(
1248+
"Manual OAuth client setup required.\n\n",
1249+
"Step A — Consent screen (if not configured):\n",
1250+
"https://console.cloud.google.com/apis/credentials/consent?project={project}\n",
1251+
"→ User Type: External, then save through all screens.\n\n",
1252+
"Step B — Create an OAuth client:\n",
1253+
"https://console.cloud.google.com/apis/credentials?project={project}\n",
1254+
"→ 'Create Credentials' → 'OAuth client ID'\n",
1255+
"→ Application type: Desktop app\n",
1256+
"→ Redirect URI: http://localhost (auto-negotiated; no manual entry needed)\n\n",
1257+
"Copy the Client ID and Client Secret from the dialog, then paste them below."
1258+
),
1259+
project = ctx.project_id
11941260
))
11951261
.ok();
11961262

0 commit comments

Comments
 (0)