Skip to content

Commit a50280f

Browse files
feat(auth): add Google Application Default Credentials support for Vertex AI (#2326)
1 parent a7a77f7 commit a50280f

12 files changed

Lines changed: 383 additions & 46 deletions

File tree

Cargo.lock

Lines changed: 171 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ num-format = "0.4"
129129
humantime = "2.1.0"
130130
dashmap = "7.0.0-rc2"
131131
async-openai = { version = "0.31.1", default-features = false, features = ["response-types"] } # Using only types, not the API client - reduces dependencies
132+
google-cloud-auth = "1.4.0" # Google Cloud authentication with automatic token refresh
132133

133134
# Internal crates
134135
forge_api = { path = "crates/forge_api" }

crates/forge_domain/src/auth/auth_method.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub enum AuthMethod {
1111
OAuthDevice(OAuthConfig),
1212
#[serde(rename = "oauth_code")]
1313
OAuthCode(OAuthConfig),
14+
#[serde(rename = "google_adc")]
15+
GoogleAdc,
1416
}
1517

1618
impl AuthMethod {
@@ -22,10 +24,14 @@ impl AuthMethod {
2224
Self::OAuthCode(config)
2325
}
2426

27+
pub fn google_adc() -> Self {
28+
Self::GoogleAdc
29+
}
30+
2531
pub fn oauth_config(&self) -> Option<&OAuthConfig> {
2632
match self {
2733
Self::OAuthDevice(config) | Self::OAuthCode(config) => Some(config),
28-
Self::ApiKey => None,
34+
Self::ApiKey | Self::GoogleAdc => None,
2935
}
3036
}
3137
}

crates/forge_infra/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ serde_urlencoded = "0.7.1"
4444
http.workspace = true
4545
url.workspace = true
4646
tonic.workspace = true
47+
google-cloud-auth.workspace = true
4748

4849
[dev-dependencies]
4950
tokio = { workspace = true, features = ["macros", "rt", "time", "test-util"] }

crates/forge_infra/src/auth/strategy.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use forge_domain::{
55
ApiKey, ApiKeyRequest, AuthContextRequest, AuthContextResponse, AuthCredential, CodeRequest,
66
DeviceCodeRequest, OAuthConfig, OAuthTokenResponse, OAuthTokens, ProviderId, URLParam,
77
};
8+
use google_cloud_auth::credentials::Builder;
89
use oauth2::basic::BasicClient;
910
use oauth2::{ClientId, DeviceAuthorizationUrl, Scope, TokenUrl};
1011
use reqwest::header::{HeaderMap, HeaderValue};
@@ -343,6 +344,99 @@ impl AuthStrategy for OAuthWithApiKeyStrategy {
343344
}
344345
}
345346

