diff --git a/app/src/lib.rs b/app/src/lib.rs index 8b5975b6a93..a55b19fc9d9 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2162,7 +2162,21 @@ pub(crate) fn initialize_app( ctx.add_singleton_model(system::SystemInfo::new); } - // `IapManager` drives gcloud-based IAP token refresh for staging builds. + // In a sandboxed Oz runner, mint IAP tokens by self-minting via Workload + // Identity Federation (impersonating the IAP access service account). Gated + // on runner context (ambient-agent run id) so local staging clients keep + // using the gcloud path. + #[cfg(not(target_family = "wasm"))] + let managed_iap_mint = std::env::var(warp_cli::OZ_RUN_ID_ENV).is_ok().then(|| { + let client = server_api_provider.as_ref(ctx).get_managed_secrets_client(); + warp_server_client::iap::ManagedIapMint::new(Arc::new( + crate::server::iap_identity_minter::ManagedSecretsIapMinter::new(client), + )) + }); + #[cfg(target_family = "wasm")] + let managed_iap_mint: Option = None; + + // `IapManager` drives IAP token refresh for staging builds. // Register it after `LocalShellState`: the Manager needs to know where the gcloud // cli lives & thus needs PATH config set by ~/.zshrc et al. // @@ -2184,7 +2198,7 @@ pub(crate) fn initialize_app( path_future }); - IapManager::new(iap_state, path_resolver, ctx) + IapManager::new(iap_state, path_resolver, managed_iap_mint, ctx) }); // Subscribe to IAP manager events to show toasts when refresh fails. ctx.subscribe_to_model(&IapManager::handle(ctx), |_, e, ctx| { diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 376c3b8ec99..f80f6b6e33b 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -119,6 +119,7 @@ fn initialize_app(app: &mut App) { IapManager::new( None, Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::))), + None, ctx, ) }); diff --git a/app/src/server/iap_identity_minter.rs b/app/src/server/iap_identity_minter.rs new file mode 100644 index 00000000000..04f5a2b6e4d --- /dev/null +++ b/app/src/server/iap_identity_minter.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt as _; +use vec1::vec1; +use warp_managed_secrets::client::{IdentityTokenOptions, ManagedSecretsClient}; +use warp_server_client::iap::IapIdentityTokenMinter; +use warpui::r#async::BoxFuture; + +/// Mints Warp-signed OIDC identity tokens for the runner-context IAP Workload +/// Identity Federation flow, backed by the managed-secrets client. Lives in the +/// app crate so `warp_server_client` need not depend on the managed-secrets +/// stack. +pub struct ManagedSecretsIapMinter { + client: Arc, +} + +impl ManagedSecretsIapMinter { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +impl IapIdentityTokenMinter for ManagedSecretsIapMinter { + fn mint_identity_token( + &self, + audience: String, + requested_duration: Duration, + ) -> BoxFuture<'static, anyhow::Result> { + let client = self.client.clone(); + async move { + let token = client + .issue_task_identity_token(IdentityTokenOptions { + audience, + requested_duration, + subject_template: vec1!["principal".to_string()], + }) + .await?; + Ok(token.token) + } + .boxed() + } +} diff --git a/app/src/server/mod.rs b/app/src/server/mod.rs index c9734dca9e0..66f2c6d8c95 100644 --- a/app/src/server/mod.rs +++ b/app/src/server/mod.rs @@ -2,6 +2,7 @@ pub mod block; pub mod cloud_objects; pub mod experiments; pub mod graphql; +pub mod iap_identity_minter; pub mod ids; pub mod network_log_pane_manager; pub mod network_log_view; diff --git a/app/src/settings_view/main_page.rs b/app/src/settings_view/main_page.rs index f1c80d2c924..748c009acaf 100644 --- a/app/src/settings_view/main_page.rs +++ b/app/src/settings_view/main_page.rs @@ -1114,9 +1114,6 @@ impl SettingsWidget for IapCredentialsWidget { (format!("Loaded (refreshes in ~{mins}m)"), active) } IapCredentialsState::Failed { message, .. } => (format!("Failed: {message}"), ansi_red), - IapCredentialsState::EnvInjected { .. } => { - ("Using injected token (WARP_IAP_TOKEN)".to_string(), active) - } }; let is_refreshing = matches!(state, IapCredentialsState::Refreshing { .. }); diff --git a/app/src/terminal/shared_session/sharer/network_tests.rs b/app/src/terminal/shared_session/sharer/network_tests.rs index 40e0a6a8d7b..88effc3d536 100644 --- a/app/src/terminal/shared_session/sharer/network_tests.rs +++ b/app/src/terminal/shared_session/sharer/network_tests.rs @@ -661,6 +661,7 @@ fn test_messages_are_buffered_while_reconnecting() { IapManager::new( None, Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::))), + None, ctx, ) }); diff --git a/app/src/test_util/terminal.rs b/app/src/test_util/terminal.rs index 57ae85035e7..f39e674f0f1 100644 --- a/app/src/test_util/terminal.rs +++ b/app/src/test_util/terminal.rs @@ -82,6 +82,7 @@ pub fn initialize_app_for_terminal_view(app: &mut App) { IapManager::new( None, Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::))), + None, ctx, ) }); diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 9d0425efe0f..94ecb2cecc0 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -208,6 +208,7 @@ pub(crate) fn initialize_app(app: &mut App) { warp_server_client::iap::IapManager::new( None, Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::))), + None, ctx, ) }); diff --git a/crates/warp_core/src/channel/config.rs b/crates/warp_core/src/channel/config.rs index d9452aca99c..85ce3d9d237 100644 --- a/crates/warp_core/src/channel/config.rs +++ b/crates/warp_core/src/channel/config.rs @@ -34,6 +34,12 @@ pub struct IapConfig { pub audiences: Cow<'static, str>, /// The service account email to impersonate when acquiring IAP credentials. pub service_account_email: Cow<'static, str>, + /// The Workload Identity Federation provider resource name for the `oz-agents` + /// pool (`//iam.googleapis.com/projects/{num}/locations/global/workloadIdentityPools/{pool}/providers/{provider}`). + /// When set, sandboxed Oz runners self-mint IAP tokens by impersonating + /// [`Self::service_account_email`] via this provider. + #[serde(default)] + pub federation_audience: Option>, } #[derive(Debug, Deserialize, Serialize)] diff --git a/crates/warp_server_client/src/iap.rs b/crates/warp_server_client/src/iap.rs index bf9cf99cb23..86becb8bd47 100644 --- a/crates/warp_server_client/src/iap.rs +++ b/crates/warp_server_client/src/iap.rs @@ -5,6 +5,7 @@ use anyhow::Result; use base64::Engine; use blocking::unblock; use instant::Instant; +use serde::{Deserialize, Serialize}; use warp_core::channel::IapConfig; use warpui_core::r#async::{BoxFuture, FutureExt as _, Timer}; use warpui_core::{AppContext, Entity, ModelContext, SingletonEntity}; @@ -12,7 +13,6 @@ use warpui_core::{AppContext, Entity, ModelContext, SingletonEntity}; use websocket::connect_error_http_response; const PROACTIVE_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60); -const INJECTED_TOKEN_ENV_VAR: &str = "WARP_IAP_TOKEN"; const BASE_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(30); const MAX_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); @@ -22,8 +22,43 @@ const MAX_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(5 * 60); /// bad credentials) doesn't loop forever. const MAX_FAILURE_RETRIES: u32 = 5; +// Endpoints and constants for the runner-context Workload Identity Federation +// mint (GCP STS token exchange + IAM Credentials `generateIdToken`). +const STS_TOKEN_URL: &str = "https://sts.googleapis.com/v1/token"; +const IAM_GENERATE_ID_TOKEN_URL: &str = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{sa_email}:generateIdToken"; +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 SUBJECT_TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token"; +const REQUESTED_TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token"; +const WIF_IDENTITY_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60); +const WIF_MINT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + pub type PathResolver = Box BoxFuture<'static, Option>>; +/// Mints a Warp-signed OIDC identity token for a given audience. Implemented in +/// the `app` crate over the managed-secrets client so this crate need not depend +/// on the managed-secrets stack. +pub trait IapIdentityTokenMinter: Send + Sync + 'static { + fn mint_identity_token( + &self, + audience: String, + requested_duration: Duration, + ) -> BoxFuture<'static, Result>; +} + +/// Lets a sandboxed Oz runner self-mint IAP tokens via Workload Identity +/// Federation. Present only in runner context. +#[derive(Clone)] +pub struct ManagedIapMint { + minter: Arc, +} + +impl ManagedIapMint { + pub fn new(minter: Arc) -> Self { + Self { minter } + } +} + #[derive(Debug, Clone)] pub struct CachedToken { pub token: String, @@ -52,15 +87,6 @@ pub enum IapCredentialsState { // in case the last token still works... we can try to use that for a couple more mins previous: Option, }, - /// Represents a terminal state in the iap creds state machine. - /// The gcloud refresh loop will never run, and an IAP challenge is logged - /// rather than triggering a refresh (we have no way to refresh a new token - /// from ambient agent context yet). - /// TODO(Isaiah/Jason): implement token refreshing scheme. - /// see: https://linear.app/warpdotdev/issue/REMOTE-1370/refresh-github-token - EnvInjected { - token: String, - }, } impl IapCredentialsState { @@ -69,7 +95,7 @@ impl IapCredentialsState { IapCredentialsState::Loaded(cached) => Some(cached.clone()), IapCredentialsState::Refreshing { previous } | IapCredentialsState::Failed { previous, .. } => previous.clone(), - IapCredentialsState::EnvInjected { .. } | IapCredentialsState::Missing => None, + IapCredentialsState::Missing => None, } } } @@ -77,20 +103,20 @@ impl IapCredentialsState { pub struct IapState { audiences: String, service_account_email: String, + federation_audience: Option, inner: RwLock, } impl IapState { pub fn new(config: &IapConfig) -> Self { - let initial = std::env::var(INJECTED_TOKEN_ENV_VAR) - .ok() - .filter(|s| !s.is_empty()) - .map(|token| IapCredentialsState::EnvInjected { token }) - .unwrap_or(IapCredentialsState::Missing); Self { audiences: config.audiences.to_string(), service_account_email: config.service_account_email.to_string(), - inner: RwLock::new(initial), + federation_audience: config + .federation_audience + .as_ref() + .map(|audience| audience.to_string()), + inner: RwLock::new(IapCredentialsState::Missing), } } @@ -102,7 +128,6 @@ impl IapState { // IAP challenge. Returning `None` lets the caller proceed without a // doomed token while the reactive refresh recovers. IapCredentialsState::Loaded(cached) => cached.valid_token(), - IapCredentialsState::EnvInjected { token } => Some(token.clone()), IapCredentialsState::Refreshing { previous } | IapCredentialsState::Failed { previous, .. } => { previous.as_ref().and_then(CachedToken::valid_token) @@ -128,6 +153,10 @@ impl IapState { &self.service_account_email } + pub fn federation_audience(&self) -> Option<&str> { + self.federation_audience.as_deref() + } + fn set_refreshing(&self) { let mut state = self.inner.write().expect("IAP state lock poisoned"); *state = IapCredentialsState::Refreshing { @@ -159,6 +188,10 @@ impl http_client::iap::IapTokenProvider for IapState { pub struct IapManager { state: Option>, path_resolver: PathResolver, + /// Runner-context Workload Identity Federation mint. When present (and the + /// config carries a federation audience), refreshes self-mint via WIF + /// instead of shelling out to gcloud. + managed_mint: Option, /// Number of consecutive failed fetches since the last success. consecutive_failures: u32, } @@ -177,11 +210,13 @@ impl IapManager { pub fn new( state: Option>, path_resolver: PathResolver, + managed_mint: Option, ctx: &mut ModelContext, ) -> Self { let mut manager = Self { state, path_resolver, + managed_mint, consecutive_failures: 0, }; manager.start_refresh(ctx); @@ -207,16 +242,6 @@ impl IapManager { } pub fn handle_challenge(&mut self, ctx: &mut ModelContext) { - let Some(state) = self.state.as_ref() else { - return; - }; - if matches!(state.state(), IapCredentialsState::EnvInjected { .. }) { - log::warn!( - "Env-injected IAP token ({INJECTED_TOKEN_ENV_VAR}) was rejected by IAP; \ - token is likely stale — re-inject to recover" - ); - return; - } self.consecutive_failures = 0; self.start_refresh(ctx); } @@ -225,14 +250,21 @@ impl IapManager { let Some(state) = self.state.clone() else { return; }; - // Don't touch state if a refresh is already running, or if we're - // in the terminal env-injected state (no refresh path exists). - if matches!( - state.state(), - IapCredentialsState::Refreshing { .. } | IapCredentialsState::EnvInjected { .. } - ) { + // Don't touch state if a refresh is already running. + if matches!(state.state(), IapCredentialsState::Refreshing { .. }) { + return; + } + + // Runner context: self-mint via Workload Identity Federation. This is the + // only refresh path that works in a sandboxed Oz runner, which ships + // without gcloud. + if let Some(mint) = self.managed_mint.clone() + && let Some(federation_audience) = state.federation_audience().map(str::to_owned) + { + self.start_wif_refresh(state, mint, federation_audience, ctx); return; } + state.set_refreshing(); ctx.emit(IapManagerEvent::StateChanged); ctx.notify(); @@ -272,38 +304,72 @@ impl IapManager { }) .await }, - move |manager, result, ctx| { - let Some(state) = manager.state.as_ref() else { - return; - }; - match result { - Ok(cached) => { - let expires_at = cached.expires_at; - state.set_loaded(cached); - manager.consecutive_failures = 0; - log::info!("Warp Staging IAP token refreshed"); - ctx.emit(IapManagerEvent::StateChanged); - ctx.notify(); - manager.schedule_next_refresh(expires_at, ctx); - } - Err(err) => { - let message = format!("{err:#}"); - log::warn!("Warp Staging IAP token fetch failed: {message}"); - let is_first_failure_of_streak = manager.consecutive_failures == 0; - state.set_failed(message.clone()); - ctx.emit(IapManagerEvent::RefreshFailed { - message, - is_first_failure_of_streak, - }); - ctx.emit(IapManagerEvent::StateChanged); - ctx.notify(); - manager.schedule_failure_retry(ctx); - } - } + move |manager, result, ctx| manager.apply_refresh_result(result, ctx), + ); + } + + /// Runner-context refresh: mint an IAP-valid ID token via Workload Identity + /// Federation (Warp OIDC JWT -> STS -> IAM `generateIdToken`). No gcloud. + fn start_wif_refresh( + &mut self, + state: Arc, + mint: ManagedIapMint, + federation_audience: String, + ctx: &mut ModelContext, + ) { + state.set_refreshing(); + ctx.emit(IapManagerEvent::StateChanged); + ctx.notify(); + + let minter = mint.minter.clone(); + let iap_audience = state.audiences().to_string(); + let service_account_email = state.service_account_email().to_string(); + + ctx.spawn( + async move { + fetch_iap_token_via_wif( + minter, + federation_audience, + iap_audience, + service_account_email, + ) + .await }, + move |manager, result, ctx| manager.apply_refresh_result(result, ctx), ); } + /// Shared completion handler for both the gcloud and WIF refresh paths. + fn apply_refresh_result(&mut self, result: Result, ctx: &mut ModelContext) { + let Some(state) = self.state.as_ref() else { + return; + }; + match result { + Ok(cached) => { + let expires_at = cached.expires_at; + state.set_loaded(cached); + self.consecutive_failures = 0; + log::info!("Warp Staging IAP token refreshed"); + ctx.emit(IapManagerEvent::StateChanged); + ctx.notify(); + self.schedule_next_refresh(expires_at, ctx); + } + Err(err) => { + let message = format!("{err:#}"); + log::warn!("Warp Staging IAP token fetch failed: {message}"); + let is_first_failure_of_streak = self.consecutive_failures == 0; + state.set_failed(message.clone()); + ctx.emit(IapManagerEvent::RefreshFailed { + message, + is_first_failure_of_streak, + }); + ctx.emit(IapManagerEvent::StateChanged); + ctx.notify(); + self.schedule_failure_retry(ctx); + } + } + } + fn schedule_next_refresh(&mut self, expires_at: Instant, ctx: &mut ModelContext) { let sleep_duration = expires_at .saturating_duration_since(Instant::now()) @@ -372,6 +438,106 @@ impl Entity for IapManager { impl SingletonEntity for IapManager {} +#[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(Deserialize)] +struct StsTokenExchangeResponse { + access_token: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GenerateIdTokenRequest<'a> { + audience: &'a str, + include_email: bool, +} + +#[derive(Deserialize)] +struct GenerateIdTokenResponse { + token: String, +} + +/// Mints an IAP-valid ID token for a sandboxed Oz runner via Workload Identity +/// Federation: Warp OIDC JWT -> GCP STS federated token -> IAM `generateIdToken` +/// impersonating the IAP access service account. Requires no local gcloud. +async fn fetch_iap_token_via_wif( + minter: Arc, + federation_audience: String, + iap_audience: String, + service_account_email: String, +) -> Result { + // Leg 1: Warp-signed OIDC JWT (audience = the WIF provider resource name). + let identity_token = minter + .mint_identity_token(federation_audience.clone(), WIF_IDENTITY_TOKEN_DURATION) + .await + .map_err(|err| anyhow::anyhow!("failed to mint Warp identity token: {err:#}"))?; + + // Leg 2: exchange the JWT at GCP STS for a federated access token. + let response = http_client::Client::new() + .post(STS_TOKEN_URL) + .form(&StsTokenExchangeRequest { + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + audience: &federation_audience, + scope: CLOUD_PLATFORM_SCOPE, + requested_token_type: REQUESTED_TOKEN_TYPE_ACCESS_TOKEN, + subject_token: &identity_token, + subject_token_type: SUBJECT_TOKEN_TYPE_ID_TOKEN, + }) + .timeout(WIF_MINT_REQUEST_TIMEOUT) + .send() + .await + .map_err(|err| anyhow::anyhow!("STS token exchange request failed: {err:#}"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("STS token exchange failed (status {status}): {body}"); + } + let sts_response: StsTokenExchangeResponse = response + .json() + .await + .map_err(|err| anyhow::anyhow!("failed to parse the STS response: {err:#}"))?; + + // Leg 3: impersonate the IAP access service account to mint an ID token whose + // audience is the IAP OAuth client ID. IAM authorizes this only if the + // runner's federated identity holds roles/iam.serviceAccountTokenCreator on + // the service account. + let url = IAM_GENERATE_ID_TOKEN_URL.replace("{sa_email}", &service_account_email); + let response = http_client::Client::new() + .post(&url) + .bearer_auth(&sts_response.access_token) + .json(&GenerateIdTokenRequest { + audience: &iap_audience, + include_email: true, + }) + .timeout(WIF_MINT_REQUEST_TIMEOUT) + .send() + .await + .map_err(|err| anyhow::anyhow!("generateIdToken request failed: {err:#}"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("generateIdToken failed (status {status}): {body}"); + } + let id_token_response: GenerateIdTokenResponse = response + .json() + .await + .map_err(|err| anyhow::anyhow!("failed to parse the generateIdToken response: {err:#}"))?; + + let expires_at = get_expires_at(&id_token_response.token)?; + Ok(CachedToken { + token: id_token_response.token, + expires_at, + }) +} + /// How long to wait for `auth print-identity-token` command to respond before killing it. const GCLOUD_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/crates/warp_server_client/src/iap_tests.rs b/crates/warp_server_client/src/iap_tests.rs index 087b97b64dc..66a7c64646b 100644 --- a/crates/warp_server_client/src/iap_tests.rs +++ b/crates/warp_server_client/src/iap_tests.rs @@ -27,6 +27,7 @@ fn test_state() -> IapState { IapState::new(&IapConfig { audiences: "iap-client-id".into(), service_account_email: "iap-access@example.iam.gserviceaccount.com".into(), + federation_audience: None, }) } @@ -118,3 +119,28 @@ fn get_cached_failed_uses_valid_previous_token() { state.set_failed("gcloud blew up".to_string()); assert_eq!(state.get_cached().as_deref(), Some("prev-token")); } + +#[test] +fn generate_id_token_request_uses_camel_case_include_email() { + let value = serde_json::to_value(GenerateIdTokenRequest { + audience: "iap-client-id", + include_email: true, + }) + .unwrap(); + assert_eq!(value["audience"], "iap-client-id"); + assert_eq!(value["includeEmail"], true); +} + +#[test] +fn generate_id_token_response_parses_token() { + let parsed: GenerateIdTokenResponse = + serde_json::from_str(r#"{"token": "an-id-token"}"#).unwrap(); + assert_eq!(parsed.token, "an-id-token"); +} + +#[test] +fn sts_response_parses_and_ignores_extra_fields() { + let parsed: StsTokenExchangeResponse = + serde_json::from_str(r#"{"access_token": "federated", "expires_in": 3600}"#).unwrap(); + assert_eq!(parsed.access_token, "federated"); +}