From 450300e62a32ac90aba8025cfec029a736bbe55b Mon Sep 17 00:00:00 2001 From: Isaiah Date: Sun, 12 Jul 2026 20:01:06 -0400 Subject: [PATCH 1/2] Self-mint IAP tokens for Oz runners via Workload Identity Federation Sandboxed Oz runners authenticate to staging through IAP using a server-injected WARP_IAP_TOKEN minted as the warp-server service account. That identity isn't an authorized IAP member (403s), and the injected token lands in the terminal EnvInjected state which never refreshes (REMOTE-1370). This gives IapManager a runner-context refresh path that self-mints an IAP-valid ID token via WIF, mirroring the REV-1599 GEAP mint chain: 1. Warp OIDC JWT (audience = the oz-agents WIF provider resource name) 2. GCP STS token exchange -> federated access token 3. IAM Credentials generateIdToken impersonating iap-access@ (audience = the IAP OAuth client ID), which IS an authorized IAP member The minted token flows through the existing IapManager lifecycle (proactive refresh before expiry, reactive refresh on 403), closing the no-refresh gap. Native HTTP is used throughout since the runner image ships without gcloud. - IapConfig gains federation_audience (the WIF provider resource name); the private staging channel config must populate it for this to activate. - The mint is gated on runner context (ambient-agent run id), so local staging clients keep using the gcloud impersonation path unchanged. - The env-injected token is kept as an initial seed and served until the first WIF mint lands, then refreshed like any managed token. Co-Authored-By: Oz --- app/src/lib.rs | 19 +- app/src/pane_group/mod_tests.rs | 1 + app/src/server/iap_identity_minter.rs | 43 +++ app/src/server/mod.rs | 1 + .../shared_session/sharer/network_tests.rs | 1 + app/src/test_util/terminal.rs | 1 + app/src/workspace/view_tests.rs | 1 + crates/warp_core/src/channel/config.rs | 7 + crates/warp_server_client/src/iap.rs | 284 +++++++++++++++--- crates/warp_server_client/src/iap_tests.rs | 46 +++ 10 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 app/src/server/iap_identity_minter.rs diff --git a/app/src/lib.rs b/app/src/lib.rs index 8b5975b6a93..da3229a745e 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2162,7 +2162,22 @@ 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, refresh IAP tokens by self-minting via Workload + // Identity Federation (impersonating the IAP access service account) rather + // than relying on the server-injected, unrefreshable `WARP_IAP_TOKEN`. 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 +2199,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/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..3bc58d6149a 100644 --- a/crates/warp_core/src/channel/config.rs +++ b/crates/warp_core/src/channel/config.rs @@ -34,6 +34,13 @@ 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 instead of relying on a + /// server-injected `WARP_IAP_TOKEN`. + #[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..df221d0274f 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}; @@ -22,8 +23,44 @@ 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 instead of relying on a server-injected `WARP_IAP_TOKEN` (which +/// has no refresh path). 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, @@ -69,7 +106,15 @@ impl IapCredentialsState { IapCredentialsState::Loaded(cached) => Some(cached.clone()), IapCredentialsState::Refreshing { previous } | IapCredentialsState::Failed { previous, .. } => previous.clone(), - IapCredentialsState::EnvInjected { .. } | IapCredentialsState::Missing => None, + // Keep serving the env-injected seed while the first managed refresh + // is in flight, provided it still has a parseable future expiry. + IapCredentialsState::EnvInjected { token } => { + get_expires_at(token).ok().map(|expires_at| CachedToken { + token: token.clone(), + expires_at, + }) + } + IapCredentialsState::Missing => None, } } } @@ -77,6 +122,7 @@ impl IapCredentialsState { pub struct IapState { audiences: String, service_account_email: String, + federation_audience: Option, inner: RwLock, } @@ -90,6 +136,10 @@ impl IapState { Self { audiences: config.audiences.to_string(), service_account_email: config.service_account_email.to_string(), + federation_audience: config + .federation_audience + .as_ref() + .map(|audience| audience.to_string()), inner: RwLock::new(initial), } } @@ -128,6 +178,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 +213,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 +235,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); @@ -210,7 +270,11 @@ impl IapManager { let Some(state) = self.state.as_ref() else { return; }; - if matches!(state.state(), IapCredentialsState::EnvInjected { .. }) { + // An env-injected token with no managed-mint fallback cannot be + // refreshed (no gcloud in the runner, no WIF configured). + if self.managed_mint.is_none() + && 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" @@ -225,14 +289,28 @@ 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), and it replaces the otherwise-terminal env-injected + // token so it can refresh proactively and on challenge. + 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; + } + + // Local (non-runner) staging without a managed mint: the env-injected + // token is terminal — there's no gcloud impersonation flow to refresh it. + if matches!(state.state(), IapCredentialsState::EnvInjected { .. }) { + return; + } + state.set_refreshing(); ctx.emit(IapManagerEvent::StateChanged); ctx.notify(); @@ -272,38 +350,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 +484,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..d4a896301e0 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,48 @@ 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 env_injected_seed_is_served_as_previous_when_unexpired() { + let token = jwt_with_payload(&format!(r#"{{"exp": {}}}"#, now_unix() + 3600)); + let state = IapCredentialsState::EnvInjected { + token: token.clone(), + }; + let previous = state + .previous_token() + .expect("unexpired seed should convert"); + assert_eq!(previous.token, token); +} + +#[test] +fn env_injected_seed_dropped_as_previous_when_expired() { + let state = IapCredentialsState::EnvInjected { + token: jwt_with_payload(r#"{"exp": 1}"#), + }; + assert!(state.previous_token().is_none()); +} + +#[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"); +} From 25dc7223d745be15f60fb9974e1d1c2948ff543f Mon Sep 17 00:00:00 2001 From: Isaiah Date: Sun, 12 Jul 2026 20:35:59 -0400 Subject: [PATCH 2/2] Remove WARP_IAP_TOKEN env-injected handling from the client The env-injected token is minted as the warp-server service account, which isn't an authorized IAP member, so it 403s today and provides no working fallback. With WIF self-minting in place there's no reason to keep it. Drops the EnvInjected state variant and all its handling (seed, get_cached, previous_token, challenge/refresh special-cases, and the settings-page status arm). Runner IAP now depends entirely on the WIF mint. Co-Authored-By: Oz --- app/src/lib.rs | 5 +- app/src/settings_view/main_page.rs | 3 -- crates/warp_core/src/channel/config.rs | 3 +- crates/warp_server_client/src/iap.rs | 54 ++-------------------- crates/warp_server_client/src/iap_tests.rs | 20 -------- 5 files changed, 7 insertions(+), 78 deletions(-) diff --git a/app/src/lib.rs b/app/src/lib.rs index da3229a745e..a55b19fc9d9 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2162,9 +2162,8 @@ pub(crate) fn initialize_app( ctx.add_singleton_model(system::SystemInfo::new); } - // In a sandboxed Oz runner, refresh IAP tokens by self-minting via Workload - // Identity Federation (impersonating the IAP access service account) rather - // than relying on the server-injected, unrefreshable `WARP_IAP_TOKEN`. Gated + // 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"))] 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/crates/warp_core/src/channel/config.rs b/crates/warp_core/src/channel/config.rs index 3bc58d6149a..85ce3d9d237 100644 --- a/crates/warp_core/src/channel/config.rs +++ b/crates/warp_core/src/channel/config.rs @@ -37,8 +37,7 @@ pub struct IapConfig { /// 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 instead of relying on a - /// server-injected `WARP_IAP_TOKEN`. + /// [`Self::service_account_email`] via this provider. #[serde(default)] pub federation_audience: Option>, } diff --git a/crates/warp_server_client/src/iap.rs b/crates/warp_server_client/src/iap.rs index df221d0274f..86becb8bd47 100644 --- a/crates/warp_server_client/src/iap.rs +++ b/crates/warp_server_client/src/iap.rs @@ -13,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); @@ -48,8 +47,7 @@ pub trait IapIdentityTokenMinter: Send + Sync + 'static { } /// Lets a sandboxed Oz runner self-mint IAP tokens via Workload Identity -/// Federation instead of relying on a server-injected `WARP_IAP_TOKEN` (which -/// has no refresh path). Present only in runner context. +/// Federation. Present only in runner context. #[derive(Clone)] pub struct ManagedIapMint { minter: Arc, @@ -89,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 { @@ -106,14 +95,6 @@ impl IapCredentialsState { IapCredentialsState::Loaded(cached) => Some(cached.clone()), IapCredentialsState::Refreshing { previous } | IapCredentialsState::Failed { previous, .. } => previous.clone(), - // Keep serving the env-injected seed while the first managed refresh - // is in flight, provided it still has a parseable future expiry. - IapCredentialsState::EnvInjected { token } => { - get_expires_at(token).ok().map(|expires_at| CachedToken { - token: token.clone(), - expires_at, - }) - } IapCredentialsState::Missing => None, } } @@ -128,11 +109,6 @@ pub struct IapState { 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(), @@ -140,7 +116,7 @@ impl IapState { .federation_audience .as_ref() .map(|audience| audience.to_string()), - inner: RwLock::new(initial), + inner: RwLock::new(IapCredentialsState::Missing), } } @@ -152,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) @@ -267,20 +242,6 @@ impl IapManager { } pub fn handle_challenge(&mut self, ctx: &mut ModelContext) { - let Some(state) = self.state.as_ref() else { - return; - }; - // An env-injected token with no managed-mint fallback cannot be - // refreshed (no gcloud in the runner, no WIF configured). - if self.managed_mint.is_none() - && 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); } @@ -295,9 +256,8 @@ impl IapManager { } // 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), and it replaces the otherwise-terminal env-injected - // token so it can refresh proactively and on challenge. + // 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) { @@ -305,12 +265,6 @@ impl IapManager { return; } - // Local (non-runner) staging without a managed mint: the env-injected - // token is terminal — there's no gcloud impersonation flow to refresh it. - if matches!(state.state(), IapCredentialsState::EnvInjected { .. }) { - return; - } - state.set_refreshing(); ctx.emit(IapManagerEvent::StateChanged); ctx.notify(); diff --git a/crates/warp_server_client/src/iap_tests.rs b/crates/warp_server_client/src/iap_tests.rs index d4a896301e0..66a7c64646b 100644 --- a/crates/warp_server_client/src/iap_tests.rs +++ b/crates/warp_server_client/src/iap_tests.rs @@ -120,26 +120,6 @@ fn get_cached_failed_uses_valid_previous_token() { assert_eq!(state.get_cached().as_deref(), Some("prev-token")); } -#[test] -fn env_injected_seed_is_served_as_previous_when_unexpired() { - let token = jwt_with_payload(&format!(r#"{{"exp": {}}}"#, now_unix() + 3600)); - let state = IapCredentialsState::EnvInjected { - token: token.clone(), - }; - let previous = state - .previous_token() - .expect("unexpired seed should convert"); - assert_eq!(previous.token, token); -} - -#[test] -fn env_injected_seed_dropped_as_previous_when_expired() { - let state = IapCredentialsState::EnvInjected { - token: jwt_with_payload(r#"{"exp": 1}"#), - }; - assert!(state.previous_token().is_none()); -} - #[test] fn generate_id_token_request_uses_camel_case_include_email() { let value = serde_json::to_value(GenerateIdTokenRequest {