diff --git a/crates/ironclaw_auth/CLAUDE.md b/crates/ironclaw_auth/CLAUDE.md index 031cbf9c1e4..2f844976902 100644 --- a/crates/ironclaw_auth/CLAUDE.md +++ b/crates/ironclaw_auth/CLAUDE.md @@ -1,6 +1,7 @@ # ironclaw_auth Guardrails - Own product-facing auth vocabulary and fake services only. +- Exception: `ProviderBackedCredentialAccountService` may live here because refresh serialization and status projection belong at the `CredentialAccountService` boundary, while raw provider/token material stays behind `AuthProviderClient` and secret boundaries. - Keep Reborn auth code independent from V1 route handlers, V1 pending state, V1 extension manager authority, and V1 secret-store implementation details. - Serializable records may contain hashes, ids, handles, statuses, and redacted metadata. They must not contain raw OAuth state, PKCE verifiers, authorization codes, tokens, secret values, provider response bodies, backend internals, or host paths. - Raw OAuth callback material may appear only in non-serializable one-shot inputs to provider exchange boundaries. diff --git a/crates/ironclaw_auth/Cargo.toml b/crates/ironclaw_auth/Cargo.toml index e12187cccd4..83da2064c2e 100644 --- a/crates/ironclaw_auth/Cargo.toml +++ b/crates/ironclaw_auth/Cargo.toml @@ -23,10 +23,10 @@ serde_json = "1" sha2 = "0.10" subtle = "2" thiserror = "2" -tokio = { version = "1", features = ["rt"] } +tokio = { version = "1", features = ["rt", "sync"] } url = "2" uuid = { version = "1", features = ["v4", "serde"] } [dev-dependencies] serde_json = "1" -tokio = { version = "1", features = ["macros", "rt"] } +tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/crates/ironclaw_auth/src/credential.rs b/crates/ironclaw_auth/src/credential.rs index da034c0759b..386d526e8ec 100644 --- a/crates/ironclaw_auth/src/credential.rs +++ b/crates/ironclaw_auth/src/credential.rs @@ -1,12 +1,14 @@ -use std::fmt; +use std::{collections::HashMap, fmt, sync::Arc, sync::Mutex}; use async_trait::async_trait; use ironclaw_host_api::{ExtensionId, SecretHandle}; use serde::{Deserialize, Serialize}; +use tokio::sync::OwnedMutexGuard; use crate::{ - AuthProductError, CredentialAccountId, CredentialAccountLabel, ProviderScope, Timestamp, - ids::AuthProviderId, scope::AuthProductScope, + AuthProductError, AuthProviderClient, CredentialAccountId, CredentialAccountLabel, + OAuthProviderRefreshRequest, ProviderScope, Timestamp, ids::AuthProviderId, + scope::AuthProductScope, scope_matches, }; /// Credential account status projected to product surfaces. @@ -518,3 +520,384 @@ pub trait CredentialSetupService: Send + Sync { request: CredentialAccountMutation, ) -> Result; } + +/// Credential account service that refreshes through the provider client and +/// persists account mutations through the backing account/setup services. +pub struct ProviderBackedCredentialAccountService { + accounts: Arc, + setup: Arc, + provider: Arc, + refresh_locks: Mutex>>>, +} + +impl ProviderBackedCredentialAccountService { + pub fn new( + accounts: Arc, + setup: Arc, + provider: Arc, + ) -> Self { + Self { + accounts, + setup, + provider, + refresh_locks: Mutex::new(HashMap::new()), + } + } + + fn refresh_lock(&self, account_id: CredentialAccountId) -> Arc> { + let mut refresh_locks = self + .refresh_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + refresh_locks + .entry(account_id) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + async fn acquire_refresh_lock(&self, account_id: CredentialAccountId) -> OwnedMutexGuard<()> { + self.refresh_lock(account_id).lock_owned().await + } + + fn release_refresh_lock(&self, account_id: CredentialAccountId) { + let mut refresh_locks = self + .refresh_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if refresh_locks + .get(&account_id) + .is_some_and(|lock| Arc::strong_count(lock) == 1) + { + refresh_locks.remove(&account_id); + } + } + + fn refresh_lookup_request( + request: &CredentialRefreshRequest, + ) -> CredentialAccountLookupRequest { + let mut lookup = + CredentialAccountLookupRequest::new(request.scope.clone(), request.account_id); + if let Some(requester_extension) = request.requester_extension.clone() { + lookup = lookup.for_extension(requester_extension); + } + lookup + } + + fn validate_refresh_target( + account: &CredentialAccount, + request: &CredentialRefreshRequest, + ) -> Result<(), AuthProductError> { + if !scope_matches(&request.scope, &account.scope) || account.provider != request.provider { + return Err(AuthProductError::CrossScopeDenied); + } + if !account_is_authorized_for_requester(account, request.requester_extension.as_ref()) { + return Err(AuthProductError::CrossScopeDenied); + } + if matches!( + account.status, + CredentialAccountStatus::Missing + | CredentialAccountStatus::PendingSetup + | CredentialAccountStatus::Inactive + | CredentialAccountStatus::Revoked + ) { + return Err(AuthProductError::CredentialMissing); + } + Ok(()) + } + + fn account_update( + account: &CredentialAccount, + access_secret: Option, + refresh_secret: Option, + status: CredentialAccountStatus, + scopes: Vec, + ) -> CredentialAccountMutation { + CredentialAccountMutation::Update(CredentialAccountUpdate { + account_id: account.id, + account: NewCredentialAccount { + scope: account.scope.clone(), + provider: account.provider.clone(), + label: account.label.clone(), + status, + ownership: account.ownership, + owner_extension: account.owner_extension.clone(), + granted_extensions: account.granted_extensions.clone(), + access_secret, + refresh_secret, + scopes, + }, + }) + } + + async fn report_for( + &self, + account: &CredentialAccount, + requester_extension: Option<&ExtensionId>, + refreshed: bool, + ) -> Result { + let recovery_request = + CredentialRecoveryRequest::new(account.scope.clone(), account.provider.clone()); + let recovery = self + .accounts + .project_credential_recovery(match requester_extension { + Some(requester_extension) => { + recovery_request.for_extension(requester_extension.clone()) + } + None => recovery_request, + }) + // silent-ok: refresh reporting is allowed to degrade to the + // single-account projection when the broader recovery projection + // lookup fails; the refresh mutation has already been applied and + // the caller still gets the refreshed account snapshot. + .await + .unwrap_or_else(|_| single_account_recovery(account)); + Ok(CredentialRefreshReport { + account: account.projection(), + recovery, + refreshed, + }) + } +} + +#[async_trait] +impl CredentialAccountService for ProviderBackedCredentialAccountService { + async fn create_account( + &self, + request: NewCredentialAccount, + ) -> Result { + self.accounts.create_account(request).await + } + + async fn get_account( + &self, + request: CredentialAccountLookupRequest, + ) -> Result, AuthProductError> { + self.accounts.get_account(request).await + } + + async fn list_accounts( + &self, + request: CredentialAccountListRequest, + ) -> Result { + self.accounts.list_accounts(request).await + } + + async fn update_status( + &self, + scope: &AuthProductScope, + account_id: CredentialAccountId, + status: CredentialAccountStatus, + ) -> Result { + self.accounts.update_status(scope, account_id, status).await + } + + async fn select_unique_configured_account( + &self, + request: CredentialAccountSelectionRequest, + ) -> Result { + self.accounts + .select_unique_configured_account(request) + .await + } + + async fn project_credential_recovery( + &self, + request: CredentialRecoveryRequest, + ) -> Result { + self.accounts.project_credential_recovery(request).await + } + + async fn select_configured_account( + &self, + request: CredentialAccountChoiceRequest, + ) -> Result { + self.accounts.select_configured_account(request).await + } + + async fn refresh_account( + &self, + request: CredentialRefreshRequest, + ) -> Result { + let lookup_request = Self::refresh_lookup_request(&request); + let initial_account = self + .accounts + .get_account(lookup_request.clone()) + .await? + .ok_or(AuthProductError::CredentialMissing)?; + Self::validate_refresh_target(&initial_account, &request)?; + let refresh_lock = self.acquire_refresh_lock(initial_account.id).await; + let result = async { + let account = self + .accounts + .get_account(lookup_request.clone()) + .await? + .ok_or(AuthProductError::CredentialMissing)?; + if account != initial_account { + return self + .report_for(&account, request.requester_extension.as_ref(), false) + .await; + } + + let Some(refresh_secret) = account.refresh_secret.clone() else { + let updated = self + .setup + .create_or_update_account(Self::account_update( + &account, + account.access_secret.clone(), + account.refresh_secret.clone(), + CredentialAccountStatus::RefreshFailed, + account.scopes.clone(), + )) + .await?; + return self + .report_for(&updated, request.requester_extension.as_ref(), false) + .await; + }; + + let provider_request = OAuthProviderRefreshRequest { + provider: account.provider.clone(), + scope: account.scope.clone(), + account_id: account.id, + refresh_secret: refresh_secret.clone(), + scopes: account.scopes.clone(), + }; + + match self.provider.refresh_token(provider_request).await { + Ok(refresh) => { + let current = self + .accounts + .get_account(lookup_request.clone()) + .await? + .ok_or(AuthProductError::CredentialMissing)?; + if current != account { + return self + .report_for(¤t, request.requester_extension.as_ref(), false) + .await; + } + if refresh.provider != current.provider { + return Err(AuthProductError::CrossScopeDenied); + } + let refresh_secret = refresh + .refresh_secret + .or_else(|| current.refresh_secret.clone()); + let updated = self + .setup + .create_or_update_account(Self::account_update( + ¤t, + Some(refresh.access_secret), + refresh_secret, + CredentialAccountStatus::Configured, + refresh.scopes, + )) + .await?; + self.report_for(&updated, request.requester_extension.as_ref(), true) + .await + } + Err(AuthProductError::RefreshFailed | AuthProductError::TokenExchangeFailed) => { + let current = self + .accounts + .get_account(lookup_request.clone()) + .await? + .ok_or(AuthProductError::CredentialMissing)?; + if current != account { + return self + .report_for(¤t, request.requester_extension.as_ref(), false) + .await; + } + let updated = self + .setup + .create_or_update_account(Self::account_update( + ¤t, + current.access_secret.clone(), + current.refresh_secret.clone(), + CredentialAccountStatus::RefreshFailed, + current.scopes.clone(), + )) + .await?; + self.report_for(&updated, request.requester_extension.as_ref(), false) + .await + } + Err(error) => Err(error), + } + } + .await; + drop(refresh_lock); + self.release_refresh_lock(initial_account.id); + result + } +} + +fn account_is_authorized_for_requester( + account: &CredentialAccount, + requester_extension: Option<&ExtensionId>, +) -> bool { + match account.ownership { + CredentialOwnership::UserReusable => true, + CredentialOwnership::ExtensionOwned => account + .owner_extension + .as_ref() + .is_some_and(|owner_extension| requester_extension == Some(owner_extension)), + CredentialOwnership::SharedAdminManaged => requester_extension + .is_some_and(|requester| account.granted_extensions.contains(requester)), + CredentialOwnership::System => false, + } +} + +fn single_account_recovery(account: &CredentialAccount) -> CredentialRecoveryProjection { + let (kind, reason) = recovery_kind_and_reason_for_status(account.status); + match kind { + CredentialRecoveryKind::Configured => { + CredentialRecoveryProjection::configured(account.provider.clone(), account.projection()) + } + CredentialRecoveryKind::SetupRequired => CredentialRecoveryProjection::setup_required( + account.provider.clone(), + reason, + vec![account.projection()], + ), + CredentialRecoveryKind::ReauthorizeRequired => { + CredentialRecoveryProjection::reauthorize_required( + account.provider.clone(), + reason, + vec![account.projection()], + ) + } + CredentialRecoveryKind::AccountSelectionRequired => { + unreachable!("single account recovery cannot produce account selection required") + } + } +} + +fn recovery_kind_and_reason_for_status( + status: CredentialAccountStatus, +) -> (CredentialRecoveryKind, CredentialRecoveryReason) { + match status { + CredentialAccountStatus::Configured => ( + CredentialRecoveryKind::Configured, + CredentialRecoveryReason::Configured, + ), + CredentialAccountStatus::PendingSetup => ( + CredentialRecoveryKind::SetupRequired, + CredentialRecoveryReason::PendingSetup, + ), + CredentialAccountStatus::Missing => ( + CredentialRecoveryKind::SetupRequired, + CredentialRecoveryReason::AccountMissing, + ), + CredentialAccountStatus::Inactive => ( + CredentialRecoveryKind::SetupRequired, + CredentialRecoveryReason::AccountInactive, + ), + CredentialAccountStatus::Expired => ( + CredentialRecoveryKind::ReauthorizeRequired, + CredentialRecoveryReason::AccountExpired, + ), + CredentialAccountStatus::RefreshFailed => ( + CredentialRecoveryKind::ReauthorizeRequired, + CredentialRecoveryReason::RefreshFailed, + ), + CredentialAccountStatus::Revoked => ( + CredentialRecoveryKind::ReauthorizeRequired, + CredentialRecoveryReason::AccountRevoked, + ), + } +} diff --git a/crates/ironclaw_auth/src/fakes.rs b/crates/ironclaw_auth/src/fakes.rs index c4bbf32ba2b..e2620a546d3 100644 --- a/crates/ironclaw_auth/src/fakes.rs +++ b/crates/ironclaw_auth/src/fakes.rs @@ -625,6 +625,7 @@ impl CredentialAccountService for InMemoryAuthProductServices { }; OAuthProviderRefreshRequest { provider: account.provider.clone(), + scope: account.scope.clone(), account_id: account.id, refresh_secret, scopes: account.scopes.clone(), diff --git a/crates/ironclaw_auth/src/lib.rs b/crates/ironclaw_auth/src/lib.rs index 522859e1aba..b95dbf24637 100644 --- a/crates/ironclaw_auth/src/lib.rs +++ b/crates/ironclaw_auth/src/lib.rs @@ -31,7 +31,7 @@ pub use credential::{ CredentialAccountStatus, CredentialAccountUpdate, CredentialOwnership, CredentialRecoveryKind, CredentialRecoveryProjection, CredentialRecoveryReason, CredentialRecoveryRequest, CredentialRecoveryState, CredentialRefreshReport, CredentialRefreshRequest, - CredentialSetupService, NewCredentialAccount, + CredentialSetupService, NewCredentialAccount, ProviderBackedCredentialAccountService, }; pub use error::{AuthErrorCode, AuthProductError}; pub use fakes::InMemoryAuthProductServices; diff --git a/crates/ironclaw_auth/src/provider.rs b/crates/ironclaw_auth/src/provider.rs index f71caac187c..bbbefe495c4 100644 --- a/crates/ironclaw_auth/src/provider.rs +++ b/crates/ironclaw_auth/src/provider.rs @@ -107,6 +107,7 @@ pub struct OAuthProviderExchange { #[derive(Clone, PartialEq, Eq)] pub struct OAuthProviderRefreshRequest { pub provider: AuthProviderId, + pub scope: AuthProductScope, pub account_id: CredentialAccountId, pub refresh_secret: SecretHandle, pub scopes: Vec, @@ -117,6 +118,7 @@ impl fmt::Debug for OAuthProviderRefreshRequest { formatter .debug_struct("OAuthProviderRefreshRequest") .field("provider", &self.provider) + .field("scope", &self.scope) .field("account_id", &self.account_id) .field("refresh_secret", &"[REDACTED]") .field("scopes", &self.scopes) @@ -145,6 +147,14 @@ pub trait AuthProviderClient: Send + Sync { &self, request: OAuthProviderRefreshRequest, ) -> Result; + + async fn cleanup_exchange( + &self, + _context: OAuthProviderExchangeContext, + _exchange: &OAuthProviderExchange, + ) -> Result<(), AuthProductError> { + Ok(()) + } } pub fn validate_provider_callback_request( diff --git a/crates/ironclaw_auth/tests/auth_product_contract/refresh_contract.rs b/crates/ironclaw_auth/tests/auth_product_contract/refresh_contract.rs index f9e38abdf44..a3f5c347a90 100644 --- a/crates/ironclaw_auth/tests/auth_product_contract/refresh_contract.rs +++ b/crates/ironclaw_auth/tests/auth_product_contract/refresh_contract.rs @@ -1,4 +1,78 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + use crate::common::*; +use async_trait::async_trait; +use ironclaw_auth::{OAuthProviderRefresh, ProviderBackedCredentialAccountService}; +use tokio::sync::Notify; + +struct BlockingRefreshProvider { + inner: Arc, + refresh_calls: AtomicUsize, + refresh_started: Notify, + release_refresh: Notify, + refresh_released: AtomicBool, +} + +impl BlockingRefreshProvider { + fn new(inner: Arc) -> Self { + Self { + inner, + refresh_calls: AtomicUsize::new(0), + refresh_started: Notify::new(), + release_refresh: Notify::new(), + refresh_released: AtomicBool::new(false), + } + } + + fn refresh_call_count(&self) -> usize { + self.refresh_calls.load(Ordering::SeqCst) + } + + async fn wait_for_first_refresh_start(&self) { + self.refresh_started.notified().await; + } + + fn release_refresh(&self) { + self.refresh_released.store(true, Ordering::SeqCst); + self.release_refresh.notify_waiters(); + } +} + +fn provider_backed_auth( + services: Arc, +) -> Arc { + Arc::new(ProviderBackedCredentialAccountService::new( + services.clone(), + services.clone(), + services, + )) +} + +#[async_trait] +impl AuthProviderClient for BlockingRefreshProvider { + async fn exchange_callback( + &self, + context: ironclaw_auth::OAuthProviderExchangeContext, + request: ironclaw_auth::OAuthProviderCallbackRequest, + ) -> Result { + self.inner.exchange_callback(context, request).await + } + + async fn refresh_token( + &self, + request: OAuthProviderRefreshRequest, + ) -> Result { + self.refresh_calls.fetch_add(1, Ordering::SeqCst); + self.refresh_started.notify_one(); + while !self.refresh_released.load(Ordering::SeqCst) { + self.release_refresh.notified().await; + } + self.inner.refresh_token(request).await + } +} #[tokio::test] async fn credential_refresh_updates_account_through_provider_boundary() { @@ -62,9 +136,10 @@ async fn credential_refresh_updates_account_through_provider_boundary() { #[tokio::test] async fn credential_refresh_failure_becomes_recoverable_status() { - let services = InMemoryAuthProductServices::new(); + let services = Arc::new(InMemoryAuthProductServices::new()); + let auth = provider_backed_auth(services.clone()); let owner = scope("alice"); - let account = services + let account = auth .create_account(NewCredentialAccount { scope: owner.clone(), provider: provider(), @@ -81,7 +156,7 @@ async fn credential_refresh_failure_becomes_recoverable_status() { .expect("configured account"); services.fail_next_refresh_for_tests(account.id); - let report = services + let report = auth .refresh_account(CredentialRefreshRequest::new( owner.clone(), provider(), @@ -123,6 +198,88 @@ async fn credential_refresh_failure_becomes_recoverable_status() { assert!(!serialized.contains("/host/path")); } +#[tokio::test] +async fn concurrent_refreshes_for_same_account_are_single_flight() { + let services = Arc::new(InMemoryAuthProductServices::new()); + let provider_client = Arc::new(BlockingRefreshProvider::new(services.clone())); + let auth = Arc::new(ProviderBackedCredentialAccountService::new( + services.clone(), + services.clone(), + provider_client.clone(), + )); + + let owner = scope("alice"); + let account = auth + .create_account(NewCredentialAccount { + scope: owner.clone(), + provider: provider(), + label: label("work"), + status: CredentialAccountStatus::Expired, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("github-concurrent-access").unwrap()), + refresh_secret: Some(SecretHandle::new("github-concurrent-refresh").unwrap()), + scopes: provider_scopes(&["repo"]), + }) + .await + .expect("expired account"); + + let first_refresh = { + let auth = auth.clone(); + let owner = owner.clone(); + tokio::spawn(async move { + auth.refresh_account(CredentialRefreshRequest::new(owner, provider(), account.id)) + .await + }) + }; + + provider_client.wait_for_first_refresh_start().await; + + let second_refresh = { + let auth = auth.clone(); + let owner = owner.clone(); + tokio::spawn(async move { + auth.refresh_account(CredentialRefreshRequest::new(owner, provider(), account.id)) + .await + }) + }; + + for _ in 0..5 { + tokio::task::yield_now().await; + } + assert_eq!(provider_client.refresh_call_count(), 1); + + provider_client.release_refresh(); + + let first_report = first_refresh + .await + .expect("first refresh task") + .expect("first refresh"); + let second_report = second_refresh + .await + .expect("second refresh task") + .expect("second refresh"); + + assert!(first_report.refreshed); + assert!(!second_report.refreshed); + assert_eq!(provider_client.refresh_call_count(), 1); + + let stored = auth + .get_account(CredentialAccountLookupRequest::new( + owner.clone(), + account.id, + )) + .await + .expect("lookup") + .expect("refreshed account"); + assert_eq!(stored.status, CredentialAccountStatus::Configured); + assert_eq!( + second_report.account.status, + CredentialAccountStatus::Configured + ); +} + #[tokio::test] async fn stale_refresh_success_does_not_overwrite_concurrent_refresh() { let services = InMemoryAuthProductServices::new(); @@ -228,9 +385,10 @@ async fn stale_refresh_failure_does_not_mark_concurrent_refresh_failed() { #[tokio::test] async fn credential_refresh_without_refresh_secret_becomes_recoverable_status() { - let services = InMemoryAuthProductServices::new(); + let services = Arc::new(InMemoryAuthProductServices::new()); + let auth = provider_backed_auth(services.clone()); let owner = scope("alice"); - let account = services + let account = auth .create_account(NewCredentialAccount { scope: owner.clone(), provider: provider(), @@ -246,7 +404,7 @@ async fn credential_refresh_without_refresh_secret_becomes_recoverable_status() .await .expect("expired account"); - let report = services + let report = auth .refresh_account(CredentialRefreshRequest::new( owner.clone(), provider(), @@ -280,6 +438,95 @@ async fn credential_refresh_without_refresh_secret_becomes_recoverable_status() assert_eq!(failed.status, CredentialAccountStatus::RefreshFailed); } +#[tokio::test] +async fn provider_backed_refresh_preserves_requester_for_authorized_extensions() { + let services = Arc::new(InMemoryAuthProductServices::new()); + let auth = provider_backed_auth(services.clone()); + let owner = scope("alice"); + let extension_owned = ExtensionId::new("github-extension-owned").unwrap(); + let shared_admin = ExtensionId::new("github-shared-admin").unwrap(); + + let extension_account = auth + .create_account(NewCredentialAccount { + scope: owner.clone(), + provider: provider(), + label: label("extension owned"), + status: CredentialAccountStatus::Expired, + ownership: CredentialOwnership::ExtensionOwned, + owner_extension: Some(extension_owned.clone()), + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("github-extension-owned-access").unwrap()), + refresh_secret: Some(SecretHandle::new("github-extension-owned-refresh").unwrap()), + scopes: provider_scopes(&["repo"]), + }) + .await + .expect("extension-owned account"); + + let extension_report = auth + .refresh_account( + CredentialRefreshRequest::new(owner.clone(), provider(), extension_account.id) + .for_extension(extension_owned.clone()), + ) + .await + .expect("extension-owned refresh"); + assert!(extension_report.refreshed); + assert_eq!( + extension_report.account.status, + CredentialAccountStatus::Configured + ); + assert_eq!( + extension_report.recovery.kind(), + CredentialRecoveryKind::Configured + ); + assert_eq!( + extension_report + .recovery + .selected_account() + .map(|account| account.id), + Some(extension_account.id) + ); + + let shared_account = auth + .create_account(NewCredentialAccount { + scope: owner.clone(), + provider: provider(), + label: label("shared admin"), + status: CredentialAccountStatus::Expired, + ownership: CredentialOwnership::SharedAdminManaged, + owner_extension: None, + granted_extensions: vec![shared_admin.clone()], + access_secret: Some(SecretHandle::new("github-shared-admin-access").unwrap()), + refresh_secret: Some(SecretHandle::new("github-shared-admin-refresh").unwrap()), + scopes: provider_scopes(&["repo"]), + }) + .await + .expect("shared-admin account"); + + let shared_report = auth + .refresh_account( + CredentialRefreshRequest::new(owner, provider(), shared_account.id) + .for_extension(shared_admin), + ) + .await + .expect("shared-admin refresh"); + assert!(shared_report.refreshed); + assert_eq!( + shared_report.account.status, + CredentialAccountStatus::Configured + ); + assert_eq!( + shared_report.recovery.kind(), + CredentialRecoveryKind::Configured + ); + assert_eq!( + shared_report + .recovery + .selected_account() + .map(|account| account.id), + Some(shared_account.id) + ); +} + #[tokio::test] async fn credential_refresh_rejects_terminal_statuses_even_with_refresh_secret() { let services = InMemoryAuthProductServices::new(); @@ -421,6 +668,7 @@ async fn credential_refresh_revalidates_scope_provider_and_grants() { fn provider_refresh_request_debug_redacts_secret_handle() { let request = OAuthProviderRefreshRequest { provider: provider(), + scope: scope("alice"), account_id: ironclaw_auth::CredentialAccountId::new(), refresh_secret: SecretHandle::new("github-debug-refresh-secret").unwrap(), scopes: provider_scopes(&["repo"]), diff --git a/crates/ironclaw_first_party_extensions/src/gsuite/credential.rs b/crates/ironclaw_first_party_extensions/src/gsuite/credential.rs index e91a8ca38ea..46d4421a352 100644 --- a/crates/ironclaw_first_party_extensions/src/gsuite/credential.rs +++ b/crates/ironclaw_first_party_extensions/src/gsuite/credential.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use ironclaw_auth::{ AuthProductError, AuthProductScope, AuthProviderId, AuthSurface, CredentialAccountId, CredentialAccountLookupRequest, CredentialAccountSelectionRequest, CredentialAccountService, - CredentialAccountStatus, CredentialRecoveryProjection, GOOGLE_PROVIDER_ID, ProviderScope, + CredentialAccountStatus, CredentialRecoveryProjection, CredentialRefreshRequest, + GOOGLE_PROVIDER_ID, ProviderScope, }; use ironclaw_host_api::{ExtensionId, ResourceScope, SecretHandle}; use thiserror::Error; @@ -64,11 +65,29 @@ impl GoogleCredentialResolver { &provider, ) .await?; + self.resolve_account( + scope, + requester_extension, + selected_account.id, + required_scopes, + ) + .await + } + + pub async fn resolve_account( + &self, + scope: &ResourceScope, + requester_extension: &ExtensionId, + account_id: CredentialAccountId, + required_scopes: &[ProviderScope], + ) -> Result { + let auth_scope = AuthProductScope::new(scope.clone(), AuthSurface::Api); + let provider = google_provider_id()?; let account = self .recoverable_lookup( self.accounts .get_account( - CredentialAccountLookupRequest::new(auth_scope, selected_account.id) + CredentialAccountLookupRequest::new(auth_scope, account_id) .for_extension(requester_extension.clone()), ) .await, @@ -102,6 +121,29 @@ impl GoogleCredentialResolver { }) } + pub async fn refresh( + &self, + scope: &ResourceScope, + requester_extension: &ExtensionId, + account_id: CredentialAccountId, + ) -> Result<(), GoogleCredentialError> { + let auth_scope = AuthProductScope::new(scope.clone(), AuthSurface::Api); + let provider = google_provider_id()?; + self.recoverable_result( + self.accounts + .refresh_account( + CredentialRefreshRequest::new(auth_scope, provider.clone(), account_id) + .for_extension(requester_extension.clone()), + ) + .await, + scope, + requester_extension, + &provider, + ) + .await + .map(|_| ()) + } + async fn recovery_required( &self, scope: &ResourceScope, diff --git a/crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs b/crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs index 6639b688300..c301714508c 100644 --- a/crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs +++ b/crates/ironclaw_first_party_extensions/src/gsuite/handlers.rs @@ -75,9 +75,57 @@ impl GsuiteExecutor { .await .map_err(map_credential_error)?; let execution = capability_execution(capability, request.input)?; - let (response, network_egress_bytes) = execution + let (response, network_egress_bytes) = match execution .execute(&request, credential.access_secret) - .await?; + .await? + { + CapabilityExecutionOutcome::Response { + response, + network_egress_bytes, + } => (response, network_egress_bytes), + CapabilityExecutionOutcome::AuthExpired { + network_egress_bytes, + } => { + self.resolver + .refresh(request.scope, &extension, credential.account_id) + .await + .map_err(|error| { + add_network_usage(map_credential_error(error), network_egress_bytes) + })?; + let refreshed = self + .resolver + .resolve_account(request.scope, &extension, credential.account_id, &scopes) + .await + .map_err(|error| { + add_network_usage(map_credential_error(error), network_egress_bytes) + })?; + let retry_execution = capability_execution(capability, request.input)?; + match retry_execution + .execute(&request, refreshed.access_secret) + .await + .map_err(|error| add_network_usage(error, network_egress_bytes))? + { + CapabilityExecutionOutcome::Response { + response, + network_egress_bytes: retry_network_egress_bytes, + } => ( + response, + network_egress_bytes.saturating_add(retry_network_egress_bytes), + ), + CapabilityExecutionOutcome::AuthExpired { + network_egress_bytes: retry_network_egress_bytes, + } => { + return Err(GsuiteDispatchError::new(RuntimeDispatchErrorKind::Backend) + .with_reason(GsuiteCredentialDispatchReason::BackendAuth) + .with_usage(ResourceUsage { + network_egress_bytes: network_egress_bytes + .saturating_add(retry_network_egress_bytes), + ..ResourceUsage::default() + })); + } + } + } + }; let output = response_output(&response)?; let wall_clock_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX); let output_bytes = serde_json::to_vec(&output) @@ -165,12 +213,22 @@ enum CapabilityExecution { AddAttendees(CalendarAddAttendeesInput), } +enum CapabilityExecutionOutcome { + Response { + response: ironclaw_host_api::RuntimeHttpEgressResponse, + network_egress_bytes: u64, + }, + AuthExpired { + network_egress_bytes: u64, + }, +} + impl CapabilityExecution { async fn execute( self, request: &GsuiteDispatchRequest<'_>, access_secret: ironclaw_host_api::SecretHandle, - ) -> Result<(ironclaw_host_api::RuntimeHttpEgressResponse, u64), GsuiteDispatchError> { + ) -> Result { match self { Self::Single { method, url, body } => { let response = execute_runtime_http( @@ -179,7 +237,7 @@ impl CapabilityExecution { ) .await?; let network_egress_bytes = response.request_bytes; - Ok((response, network_egress_bytes)) + Ok(response_outcome(response, network_egress_bytes)) } Self::AddAttendees(input) => execute_add_attendees(request, access_secret, input).await, } @@ -190,7 +248,7 @@ async fn execute_add_attendees( request: &GsuiteDispatchRequest<'_>, access_secret: ironclaw_host_api::SecretHandle, input: CalendarAddAttendeesInput, -) -> Result<(ironclaw_host_api::RuntimeHttpEgressResponse, u64), GsuiteDispatchError> { +) -> Result { let url = input.event_path.url(); let current_response = execute_runtime_http( runtime_request( @@ -204,6 +262,11 @@ async fn execute_add_attendees( ) .await?; let mut network_egress_bytes = current_response.request_bytes; + if is_google_auth_expired_response(¤t_response) { + return Ok(CapabilityExecutionOutcome::AuthExpired { + network_egress_bytes, + }); + } let current = response_body_json(¤t_response) .map_err(|error| add_network_usage(error, network_egress_bytes))?; let existing = current @@ -227,7 +290,23 @@ async fn execute_add_attendees( .await .map_err(|error| add_network_usage(error, network_egress_bytes))?; network_egress_bytes = network_egress_bytes.saturating_add(response.request_bytes); - Ok((response, network_egress_bytes)) + Ok(response_outcome(response, network_egress_bytes)) +} + +fn response_outcome( + response: ironclaw_host_api::RuntimeHttpEgressResponse, + network_egress_bytes: u64, +) -> CapabilityExecutionOutcome { + if is_google_auth_expired_response(&response) { + CapabilityExecutionOutcome::AuthExpired { + network_egress_bytes, + } + } else { + CapabilityExecutionOutcome::Response { + response, + network_egress_bytes, + } + } } async fn execute_runtime_http( @@ -262,6 +341,12 @@ fn response_body_json( } } +fn is_google_auth_expired_response( + response: &ironclaw_host_api::RuntimeHttpEgressResponse, +) -> bool { + response.status == 401 +} + fn required_provider_scopes( capability: &GsuiteCapabilitySpec, ) -> Result, GsuiteDispatchError> { @@ -1011,6 +1096,31 @@ mod tests { } } + #[test] + fn is_google_auth_expired_response_only_matches_401() { + let response = RuntimeHttpEgressResponse { + status: 401, + headers: Vec::new(), + body: Vec::new(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + }; + assert!(is_google_auth_expired_response(&response)); + + let response = RuntimeHttpEgressResponse { + status: 403, + headers: Vec::new(), + body: Vec::new(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + }; + assert!(!is_google_auth_expired_response(&response)); + } + #[test] fn input_validation_tests() { let input = json!({ diff --git a/crates/ironclaw_first_party_extensions/tests/gsuite_core.rs b/crates/ironclaw_first_party_extensions/tests/gsuite_core.rs index 2f7da99d511..6702c990972 100644 --- a/crates/ironclaw_first_party_extensions/tests/gsuite_core.rs +++ b/crates/ironclaw_first_party_extensions/tests/gsuite_core.rs @@ -1,11 +1,17 @@ mod support; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; +use async_trait::async_trait; use ironclaw_auth::{ - AuthProviderId, CredentialAccountLabel, CredentialAccountStatus, CredentialOwnership, - GOOGLE_CALENDAR_READONLY_SCOPE, GOOGLE_GMAIL_MODIFY_SCOPE, GOOGLE_GMAIL_READONLY_SCOPE, - GOOGLE_GMAIL_SEND_SCOPE, InMemoryAuthProductServices, NewCredentialAccount, + AuthProductError, AuthProductScope, AuthProviderId, CredentialAccount, + CredentialAccountChoiceRequest, CredentialAccountLabel, CredentialAccountListPage, + CredentialAccountListRequest, CredentialAccountLookupRequest, CredentialAccountProjection, + CredentialAccountSelectionRequest, CredentialAccountStatus, CredentialOwnership, + CredentialRecoveryProjection, CredentialRecoveryRequest, CredentialRefreshReport, + CredentialRefreshRequest, GOOGLE_CALENDAR_READONLY_SCOPE, GOOGLE_GMAIL_MODIFY_SCOPE, + GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE, InMemoryAuthProductServices, + NewCredentialAccount, }; use ironclaw_first_party_extensions::{ CALENDAR_ADD_ATTENDEES_CAPABILITY_ID, CALENDAR_CREATE_EVENT_CAPABILITY_ID, @@ -126,6 +132,380 @@ async fn calendar_create_event_does_not_forward_list_query_fields() { ); } +#[tokio::test] +async fn gsuite_handler_refreshes_expired_google_token_once_and_retries() { + let scope = scope(); + let auth = Arc::new(InMemoryAuthProductServices::new()); + ironclaw_auth::CredentialAccountService::create_account( + auth.as_ref(), + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-old-refresh").unwrap()), + scopes: vec![provider_scope(GOOGLE_GMAIL_SEND_SCOPE)], + }, + ) + .await + .unwrap(); + let egress = Arc::new(RecordingEgress::with_responses(vec![ + RecordingEgress::json_status( + 401, + json!({"error":{"status":"UNAUTHENTICATED","message":"expired"}}), + ), + RecordingEgress::json(json!({"id":"sent-after-refresh"})), + ])); + + let output = dispatch_ok( + auth.clone(), + scope.clone(), + GMAIL_SEND_MESSAGE_CAPABILITY_ID, + json!({ "message": { "raw": "base64url-rfc822" } }), + egress.clone(), + ) + .await; + + assert_eq!(output["status"], 200); + assert_eq!(egress.requests().len(), 2); + let refreshed = ironclaw_auth::CredentialAccountService::select_unique_configured_account( + auth.as_ref(), + ironclaw_auth::CredentialAccountSelectionRequest::new( + auth_scope(&scope), + google_provider_id().unwrap(), + ) + .for_extension(ExtensionId::new("gmail").unwrap()), + ) + .await + .unwrap(); + assert_eq!(refreshed.status, CredentialAccountStatus::Configured); +} + +#[tokio::test] +async fn gsuite_handler_refresh_retries_with_the_same_account_after_account_selection_changes() { + let scope = scope(); + let seed_auth = InMemoryAuthProductServices::new(); + let initial_account = ironclaw_auth::CredentialAccountService::create_account( + &seed_auth, + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-old-refresh").unwrap()), + scopes: vec![provider_scope(GOOGLE_GMAIL_SEND_SCOPE)], + }, + ) + .await + .unwrap(); + let alternate_account = ironclaw_auth::CredentialAccountService::create_account( + &seed_auth, + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("personal google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-other-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-other-refresh").unwrap()), + scopes: vec![provider_scope(GOOGLE_GMAIL_SEND_SCOPE)], + }, + ) + .await + .unwrap(); + let auth = Arc::new(AccountSwitchingAuthService::new( + initial_account.clone(), + alternate_account.clone(), + )); + let egress = Arc::new(RecordingEgress::with_responses(vec![ + RecordingEgress::json_status( + 401, + json!({"error":{"status":"UNAUTHENTICATED","message":"expired"}}), + ), + RecordingEgress::json(json!({"id":"sent-after-refresh"})), + ])); + let capability_id = capability_id(GMAIL_SEND_MESSAGE_CAPABILITY_ID); + + let output = GsuiteExecutor::new(auth.clone()) + .dispatch(GsuiteDispatchRequest { + capability_id: &capability_id, + scope: &scope, + input: &json!({ "message": { "raw": "base64url-rfc822" } }), + runtime_http_egress: egress.clone(), + }) + .await + .unwrap() + .output; + + assert_eq!(output["status"], 200); + let requests = egress.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].credential_injections[0].handle, + SecretHandle::new("google-old-access").unwrap() + ); + assert_eq!( + requests[1].credential_injections[0].handle, + SecretHandle::new("google-refreshed-access").unwrap() + ); + let state = auth.state.lock().expect("auth state"); + assert_eq!(state.select_unique_calls, 1); + assert_eq!(state.refresh_calls, 1); + assert_eq!(state.initial_account.id, initial_account.id); + assert_eq!(state.alternate_account.id, alternate_account.id); +} + +#[tokio::test] +async fn gsuite_handler_does_not_refresh_on_non_401_unauthenticated_response() { + let scope = scope(); + let auth = Arc::new(InMemoryAuthProductServices::new()); + ironclaw_auth::CredentialAccountService::create_account( + auth.as_ref(), + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-old-refresh").unwrap()), + scopes: vec![provider_scope(GOOGLE_GMAIL_SEND_SCOPE)], + }, + ) + .await + .unwrap(); + let egress = Arc::new(RecordingEgress::with_responses(vec![ + RecordingEgress::json_status( + 403, + json!({"error":{"status":"UNAUTHENTICATED","message":"expired"}}), + ), + ])); + + let output = dispatch_ok( + auth.clone(), + scope.clone(), + GMAIL_SEND_MESSAGE_CAPABILITY_ID, + json!({ "message": { "raw": "base64url-rfc822" } }), + egress.clone(), + ) + .await; + + assert_eq!(output["status"], 403); + assert_eq!(output["body"]["error"]["status"], "UNAUTHENTICATED"); + assert_eq!(egress.requests().len(), 1); + let account = ironclaw_auth::CredentialAccountService::select_unique_configured_account( + auth.as_ref(), + ironclaw_auth::CredentialAccountSelectionRequest::new( + auth_scope(&scope), + google_provider_id().unwrap(), + ) + .for_extension(ExtensionId::new("gmail").unwrap()), + ) + .await + .unwrap(); + assert_eq!(account.status, CredentialAccountStatus::Configured); +} + +#[tokio::test] +async fn gsuite_handler_errors_when_refresh_retry_is_still_auth_expired() { + let scope = scope(); + let auth = Arc::new(InMemoryAuthProductServices::new()); + ironclaw_auth::CredentialAccountService::create_account( + auth.as_ref(), + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-old-refresh").unwrap()), + scopes: vec![provider_scope(GOOGLE_GMAIL_SEND_SCOPE)], + }, + ) + .await + .unwrap(); + let egress = Arc::new(RecordingEgress::with_responses(vec![ + RecordingEgress::json_status( + 401, + json!({"error":{"status":"UNAUTHENTICATED","message":"expired"}}), + ), + RecordingEgress::json_status( + 401, + json!({"error":{"status":"UNAUTHENTICATED","message":"still expired"}}), + ), + ])); + let capability_id = capability_id(GMAIL_SEND_MESSAGE_CAPABILITY_ID); + + let error = GsuiteExecutor::new(auth) + .dispatch(GsuiteDispatchRequest { + capability_id: &capability_id, + scope: &scope, + input: &json!({ "message": { "raw": "base64url-rfc822" } }), + runtime_http_egress: egress.clone(), + }) + .await + .expect_err("retry auth expiry should fail"); + + assert_eq!(error.kind(), RuntimeDispatchErrorKind::Backend); + assert_eq!( + error.reason(), + Some(&GsuiteCredentialDispatchReason::BackendAuth) + ); + assert_eq!(egress.requests().len(), 2); + assert_eq!( + error.usage().map(|usage| usage.network_egress_bytes), + Some(246) + ); +} + +struct AccountSwitchingAuthService { + state: Mutex, +} + +struct AccountSwitchingAuthState { + initial_account: CredentialAccount, + alternate_account: CredentialAccount, + select_unique_calls: usize, + refresh_calls: usize, +} + +impl AccountSwitchingAuthService { + fn new(initial_account: CredentialAccount, alternate_account: CredentialAccount) -> Self { + Self { + state: Mutex::new(AccountSwitchingAuthState { + initial_account, + alternate_account, + select_unique_calls: 0, + refresh_calls: 0, + }), + } + } +} + +#[async_trait] +impl ironclaw_auth::CredentialAccountService for AccountSwitchingAuthService { + async fn create_account( + &self, + _request: NewCredentialAccount, + ) -> Result { + Err(AuthProductError::BackendUnavailable) + } + + async fn get_account( + &self, + request: CredentialAccountLookupRequest, + ) -> Result, AuthProductError> { + let state = self.state.lock().expect("auth state"); + Ok(if request.account_id == state.initial_account.id { + Some(state.initial_account.clone()) + } else if request.account_id == state.alternate_account.id { + Some(state.alternate_account.clone()) + } else { + None + }) + } + + async fn list_accounts( + &self, + _request: CredentialAccountListRequest, + ) -> Result { + let state = self.state.lock().expect("auth state"); + Ok(CredentialAccountListPage { + accounts: vec![ + state.initial_account.projection(), + state.alternate_account.projection(), + ], + next_cursor: None, + }) + } + + async fn update_status( + &self, + _scope: &AuthProductScope, + account_id: ironclaw_auth::CredentialAccountId, + status: CredentialAccountStatus, + ) -> Result { + let mut state = self.state.lock().expect("auth state"); + let account = if account_id == state.initial_account.id { + &mut state.initial_account + } else if account_id == state.alternate_account.id { + &mut state.alternate_account + } else { + return Err(AuthProductError::CredentialMissing); + }; + account.status = status; + Ok(account.clone()) + } + + async fn select_unique_configured_account( + &self, + _request: CredentialAccountSelectionRequest, + ) -> Result { + let mut state = self.state.lock().expect("auth state"); + state.select_unique_calls += 1; + Ok(if state.select_unique_calls == 1 { + state.initial_account.projection() + } else { + state.alternate_account.projection() + }) + } + + async fn project_credential_recovery( + &self, + _request: CredentialRecoveryRequest, + ) -> Result { + let state = self.state.lock().expect("auth state"); + Ok(CredentialRecoveryProjection::configured( + google_provider_id().unwrap(), + state.initial_account.projection(), + )) + } + + async fn select_configured_account( + &self, + _request: CredentialAccountChoiceRequest, + ) -> Result { + Err(AuthProductError::BackendUnavailable) + } + + async fn refresh_account( + &self, + request: CredentialRefreshRequest, + ) -> Result { + let mut state = self.state.lock().expect("auth state"); + state.refresh_calls += 1; + if request.account_id != state.initial_account.id { + return Err(AuthProductError::CredentialMissing); + } + state.initial_account.access_secret = + Some(SecretHandle::new("google-refreshed-access").unwrap()); + let account = state.initial_account.clone(); + Ok(CredentialRefreshReport { + account: account.projection(), + recovery: CredentialRecoveryProjection::configured( + google_provider_id().unwrap(), + account.projection(), + ), + refreshed: true, + }) + } +} + #[tokio::test] async fn gsuite_handler_rejects_oversized_request_body_before_egress() { let scope = scope(); @@ -855,6 +1235,89 @@ fn gsuite_resource_profile_allows_wrapped_response_headroom() { ); } +#[tokio::test] +async fn add_attendees_refreshes_expired_get_and_retries_patch() { + let scope = scope(); + let auth = Arc::new(InMemoryAuthProductServices::new()); + ironclaw_auth::CredentialAccountService::create_account( + auth.as_ref(), + NewCredentialAccount { + scope: auth_scope(&scope), + provider: google_provider_id().unwrap(), + label: CredentialAccountLabel::new("work google").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(SecretHandle::new("google-old-refresh").unwrap()), + scopes: vec![provider_scope(ironclaw_auth::GOOGLE_CALENDAR_EVENTS_SCOPE)], + }, + ) + .await + .unwrap(); + let capability_id = capability_id(CALENDAR_ADD_ATTENDEES_CAPABILITY_ID); + let egress = Arc::new(RecordingEgress::with_responses(vec![ + RecordingEgress::json_status( + 401, + json!({"error":{"status":"UNAUTHENTICATED","message":"expired"}}), + ), + RecordingEgress::json_with_request_bytes( + json!({ + "attendees":[{"email":"existing@example.com"}], + "etag":"retry-get-etag" + }), + 101, + ), + RecordingEgress::json_with_request_bytes(json!({"id":"evt-1","updated":true}), 211), + ])); + + let result = GsuiteExecutor::new(auth) + .dispatch(GsuiteDispatchRequest { + capability_id: &capability_id, + scope: &scope, + input: &json!({ + "calendar_id": "primary", + "event_id": "evt-1", + "attendees": [{"email": "new@example.com"}] + }), + runtime_http_egress: egress.clone(), + }) + .await + .unwrap(); + + assert_eq!(result.output["status"], 200); + assert_eq!(result.output["body"]["id"], "evt-1"); + assert_eq!(result.output["body"]["updated"], true); + assert_eq!(result.output["redaction_applied"], true); + + let requests = egress.requests(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method, NetworkMethod::Get); + assert!(requests[0].url.ends_with("/calendars/primary/events/evt-1")); + assert_eq!(requests[1].method, NetworkMethod::Get); + assert!(requests[1].url.ends_with("/calendars/primary/events/evt-1")); + assert_eq!(requests[2].method, NetworkMethod::Patch); + assert!(requests[2].url.ends_with("/calendars/primary/events/evt-1")); + assert_eq!( + requests[2] + .headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("if-match")) + .map(|(_, value)| value.as_str()), + Some("retry-get-etag") + ); + let patch_body: serde_json::Value = serde_json::from_slice(&requests[2].body).unwrap(); + assert_eq!( + patch_body["attendees"], + json!([ + {"email":"existing@example.com"}, + {"email":"new@example.com"} + ]) + ); + assert_eq!(result.usage.network_egress_bytes, 435); +} + #[tokio::test] async fn add_attendees_reports_both_google_api_requests() { let scope = scope(); diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index b28d5a26986..4614eade6d4 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -500,6 +500,14 @@ impl SecretStore for SharedSecretStore { self.0.metadata(scope, handle).await } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + self.0.delete(scope, handle).await + } + async fn lease_once( &self, scope: &ResourceScope, diff --git a/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs b/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs index 5f67eef1c22..539486f8f52 100644 --- a/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs +++ b/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs @@ -3094,6 +3094,15 @@ impl SecretStore for TokioBackedSecretStore { self.inner.metadata(scope, handle).await } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + Self::yield_to_tokio().await; + self.inner.delete(scope, handle).await + } + async fn lease_once( &self, scope: &ResourceScope, diff --git a/crates/ironclaw_reborn_composition/src/auth.rs b/crates/ironclaw_reborn_composition/src/auth.rs index 392ffa40ba9..8352ec0af79 100644 --- a/crates/ironclaw_reborn_composition/src/auth.rs +++ b/crates/ironclaw_reborn_composition/src/auth.rs @@ -12,8 +12,8 @@ use ironclaw_auth::{ InMemoryAuthProductServices, ManualTokenSetupRequest, NewAuthFlow, OAuthAuthorizationUrl, OAuthCallbackClaimRequest, OAuthCallbackFailureInput, OAuthCallbackInput, OAuthProviderCallbackRequest, OAuthProviderExchangeContext, OpaqueStateHash, PkceVerifierHash, - ProviderCallbackOutcome, SecretCleanupReport, SecretCleanupRequest, SecretCleanupService, - SecretSubmitRequest, Timestamp, + ProviderBackedCredentialAccountService, ProviderCallbackOutcome, SecretCleanupReport, + SecretCleanupRequest, SecretCleanupService, SecretSubmitRequest, Timestamp, }; use ironclaw_product_workflow::ProductAuthTurnGateResumeDispatcher; use secrecy::SecretString; @@ -322,6 +322,11 @@ impl RebornProductAuthServicePorts { } pub fn with_provider_client(mut self, provider_client: Arc) -> Self { + self.credential_account_service = Arc::new(ProviderBackedCredentialAccountService::new( + self.credential_account_service, + self.credential_setup_service.clone(), + provider_client.clone(), + )); self.provider_client = provider_client; self } @@ -473,6 +478,11 @@ impl RebornProductAuthServices { } pub fn with_provider_client(mut self, provider_client: Arc) -> Self { + self.credential_account_service = Arc::new(ProviderBackedCredentialAccountService::new( + self.credential_account_service, + self.credential_setup_service.clone(), + provider_client.clone(), + )); self.provider_client = provider_client; self } @@ -569,7 +579,7 @@ impl RebornProductAuthServices { ) .await { - tracing::debug!( + tracing::warn!( flow_id = %request.flow_id, exchange_error_code = ?error_code, fail_error_code = ?fail_error.code(), @@ -579,17 +589,42 @@ impl RebornProductAuthServices { return Err(error.into()); } }; - self.flow_manager + let exchange_for_cleanup = exchange.clone(); + match self + .flow_manager .complete_oauth_callback( &request.scope, OAuthCallbackInput { flow_id: request.flow_id, - opaque_state_hash: request.opaque_state_hash, + opaque_state_hash: request.opaque_state_hash.clone(), outcome: ProviderCallbackOutcome::Authorized { exchange }, }, ) .await - .map_err(RebornOAuthCallbackError::from)? + { + Ok(completed) => completed, + Err(error) => { + if let Err(cleanup_error) = self + .provider_client + .cleanup_exchange( + OAuthProviderExchangeContext { + scope: request.scope.clone(), + flow_id: request.flow_id, + }, + &exchange_for_cleanup, + ) + .await + { + tracing::warn!( + flow_id = %request.flow_id, + completion_error_code = ?error.code(), + cleanup_error_code = ?cleanup_error.code(), + "reborn auth callback completion failed and token cleanup failed" + ); + } + return Err(error.into()); + } + } } } RebornOAuthCallbackOutcome::ProviderDenied => self diff --git a/crates/ironclaw_reborn_composition/src/google_oauth/client.rs b/crates/ironclaw_reborn_composition/src/google_oauth/client.rs index 699ec28c992..b0c6a2276ea 100644 --- a/crates/ironclaw_reborn_composition/src/google_oauth/client.rs +++ b/crates/ironclaw_reborn_composition/src/google_oauth/client.rs @@ -11,16 +11,21 @@ use ironclaw_host_api::{ CapabilityId, NetworkMethod, RuntimeCredentialInjection, RuntimeHttpEgress, RuntimeHttpEgressRequest, RuntimeKind, }; -use secrecy::SecretString; +use secrecy::{ExposeSecret, SecretString}; use crate::google_oauth::policy_authorizer::{ GoogleProviderEgressPolicyAuthorizer, google_token_network_policy, }; use crate::google_oauth::secret_sink::{ - GoogleProviderTokenSet, GoogleProviderTokenSink, GoogleProviderTokenStorageRequest, + GoogleProviderRefreshTokenStorageRequest, GoogleProviderTokenSet, GoogleProviderTokenSink, + GoogleProviderTokenStorageRequest, +}; +use crate::google_oauth::token_request::{ + serialize_authorization_code_token_request, serialize_refresh_token_request, +}; +use crate::google_oauth::token_response::{ + parse_token_response, scopes_for_exchange, scopes_for_refresh, }; -use crate::google_oauth::token_request::serialize_token_request; -use crate::google_oauth::token_response::{parse_token_response, scopes_for_exchange}; const GOOGLE_OAUTH_CAPABILITY: &str = "ironclaw_auth.google_oauth"; const DEFAULT_TIMEOUT_MS: u32 = 30_000; @@ -131,7 +136,7 @@ impl AuthProviderClient for GoogleProviderClient { return Err(AuthProductError::CrossScopeDenied); } - let body = serialize_token_request( + let body = serialize_authorization_code_token_request( self.client_id.as_str(), self.redirect_uri.as_str(), self.client_secret.as_ref(), @@ -164,6 +169,9 @@ impl AuthProviderClient for GoogleProviderClient { save_body_to: None, timeout_ms: Some(self.timeout_ms), }; + // Production host egress requires the policy staged above for this + // scope/capability. The request-carried policy is only a legacy/test + // fallback and must not be treated as authority on the production path. let response = tokio::task::spawn_blocking(move || egress.execute(egress_request)) .await .map_err(|_| AuthProductError::BackendUnavailable)?; @@ -201,8 +209,105 @@ impl AuthProviderClient for GoogleProviderClient { async fn refresh_token( &self, - _request: OAuthProviderRefreshRequest, + request: OAuthProviderRefreshRequest, ) -> Result { - Err(AuthProductError::RefreshFailed) + if request.provider.as_str() != GOOGLE_PROVIDER_ID { + return Err(AuthProductError::RefreshFailed); + } + let refresh_scope = request.scope.resource.clone(); + if refresh_scope.is_system() { + return Err(AuthProductError::CrossScopeDenied); + } + let refresh_token = self + .token_sink + .load_refresh_token(&refresh_scope, &request.refresh_secret) + .await?; + let body = serialize_refresh_token_request( + self.client_id.as_str(), + self.client_secret.as_ref(), + refresh_token.expose_secret(), + ); + let network_policy = google_token_network_policy(self.response_body_limit); + self.egress_policy_authorizer + .authorize_google_token_exchange(&refresh_scope, &self.capability_id, &network_policy) + .await?; + + let egress = Arc::clone(&self.egress); + let egress_request = RuntimeHttpEgressRequest { + runtime: self.runtime, + scope: refresh_scope.clone(), + capability_id: self.capability_id.clone(), + method: NetworkMethod::Post, + url: GOOGLE_TOKEN_ENDPOINT.to_string(), + headers: vec![ + ( + "content-type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ), + ("accept".to_string(), "application/json".to_string()), + ], + body, + network_policy, + credential_injections: Vec::::new(), + response_body_limit: Some(self.response_body_limit), + save_body_to: None, + timeout_ms: Some(self.timeout_ms), + }; + // Production host egress requires the policy staged above for this + // scope/capability. The request-carried policy is only a legacy/test + // fallback and must not be treated as authority on the production path. + let response = tokio::task::spawn_blocking(move || egress.execute(egress_request)) + .await + .map_err(|_| AuthProductError::BackendUnavailable)?; + let response = response.map_err(|_| AuthProductError::BackendUnavailable)?; + + if !(200..300).contains(&response.status) { + return Err(map_refresh_error(response.status)); + } + + let token_response = + parse_token_response(&response.body).map_err(|_| AuthProductError::RefreshFailed)?; + let scopes = scopes_for_refresh(&token_response, &request.scopes); + let stored_tokens = self + .token_sink + .store_refreshed_tokens(GoogleProviderRefreshTokenStorageRequest { + scope: refresh_scope, + account_id: request.account_id, + tokens: GoogleProviderTokenSet { + access_token: token_response.response.access_token, + refresh_token: token_response.response.refresh_token, + }, + }) + .await?; + + Ok(OAuthProviderRefresh { + provider: request.provider, + access_secret: stored_tokens.access_secret, + refresh_secret: stored_tokens.refresh_secret, + scopes, + }) + } + + async fn cleanup_exchange( + &self, + context: OAuthProviderExchangeContext, + exchange: &OAuthProviderExchange, + ) -> Result<(), AuthProductError> { + if exchange.provider.as_str() != GOOGLE_PROVIDER_ID { + return Ok(()); + } + let mut handles = vec![exchange.access_secret.clone()]; + handles.extend(exchange.refresh_secret.clone()); + self.token_sink + .delete_tokens(&context.scope.resource, &handles) + .await + } +} + +fn map_refresh_error(status: u16) -> AuthProductError { + if (500..600).contains(&status) { + AuthProductError::BackendUnavailable + } else { + AuthProductError::RefreshFailed } } diff --git a/crates/ironclaw_reborn_composition/src/google_oauth/mod.rs b/crates/ironclaw_reborn_composition/src/google_oauth/mod.rs index 35a17336b85..ef55612c471 100644 --- a/crates/ironclaw_reborn_composition/src/google_oauth/mod.rs +++ b/crates/ironclaw_reborn_composition/src/google_oauth/mod.rs @@ -53,16 +53,21 @@ mod tests { GoogleProviderEgressPolicyAuthorizer, ObligationGoogleEgressPolicyAuthorizer, }; use crate::google_oauth::secret_sink::{ - GoogleProviderStoredTokens, GoogleProviderTokenSet, GoogleProviderTokenSink, - GoogleProviderTokenStorageRequest, SecretStoreGoogleTokenSink, + GoogleProviderRefreshTokenStorageRequest, GoogleProviderStoredTokens, + GoogleProviderTokenSet, GoogleProviderTokenSink, GoogleProviderTokenStorageRequest, + SecretStoreGoogleTokenSink, }; use crate::google_oauth::token_response::{parse_token_response, scopes_for_exchange}; + use crate::{RebornAuthContinuationDispatcher, RebornProductAuthServices}; use async_trait::async_trait; use ironclaw_auth::{ - AuthFlowId, AuthProductScope, AuthProviderId, AuthSurface, AuthorizationCodeHash, - CredentialAccountLabel, GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE, - GOOGLE_PROVIDER_ID, GOOGLE_TOKEN_ENDPOINT, OAuthAuthorizationCode, OAuthClientId, - OAuthProviderCallbackRequest, OAuthProviderExchangeContext, OAuthProviderRefreshRequest, + AuthContinuationEvent, AuthFlowId, AuthProductScope, AuthProviderId, AuthSurface, + AuthorizationCodeHash, CredentialAccountLabel, CredentialAccountLookupRequest, + CredentialAccountService, CredentialAccountStatus, CredentialOwnership, + CredentialRefreshRequest, GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE, + GOOGLE_PROVIDER_ID, GOOGLE_TOKEN_ENDPOINT, InMemoryAuthProductServices, + NewCredentialAccount, OAuthAuthorizationCode, OAuthClientId, OAuthProviderCallbackRequest, + OAuthProviderExchange, OAuthProviderExchangeContext, OAuthProviderRefreshRequest, OAuthRedirectUri, PkceVerifierHash, PkceVerifierSecret, ProviderScope, }; use ironclaw_authorization::GrantAuthorizer; @@ -88,6 +93,19 @@ mod tests { use std::collections::{BTreeMap, VecDeque}; use std::sync::Mutex; + #[derive(Debug, Default)] + struct NoopContinuationDispatcher; + + #[async_trait] + impl RebornAuthContinuationDispatcher for NoopContinuationDispatcher { + async fn dispatch_auth_continuation( + &self, + _event: AuthContinuationEvent, + ) -> Result<(), AuthProductError> { + Ok(()) + } + } + #[test] fn response_body_parses_to_token_response() { let response = parse_token_response( @@ -383,6 +401,47 @@ mod tests { assert!(egress.requests().is_empty()); } + #[tokio::test] + async fn google_provider_fails_closed_when_policy_is_not_staged() { + let network = RecordingNetwork::google_token_response(); + let network_requests = network.requests_handle(); + let secret_store: Arc = Arc::new(InMemorySecretStore::new()); + let services = test_host_runtime_services() + .with_secret_store_dyn(Arc::clone(&secret_store)) + .try_with_host_http_egress(network) + .expect("host egress should wire with graph secret store"); + let runtime_ports = services + .product_auth_provider_runtime_ports() + .expect("runtime ports"); + let client = GoogleProviderClient::new( + runtime_ports.runtime_http_egress(), + Arc::new(RecordingTokenSink::new( + SecretHandle::new("google-access-secret").expect("valid handle"), + None, + )), + Arc::new(NoopPolicyAuthorizer), + OAuthClientId::new("google-client-123").expect("client id"), + OAuthRedirectUri::new("https://app.example/oauth/callback").expect("redirect uri"), + ) + .expect("client"); + + let error = client + .exchange_callback( + exchange_context(scope("google-provider-policy-missing"), AuthFlowId::new()), + callback_request(google_provider(), label("work gmail")), + ) + .await + .expect_err("unstaged policy must fail closed"); + + assert_eq!(error, AuthProductError::BackendUnavailable); + assert!( + network_requests + .lock() + .expect("network requests") + .is_empty() + ); + } + #[tokio::test] async fn google_provider_sanitizes_provider_errors() { let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { @@ -720,11 +779,274 @@ mod tests { } #[tokio::test] - async fn google_provider_refresh_token_returns_refresh_failed_without_egress() { + async fn google_provider_refresh_uses_host_egress_and_stores_access_handle_only() { + let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 200, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: br#"{ + "access_token":"provider-refreshed-access-token", + "scope":"https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send", + "expires_in":3600, + "token_type":"Bearer" + }"# + .to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: true, + })); + let sink = Arc::new(RecordingTokenSink::new( + SecretHandle::new("google-refreshed-access-secret").expect("valid handle"), + None, + )); + let policy_authorizer = Arc::new(RecordingPolicyAuthorizer::default()); + let client = GoogleProviderClient::new( + egress.clone(), + sink.clone(), + policy_authorizer.clone(), + OAuthClientId::new("google-client-123").expect("client id"), + OAuthRedirectUri::new("https://app.example/oauth/callback").expect("redirect uri"), + ) + .expect("client") + .with_runtime(RuntimeKind::FirstParty) + .with_timeout_ms(12_345); + + let refresh = client + .refresh_token(refresh_request(google_provider())) + .await + .expect("refresh"); + + assert_eq!(refresh.provider, google_provider()); + assert_eq!( + refresh.access_secret, + SecretHandle::new("google-refreshed-access-secret").unwrap() + ); + assert!(refresh.refresh_secret.is_none()); + assert_eq!( + refresh.scopes, + provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE]) + ); + assert_eq!( + sink.access_tokens(), + vec!["provider-refreshed-access-token".to_string()] + ); + assert!(sink.refresh_tokens().is_empty()); + + let requests = egress.requests(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.runtime, RuntimeKind::FirstParty); + assert_eq!(request.timeout_ms, Some(12_345)); + assert_eq!(request.method, NetworkMethod::Post); + assert_eq!(request.url, GOOGLE_TOKEN_ENDPOINT); + let form = token_request_form(&request.body); + assert_eq!( + form.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + form.get("refresh_token").map(String::as_str), + Some("stored-refresh-token") + ); + assert_eq!( + form.get("client_id").map(String::as_str), + Some("google-client-123") + ); + assert!(request.network_policy.deny_private_ip_ranges); + assert_eq!( + request + .network_policy + .allowed_targets + .iter() + .map(|target| (target.scheme, target.host_pattern.as_str())) + .collect::>(), + vec![(Some(NetworkScheme::Https), "oauth2.googleapis.com")] + ); + assert_eq!(policy_authorizer.authorizations().len(), 1); + } + + #[tokio::test] + async fn google_provider_refresh_rejects_system_scope_before_side_effects() { + let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 200, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: br#"{ + "access_token":"provider-refreshed-access-token", + "scope":"https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send", + "expires_in":3600, + "token_type":"Bearer" + }"# + .to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: true, + })); + let sink = Arc::new(RecordingTokenSink::new( + SecretHandle::new("google-refreshed-access-secret").expect("valid handle"), + None, + )); + let policy_authorizer = Arc::new(RecordingPolicyAuthorizer::default()); + let client = GoogleProviderClient::new( + egress.clone(), + sink.clone(), + policy_authorizer.clone(), + OAuthClientId::new("google-client-123").expect("client id"), + OAuthRedirectUri::new("https://app.example/oauth/callback").expect("redirect uri"), + ) + .expect("client"); + + let error = client + .refresh_token(OAuthProviderRefreshRequest { + provider: google_provider(), + scope: AuthProductScope::new(ResourceScope::system(), AuthSurface::Callback), + account_id: ironclaw_auth::CredentialAccountId::new(), + refresh_secret: SecretHandle::new("google-refresh-secret").expect("valid handle"), + scopes: provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE]), + }) + .await + .expect_err("system-scoped refresh is rejected"); + + assert_eq!(error, AuthProductError::CrossScopeDenied); + assert!(egress.requests().is_empty()); + assert!(policy_authorizer.authorizations().is_empty()); + assert!(sink.access_tokens().is_empty()); + assert!(sink.deleted_handles().is_empty()); + } + + #[tokio::test] + async fn google_provider_refresh_maps_http_5xx_to_backend_unavailable() { + let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 503, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: br#"{"error":"unavailable"}"#.to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + })); + let client = google_client(egress, Arc::new(RecordingPolicyAuthorizer::default())); + + let error = client + .refresh_token(refresh_request(google_provider())) + .await + .expect_err("5xx response is backend unavailable"); + assert_eq!(error, AuthProductError::BackendUnavailable); + } + + #[tokio::test] + async fn product_auth_refresh_uses_concrete_google_provider_and_updates_account() { + let owner = scope("google-product-refresh"); + let auth = Arc::new(InMemoryAuthProductServices::new()); + let old_access = SecretHandle::new("google-product-old-access").expect("valid handle"); + let old_refresh = SecretHandle::new("google-product-old-refresh").expect("valid handle"); + let account = CredentialAccountService::create_account( + auth.as_ref(), + NewCredentialAccount { + scope: owner.clone(), + provider: google_provider(), + label: label("work gmail"), + status: CredentialAccountStatus::Expired, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(old_access.clone()), + refresh_secret: Some(old_refresh.clone()), + scopes: provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE]), + }, + ) + .await + .expect("account"); + let secret_store: Arc = Arc::new(InMemorySecretStore::new()); + secret_store + .put( + owner.resource.clone(), + old_refresh.clone(), + SecretString::from("stored-refresh-token"), + ) + .await + .expect("store refresh secret"); let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { status: 200, + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: br#"{ + "access_token":"provider-product-refreshed-access-token", + "scope":"https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send", + "expires_in":3600, + "token_type":"Bearer" + }"# + .to_vec(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: true, + })); + let google_client: Arc = Arc::new( + GoogleProviderClient::new( + egress.clone(), + Arc::new(SecretStoreGoogleTokenSink { + store: secret_store, + }), + Arc::new(RecordingPolicyAuthorizer::default()), + OAuthClientId::new("google-client-123").expect("client id"), + OAuthRedirectUri::new("https://app.example/oauth/callback").expect("redirect uri"), + ) + .expect("client"), + ); + let services = RebornProductAuthServices::from_shared( + auth.clone(), + Arc::new(NoopContinuationDispatcher), + ) + .with_provider_client(google_client); + + let report = services + .refresh_credential_account(CredentialRefreshRequest::new( + owner.clone(), + google_provider(), + account.id, + )) + .await + .expect("product auth refresh"); + + assert!(report.refreshed); + assert_eq!(report.account.status, CredentialAccountStatus::Configured); + let stored = CredentialAccountService::get_account( + auth.as_ref(), + CredentialAccountLookupRequest::new(owner.clone(), account.id), + ) + .await + .expect("stored account") + .expect("account exists"); + assert_eq!(stored.status, CredentialAccountStatus::Configured); + assert_ne!(stored.access_secret, Some(old_access)); + assert_eq!(stored.refresh_secret, Some(old_refresh)); + assert_eq!( + stored.scopes, + provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE]) + ); + let requests = egress.requests(); + assert_eq!(requests.len(), 1); + let form = token_request_form(&requests[0].body); + assert_eq!( + form.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + form.get("refresh_token").map(String::as_str), + Some("stored-refresh-token") + ); + assert_eq!( + form.get("client_id").map(String::as_str), + Some("google-client-123") + ); + } + + #[tokio::test] + async fn google_provider_refresh_sanitizes_provider_and_egress_failures() { + let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 400, headers: Vec::new(), - body: Vec::new(), + body: br#"{"error":"invalid_grant","error_description":"raw refresh body"}"#.to_vec(), saved_body: None, request_bytes: 0, response_bytes: 0, @@ -738,17 +1060,118 @@ mod tests { let google_error = client .refresh_token(refresh_request(google_provider())) .await - .expect_err("google refresh is unsupported"); + .expect_err("google refresh failure is sanitized"); assert_eq!(google_error, AuthProductError::RefreshFailed); + assert!(!google_error.to_string().contains("raw refresh body")); + let egress_error = Arc::new(RecordingEgress::err(RuntimeHttpEgressError::Network { + reason: "raw refresh network secret".to_string(), + request_bytes: 0, + response_bytes: 0, + })); + let client = google_client(egress_error, Arc::new(RecordingPolicyAuthorizer::default())); + let error = client + .refresh_token(refresh_request(google_provider())) + .await + .expect_err("egress failure is sanitized"); + assert_eq!(error, AuthProductError::BackendUnavailable); + assert!(!error.to_string().contains("raw refresh network secret")); + } + + #[tokio::test] + async fn google_provider_refresh_rejects_non_google_provider_before_egress() { + let egress = Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 200, + headers: Vec::new(), + body: Vec::new(), + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + })); + let client = google_client( + Arc::clone(&egress), + Arc::new(RecordingPolicyAuthorizer::default()), + ); let non_google_error = client .refresh_token(refresh_request(provider())) .await - .expect_err("non-google refresh is unsupported"); + .expect_err("non-google refresh is rejected"); assert_eq!(non_google_error, AuthProductError::RefreshFailed); assert!(egress.requests().is_empty()); } + #[tokio::test] + async fn google_provider_cleanup_exchange_deletes_google_handles_and_skips_non_google() { + let access_handle = + SecretHandle::new("google-cleanup-access-secret").expect("valid handle"); + let refresh_handle = + SecretHandle::new("google-cleanup-refresh-secret").expect("valid handle"); + let sink = Arc::new(RecordingTokenSink::new( + access_handle.clone(), + Some(refresh_handle.clone()), + )); + let client = GoogleProviderClient::new( + Arc::new(RecordingEgress::ok(RuntimeHttpEgressResponse { + status: 200, + headers: vec![], + body: Vec::new(), + request_bytes: 0, + response_bytes: 0, + saved_body: None, + redaction_applied: false, + })), + sink.clone(), + Arc::new(RecordingPolicyAuthorizer::default()), + OAuthClientId::new("google-client-123").expect("client id"), + OAuthRedirectUri::new("https://app.example/oauth/callback").expect("redirect uri"), + ) + .expect("client"); + + let google_exchange = OAuthProviderExchange { + provider: google_provider(), + account_label: label("work gmail"), + authorization_code_hash: code_hash("cleanup-code-hash"), + pkce_verifier_hash: pkce_hash("cleanup-pkce-hash"), + access_secret: access_handle.clone(), + refresh_secret: Some(refresh_handle.clone()), + scopes: provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE]), + account_id: None, + }; + client + .cleanup_exchange( + exchange_context(scope("google-cleanup"), AuthFlowId::new()), + &google_exchange, + ) + .await + .expect("google cleanup"); + assert_eq!( + sink.deleted_handles(), + vec![vec![access_handle.clone(), refresh_handle.clone()]] + ); + + sink.clear_deleted_handles(); + + let non_google_exchange = OAuthProviderExchange { + provider: provider(), + account_label: label("work github"), + authorization_code_hash: code_hash("cleanup-non-google-code-hash"), + pkce_verifier_hash: pkce_hash("cleanup-non-google-pkce-hash"), + access_secret: access_handle, + refresh_secret: Some(refresh_handle), + scopes: provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE]), + account_id: None, + }; + client + .cleanup_exchange( + exchange_context(scope("google-cleanup-non-google"), AuthFlowId::new()), + &non_google_exchange, + ) + .await + .expect("non-google cleanup"); + assert!(sink.deleted_handles().is_empty()); + } + #[tokio::test] async fn google_egress_authorizer_stages_policy_as_system_auth_capability() { let handler = Arc::new(RecordingObligationHandler::default()); @@ -881,8 +1304,10 @@ mod tests { } fn refresh_request(provider: AuthProviderId) -> OAuthProviderRefreshRequest { + let scope = scope("google-refresh"); OAuthProviderRefreshRequest { provider, + scope, account_id: ironclaw_auth::CredentialAccountId::new(), refresh_secret: SecretHandle::new("google-refresh-secret").expect("valid handle"), scopes: provider_scopes(&[GOOGLE_GMAIL_READONLY_SCOPE, GOOGLE_GMAIL_SEND_SCOPE]), @@ -1034,6 +1459,7 @@ mod tests { flow_ids: Mutex>, access_tokens: Mutex>, refresh_tokens: Mutex>, + deleted_handles: Mutex>>, access_handle: SecretHandle, refresh_handle: Option, } @@ -1045,6 +1471,7 @@ mod tests { flow_ids: Mutex::new(Vec::new()), access_tokens: Mutex::new(Vec::new()), refresh_tokens: Mutex::new(Vec::new()), + deleted_handles: Mutex::new(Vec::new()), access_handle, refresh_handle, } @@ -1065,6 +1492,20 @@ mod tests { fn refresh_tokens(&self) -> Vec { self.refresh_tokens.lock().expect("refresh tokens").clone() } + + fn deleted_handles(&self) -> Vec> { + self.deleted_handles + .lock() + .expect("deleted handles") + .clone() + } + + fn clear_deleted_handles(&self) { + self.deleted_handles + .lock() + .expect("deleted handles") + .clear(); + } } #[async_trait] @@ -1097,6 +1538,51 @@ mod tests { refresh_secret: self.refresh_handle.clone(), }) } + + async fn store_refreshed_tokens( + &self, + request: GoogleProviderRefreshTokenStorageRequest, + ) -> Result { + self.scopes + .lock() + .expect("scopes") + .push(request.scope.clone()); + let tokens = request.tokens; + self.access_tokens + .lock() + .expect("access tokens") + .push(tokens.access_token.expose_secret().to_string()); + if let Some(refresh_token) = tokens.refresh_token { + self.refresh_tokens + .lock() + .expect("refresh tokens") + .push(refresh_token.expose_secret().to_string()); + } + Ok(GoogleProviderStoredTokens { + access_secret: self.access_handle.clone(), + refresh_secret: self.refresh_handle.clone(), + }) + } + + async fn load_refresh_token( + &self, + _scope: &ResourceScope, + _handle: &SecretHandle, + ) -> Result { + Ok(secret("stored-refresh-token")) + } + + async fn delete_tokens( + &self, + _scope: &ResourceScope, + handles: &[SecretHandle], + ) -> Result<(), AuthProductError> { + self.deleted_handles + .lock() + .expect("deleted handles") + .push(handles.to_vec()); + Ok(()) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1151,6 +1637,20 @@ mod tests { } } + struct NoopPolicyAuthorizer; + + #[async_trait] + impl GoogleProviderEgressPolicyAuthorizer for NoopPolicyAuthorizer { + async fn authorize_google_token_exchange( + &self, + _scope: &ResourceScope, + _capability_id: &CapabilityId, + _policy: &NetworkPolicy, + ) -> Result<(), AuthProductError> { + Ok(()) + } + } + struct FailingTokenSink { error: AuthProductError, } @@ -1163,6 +1663,29 @@ mod tests { ) -> Result { Err(self.error.clone()) } + + async fn store_refreshed_tokens( + &self, + _request: GoogleProviderRefreshTokenStorageRequest, + ) -> Result { + Err(self.error.clone()) + } + + async fn load_refresh_token( + &self, + _scope: &ResourceScope, + _handle: &SecretHandle, + ) -> Result { + Ok(secret("stored-refresh-token")) + } + + async fn delete_tokens( + &self, + _scope: &ResourceScope, + _handles: &[SecretHandle], + ) -> Result<(), AuthProductError> { + Ok(()) + } } #[derive(Debug)] @@ -1232,6 +1755,14 @@ mod tests { Ok(None) } + async fn delete( + &self, + _scope: &ResourceScope, + _handle: &SecretHandle, + ) -> Result { + Ok(true) + } + async fn lease_once( &self, _scope: &ResourceScope, diff --git a/crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs b/crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs index b7ebe8e8506..0791e365d05 100644 --- a/crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs +++ b/crates/ironclaw_reborn_composition/src/google_oauth/secret_sink.rs @@ -1,7 +1,7 @@ use std::{fmt, sync::Arc}; use async_trait::async_trait; -use ironclaw_auth::{AuthFlowId, AuthProductError}; +use ironclaw_auth::{AuthFlowId, AuthProductError, CredentialAccountId}; use ironclaw_host_api::{ResourceScope, SecretHandle}; use ironclaw_secrets::SecretStore; use secrecy::SecretString; @@ -13,6 +13,23 @@ pub(super) trait GoogleProviderTokenSink: Send + Sync { &self, request: GoogleProviderTokenStorageRequest, ) -> Result; + + async fn store_refreshed_tokens( + &self, + request: GoogleProviderRefreshTokenStorageRequest, + ) -> Result; + + async fn load_refresh_token( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result; + + async fn delete_tokens( + &self, + scope: &ResourceScope, + handles: &[SecretHandle], + ) -> Result<(), AuthProductError>; } /// Raw Google token material passed exactly once to the injected storage @@ -54,6 +71,25 @@ impl fmt::Debug for GoogleProviderTokenStorageRequest { } } +/// Scoped token-storage request for account refresh. Raw token material is +/// passed exactly once to storage, and returned values are handles only. +pub(super) struct GoogleProviderRefreshTokenStorageRequest { + pub(super) scope: ResourceScope, + pub(super) account_id: CredentialAccountId, + pub(super) tokens: GoogleProviderTokenSet, +} + +impl fmt::Debug for GoogleProviderRefreshTokenStorageRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GoogleProviderRefreshTokenStorageRequest") + .field("scope", &self.scope) + .field("account_id", &self.account_id) + .field("tokens", &self.tokens) + .finish() + } +} + /// Durable secret handles produced after Google OAuth token material is stored. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct GoogleProviderStoredTokens { @@ -95,10 +131,65 @@ impl GoogleProviderTokenSink for SecretStoreGoogleTokenSink { let refresh_secret = match refresh_token { Some(refresh_token) => { let handle = refresh_handle.ok_or(AuthProductError::BackendUnavailable)?; - self.store + if let Err(error) = self + .store + .put(scope.clone(), handle.clone(), refresh_token) + .await + { + if let Err(delete_error) = self.store.delete(&scope, &access_secret).await { + tracing::debug!( + secret_store_reason = delete_error.stable_reason(), + "google oauth callback cleanup failed after refresh token write failure" + ); + } + return Err(map_secret_store_error(error)); + } + Some(handle) + } + None => None, + }; + + Ok(GoogleProviderStoredTokens { + access_secret, + refresh_secret, + }) + } + + async fn store_refreshed_tokens( + &self, + request: GoogleProviderRefreshTokenStorageRequest, + ) -> Result { + let access_secret = google_refresh_token_handle(&request, "access")?; + let refresh_handle = request + .tokens + .refresh_token + .as_ref() + .map(|_| google_refresh_token_handle(&request, "refresh")) + .transpose()?; + let GoogleProviderRefreshTokenStorageRequest { + scope, + tokens, + account_id: _, + } = request; + let GoogleProviderTokenSet { + access_token, + refresh_token, + } = tokens; + self.store + .put(scope.clone(), access_secret.clone(), access_token) + .await + .map_err(map_secret_store_error)?; + + let refresh_secret = match refresh_token { + Some(refresh_token) => { + let handle = refresh_handle.ok_or(AuthProductError::BackendUnavailable)?; + if let Err(error) = self + .store .put(scope.clone(), handle.clone(), refresh_token) .await - .map_err(|_| AuthProductError::BackendUnavailable)?; + { + return Err(map_secret_store_error(error)); + } Some(handle) } None => None, @@ -109,6 +200,41 @@ impl GoogleProviderTokenSink for SecretStoreGoogleTokenSink { refresh_secret, }) } + + async fn load_refresh_token( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + let lease = self + .store + .lease_once(scope, handle) + .await + .map_err(map_refresh_secret_error)?; + self.store + .consume(scope, lease.id) + .await + .map_err(map_refresh_secret_error) + } + + async fn delete_tokens( + &self, + scope: &ResourceScope, + handles: &[SecretHandle], + ) -> Result<(), AuthProductError> { + let mut first_error = None; + for handle in handles { + if let Err(error) = self.store.delete(scope, handle).await + && first_error.is_none() + { + first_error = Some(map_secret_store_error(error)); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } } fn google_token_handle( @@ -121,3 +247,252 @@ fn google_token_handle( )) .map_err(|_| AuthProductError::BackendUnavailable) } + +fn google_refresh_token_handle( + request: &GoogleProviderRefreshTokenStorageRequest, + token_kind: &'static str, +) -> Result { + SecretHandle::new(format!( + "google-oauth-refresh-{token_kind}-{}", + request.account_id + )) + .map_err(|_| AuthProductError::BackendUnavailable) +} + +fn map_secret_store_error(error: ironclaw_secrets::SecretStoreError) -> AuthProductError { + tracing::debug!( + secret_store_reason = error.stable_reason(), + "google oauth secret store operation failed" + ); + AuthProductError::BackendUnavailable +} + +fn map_refresh_secret_error(error: ironclaw_secrets::SecretStoreError) -> AuthProductError { + if error.is_unknown_secret() + || error.is_unknown_lease() + || error.is_consumed() + || error.is_revoked() + || error.is_expired() + { + AuthProductError::RefreshFailed + } else { + AuthProductError::BackendUnavailable + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use ironclaw_host_api::{InvocationId, ResourceScope, SecretHandle, TenantId, UserId}; + use ironclaw_secrets::{ + SecretLease, SecretLeaseId, SecretMaterial, SecretMetadata, SecretStore, + }; + use secrecy::SecretString; + + use super::{ + GoogleProviderRefreshTokenStorageRequest, GoogleProviderTokenSet, GoogleProviderTokenSink, + SecretStoreGoogleTokenSink, google_refresh_token_handle, + }; + + fn sample_scope(invocation_id: InvocationId) -> ResourceScope { + ResourceScope { + tenant_id: TenantId::new("tenant-a").unwrap(), + user_id: UserId::new("user-a").unwrap(), + agent_id: None, + project_id: None, + mission_id: None, + thread_id: None, + invocation_id, + } + } + + fn sample_request( + invocation_id: InvocationId, + account_id: ironclaw_auth::CredentialAccountId, + ) -> GoogleProviderRefreshTokenStorageRequest { + GoogleProviderRefreshTokenStorageRequest { + scope: sample_scope(invocation_id), + account_id, + tokens: GoogleProviderTokenSet { + access_token: SecretString::new("access".into()), + refresh_token: Some(SecretString::new("refresh".into())), + }, + } + } + + struct RecordingSecretStore { + puts: Mutex>, + deleted: Mutex>, + failing_handles: HashSet, + } + + impl RecordingSecretStore { + fn new(failing_handles: impl IntoIterator>) -> Self { + Self { + puts: Mutex::new(Vec::new()), + deleted: Mutex::new(Vec::new()), + failing_handles: failing_handles.into_iter().map(Into::into).collect(), + } + } + + fn put_handles(&self) -> Vec { + self.puts.lock().unwrap().clone() + } + + fn deleted_handles(&self) -> Vec { + self.deleted.lock().unwrap().clone() + } + } + + #[async_trait] + impl SecretStore for RecordingSecretStore { + async fn put( + &self, + scope: ResourceScope, + handle: SecretHandle, + _material: SecretMaterial, + ) -> Result { + self.puts.lock().unwrap().push(handle.as_str().to_string()); + if self.failing_handles.contains(handle.as_str()) { + Err(ironclaw_secrets::SecretStoreError::BackendMisconfigured { + reason: format!("failed to write {}", handle.as_str()), + }) + } else { + Ok(SecretMetadata { scope, handle }) + } + } + + async fn metadata( + &self, + _scope: &ResourceScope, + _handle: &SecretHandle, + ) -> Result, ironclaw_secrets::SecretStoreError> { + unreachable!("not used in tests") + } + + async fn delete( + &self, + _scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + self.deleted + .lock() + .unwrap() + .push(handle.as_str().to_string()); + if self.failing_handles.contains(handle.as_str()) { + Err(ironclaw_secrets::SecretStoreError::BackendMisconfigured { + reason: format!("failed to delete {}", handle.as_str()), + }) + } else { + Ok(true) + } + } + + async fn lease_once( + &self, + _scope: &ResourceScope, + _handle: &SecretHandle, + ) -> Result { + unreachable!("not used in tests") + } + + async fn consume( + &self, + _scope: &ResourceScope, + _lease_id: SecretLeaseId, + ) -> Result { + unreachable!("not used in tests") + } + + async fn revoke( + &self, + _scope: &ResourceScope, + _lease_id: SecretLeaseId, + ) -> Result { + unreachable!("not used in tests") + } + + async fn leases_for_scope( + &self, + _scope: &ResourceScope, + ) -> Result, ironclaw_secrets::SecretStoreError> { + unreachable!("not used in tests") + } + } + + #[tokio::test] + async fn google_refresh_token_handles_ignore_invocation_id() { + let account_id = ironclaw_auth::CredentialAccountId::new(); + let request_a = sample_request(InvocationId::new(), account_id); + let request_b = sample_request(InvocationId::new(), account_id); + + let access_a = google_refresh_token_handle(&request_a, "access").unwrap(); + let access_b = google_refresh_token_handle(&request_b, "access").unwrap(); + let refresh_a = google_refresh_token_handle(&request_a, "refresh").unwrap(); + let refresh_b = google_refresh_token_handle(&request_b, "refresh").unwrap(); + + assert_eq!(access_a, access_b); + assert_eq!(refresh_a, refresh_b); + } + + #[tokio::test] + async fn delete_tokens_attempts_every_handle_before_returning_first_error() { + let store = Arc::new(RecordingSecretStore::new(["second"])); + let sink = SecretStoreGoogleTokenSink { + store: store.clone(), + }; + let scope = sample_scope(InvocationId::new()); + let handles = vec![ + SecretHandle::new("first").unwrap(), + SecretHandle::new("second").unwrap(), + SecretHandle::new("third").unwrap(), + ]; + + let error = sink.delete_tokens(&scope, &handles).await.unwrap_err(); + + assert_eq!(error, ironclaw_auth::AuthProductError::BackendUnavailable); + assert_eq!( + store.deleted_handles(), + vec![ + "first".to_string(), + "second".to_string(), + "third".to_string() + ] + ); + } + + #[tokio::test] + async fn store_refreshed_tokens_keeps_access_secret_when_refresh_write_fails() { + let account_id = ironclaw_auth::CredentialAccountId::new(); + let scope = sample_scope(InvocationId::new()); + let request = GoogleProviderRefreshTokenStorageRequest { + scope: scope.clone(), + account_id, + tokens: GoogleProviderTokenSet { + access_token: SecretString::new("access".into()), + refresh_token: Some(SecretString::new("refresh".into())), + }, + }; + let access_handle = google_refresh_token_handle(&request, "access").unwrap(); + let refresh_handle = google_refresh_token_handle(&request, "refresh").unwrap(); + let store = Arc::new(RecordingSecretStore::new([refresh_handle.as_str()])); + let sink = SecretStoreGoogleTokenSink { + store: store.clone(), + }; + + let error = sink.store_refreshed_tokens(request).await.unwrap_err(); + + assert_eq!(error, ironclaw_auth::AuthProductError::BackendUnavailable); + assert_eq!( + store.put_handles(), + vec![ + access_handle.as_str().to_string(), + refresh_handle.as_str().to_string() + ] + ); + assert!(store.deleted_handles().is_empty()); + } +} diff --git a/crates/ironclaw_reborn_composition/src/google_oauth/token_request.rs b/crates/ironclaw_reborn_composition/src/google_oauth/token_request.rs index 67fe3a6f4b3..d7d623f53db 100644 --- a/crates/ironclaw_reborn_composition/src/google_oauth/token_request.rs +++ b/crates/ironclaw_reborn_composition/src/google_oauth/token_request.rs @@ -1,7 +1,7 @@ use secrecy::{ExposeSecret, SecretString}; use url::form_urlencoded::Serializer; -pub(super) fn serialize_token_request( +pub(super) fn serialize_authorization_code_token_request( client_id: &str, redirect_uri: &str, client_secret: Option<&SecretString>, @@ -20,3 +20,19 @@ pub(super) fn serialize_token_request( } serializer.finish().into_bytes() } + +pub(super) fn serialize_refresh_token_request( + client_id: &str, + client_secret: Option<&SecretString>, + refresh_token: &str, +) -> Vec { + let mut serializer = Serializer::new(String::new()); + serializer + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token) + .append_pair("client_id", client_id); + if let Some(client_secret) = client_secret { + serializer.append_pair("client_secret", client_secret.expose_secret()); + } + serializer.finish().into_bytes() +} diff --git a/crates/ironclaw_reborn_composition/src/google_oauth/token_response.rs b/crates/ironclaw_reborn_composition/src/google_oauth/token_response.rs index 506db38e98f..c2877987b34 100644 --- a/crates/ironclaw_reborn_composition/src/google_oauth/token_response.rs +++ b/crates/ironclaw_reborn_composition/src/google_oauth/token_response.rs @@ -56,3 +56,14 @@ pub(super) fn scopes_for_exchange( Err(AuthProductError::TokenExchangeFailed) } } + +pub(super) fn scopes_for_refresh( + token_response: &ParsedGoogleTokenResponse, + existing_scopes: &[ProviderScope], +) -> Vec { + if token_response.scope_was_present { + token_response.response.scopes.clone() + } else { + existing_scopes.to_vec() + } +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 6f60da7ffb5..a699d61e0cc 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -695,6 +695,14 @@ impl SecretStore for SharedSecretStore { self.inner.metadata(scope, handle).await } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + self.inner.delete(scope, handle).await + } + async fn lease_once( &self, scope: &ResourceScope, diff --git a/crates/ironclaw_reborn_composition/tests/auth_callbacks.rs b/crates/ironclaw_reborn_composition/tests/auth_callbacks.rs index 92df2ef2d28..f47f0d48804 100644 --- a/crates/ironclaw_reborn_composition/tests/auth_callbacks.rs +++ b/crates/ironclaw_reborn_composition/tests/auth_callbacks.rs @@ -8,7 +8,7 @@ use chrono::{Duration, Utc}; use ironclaw_auth::{ AuthChallenge, AuthContinuationEvent, AuthContinuationRef, AuthErrorCode, AuthFlowId, AuthFlowKind, AuthProductError, AuthProductScope, AuthProviderClient, AuthProviderId, - AuthSessionId, AuthSurface, AuthorizationCodeHash, CredentialAccountLabel, + AuthSessionId, AuthSurface, AuthorizationCodeHash, CredentialAccountId, CredentialAccountLabel, InMemoryAuthProductServices, LifecyclePackageRef, NewAuthFlow, OAuthAuthorizationCode, OAuthAuthorizationUrl, OAuthProviderCallbackRequest, OAuthProviderExchange, OAuthProviderExchangeContext, OAuthProviderRefresh, OAuthProviderRefreshRequest, @@ -163,6 +163,58 @@ struct FailingContinuationDispatcher { error: AuthProductError, } +#[derive(Default)] +struct CleanupRecordingProviderClient { + cleaned: Mutex>, +} + +impl CleanupRecordingProviderClient { + fn cleaned(&self) -> Vec { + self.cleaned + .lock() + .expect("cleaned handles lock poisoned") + .clone() + } +} + +#[async_trait] +impl AuthProviderClient for CleanupRecordingProviderClient { + async fn exchange_callback( + &self, + _context: OAuthProviderExchangeContext, + request: OAuthProviderCallbackRequest, + ) -> Result { + Ok(OAuthProviderExchange { + provider: request.provider, + account_label: request.account_label, + authorization_code_hash: request.authorization_code_hash, + pkce_verifier_hash: request.pkce_verifier_hash, + access_secret: SecretHandle::new("orphan-access").unwrap(), + refresh_secret: Some(SecretHandle::new("orphan-refresh").unwrap()), + scopes: request.scopes, + account_id: Some(CredentialAccountId::new()), + }) + } + + async fn refresh_token( + &self, + _request: OAuthProviderRefreshRequest, + ) -> Result { + Err(AuthProductError::RefreshFailed) + } + + async fn cleanup_exchange( + &self, + _context: OAuthProviderExchangeContext, + exchange: &OAuthProviderExchange, + ) -> Result<(), AuthProductError> { + let mut cleaned = self.cleaned.lock().expect("cleaned handles lock poisoned"); + cleaned.push(exchange.access_secret.clone()); + cleaned.extend(exchange.refresh_secret.clone()); + Ok(()) + } +} + #[async_trait] impl RebornAuthContinuationDispatcher for FailingContinuationDispatcher { async fn dispatch_auth_continuation( @@ -363,6 +415,29 @@ async fn oauth_callback_handler_reports_completed_continuation_dispatch_failure_ assert_eq!(dispatcher.events().len(), 1); } +#[tokio::test] +async fn oauth_callback_handler_cleans_provider_tokens_when_completion_rejects_exchange() { + let dispatcher = Arc::new(RecordingContinuationDispatcher::default()); + let provider_client = Arc::new(CleanupRecordingProviderClient::default()); + let services = auth_services(dispatcher).with_provider_client(provider_client.clone()); + let owner = scope("alice"); + let flow_id = create_flow(&services, owner.clone()).await; + + let error = services + .handle_oauth_callback(authorized_request(owner, flow_id)) + .await + .expect_err("invalid exchange account id is rejected"); + + assert_eq!(error.code, AuthErrorCode::CrossScopeDenied); + assert_eq!( + provider_client.cleaned(), + vec![ + SecretHandle::new("orphan-access").unwrap(), + SecretHandle::new("orphan-refresh").unwrap(), + ] + ); +} + #[tokio::test] async fn oauth_callback_handler_rejects_wrong_state_without_provider_exchange_or_dispatch() { let dispatcher = Arc::new(RecordingContinuationDispatcher::default()); diff --git a/crates/ironclaw_reborn_composition/tests/auth_lifecycle.rs b/crates/ironclaw_reborn_composition/tests/auth_lifecycle.rs index dbc76caef41..4e0e841131c 100644 --- a/crates/ironclaw_reborn_composition/tests/auth_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/tests/auth_lifecycle.rs @@ -1,12 +1,14 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use ironclaw_auth::{ AuthContinuationEvent, AuthProductError, AuthProductScope, AuthProviderId, AuthSessionId, AuthSurface, CredentialAccountLookupRequest, CredentialAccountService, CredentialAccountStatus, CredentialOwnership, CredentialRefreshRequest, InMemoryAuthProductServices, - NewCredentialAccount, ProviderScope, SecretCleanupAction, SecretCleanupQuarantineReason, - SecretCleanupRequest, + NewCredentialAccount, OAuthProviderCallbackRequest, OAuthProviderExchange, + OAuthProviderExchangeContext, OAuthProviderRefresh, OAuthProviderRefreshRequest, ProviderScope, + SecretCleanupAction, SecretCleanupQuarantineReason, SecretCleanupRequest, }; use ironclaw_host_api::{ExtensionId, InvocationId, ResourceScope, SecretHandle, UserId}; use ironclaw_reborn_composition::{RebornAuthContinuationDispatcher, RebornProductAuthServices}; @@ -24,6 +26,59 @@ impl RebornAuthContinuationDispatcher for NoopContinuationDispatcher { } } +struct AccessOnlyRefreshProvider; + +#[async_trait] +impl ironclaw_auth::AuthProviderClient for AccessOnlyRefreshProvider { + async fn exchange_callback( + &self, + _context: OAuthProviderExchangeContext, + _request: OAuthProviderCallbackRequest, + ) -> Result { + Err(AuthProductError::TokenExchangeFailed) + } + + async fn refresh_token( + &self, + request: OAuthProviderRefreshRequest, + ) -> Result { + Ok(OAuthProviderRefresh { + provider: request.provider, + access_secret: SecretHandle::new("google-new-access").unwrap(), + refresh_secret: None, + scopes: request.scopes, + }) + } +} + +struct CountingRefreshProvider { + calls: AtomicUsize, +} + +#[async_trait] +impl ironclaw_auth::AuthProviderClient for CountingRefreshProvider { + async fn exchange_callback( + &self, + _context: OAuthProviderExchangeContext, + _request: OAuthProviderCallbackRequest, + ) -> Result { + Err(AuthProductError::TokenExchangeFailed) + } + + async fn refresh_token( + &self, + request: OAuthProviderRefreshRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(OAuthProviderRefresh { + provider: request.provider, + access_secret: SecretHandle::new("google-counted-access").unwrap(), + refresh_secret: Some(SecretHandle::new("google-counted-refresh").unwrap()), + scopes: request.scopes, + }) + } +} + fn scope(user: &str) -> AuthProductScope { AuthProductScope::new( ResourceScope::local_default(UserId::new(user).unwrap(), InvocationId::new()).unwrap(), @@ -130,6 +185,89 @@ async fn refresh_credential_account_maps_facade_errors_to_stable_codes() { assert!(!serialized.contains("github-shared-refresh")); } +#[tokio::test] +async fn refresh_credential_account_rejects_system_owned_accounts_before_provider_call() { + let auth = Arc::new(InMemoryAuthProductServices::new()); + let owner = scope("alice"); + let account = auth + .create_account(NewCredentialAccount { + scope: owner.clone(), + provider: provider(), + label: ironclaw_auth::CredentialAccountLabel::new("system").unwrap(), + status: CredentialAccountStatus::Configured, + ownership: CredentialOwnership::System, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("github-system-access").unwrap()), + refresh_secret: Some(SecretHandle::new("github-system-refresh").unwrap()), + scopes: vec![provider_scope("repo")], + }) + .await + .unwrap(); + let refresh_provider = Arc::new(CountingRefreshProvider { + calls: AtomicUsize::new(0), + }); + let services = auth_services(Arc::clone(&auth)).with_provider_client(refresh_provider.clone()); + + let error = services + .refresh_credential_account(CredentialRefreshRequest::new( + owner.clone(), + provider(), + account.id, + )) + .await + .expect_err("system-owned accounts cannot refresh"); + + assert_eq!(error.code, ironclaw_auth::AuthErrorCode::CrossScopeDenied); + assert_eq!(refresh_provider.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn refresh_credential_account_with_provider_keeps_existing_refresh_handle_when_omitted() { + let auth = Arc::new(InMemoryAuthProductServices::new()); + let owner = scope("alice"); + let old_refresh = SecretHandle::new("google-existing-refresh").unwrap(); + let account = auth + .create_account(NewCredentialAccount { + scope: owner.clone(), + provider: provider(), + label: ironclaw_auth::CredentialAccountLabel::new("work").unwrap(), + status: CredentialAccountStatus::Expired, + ownership: CredentialOwnership::UserReusable, + owner_extension: None, + granted_extensions: Vec::new(), + access_secret: Some(SecretHandle::new("google-old-access").unwrap()), + refresh_secret: Some(old_refresh.clone()), + scopes: vec![provider_scope("repo")], + }) + .await + .unwrap(); + let services = + auth_services(Arc::clone(&auth)).with_provider_client(Arc::new(AccessOnlyRefreshProvider)); + + let report = services + .refresh_credential_account(CredentialRefreshRequest::new( + owner.clone(), + provider(), + account.id, + )) + .await + .unwrap(); + + assert!(report.refreshed); + assert_eq!(report.account.status, CredentialAccountStatus::Configured); + let stored = auth + .get_account(CredentialAccountLookupRequest::new(owner, account.id)) + .await + .unwrap() + .expect("refreshed account"); + assert_eq!( + stored.access_secret, + Some(SecretHandle::new("google-new-access").unwrap()) + ); + assert_eq!(stored.refresh_secret, Some(old_refresh)); +} + #[tokio::test] async fn cleanup_credentials_for_lifecycle_uses_facade_and_quarantine_report() { let auth = Arc::new(InMemoryAuthProductServices::new()); diff --git a/crates/ironclaw_secrets/src/filesystem_store.rs b/crates/ironclaw_secrets/src/filesystem_store.rs index e4c5bccb6db..5080f9dc4f5 100644 --- a/crates/ironclaw_secrets/src/filesystem_store.rs +++ b/crates/ironclaw_secrets/src/filesystem_store.rs @@ -366,6 +366,19 @@ where })) } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + let path = secret_path(scope, handle)?; + match self.filesystem.delete(scope, &path).await { + Ok(()) => Ok(true), + Err(error) if is_not_found(&error) => Ok(false), + Err(error) => Err(fs_to_secret_store_error(error)), + } + } + async fn lease_once( &self, scope: &ResourceScope, @@ -1517,6 +1530,7 @@ fn sanitize_error_kind(reason: String) -> String { #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use chrono::Utc; use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem}; @@ -1607,6 +1621,81 @@ mod tests { } } + struct DeleteCountingBackend { + inner: Arc, + get_calls: AtomicUsize, + delete_calls: AtomicUsize, + } + + impl DeleteCountingBackend { + fn new(inner: Arc) -> Self { + Self { + inner, + get_calls: AtomicUsize::new(0), + delete_calls: AtomicUsize::new(0), + } + } + + fn get_calls(&self) -> usize { + self.get_calls.load(Ordering::SeqCst) + } + + fn delete_calls(&self) -> usize { + self.delete_calls.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl RootFilesystem for DeleteCountingBackend { + fn capabilities(&self) -> BackendCapabilities { + self.inner.capabilities() + } + + async fn put( + &self, + path: &VirtualPath, + entry: Entry, + cas: CasExpectation, + ) -> Result { + self.inner.put(path, entry, cas).await + } + + async fn get(&self, path: &VirtualPath) -> Result, FilesystemError> { + self.get_calls.fetch_add(1, Ordering::SeqCst); + self.inner.get(path).await + } + + async fn list_dir(&self, path: &VirtualPath) -> Result, FilesystemError> { + self.inner.list_dir(path).await + } + + async fn stat(&self, path: &VirtualPath) -> Result { + self.inner.stat(path).await + } + + async fn delete(&self, path: &VirtualPath) -> Result<(), FilesystemError> { + self.delete_calls.fetch_add(1, Ordering::SeqCst); + self.inner.delete(path).await + } + + async fn query( + &self, + path: &VirtualPath, + filter: &Filter, + page: Page, + ) -> Result, FilesystemError> { + self.inner.query(path, filter, page).await + } + + async fn ensure_index( + &self, + path: &VirtualPath, + spec: &IndexSpec, + ) -> Result<(), FilesystemError> { + self.inner.ensure_index(path, spec).await + } + } + #[tokio::test] async fn filesystem_secret_store_round_trips_material() { let fs = Arc::new(InMemoryBackend::new()); @@ -1634,6 +1723,35 @@ mod tests { assert!(second.is_consumed()); } + #[tokio::test] + async fn filesystem_secret_store_delete_skips_pre_read() { + let backend = Arc::new(DeleteCountingBackend::new(Arc::new(InMemoryBackend::new()))); + let store = + FilesystemSecretStore::new(default_scoped_fs(Arc::clone(&backend)), test_crypto()); + let scope = sample_scope("tenant-a", "user-a"); + let handle = SecretHandle::new("api_key").unwrap(); + + store + .put( + scope.clone(), + handle.clone(), + SecretMaterial::from("super-secret"), + ) + .await + .unwrap(); + let get_calls_before_delete = backend.get_calls(); + + let removed = store.delete(&scope, &handle).await.unwrap(); + + assert!(removed); + assert_eq!( + backend.get_calls(), + get_calls_before_delete, + "delete should not add a pre-read before the backend delete path" + ); + assert_eq!(backend.delete_calls(), 1); + } + #[tokio::test] async fn filesystem_secret_store_encrypts_at_rest() { let fs = Arc::new(InMemoryBackend::new()); @@ -1906,7 +2024,7 @@ mod tests { // second attempt succeeds. use std::sync::Arc as StdArc; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::atomic::AtomicBool; use ironclaw_filesystem::{ BackendCapabilities, DirEntry, FileStat, Filter, IndexSpec, Page, RecordVersion, diff --git a/crates/ironclaw_secrets/src/lib.rs b/crates/ironclaw_secrets/src/lib.rs index 7e8bab7035f..5762e4d0013 100644 --- a/crates/ironclaw_secrets/src/lib.rs +++ b/crates/ironclaw_secrets/src/lib.rs @@ -1002,6 +1002,13 @@ pub trait SecretStore: Send + Sync { handle: &SecretHandle, ) -> Result, SecretStoreError>; + /// Deletes a scoped secret if it exists. Returns whether a stored secret was removed. + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result; + /// Creates a one-shot lease for later secret consumption. async fn lease_once( &self, @@ -1181,6 +1188,25 @@ where } } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + let legacy_user_id = scoped_legacy_user_id(scope); + let removed = self + .inner + .delete(&legacy_user_id, handle.as_str()) + .await + .map_err(map_legacy_secret_error)?; + if removed { + self.lock_leases()?.retain(|key, record| { + !(key.matches_scope(scope) && record.lease.handle == *handle) + }); + } + Ok(removed) + } + async fn lease_once( &self, scope: &ResourceScope, @@ -1388,6 +1414,14 @@ impl SecretStore for InMemorySecretStore { self.inner.metadata(scope, handle).await } + async fn delete( + &self, + scope: &ResourceScope, + handle: &SecretHandle, + ) -> Result { + self.inner.delete(scope, handle).await + } + async fn lease_once( &self, scope: &ResourceScope, @@ -1461,6 +1495,36 @@ mod tests { assert!(scoped_legacy_user_id(&none_agent).contains("\"agent_id\":null")); } + #[tokio::test] + async fn scoped_secret_delete_removes_only_matching_scope() { + let store = InMemorySecretStore::new(); + let owner = sample_scope("tenant-a", "user-a"); + let other = sample_scope("tenant-a", "user-b"); + let handle = SecretHandle::new("google-refresh").unwrap(); + + store + .put( + owner.clone(), + handle.clone(), + SecretMaterial::from("owner-secret"), + ) + .await + .unwrap(); + store + .put( + other.clone(), + handle.clone(), + SecretMaterial::from("other-secret"), + ) + .await + .unwrap(); + + assert!(store.delete(&owner, &handle).await.unwrap()); + assert!(!store.delete(&owner, &handle).await.unwrap()); + assert!(store.metadata(&owner, &handle).await.unwrap().is_none()); + assert!(store.metadata(&other, &handle).await.unwrap().is_some()); + } + #[test] fn credential_account_id_validates_and_round_trips() { let id = CredentialAccountId::new("openai_prod-1").unwrap(); diff --git a/crates/ironclaw_secrets/tests/secret_store_contract.rs b/crates/ironclaw_secrets/tests/secret_store_contract.rs index 4795a797910..773cf62f739 100644 --- a/crates/ironclaw_secrets/tests/secret_store_contract.rs +++ b/crates/ironclaw_secrets/tests/secret_store_contract.rs @@ -51,6 +51,48 @@ async fn secret_store_consumes_one_shot_secret_lease() { assert!(second.is_consumed()); } +#[tokio::test] +async fn consuming_one_lease_does_not_delete_underlying_secret() { + let store = InMemorySecretStore::new(); + let scope = sample_scope("tenant-a", "user-a"); + let handle = SecretHandle::new("refresh_token").unwrap(); + store + .put( + scope.clone(), + handle.clone(), + SecretMaterial::from("refresh-secret"), + ) + .await + .unwrap(); + + let first_lease = store.lease_once(&scope, &handle).await.unwrap(); + assert_eq!( + store + .consume(&scope, first_lease.id) + .await + .unwrap() + .expose_secret(), + "refresh-secret" + ); + assert!( + store + .consume(&scope, first_lease.id) + .await + .unwrap_err() + .is_consumed() + ); + + let second_lease = store.lease_once(&scope, &handle).await.unwrap(); + assert_eq!( + store + .consume(&scope, second_lease.id) + .await + .unwrap() + .expose_secret(), + "refresh-secret" + ); +} + #[tokio::test] async fn secret_store_isolates_same_handle_between_tenants() { let store = InMemorySecretStore::new(); @@ -101,6 +143,45 @@ async fn secret_store_isolates_same_handle_between_tenants() { assert!(cross_scope.is_unknown_lease()); } +#[tokio::test] +async fn secret_store_delete_is_idempotent_and_scope_isolated() { + let store = InMemorySecretStore::new(); + let owner = sample_scope("tenant-a", "user-a"); + let other = sample_scope("tenant-a", "user-b"); + let handle = SecretHandle::new("shared_name").unwrap(); + store + .put( + owner.clone(), + handle.clone(), + SecretMaterial::from("owner-secret"), + ) + .await + .unwrap(); + store + .put( + other.clone(), + handle.clone(), + SecretMaterial::from("other-secret"), + ) + .await + .unwrap(); + + assert!(store.delete(&owner, &handle).await.unwrap()); + assert!(!store.delete(&owner, &handle).await.unwrap()); + assert!(store.metadata(&owner, &handle).await.unwrap().is_none()); + assert!(store.metadata(&other, &handle).await.unwrap().is_some()); + + let other_lease = store.lease_once(&other, &handle).await.unwrap(); + assert_eq!( + store + .consume(&other, other_lease.id) + .await + .unwrap() + .expose_secret(), + "other-secret" + ); +} + #[tokio::test] async fn secret_store_isolates_same_handle_between_users_and_projects() { let store = InMemorySecretStore::new();