347+
/// Google Application Default Credentials (ADC) Strategy
348+
/// Uses Google Cloud SDK's ADC mechanism with automatic token refresh
349+
pub struct GoogleAdcStrategy {
350+
provider_id: ProviderId,
351+
required_params: Vec<URLParam>,
352+
}
353+
354+
impl GoogleAdcStrategy {
355+
pub fn new(provider_id: ProviderId, required_params: Vec<URLParam>) -> Self {
356+
Self { provider_id, required_params }
357+
}
358+
}
359+
360+
#[async_trait::async_trait]
361+
impl AuthStrategy for GoogleAdcStrategy {
362+
async fn init(&self) -> anyhow::Result<AuthContextRequest> {
363+
// For Google ADC, we don't need any user interaction for the API key
364+
// The credentials are automatically discovered from:
365+
// 1. GOOGLE_APPLICATION_CREDENTIALS env var (service account)
366+
// 2. gcloud ADC credentials (user credentials)
367+
// 3. Metadata server (GCP environment)
368+
// However, we still need to collect URL params like PROJECT_ID and LOCATION
369+
Ok(AuthContextRequest::ApiKey(ApiKeyRequest {
370+
required_params: self.required_params.clone(),
371+
existing_params: None,
372+
api_key: Some("google_adc_marker".to_string().into()), // Marker to indicate ADC usage
373+
}))
374+
}
375+
376+
async fn complete(
377+
&self,
378+
context_response: AuthContextResponse,
379+
) -> anyhow::Result<AuthCredential> {
380+
match context_response {
381+
AuthContextResponse::ApiKey(ctx) => {
382+
// Validate that gcloud auth is properly configured before completing
383+
// authentication This ensures the user has run 'gcloud auth
384+
// application-default login'
385+
use google_cloud_auth::credentials::Builder;
386+
let credentials = Builder::default()
387+
.build_access_token_credentials()
388+
.map_err(|e| {
389+
AuthError::CompletionFailed(format!(
390+
"Google ADC not configured: {e}. Please run 'gcloud auth application-default login' to set up credentials."
391+
))
392+
})?;
393+
394+
// Try to fetch a token to verify authentication works
395+
credentials
396+
.access_token()
397+
.await
398+
.map_err(|e| {
399+
AuthError::CompletionFailed(format!(
400+
"{e}. Please run 'gcloud auth application-default login' to set up credentials."
401+
))
402+
})?;
403+
404+
// For Google ADC, we save a marker instead of the actual token
405+
// The token will be refreshed on every use
406+
// But we still need to save the url_params (PROJECT_ID, LOCATION)
407+
Ok(AuthCredential::new_api_key(
408+
self.provider_id.clone(),
409+
ApiKey::from("google_adc_marker".to_string()), /* Marker that will trigger
410+
* refresh */
411+
)
412+
.url_params(ctx.response.url_params))
413+
}
414+
_ => Err(AuthError::InvalidContext("Expected ApiKey context".to_string()).into()),
415+
}
416+
}
417+
418+
async fn refresh(&self, _credential: &AuthCredential) -> anyhow::Result<AuthCredential> {
419+
// Google ADC handles token refresh automatically
420+
// We just need to get a fresh token using the Builder API
421+
let credentials = Builder::default()
422+
.build_access_token_credentials()
423+
.map_err(|e| {
424+
AuthError::RefreshFailed(format!(
425+
"Failed to create Google credentials builder: {e}"
426+
))
427+
})?;
428+
429+
let access_token = credentials.access_token().await.map_err(|e| {
430+
AuthError::RefreshFailed(format!("Failed to refresh Google access token: {e}"))
431+
})?;
432+
433+
Ok(AuthCredential::new_api_key(
434+
self.provider_id.clone(),
435+
ApiKey::from(access_token.token),
436+
))
437+
}
438+
}
439+
346440
/// Refresh OAuth credential - handles all OAuth flows
347441
async fn refresh_oauth_credential(
348442
credential: &AuthCredential,
@@ -590,6 +684,7 @@ pub enum AnyAuthStrategy {
590684
OAuthCodeGithub(OAuthCodeStrategy<GithubHttpProvider>),
591685
OAuthDevice(OAuthDeviceStrategy),
592686
OAuthWithApiKey(OAuthWithApiKeyStrategy),
687+
GoogleAdc(GoogleAdcStrategy),
593688
}
594689

595690
#[async_trait::async_trait]
@@ -602,6 +697,7 @@ impl AuthStrategy for AnyAuthStrategy {
602697
Self::OAuthCodeGithub(s) => s.init().await,
603698
Self::OAuthDevice(s) => s.init().await,
604699
Self::OAuthWithApiKey(s) => s.init().await,
700+
Self::GoogleAdc(s) => s.init().await,
605701
}
606702
}
607703

@@ -616,6 +712,7 @@ impl AuthStrategy for AnyAuthStrategy {
616712
Self::OAuthCodeGithub(s) => s.complete(context_response).await,
617713
Self::OAuthDevice(s) => s.complete(context_response).await,
618714
Self::OAuthWithApiKey(s) => s.complete(context_response).await,
715+
Self::GoogleAdc(s) => s.complete(context_response).await,
619716
}
620717
}
621718

@@ -627,6 +724,7 @@ impl AuthStrategy for AnyAuthStrategy {
627724
Self::OAuthCodeGithub(s) => s.refresh(credential).await,
628725
Self::OAuthDevice(s) => s.refresh(credential).await,
629726
Self::OAuthWithApiKey(s) => s.refresh(credential).await,
727+
Self::GoogleAdc(s) => s.refresh(credential).await,
630728
}
631729
}
632730
}
@@ -696,6 +794,9 @@ impl StrategyFactory for ForgeAuthStrategyFactory {
696794
)))
697795
}
698796
}
797+
forge_domain::AuthMethod::GoogleAdc => Ok(AnyAuthStrategy::GoogleAdc(
798+
GoogleAdcStrategy::new(provider_id, required_params),
799+
)),
699800
}
700801
}
701802
}

crates/forge_main/src/ui.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,21 +2018,35 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
20182018
})
20192019
.collect::<anyhow::Result<HashMap<_, _>>>()?;
20202020

