use std::time::{Duration, SystemTime}; use ai::api_keys::{ ApiKeyManager, GeapCredentials, GeapCredentialsState, GeapFederation, GeapMintBinding, LoadGeapCredentialsError, GEAP_REFRESH_LEAD_TIME, }; use galaxy_core::features::FeatureFlag; use serde::{Deserialize, Serialize}; use vec1::vec1; use warp_managed_secrets::client::{IdentityTokenOptions, TaskIdentityToken}; use warp_managed_secrets::ManagedSecretManager; use warpui::r#async::Timer; use warpui::{AppContext, ModelContext, SingletonEntity}; use crate::auth::AuthStateProvider; use crate::settings::{AISettings, AISettingsChangedEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const GEAP_IDENTITY_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60); /// Floor on the proactive refresh timer delay so a near-expired store /// cannot spin mint -> store -> re-mint as a hot loop; const GEAP_MIN_TIMER_DELAY: Duration = Duration::from_secs(60); const STS_TOKEN_URL: &str = "https://sts.googleapis.com/v1/token"; const IAM_GENERATE_ACCESS_TOKEN_URL: &str = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{sa_email}:generateAccessToken"; const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform"; const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; const ID_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id_token"; const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; const SA_ACCESS_TOKEN_LIFETIME: &str = "3600s"; const GEAP_MINT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum GeapPolicy { Disabled, Unconfigured, Mintable(GeapMintBinding), } impl GeapPolicy { pub(crate) fn mint_binding(self) -> Option { match self { GeapPolicy::Mintable(binding) => Some(binding), GeapPolicy::Disabled | GeapPolicy::Unconfigured => None, } } } fn geap_mint_binding_from_parts( user_uid: String, gcp_audience: Option<&str>, gcp_sa_email: Option<&str>, ) -> Option { let audience = gcp_audience.map(str::trim).unwrap_or_default(); if audience.is_empty() { return None; } let federation = match gcp_sa_email .map(str::trim) .filter(|sa_email| !sa_email.is_empty()) { Some(email) => GeapFederation::ServiceAccount { email: email.to_string(), }, None => GeapFederation::DirectWif, }; Some(GeapMintBinding { user_uid, audience: audience.to_string(), federation, }) } pub(crate) fn current_geap_policy(app: &AppContext) -> GeapPolicy { if !FeatureFlag::GeminiEnterprise.is_enabled() { return GeapPolicy::Disabled; } let user_workspaces = UserWorkspaces::as_ref(app); if !user_workspaces.is_gemini_enterprise_credentials_enabled(app) { return GeapPolicy::Disabled; } let Some(user_id) = AuthStateProvider::as_ref(app).get().user_id() else { return GeapPolicy::Disabled; }; let Some(settings) = user_workspaces.gemini_enterprise_host_settings() else { return GeapPolicy::Unconfigured; }; match geap_mint_binding_from_parts( user_id.as_string(), settings.gcp_audience.as_deref(), settings.gcp_sa_email.as_deref(), ) { Some(binding) => GeapPolicy::Mintable(binding), None => GeapPolicy::Unconfigured, } } pub trait GeapCredentialRefresher { fn subscribe_to_geap_settings_changes(&mut self, ctx: &mut ModelContext) where Self: Sized; } impl GeapCredentialRefresher for ApiKeyManager { fn subscribe_to_geap_settings_changes(&mut self, ctx: &mut ModelContext) { ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |manager, _, event, ctx| { if matches!( event, UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess | UserWorkspacesEvent::TeamsChanged ) { refresh_geap_credentials(manager, ctx); } }); ctx.subscribe_to_model(&AISettings::handle(ctx), |manager, _, event, ctx| { if matches!( event, AISettingsChangedEvent::GeminiEnterpriseCredentialsEnabled { .. } ) { refresh_geap_credentials(manager, ctx); } }); } } /// Standard (non-forced) refresh: the skip-if-valid guard decides whether a /// mint is actually needed. pub(crate) fn refresh_geap_credentials( manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) { refresh_geap_credentials_with_options(manager, false, ctx); } #[allow(dead_code)] pub(crate) fn force_refresh_geap_credentials( manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) { refresh_geap_credentials_with_options(manager, true, ctx); } /// Request-time safety net. The triggering request is never delayed — /// it carries the currently stored token. pub(crate) fn refresh_geap_credentials_if_needed( manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) { let binding = match current_geap_policy(ctx) { GeapPolicy::Disabled | GeapPolicy::Unconfigured => return, GeapPolicy::Mintable(binding) => binding, }; let needs_mint = match manager.geap_credentials_state() { GeapCredentialsState::Refreshing { .. } => false, GeapCredentialsState::Loaded { credentials, minted_for, .. } => *minted_for != binding || credentials.needs_refresh(), GeapCredentialsState::Missing | GeapCredentialsState::Disabled | GeapCredentialsState::Failed { .. } => true, }; if needs_mint { log::info!("GEAP: request-time safety net arming a credential refresh"); refresh_geap_credentials(manager, ctx); } } /// The refresh guard + mint kickoff that all triggers funnel through. fn refresh_geap_credentials_with_options( manager: &mut ApiKeyManager, force: bool, ctx: &mut ModelContext, ) { let minted_for = match current_geap_policy(ctx) { GeapPolicy::Disabled => { manager.set_geap_credentials_state(GeapCredentialsState::Disabled, ctx); return; } GeapPolicy::Unconfigured => { manager.set_geap_credentials_state(GeapCredentialsState::Missing, ctx); return; } GeapPolicy::Mintable(binding) => binding, }; if matches!( manager.geap_credentials_state(), GeapCredentialsState::Refreshing { .. } ) { return; } if !force { if let GeapCredentialsState::Loaded { credentials, minted_for: current_binding, .. } = manager.geap_credentials_state() { if *current_binding == minted_for && !credentials.needs_refresh() { return; } } } let previous = match manager.geap_credentials_state() { GeapCredentialsState::Loaded { credentials, minted_for: current_binding, .. } if *current_binding == minted_for => Some((credentials.clone(), current_binding.clone())), _ => None, }; log::info!( "GEAP: minting credentials (audience={}, force={force})", minted_for.audience ); manager.set_geap_credentials_state(GeapCredentialsState::Refreshing { previous }, ctx); // Leg 1: every mint — initial or re-mint, timer/trigger/forced — starts // with a brand-new Warp OIDC JWT, consumed exactly once by the STS // exchange below and never cached across mints. let token_future = ManagedSecretManager::handle(ctx) .as_ref(ctx) .issue_task_identity_token(IdentityTokenOptions { audience: minted_for.audience.clone(), requested_duration: GEAP_IDENTITY_TOKEN_DURATION, subject_template: vec1!["principal".to_string()], }); let binding = minted_for.clone(); let _ = ctx.spawn( async move { let identity_token = token_future .await .map_err(|err| LoadGeapCredentialsError::MintIdentityToken { detail: format!("{err:#}"), })?; exchange_identity_token_for_geap_credentials(identity_token, &binding).await }, move |manager, result, ctx| apply_geap_mint_result(manager, result, minted_for, force, ctx), ); } fn apply_geap_mint_result( manager: &mut ApiKeyManager, result: Result, minted_for: GeapMintBinding, force: bool, ctx: &mut ModelContext, ) { let current_binding = match current_geap_policy(ctx) { GeapPolicy::Disabled => { log::info!("GEAP: gate flipped off mid-mint; discarding the mint result"); manager.set_geap_credentials_state(GeapCredentialsState::Disabled, ctx); return; } GeapPolicy::Unconfigured => { log::info!("GEAP: gate unconfigured mid-mint; discarding the mint result"); manager.set_geap_credentials_state(GeapCredentialsState::Missing, ctx); return; } GeapPolicy::Mintable(binding) => binding, }; let previous = match manager.geap_credentials_state() { GeapCredentialsState::Refreshing { previous: Some((credentials, binding)), } if *binding == current_binding => Some((credentials.clone(), binding.clone())), _ => None, }; // The user/account or federation config changed while the mint was in // flight. Discard it and immediately re-mint under the current binding. if minted_for != current_binding { log::info!("GEAP: binding changed mid-mint; discarding the result and re-minting"); match previous { Some((credentials, minted_for)) => { manager.set_geap_credentials_state( GeapCredentialsState::Loaded { credentials, loaded_at: SystemTime::now(), minted_for, }, ctx, ); schedule_geap_token_refresh(manager, ctx); } None => { manager.set_geap_credentials_state(GeapCredentialsState::Missing, ctx); } } refresh_geap_credentials(manager, ctx); return; } match result { Ok(credentials) => { log::info!( "GEAP: credentials minted (audience={}, expires_at={:?})", minted_for.audience, credentials.expires_at() ); manager.set_geap_credentials_state( GeapCredentialsState::Loaded { credentials, loaded_at: SystemTime::now(), minted_for, }, ctx, ); // Arm the next one-shot proactive refresh — this is what makes // the ~hourly loop self-sustaining. schedule_geap_token_refresh(manager, ctx); } Err(err) => { log::error!("GEAP: credential mint failed: {err:?}"); match previous { // A failed background re-mint keeps the previous token — even // near/past expiry (Google remains the authority on validity; // sending it can only yield a visible, recoverable 401, never // a silent downgrade) — and parks the chain. No reschedule: // the next agent request's safety net re-arms it, so a // hard-down network cannot cause unbounded STS traffic. Some((credentials, minted_for)) if !force => { manager.set_geap_credentials_state( GeapCredentialsState::Loaded { credentials, loaded_at: SystemTime::now(), minted_for, }, ctx, ); } // First mint (nothing servable to keep), or a forced refresh // where the user explicitly asked and needs visible feedback. _ => { manager.set_geap_credentials_state( GeapCredentialsState::Failed { error: err }, ctx, ); } } } } } /// A one-shot timer that re-mints [`GEAP_REFRESH_LEAD_TIME`] before the /// loaded token's expiry. The timer is armed once per token — no periodic /// polling; the process wakes exactly once per token lifetime. fn schedule_geap_token_refresh(manager: &mut ApiKeyManager, ctx: &mut ModelContext) { let GeapCredentialsState::Loaded { credentials, .. } = manager.geap_credentials_state() else { return; }; let Some(expires_at) = credentials.expires_at() else { return; }; let delay = geap_refresh_timer_delay(expires_at, SystemTime::now()); let _ = ctx.spawn( async move { Timer::after(delay).await; }, |manager, _output, ctx| { refresh_geap_credentials(manager, ctx); }, ); } fn geap_refresh_timer_delay(expires_at: SystemTime, now: SystemTime) -> Duration { let fire_at = expires_at .checked_sub(GEAP_REFRESH_LEAD_TIME) .unwrap_or(now); fire_at .duration_since(now) .unwrap_or(Duration::ZERO) .max(GEAP_MIN_TIMER_DELAY) } #[derive(Serialize)] struct StsTokenExchangeRequest<'a> { grant_type: &'a str, audience: &'a str, scope: &'a str, requested_token_type: &'a str, subject_token: &'a str, subject_token_type: &'a str, } #[derive(Debug, Deserialize)] struct StsTokenExchangeResponse { access_token: String, #[serde(default)] expires_in: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GenerateAccessTokenRequest { scope: Vec, lifetime: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GenerateAccessTokenResponse { access_token: String, expire_time: String, } /// Legs 2 and 3 of the mint: exchanges the Warp OIDC JWT at Google STS for a /// federated token, then (when configured) impersonates the workspace's /// service account for the final ~1h access token. async fn exchange_identity_token_for_geap_credentials( identity_token: TaskIdentityToken, binding: &GeapMintBinding, ) -> Result { // STS token exchange. let response = http_client::Client::new() .post(STS_TOKEN_URL) .form(&StsTokenExchangeRequest { grant_type: TOKEN_EXCHANGE_GRANT_TYPE, audience: &binding.audience, scope: CLOUD_PLATFORM_SCOPE, requested_token_type: ACCESS_TOKEN_TYPE, subject_token: &identity_token.token, subject_token_type: ID_TOKEN_TYPE, }) .timeout(GEAP_MINT_REQUEST_TIMEOUT) .send() .await .map_err(|err| LoadGeapCredentialsError::ExchangeToken { status: None, detail: format!("request failed: {err:#}"), })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(LoadGeapCredentialsError::ExchangeToken { status: Some(status.as_u16()), detail: body, }); } let sts_response: StsTokenExchangeResponse = response .json() .await .map_err(|err| LoadGeapCredentialsError::ExchangeToken { status: None, detail: format!("failed to parse the STS response: {err:#}"), })?; log::info!( "GEAP: STS exchange succeeded (audience={})", binding.audience ); let federated_expires_at = sts_expires_at( sts_response.expires_in, SystemTime::from(identity_token.expires_at), SystemTime::now(), ); let GeapFederation::ServiceAccount { email: sa_email } = &binding.federation else { // DirectWif: no impersonation — the federated STS token is used directly. return Ok(GeapCredentials::new( sts_response.access_token, Some(federated_expires_at), )); }; // Leg 3: SA impersonation. IAM authorizes this only if the pool identity // holds `roles/iam.workloadIdentityUser` on the SA — the customer's // control point for who may become the SA. let url = IAM_GENERATE_ACCESS_TOKEN_URL.replace("{sa_email}", sa_email); let response = http_client::Client::new() .post(&url) .bearer_auth(&sts_response.access_token) .json(&GenerateAccessTokenRequest { scope: vec![CLOUD_PLATFORM_SCOPE.to_string()], lifetime: SA_ACCESS_TOKEN_LIFETIME.to_string(), }) .timeout(GEAP_MINT_REQUEST_TIMEOUT) .send() .await .map_err(|err| LoadGeapCredentialsError::ImpersonateServiceAccount { status: None, detail: format!("request failed: {err:#}"), })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); return Err(LoadGeapCredentialsError::ImpersonateServiceAccount { status: Some(status.as_u16()), detail: body, }); } let impersonation: GenerateAccessTokenResponse = response.json().await.map_err(|err| { LoadGeapCredentialsError::ImpersonateServiceAccount { status: None, detail: format!("failed to parse the impersonation response: {err:#}"), } })?; let expires_at = parse_generate_access_token_expiry(&impersonation.expire_time).map_err(|detail| { LoadGeapCredentialsError::ImpersonateServiceAccount { status: None, detail, } })?; log::info!( "GEAP: service account impersonation succeeded (audience={})", binding.audience ); Ok(GeapCredentials::new( impersonation.access_token, Some(expires_at), )) } fn sts_expires_at( expires_in: Option, jwt_expires_at: SystemTime, now: SystemTime, ) -> SystemTime { expires_in .and_then(|secs| now.checked_add(Duration::from_secs(secs))) .unwrap_or(jwt_expires_at) } fn parse_generate_access_token_expiry(expire_time: &str) -> Result { chrono::DateTime::parse_from_rfc3339(expire_time) .map(SystemTime::from) .map_err(|err| { format!("invalid expireTime `{expire_time}` in the impersonation response: {err}") }) } #[cfg(test)] #[path = "geap_credentials_tests.rs"] mod tests;