use std::time::{Duration, SystemTime}; use crate::settings::{AISettings, AISettingsChangedEvent}; use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; pub use ai::api_keys::AwsCredentials; use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy, AwsCredentialsState}; use anyhow::Context; use aws_credential_types::provider::error::CredentialsError; use aws_credential_types::provider::ProvideCredentials; use futures::channel::oneshot::channel; use futures::future::BoxFuture; use tokio::sync::OnceCell; use vec1::vec1; use warp_managed_secrets::{client::IdentityTokenOptions, ManagedSecretManager}; use warpui::{ModelContext, ModelHandle, SingletonEntity}; /// Errors that can occur when loading AWS credentials. #[derive(Debug, Clone)] pub enum LoadAwsCredentialsError { /// No AWS credentials are configured on this machine. /// The user needs to configure credentials via environment variables, /// shared credentials file (~/.aws/credentials), or other AWS credential sources. NotConfigured, /// AWS credentials are configured but could not be loaded. /// This can happen when credentials are expired, invalid, or the /// credential source (e.g., SSO session) needs to be refreshed. CredentialsLoadFailed(String), } impl std::fmt::Display for LoadAwsCredentialsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NotConfigured => write!(f, "No AWS credentials configured"), Self::CredentialsLoadFailed(msg) => { write!(f, "Failed to load AWS credentials: {msg}") } } } } fn aws_profile_reference_for_message(profile: &str, capitalize_first_word: bool) -> String { let profile = profile.trim(); if profile.is_empty() { if capitalize_first_word { "The default AWS profile".to_string() } else { "the default AWS profile".to_string() } } else { let article = if capitalize_first_word { "The" } else { "the" }; format!("{article} AWS profile `{profile}`") } } fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String { match err { CredentialsError::CredentialsNotLoaded(_) => format!( "AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.", aws_profile_reference_for_message(profile, false) ), CredentialsError::ProviderTimedOut(_) => { "Timed out while loading AWS credentials. Refresh and try again.".to_string() } CredentialsError::InvalidConfiguration(_) => format!( "{} is invalid or incomplete in your local AWS configuration. Update your AWS profile settings and credentials, then refresh.", aws_profile_reference_for_message(profile, true) ), CredentialsError::ProviderError(_) => { "Unable to load AWS credentials from your configured provider. Refresh your AWS login and try again." .to_string() } CredentialsError::Unhandled(_) => { "Unexpected error while loading AWS credentials. Refresh your AWS login and try again." .to_string() } _ => "Unable to load AWS credentials. Refresh your AWS login and try again." .to_string(), } } impl std::error::Error for LoadAwsCredentialsError {} const AWS_BEDROCK_STS_AUDIENCE: &str = "sts.amazonaws.com"; const BEDROCK_IDENTITY_TOKEN_DURATION: Duration = Duration::from_secs(60 * 60); pub(crate) fn aws_role_session_name(run_id: &str) -> String { format!("Oz_Run_{run_id}") } /// Cached STS client for OIDC credential refreshes. /// /// `AssumeRoleWithWebIdentity` is unauthenticated (the web identity token is the /// credential), so we skip the default credentials chain via `no_credentials()` /// and reuse a single client across refreshes. static STS_CLIENT: OnceCell = OnceCell::const_new(); async fn sts_client() -> &'static aws_sdk_sts::Client { STS_CLIENT .get_or_init(|| async { let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) .no_credentials() .load() .await; aws_sdk_sts::Client::new(&config) }) .await } fn aws_credentials_state_for_error(err: LoadAwsCredentialsError) -> AwsCredentialsState { match err { LoadAwsCredentialsError::NotConfigured => AwsCredentialsState::Missing, LoadAwsCredentialsError::CredentialsLoadFailed(message) => { AwsCredentialsState::Failed { message } } } } /// Loads AWS credentials from the AWS SDK. /// /// # Arguments /// * `profile` - AWS profile name. If empty, uses the default AWS SDK behavior /// (checks AWS_PROFILE env var, then uses "default"). pub async fn load_aws_credentials_from_sdk( profile: &str, ) -> Result { let region_provider = aws_config::meta::region::RegionProviderChain::default_provider(); let loader = aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider); let loader = if profile.trim().is_empty() { loader // Let AWS SDK use its default behavior } else { loader.profile_name(profile) }; let config = loader.load().await; let provider = config .credentials_provider() .ok_or(LoadAwsCredentialsError::NotConfigured)?; let creds = provider.provide_credentials().await.map_err(|e| { let message = user_facing_aws_credentials_error_message(&e, profile); log::warn!("{e}"); // TODO(isaiah): turn this full SDK dump back down to debug once we've resolved // the current customer-facing AWS credential issue and no longer need prod-visible // provider internals for support debugging. log::warn!("{e:#?}"); log::info!("AWS credential load failure message shown to user: {message}"); LoadAwsCredentialsError::CredentialsLoadFailed(message) })?; Ok(AwsCredentials::new( creds.access_key_id().to_string(), creds.secret_access_key().to_string(), creds.session_token().map(|s| s.to_string()), creds.expiry(), )) } /// Extension trait for `ApiKeyManager` to handle AWS credential refresh. pub trait AwsCredentialRefresher { /// Registers a `ModelEventDispatcher` to listen for block completion events. /// When a user executes a command matching the AWS auth refresh command, /// this will automatically refresh AWS credentials. fn register_model_event_dispatcher( &mut self, model_events: &ModelHandle, ctx: &mut ModelContext, ) where Self: Sized; /// Sets up subscriptions to `UserWorkspaces` and `AISettings` to refresh AWS credentials /// when workspace settings or AWS Bedrock settings change. fn subscribe_to_settings_changes(&mut self, ctx: &mut ModelContext) where Self: Sized; } impl AwsCredentialRefresher for ApiKeyManager { fn register_model_event_dispatcher( &mut self, model_events: &ModelHandle, ctx: &mut ModelContext, ) { ctx.subscribe_to_model(model_events, |manager, event, ctx| { if let ModelEvent::AfterBlockCompleted(AfterBlockCompletedEvent { block_type: BlockType::User(UserBlockCompleted { command, .. }), .. }) = event { let auth_command = &AISettings::as_ref(ctx).aws_bedrock_auth_refresh_command; if command.trim().starts_with(auth_command.trim()) { log::debug!("Detected AWS auth command completion, refreshing credentials"); drop(refresh_aws_credentials(manager, ctx)); } } }); } fn subscribe_to_settings_changes(&mut self, ctx: &mut ModelContext) { // Subscribe to UserWorkspaces events to refresh AWS credentials when workspace settings change // (this also initializes AWS credentials on app startup via TeamsChanged) ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |manager, event, ctx| { if matches!( event, UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess | UserWorkspacesEvent::TeamsChanged ) { drop(refresh_aws_credentials(manager, ctx)); } }); // Subscribe to AISettings changes to refresh AWS credentials when AWS Bedrock settings change ctx.subscribe_to_model(&AISettings::handle(ctx), |manager, event, ctx| { if matches!( event, AISettingsChangedEvent::AwsBedrockProfile { .. } | AISettingsChangedEvent::AwsBedrockAuthRefreshCommand { .. } | AISettingsChangedEvent::AwsBedrockCredentialsEnabled { .. } ) { drop(refresh_aws_credentials(manager, ctx)); } }); } } /// Refreshes AWS credentials, dispatching to the appropriate strategy. /// /// Returns a future that resolves when the refresh completes. Subscription-triggered /// callers that don't need to wait should drop the returned future — the underlying /// work has already been scheduled on the executor by the time this returns. pub(crate) fn refresh_aws_credentials( manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) -> BoxFuture<'static, Result<(), String>> { match manager.aws_credentials_refresh_strategy() { AwsCredentialsRefreshStrategy::LocalChain => { refresh_aws_credentials_local_chain(manager, ctx) } AwsCredentialsRefreshStrategy::OidcManaged { task_id, role_arn } => { refresh_aws_credentials_oidc(task_id, role_arn, manager, ctx) } } } /// Refreshes credentials from the local AWS SDK credential chain (~/.aws). fn refresh_aws_credentials_local_chain( manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) -> BoxFuture<'static, Result<(), String>> { let is_available = UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx); if !is_available { manager.set_aws_credentials_state(AwsCredentialsState::Disabled, ctx); return Box::pin(async { Ok(()) }); } let profile = (*AISettings::as_ref(ctx).aws_bedrock_profile).clone(); manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx); let (tx, rx) = channel(); // credential fetch from aws cli's disk cache let _ = ctx.spawn( async move { load_aws_credentials_from_sdk(&profile).await }, move |manager, result, ctx| { let (new_state, tx_result) = match result { Ok(credentials) => ( AwsCredentialsState::Loaded { credentials, loaded_at: SystemTime::now(), }, Ok(()), ), Err(err) => { let state = aws_credentials_state_for_error(err); let (_, message, _) = state.user_facing_components(); (state, Err(message)) } }; manager.set_aws_credentials_state(new_state, ctx); let _ = tx.send(tx_result); }, ); Box::pin(async move { rx.await .unwrap_or_else(|_| Err("Credential refresh was interrupted".to_string())) }) } /// Refreshes credentials via OIDC identity token + STS AssumeRoleWithWebIdentity. fn refresh_aws_credentials_oidc( task_id: Option, role_arn: String, manager: &mut ApiKeyManager, ctx: &mut ModelContext, ) -> BoxFuture<'static, Result<(), String>> { // Skip if credentials are already loaded and have not yet expired. if let AwsCredentialsState::Loaded { credentials, .. } = manager.aws_credentials_state() { let still_valid = credentials .expires_at() .and_then(|exp| exp.duration_since(SystemTime::now()).ok()) .is_some(); if still_valid { log::info!("Bedrock OIDC: credentials still valid, skipping refresh"); return Box::pin(async { Ok(()) }); } } let Some(task_id) = task_id else { let message = "AWS Bedrock inference requires an ambient task ID before credentials \ can be minted" .to_string(); manager.set_aws_credentials_state( AwsCredentialsState::Failed { message: message.clone(), }, ctx, ); return Box::pin(async move { Err(message) }); }; log::info!("Bedrock OIDC: preparing token mint for task {task_id:?}"); manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx); let token_future = ManagedSecretManager::handle(ctx) .as_ref(ctx) .issue_task_identity_token(IdentityTokenOptions { audience: AWS_BEDROCK_STS_AUDIENCE.to_string(), requested_duration: BEDROCK_IDENTITY_TOKEN_DURATION, subject_template: vec1!["scoped_principal".to_string()], }); let (tx, rx) = channel(); let _ = ctx.spawn( async move { let token = token_future .await .context("Failed to mint AWS Bedrock task identity token")?; let client = sts_client().await; let session_name = aws_role_session_name(&task_id); let credentials = client .assume_role_with_web_identity() .role_arn(&role_arn) .role_session_name(session_name) .web_identity_token(token.token) .send() .await .map_err(|err| { // Surface the AWS service error message for a user-friendly error. let detail = err .as_service_error() .map(|e| e.to_string()) .unwrap_or_else(|| err.to_string()); anyhow::anyhow!("STS AssumeRoleWithWebIdentity failed: {detail}") })? .credentials .context("STS response did not include credentials")?; anyhow::Ok(AwsCredentials::new( credentials.access_key_id().to_string(), credentials.secret_access_key().to_string(), Some(credentials.session_token().to_string()), SystemTime::try_from(*credentials.expiration()).ok(), )) }, move |manager, result, ctx| { let (new_state, tx_result) = match result { Ok(credentials) => { log::info!("Bedrock OIDC: credentials loaded successfully"); ( AwsCredentialsState::Loaded { credentials, loaded_at: SystemTime::now(), }, Ok(()), ) } Err(e) => { log::error!("Bedrock OIDC: failed to load credentials: {e:#}"); let message = e.to_string(); ( AwsCredentialsState::Failed { message: message.clone(), }, Err(message), ) } }; manager.set_aws_credentials_state(new_state, ctx); let _ = tx.send(tx_result); }, ); Box::pin(async move { rx.await .unwrap_or_else(|_| Err("Credential refresh was interrupted".to_string())) }) } #[cfg(test)] #[path = "aws_credentials_tests.rs"] mod tests;