@@ -24,6 +24,19 @@ use anyhow::Context;
2424
2525use crate :: credential_store;
2626
27+ /// Returns the well-known Application Default Credentials path:
28+ /// `~/.config/gcloud/application_default_credentials.json`.
29+ ///
30+ /// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is
31+ /// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS.
32+ fn adc_well_known_path ( ) -> Option < PathBuf > {
33+ dirs:: home_dir ( ) . map ( |d| {
34+ d. join ( ".config" )
35+ . join ( "gcloud" )
36+ . join ( "application_default_credentials.json" )
37+ } )
38+ }
39+
2740/// Types of credentials we support
2841#[ derive( Debug ) ]
2942enum Credential {
@@ -38,6 +51,10 @@ enum Credential {
3851/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
3952/// 2. Per-account encrypted credentials via `accounts.json` registry
4053/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
54+ /// 4. Application Default Credentials (ADC):
55+ /// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
56+ /// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
57+ /// (populated by `gcloud auth application-default login`)
4158///
4259/// When `account` is `Some`, a specific registered account is used.
4360/// When `account` is `None`, the default account from `accounts.json` is used.
@@ -201,6 +218,30 @@ async fn get_token_inner(
201218 }
202219}
203220
221+ /// Parse a plaintext JSON credential file into a [`Credential`].
222+ ///
223+ /// Determines the credential type from the `"type"` field:
224+ /// - `"service_account"` → [`Credential::ServiceAccount`]
225+ /// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`]
226+ ///
227+ /// Uses the already-parsed `serde_json::Value` to avoid a second string parse.
228+ async fn parse_credential_file ( path : & std:: path:: Path , content : & str ) -> anyhow:: Result < Credential > {
229+ let json: serde_json:: Value = serde_json:: from_str ( content)
230+ . with_context ( || format ! ( "Failed to parse credentials JSON at {}" , path. display( ) ) ) ?;
231+
232+ if json. get ( "type" ) . and_then ( |v| v. as_str ( ) ) == Some ( "service_account" ) {
233+ let key = yup_oauth2:: parse_service_account_key ( content)
234+ . with_context ( || format ! ( "Failed to parse service account key from {}" , path. display( ) ) ) ?;
235+ return Ok ( Credential :: ServiceAccount ( key) ) ;
236+ }
237+
238+ // Deserialize from the Value we already have — avoids a second string parse.
239+ let secret: yup_oauth2:: authorized_user:: AuthorizedUserSecret =
240+ serde_json:: from_value ( json)
241+ . with_context ( || format ! ( "Failed to parse authorized user credentials from {}" , path. display( ) ) ) ?;
242+ Ok ( Credential :: AuthorizedUser ( secret) )
243+ }
244+
204245async fn load_credentials_inner (
205246 env_file : Option < & str > ,
206247 enc_path : & std:: path:: Path ,
@@ -210,29 +251,10 @@ async fn load_credentials_inner(
210251 if let Some ( path) = env_file {
211252 let p = PathBuf :: from ( path) ;
212253 if p. exists ( ) {
213- // Read file content first to determine type
214254 let content = tokio:: fs:: read_to_string ( & p)
215255 . await
216256 . with_context ( || format ! ( "Failed to read credentials from {path}" ) ) ?;
217-
218- let json: serde_json:: Value =
219- serde_json:: from_str ( & content) . context ( "Failed to parse credentials JSON" ) ?;
220-
221- // Check for "type" field
222- if let Some ( type_str) = json. get ( "type" ) . and_then ( |v| v. as_str ( ) ) {
223- if type_str == "service_account" {
224- let key = yup_oauth2:: parse_service_account_key ( & content)
225- . context ( "Failed to parse service account key" ) ?;
226- return Ok ( Credential :: ServiceAccount ( key) ) ;
227- }
228- }
229-
230- // Default to parsed authorized user secret if not service account
231- // We re-parse specifically to AuthorizedUserSecret to validate fields
232- let secret: yup_oauth2:: authorized_user:: AuthorizedUserSecret =
233- serde_json:: from_str ( & content)
234- . context ( "Failed to parse authorized user credentials" ) ?;
235- return Ok ( Credential :: AuthorizedUser ( secret) ) ;
257+ return parse_credential_file ( & p, & content) . await ;
236258 }
237259 anyhow:: bail!(
238260 "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
@@ -279,9 +301,36 @@ async fn load_credentials_inner(
279301 ) ) ;
280302 }
281303
304+ // 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing)
305+ if let Ok ( adc_env) = std:: env:: var ( "GOOGLE_APPLICATION_CREDENTIALS" ) {
306+ let adc_path = PathBuf :: from ( & adc_env) ;
307+ if adc_path. exists ( ) {
308+ let content = tokio:: fs:: read_to_string ( & adc_path)
309+ . await
310+ . with_context ( || format ! ( "Failed to read ADC from {adc_env}" ) ) ?;
311+ return parse_credential_file ( & adc_path, & content) . await ;
312+ }
313+ anyhow:: bail!(
314+ "GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist"
315+ ) ;
316+ }
317+
318+ // 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json
319+ // (populated by `gcloud auth application-default login`). Silent if absent.
320+ if let Some ( well_known) = adc_well_known_path ( ) {
321+ if well_known. exists ( ) {
322+ let content = tokio:: fs:: read_to_string ( & well_known)
323+ . await
324+ . with_context ( || format ! ( "Failed to read ADC from {}" , well_known. display( ) ) ) ?;
325+ return parse_credential_file ( & well_known, & content) . await ;
326+ }
327+ }
328+
282329 anyhow:: bail!(
283330 "No credentials found. Run `gws auth setup` to configure, \
284- `gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"
331+ `gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n \
332+ Tip: Application Default Credentials (ADC) are also supported — run \
333+ `gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS."
285334 )
286335}
287336
@@ -306,6 +355,103 @@ mod tests {
306355 . contains( "No credentials found" ) ) ;
307356 }
308357
358+ #[ tokio:: test]
359+ #[ serial_test:: serial]
360+ async fn test_load_credentials_adc_env_var_authorized_user ( ) {
361+ let mut file = NamedTempFile :: new ( ) . unwrap ( ) ;
362+ let json = r#"{
363+ "client_id": "adc_id",
364+ "client_secret": "adc_secret",
365+ "refresh_token": "adc_refresh",
366+ "type": "authorized_user"
367+ }"# ;
368+ file. write_all ( json. as_bytes ( ) ) . unwrap ( ) ;
369+
370+ std:: env:: set_var (
371+ "GOOGLE_APPLICATION_CREDENTIALS" ,
372+ file. path ( ) . to_str ( ) . unwrap ( ) ,
373+ ) ;
374+
375+ let res = load_credentials_inner (
376+ None ,
377+ & PathBuf :: from ( "/missing/enc" ) ,
378+ & PathBuf :: from ( "/missing/plain" ) ,
379+ )
380+ . await ;
381+
382+ std:: env:: remove_var ( "GOOGLE_APPLICATION_CREDENTIALS" ) ;
383+
384+ match res. unwrap ( ) {
385+ Credential :: AuthorizedUser ( secret) => {
386+ assert_eq ! ( secret. client_id, "adc_id" ) ;
387+ assert_eq ! ( secret. refresh_token, "adc_refresh" ) ;
388+ }
389+ _ => panic ! ( "Expected AuthorizedUser from ADC" ) ,
390+ }
391+ }
392+
393+ #[ tokio:: test]
394+ #[ serial_test:: serial]
395+ async fn test_load_credentials_adc_env_var_service_account ( ) {
396+ let mut file = NamedTempFile :: new ( ) . unwrap ( ) ;
397+ let json = r#"{
398+ "type": "service_account",
399+ "project_id": "test-project",
400+ "private_key_id": "adc-key-id",
401+ "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
402+ "client_email": "adc-sa@test-project.iam.gserviceaccount.com",
403+ "client_id": "456",
404+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
405+ "token_uri": "https://oauth2.googleapis.com/token"
406+ }"# ;
407+ file. write_all ( json. as_bytes ( ) ) . unwrap ( ) ;
408+
409+ std:: env:: set_var (
410+ "GOOGLE_APPLICATION_CREDENTIALS" ,
411+ file. path ( ) . to_str ( ) . unwrap ( ) ,
412+ ) ;
413+
414+ let res = load_credentials_inner (
415+ None ,
416+ & PathBuf :: from ( "/missing/enc" ) ,
417+ & PathBuf :: from ( "/missing/plain" ) ,
418+ )
419+ . await ;
420+
421+ std:: env:: remove_var ( "GOOGLE_APPLICATION_CREDENTIALS" ) ;
422+
423+ match res. unwrap ( ) {
424+ Credential :: ServiceAccount ( key) => {
425+ assert_eq ! ( key. client_email, "adc-sa@test-project.iam.gserviceaccount.com" ) ;
426+ }
427+ _ => panic ! ( "Expected ServiceAccount from ADC" ) ,
428+ }
429+ }
430+
431+ #[ tokio:: test]
432+ #[ serial_test:: serial]
433+ async fn test_load_credentials_adc_env_var_missing_file ( ) {
434+ std:: env:: set_var ( "GOOGLE_APPLICATION_CREDENTIALS" , "/does/not/exist.json" ) ;
435+
436+ // When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately
437+ // rather than falling through — the user explicitly asked for this file.
438+ let err = load_credentials_inner (
439+ None ,
440+ & PathBuf :: from ( "/missing/enc" ) ,
441+ & PathBuf :: from ( "/missing/plain" ) ,
442+ )
443+ . await ;
444+
445+ std:: env:: remove_var ( "GOOGLE_APPLICATION_CREDENTIALS" ) ;
446+
447+ assert ! ( err. is_err( ) ) ;
448+ let msg = err. unwrap_err ( ) . to_string ( ) ;
449+ assert ! (
450+ msg. contains( "does not exist" ) ,
451+ "Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}"
452+ ) ;
453+ }
454+
309455 #[ tokio:: test]
310456 async fn test_load_credentials_env_file_missing ( ) {
311457 let err = load_credentials_inner (
@@ -407,18 +553,14 @@ mod tests {
407553 let old_token = std:: env:: var ( "GOOGLE_WORKSPACE_CLI_TOKEN" ) . ok ( ) ;
408554
409555 // Set the token env var
410- unsafe {
411- std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , "my-test-token" ) ;
412- }
556+ std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , "my-test-token" ) ;
413557
414558 let result = get_token ( & [ "https://www.googleapis.com/auth/drive" ] , None ) . await ;
415559
416- unsafe {
417- if let Some ( t) = old_token {
418- std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , t) ;
419- } else {
420- std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" ) ;
421- }
560+ if let Some ( t) = old_token {
561+ std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , t) ;
562+ } else {
563+ std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" ) ;
422564 }
423565
424566 assert ! ( result. is_ok( ) ) ;
@@ -431,9 +573,7 @@ mod tests {
431573 // An empty token should not short-circuit — it should be ignored
432574 // and fall through to normal credential loading.
433575 // We test with non-existent credential paths to ensure fallthrough.
434- unsafe {
435- std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , "" ) ;
436- }
576+ std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" , "" ) ;
437577
438578 let result = load_credentials_inner (
439579 None ,
@@ -442,9 +582,7 @@ mod tests {
442582 )
443583 . await ;
444584
445- unsafe {
446- std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" ) ;
447- }
585+ std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_TOKEN" ) ;
448586
449587 // Should fall through to normal credential loading, which fails
450588 // because we pointed at non-existent paths
0 commit comments