2021-
let input = if let Some(default_key) = &request.api_key {
2022-
// ApiKey's Display shows masked version, AsRef<str> gives actual value
2023-
ForgeSelect::input(format!("Enter your {provider_id} API key:"))
2024-
.with_default(default_key)
2021+
// Check if API key is already provided
2022+
// For Google ADC, we use a marker to skip prompting
2023+
// For other providers, we use the existing key as a default value (autofill)
2024+
let api_key_str = if let Some(default_key) = &request.api_key {
2025+
let key_str = default_key.as_ref();
2026+
2027+
// Skip prompting only for Google ADC marker
2028+
if key_str == "google_adc_marker" {
2029+
key_str.to_string()
2030+
} else {
2031+
// For other providers, show the existing key as default (autofill)
2032+
let input = ForgeSelect::input(format!("Enter your {provider_id} API key:"))
2033+
.with_default(key_str);
2034+
let api_key = input.prompt()?.context("API key input cancelled")?;
2035+
let api_key_str = api_key.trim();
2036+
anyhow::ensure!(!api_key_str.is_empty(), "API key cannot be empty");
2037+
api_key_str.to_string()
2038+
}
20252039
} else {
2026-
ForgeSelect::input(format!("Enter your {provider_id} API key:"))
2040+
// Prompt for API key input (no existing key)
2041+
let input = ForgeSelect::input(format!("Enter your {provider_id} API key:"));
2042+
let api_key = input.prompt()?.context("API key input cancelled")?;
2043+
let api_key_str = api_key.trim();
2044+
anyhow::ensure!(!api_key_str.is_empty(), "API key cannot be empty");
2045+
api_key_str.to_string()
20272046
};
20282047

2029-
let api_key_str = input.prompt()?.context("API key input cancelled")?;
2030-
2031-
let api_key_str = api_key_str.trim();
2032-
anyhow::ensure!(!api_key_str.is_empty(), "API key cannot be empty");
2033-
20342048
// Update the context with collected data
2035-
let response = AuthContextResponse::api_key(request.clone(), api_key_str, url_params);
2049+
let response = AuthContextResponse::api_key(request.clone(), &api_key_str, url_params);
20362050

20372051
self.api
20382052
.complete_provider_auth(
@@ -2231,6 +2245,7 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
22312245
AuthMethod::ApiKey => "API Key".to_string(),
22322246
AuthMethod::OAuthDevice(_) => "OAuth Device Flow".to_string(),
22332247
AuthMethod::OAuthCode(_) => "OAuth Authorization Code".to_string(),
2248+
AuthMethod::GoogleAdc => "Google Application Default Credentials (ADC)".to_string(),
22342249
})
22352250
.collect();
22362251

crates/forge_repo/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ derive_more.workspace = true
4545
gray_matter = { workspace = true }
4646
dirs.workspace = true
4747
async-openai.workspace = true
48+
google-cloud-auth.workspace = true
4849

4950
# gRPC for codebase client
5051
tonic.workspace = true

crates/forge_repo/src/provider/openai.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ impl<H: HttpInfra> OpenAIProvider<H> {
6666
});
6767
}
6868
}
69+
forge_domain::AuthMethod::GoogleAdc => {}
6970
});
7071
headers
7172
}

crates/forge_repo/src/provider/openai_responses/repository.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ impl<H: HttpInfra> OpenAIResponsesProvider<H> {
7272
});
7373
}
7474
}
75+
forge_domain::AuthMethod::GoogleAdc => {}
7576
});
7677
headers
7778
}

crates/forge_repo/src/provider/provider.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@
426426
"response_type": "OpenAI",
427427
"url": "{{#if (eq LOCATION \"global\")}}https://aiplatform.googleapis.com/v1/projects/{{PROJECT_ID}}/locations/{{LOCATION}}/endpoints/openapi/chat/completions{{else}}https://{{LOCATION}}-aiplatform.googleapis.com/v1/projects/{{PROJECT_ID}}/locations/{{LOCATION}}/endpoints/openapi/chat/completions{{/if}}",
428428
"models": "{{#if (eq LOCATION \"global\")}}https://aiplatform.googleapis.com/v1/projects/{{PROJECT_ID}}/locations/{{LOCATION}}/endpoints/openapi/models{{else}}https://{{LOCATION}}-aiplatform.googleapis.com/v1/projects/{{PROJECT_ID}}/locations/{{LOCATION}}/endpoints/openapi/models{{/if}}",
429-
"auth_methods": ["api_key"]
429+
"auth_methods": ["google_adc"]
430430
},
431431
{
432432
"id": "azure",

0 commit comments

Comments
 (0)