-
Notifications
You must be signed in to change notification settings - Fork 364
feat(auth): add gws auth use-adc command #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| --- | ||
| "gws": minor | ||
| --- | ||
|
|
||
| feat(auth): add `gws auth use-adc` command for Application Default Credentials | ||
|
|
||
| Adds a new `gws auth use-adc` command that imports Application Default Credentials | ||
| (ADC) from `gcloud`, eliminating the need to create custom OAuth clients for team | ||
| setups. | ||
|
|
||
| **Usage:** | ||
| ```bash | ||
| gcloud auth application-default login --project=my-project --scopes=<scopes> | ||
| gws auth use-adc | ||
| ``` | ||
|
|
||
| **Features:** | ||
| - Imports ADC credentials from gcloud to gws | ||
| - Includes `quota_project_id` support for proper API quota tracking | ||
| - Adds `x-goog-user-project` header to API requests when quota project is set | ||
| - Simplifies team onboarding (no custom OAuth client needed) | ||
|
|
||
| **Technical changes:** | ||
| - New `handle_use_adc()` function in `src/auth_commands.rs` | ||
| - Added `get_quota_project_id()` helper in `src/executor.rs` | ||
| - Request builder includes quota project header when available | ||
| - Supports both `~/.config/gcloud/` and `~/Library/Application Support/gcloud/` ADC paths | ||
|
|
||
| This makes team authentication simpler - everyone uses Google's built-in OAuth client | ||
| instead of creating/sharing custom clients. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,17 +97,19 @@ fn token_cache_path() -> PathBuf { | |
| /// Handle `gws auth <subcommand>`. | ||
| pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { | ||
| const USAGE: &str = concat!( | ||
| "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", | ||
| " status Show current authentication state\n", | ||
| " export Print decrypted credentials to stdout\n", | ||
| " logout Clear saved credentials and token cache", | ||
| "Usage: gws auth <login|setup|use-adc|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", | ||
| " use-adc Use Application Default Credentials from gcloud\n", | ||
| " (requires: gcloud auth application-default login with Workspace scopes)\n", | ||
| " status Show current authentication state\n", | ||
| " export Print decrypted credentials to stdout\n", | ||
| " logout Clear saved credentials and token cache", | ||
| ); | ||
|
|
||
| // Honour --help / -h before treating the first arg as a subcommand. | ||
|
|
@@ -119,14 +121,15 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { | |
| match args[0].as_str() { | ||
| "login" => handle_login(&args[1..]).await, | ||
| "setup" => crate::setup::run_setup(&args[1..]).await, | ||
| "use-adc" => handle_use_adc().await, | ||
| "status" => handle_status().await, | ||
| "export" => { | ||
| let unmasked = args.len() > 1 && args[1] == "--unmasked"; | ||
| handle_export(unmasked).await | ||
| } | ||
| "logout" => handle_logout(), | ||
| other => Err(GwsError::Validation(format!( | ||
| "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" | ||
| "Unknown auth subcommand: '{other}'. Use: login, setup, use-adc, status, export, logout" | ||
| ))), | ||
| } | ||
| } | ||
|
|
@@ -901,6 +904,120 @@ async fn handle_status() -> Result<(), GwsError> { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Import Application Default Credentials from gcloud and save them for gws. | ||
| /// | ||
| /// This allows users to run: | ||
| /// gcloud auth application-default login --scopes=<workspace-scopes> | ||
| /// gws auth use-adc | ||
| /// | ||
| /// Instead of creating a custom OAuth client via `gws auth setup`. | ||
| async fn handle_use_adc() -> Result<(), GwsError> { | ||
| // Try both common locations for ADC credentials | ||
| let adc_path_config = dirs::home_dir() | ||
| .ok_or_else(|| GwsError::Validation("Could not determine home directory".to_string()))? | ||
| .join(".config") | ||
| .join("gcloud") | ||
| .join("application_default_credentials.json"); | ||
|
|
||
| let adc_path_app_support = dirs::config_dir() | ||
| .ok_or_else(|| GwsError::Validation("Could not determine config directory".to_string()))? | ||
| .join("gcloud") | ||
| .join("application_default_credentials.json"); | ||
|
|
||
| let adc_path = if adc_path_config.exists() { | ||
| adc_path_config | ||
| } else if adc_path_app_support.exists() { | ||
| adc_path_app_support | ||
| } else { | ||
| adc_path_config // default for error message | ||
| }; | ||
|
|
||
| if !adc_path.exists() { | ||
| return Err(GwsError::Validation(format!( | ||
| "Application Default Credentials not found at: {}\n\n\ | ||
| Run this first:\n \ | ||
| gcloud auth application-default login --project=<project> \\\n \ | ||
| --scopes=<comma-separated-scopes>\n\n\ | ||
| Example with Workspace scopes:\n \ | ||
| gcloud auth application-default login --project=my-project \\\n \ | ||
| --scopes=openid,https://www.googleapis.com/auth/userinfo.email,\\\n \ | ||
| https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/gmail.modify", | ||
| adc_path.display() | ||
| ))); | ||
| } | ||
|
|
||
| let adc_contents = tokio::fs::read_to_string(&adc_path).await.map_err(|e| { | ||
| GwsError::Validation(format!( | ||
| "Failed to read ADC credentials from {}: {}", | ||
| adc_path.display(), | ||
| e | ||
| )) | ||
| })?; | ||
|
|
||
| let adc: serde_json::Value = serde_json::from_str(&adc_contents) | ||
| .map_err(|e| GwsError::Validation(format!("Failed to parse ADC credentials: {}", e)))?; | ||
|
|
||
| // Validate required fields exist | ||
| let client_id = adc | ||
| .get("client_id") | ||
| .and_then(|v| v.as_str()) | ||
| .ok_or_else(|| GwsError::Validation("ADC credentials missing 'client_id'".to_string()))?; | ||
|
|
||
| let client_secret = adc | ||
| .get("client_secret") | ||
| .and_then(|v| v.as_str()) | ||
| .ok_or_else(|| { | ||
| GwsError::Validation("ADC credentials missing 'client_secret'".to_string()) | ||
| })?; | ||
|
|
||
| let refresh_token = adc | ||
| .get("refresh_token") | ||
| .and_then(|v| v.as_str()) | ||
| .ok_or_else(|| { | ||
| GwsError::Validation("ADC credentials missing 'refresh_token'".to_string()) | ||
| })?; | ||
|
Comment on lines
+961
to
+978
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validation logic for fn get_string_field<'a>(json: &'a serde_json::Value, field: &str) -> Result<&'a str, GwsError> {
json.get(field)
.and_then(|v| v.as_str())
.ok_or_else(|| GwsError::Validation(format!("ADC credentials missing '{field}'")))
}
// ...
let client_id = get_string_field(&adc, "client_id")?;
let client_secret = get_string_field(&adc, "client_secret")?;
let refresh_token = get_string_field(&adc, "refresh_token")?; |
||
|
|
||
| let cred_type = adc.get("type").and_then(|v| v.as_str()); | ||
| if cred_type != Some("authorized_user") { | ||
| return Err(GwsError::Validation(format!( | ||
| "ADC credentials must be type 'authorized_user', got: {:?}", | ||
| cred_type | ||
| ))); | ||
| } | ||
|
|
||
| // Build gws credentials with the same fields | ||
| let mut gws_creds = json!({ | ||
| "type": "authorized_user", | ||
| "client_id": client_id, | ||
| "client_secret": client_secret, | ||
| "refresh_token": refresh_token, | ||
| }); | ||
|
|
||
| // Include quota_project_id if present in ADC credentials | ||
| if let Some(quota_project) = adc.get("quota_project_id").and_then(|v| v.as_str()) { | ||
| gws_creds["quota_project_id"] = json!(quota_project); | ||
| } | ||
|
|
||
| let creds_str = serde_json::to_string(&gws_creds) | ||
| .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {}", e)))?; | ||
|
|
||
| let enc_path = credential_store::save_encrypted(&creds_str) | ||
| .map_err(|e| GwsError::Validation(format!("Failed to save credentials: {}", e)))?; | ||
|
|
||
| let output = json!({ | ||
| "status": "success", | ||
| "message": "ADC credentials imported successfully.", | ||
| "credentials_file": enc_path.display().to_string(), | ||
| "quota_project_id": adc.get("quota_project_id"), | ||
| }); | ||
|
|
||
| println!( | ||
| "{}", | ||
| serde_json::to_string_pretty(&output).unwrap() | ||
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn handle_logout() -> Result<(), GwsError> { | ||
| let plain_path = plain_credentials_path(); | ||
| let enc_path = credential_store::encrypted_credentials_path(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,38 @@ use tokio::io::AsyncWriteExt; | |
| use crate::discovery::{RestDescription, RestMethod}; | ||
| use crate::error::GwsError; | ||
|
|
||
| /// Get the quota_project_id from stored credentials if present. | ||
| /// Used to set the x-goog-user-project header for ADC credentials. | ||
| fn get_quota_project_id() -> Result<String, String> { | ||
| // Check encrypted credentials first | ||
| if let Ok(contents) = crate::credential_store::load_encrypted() { | ||
| if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&contents) { | ||
| if let Some(quota_project) = creds.get("quota_project_id").and_then(|v| v.as_str()) { | ||
| return Ok(quota_project.to_string()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Check plaintext credentials | ||
| let config_dir = dirs::config_dir() | ||
| .unwrap_or_else(|| std::path::PathBuf::from(".")) | ||
| .join("gws"); | ||
| let plain_path = config_dir.join("credentials.json"); | ||
|
|
||
| if plain_path.exists() { | ||
| if let Ok(contents) = std::fs::read_to_string(&plain_path) { | ||
| if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&contents) { | ||
| if let Some(quota_project) = creds.get("quota_project_id").and_then(|v| v.as_str()) | ||
| { | ||
| return Ok(quota_project.to_string()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Err("No quota_project_id found".to_string()) | ||
| } | ||
|
Comment on lines
+34
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function reimplements logic for finding and parsing credentials, but it's incomplete. It doesn't check for the To fix this and avoid code duplication, the logic for loading credentials should be centralized and reused here. Consider moving |
||
|
|
||
| /// Tracks what authentication method was used for the request. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub enum AuthMethod { | ||
|
|
@@ -162,6 +194,12 @@ async fn build_http_request( | |
| if let Some(token) = token { | ||
| if *auth_method == AuthMethod::OAuth { | ||
| request = request.bearer_auth(token); | ||
|
|
||
| // Add quota project header if present in credentials | ||
| // This is required for ADC credentials to work properly | ||
| if let Ok(quota_project) = get_quota_project_id() { | ||
| request = request.header("x-goog-user-project", quota_project); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic for determining
adc_pathworks, but has a small issue. If neither path exists, the subsequent error message on line 936 will only showadc_path_config, which can be misleading on platforms like Windows where the correct path isadc_path_app_support. Consider modifying this to provide a more informative error message that lists all paths that were checked.