mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52551f0ef4 | ||
|
|
db6450c47a | ||
|
|
018416d3f3 | ||
|
|
5b95d22218 | ||
|
|
b92d333b0c | ||
|
|
00aba928e0 | ||
|
|
07c32b788c | ||
|
|
f40019aa4c |
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.23.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.22.0...ironclaw-v0.23.0) - 2026-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- complete multi-tenant isolation — phases 2–4 ([#1614](https://github.com/nearai/ironclaw/pull/1614))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- *(routines)* recover delete name after failed update fallback ([#1108](https://github.com/nearai/ironclaw/pull/1108))
|
||||||
|
- *(mcp)* handle 202 Accepted and wire session manager for Streamable HTTP ([#1437](https://github.com/nearai/ironclaw/pull/1437))
|
||||||
|
- *(extensions)* channel-relay auth dead-end, observability, and URL override ([#1681](https://github.com/nearai/ironclaw/pull/1681))
|
||||||
|
- *(agent)* discard truncated tool calls when finish_reason == Length ([#1631](https://github.com/nearai/ironclaw/pull/1631)) ([#1632](https://github.com/nearai/ironclaw/pull/1632))
|
||||||
|
- *(llm)* filter XML tool-call recovery by context ([#1641](https://github.com/nearai/ironclaw/pull/1641))
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Support direct hosted OAuth callbacks with proxy auth token ([#1684](https://github.com/nearai/ironclaw/pull/1684))
|
||||||
|
|
||||||
## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
|
## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -3390,7 +3390,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.22.0"
|
version = "0.23.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
|||||||
+434
-2
@@ -836,10 +836,10 @@ async fn oauth_callback_handler(
|
|||||||
|
|
||||||
let result: Result<(), String> = async {
|
let result: Result<(), String> = async {
|
||||||
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
|
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
|
||||||
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
let oauth_proxy_auth_token = flow.oauth_proxy_auth_token().unwrap_or_default();
|
||||||
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
|
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
|
||||||
proxy_url,
|
proxy_url,
|
||||||
gateway_token,
|
gateway_token: oauth_proxy_auth_token,
|
||||||
token_url: &flow.token_url,
|
token_url: &flow.token_url,
|
||||||
client_id: &flow.client_id,
|
client_id: &flow.client_id,
|
||||||
client_secret: flow.client_secret.as_deref(),
|
client_secret: flow.client_secret.as_deref(),
|
||||||
@@ -3057,6 +3057,160 @@ mod tests {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct RecordedOauthProxyRequest {
|
||||||
|
authorization: Option<String>,
|
||||||
|
form: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MockOauthProxyState {
|
||||||
|
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockOauthProxyServer {
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
requests: Arc<tokio::sync::Mutex<Vec<RecordedOauthProxyRequest>>>,
|
||||||
|
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||||
|
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockOauthProxyServer {
|
||||||
|
async fn start() -> Self {
|
||||||
|
async fn exchange_handler(
|
||||||
|
State(state): State<MockOauthProxyState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
axum::Form(form): axum::Form<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
state.requests.lock().await.push(RecordedOauthProxyRequest {
|
||||||
|
authorization: headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_string),
|
||||||
|
form,
|
||||||
|
});
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"access_token": "proxy-access-token",
|
||||||
|
"refresh_token": "proxy-refresh-token",
|
||||||
|
"expires_in": 7200
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind mock oauth proxy");
|
||||||
|
let addr = listener.local_addr().expect("mock oauth proxy addr");
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/oauth/exchange", post(exchange_handler))
|
||||||
|
.with_state(MockOauthProxyState {
|
||||||
|
requests: Arc::clone(&requests),
|
||||||
|
});
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
addr,
|
||||||
|
requests,
|
||||||
|
shutdown_tx: Some(shutdown_tx),
|
||||||
|
server_task: Some(server_task),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_url(&self) -> String {
|
||||||
|
format!("http://{}", self.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn requests(&self) -> Vec<RecordedOauthProxyRequest> {
|
||||||
|
self.requests.lock().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown(mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(task) = self.server_task.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MockOauthProxyServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(task) = self.server_task.take() {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EnvVarGuard {
|
||||||
|
key: &'static str,
|
||||||
|
original: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvVarGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: Tests use lock_env() to serialize environment access.
|
||||||
|
unsafe {
|
||||||
|
if let Some(ref value) = self.original {
|
||||||
|
std::env::set_var(self.key, value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var(self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
||||||
|
let original = std::env::var(key).ok();
|
||||||
|
// SAFETY: Tests use lock_env() to serialize environment access.
|
||||||
|
unsafe {
|
||||||
|
if let Some(value) = value {
|
||||||
|
std::env::set_var(key, value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnvVarGuard { key, original }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_pending_oauth_flow(
|
||||||
|
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||||
|
sse_manager: Option<Arc<SseManager>>,
|
||||||
|
oauth_proxy_auth_token: Option<String>,
|
||||||
|
) -> crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||||
|
crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||||
|
extension_name: "test_tool".to_string(),
|
||||||
|
display_name: "Test Tool".to_string(),
|
||||||
|
token_url: "https://example.com/token".to_string(),
|
||||||
|
client_id: "client123".to_string(),
|
||||||
|
client_secret: None,
|
||||||
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||||
|
code_verifier: Some("test-code-verifier".to_string()),
|
||||||
|
access_token_field: "access_token".to_string(),
|
||||||
|
secret_name: "test_token".to_string(),
|
||||||
|
provider: Some("google".to_string()),
|
||||||
|
validation_endpoint: None,
|
||||||
|
scopes: vec!["email".to_string()],
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
secrets,
|
||||||
|
sse_manager,
|
||||||
|
gateway_token: oauth_proxy_auth_token,
|
||||||
|
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||||
|
client_id_secret_name: None,
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
|
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
@@ -3714,6 +3868,284 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_accepts_versioned_hosted_state_without_instance_name() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||||
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||||
|
))
|
||||||
|
.expect("crypto"),
|
||||||
|
)));
|
||||||
|
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
||||||
|
|
||||||
|
let Some(created_at) = expired_flow_created_at() else {
|
||||||
|
eprintln!(
|
||||||
|
"Skipping versioned OAuth state without instance test: monotonic uptime below expiry window"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||||
|
extension_name: "test_tool".to_string(),
|
||||||
|
display_name: "Test Tool".to_string(),
|
||||||
|
token_url: "https://example.com/token".to_string(),
|
||||||
|
client_id: "client123".to_string(),
|
||||||
|
client_secret: None,
|
||||||
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||||
|
code_verifier: None,
|
||||||
|
access_token_field: "access_token".to_string(),
|
||||||
|
secret_name: "test_token".to_string(),
|
||||||
|
provider: None,
|
||||||
|
validation_endpoint: None,
|
||||||
|
scopes: vec![],
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
secrets,
|
||||||
|
sse_manager: None,
|
||||||
|
gateway_token: None,
|
||||||
|
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||||
|
client_id_secret_name: None,
|
||||||
|
created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("test_nonce".to_string(), flow);
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
let versioned_state =
|
||||||
|
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/oauth/callback?code=fake_code&state={}",
|
||||||
|
urlencoding::encode(&versioned_state)
|
||||||
|
))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
assert!(
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get("test_nonce")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_happy_path_with_gateway_token_fallback() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let proxy = MockOauthProxyServer::start().await;
|
||||||
|
// Keep the process-wide env locked for the full callback so the handler
|
||||||
|
// sees a stable proxy URL/token configuration throughout the test.
|
||||||
|
let _env_guard = crate::config::helpers::lock_env();
|
||||||
|
let _exchange_url_guard =
|
||||||
|
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
|
||||||
|
let _proxy_auth_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
|
|
||||||
|
let secrets = test_secrets_store();
|
||||||
|
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
|
||||||
|
let sse_mgr = Arc::new(SseManager::new());
|
||||||
|
let mut receiver = sse_mgr.sender().subscribe();
|
||||||
|
let flow = fresh_pending_oauth_flow(
|
||||||
|
Arc::clone(&secrets),
|
||||||
|
Some(Arc::clone(&sse_mgr)),
|
||||||
|
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("test_nonce".to_string(), flow);
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
let versioned_state =
|
||||||
|
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/oauth/callback?code=fake_code&state={}",
|
||||||
|
urlencoding::encode(&versioned_state)
|
||||||
|
))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Test Tool Connected"));
|
||||||
|
|
||||||
|
let requests = proxy.requests().await;
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].authorization.as_deref(),
|
||||||
|
Some("Bearer gateway-test-token")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code").map(String::as_str),
|
||||||
|
Some("fake_code")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code_verifier").map(String::as_str),
|
||||||
|
Some("test-code-verifier")
|
||||||
|
);
|
||||||
|
|
||||||
|
let access_token = secrets
|
||||||
|
.get_decrypted("test", "test_token")
|
||||||
|
.await
|
||||||
|
.expect("access token stored");
|
||||||
|
assert_eq!(access_token.expose(), "proxy-access-token");
|
||||||
|
|
||||||
|
let refresh_token = secrets
|
||||||
|
.get_decrypted("test", "test_token_refresh_token")
|
||||||
|
.await
|
||||||
|
.expect("refresh token stored");
|
||||||
|
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
|
||||||
|
|
||||||
|
match receiver.recv().await.expect("auth_completed event").event {
|
||||||
|
crate::channels::web::types::AppEvent::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(extension_name, "test_tool");
|
||||||
|
assert!(success, "OAuth callback should broadcast success");
|
||||||
|
}
|
||||||
|
event => panic!("expected AuthCompleted event, got {event:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_happy_path_with_dedicated_proxy_auth_token() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let proxy = MockOauthProxyServer::start().await;
|
||||||
|
// Keep the process-wide env locked for the full callback so the handler
|
||||||
|
// sees a stable proxy URL/token configuration throughout the test.
|
||||||
|
let _env_guard = crate::config::helpers::lock_env();
|
||||||
|
let _exchange_url_guard =
|
||||||
|
set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", Some(&proxy.base_url()));
|
||||||
|
let _proxy_auth_guard = set_env_var(
|
||||||
|
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||||
|
Some("shared-oauth-proxy-secret"),
|
||||||
|
);
|
||||||
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||||
|
|
||||||
|
let secrets = test_secrets_store();
|
||||||
|
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(Arc::clone(&secrets));
|
||||||
|
let sse_mgr = Arc::new(SseManager::new());
|
||||||
|
let mut receiver = sse_mgr.sender().subscribe();
|
||||||
|
let flow = fresh_pending_oauth_flow(
|
||||||
|
Arc::clone(&secrets),
|
||||||
|
Some(Arc::clone(&sse_mgr)),
|
||||||
|
crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("test_nonce".to_string(), flow);
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
let versioned_state =
|
||||||
|
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", None);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri(format!(
|
||||||
|
"/oauth/callback?code=fake_code&state={}",
|
||||||
|
urlencoding::encode(&versioned_state)
|
||||||
|
))
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Test Tool Connected"));
|
||||||
|
|
||||||
|
let requests = proxy.requests().await;
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].authorization.as_deref(),
|
||||||
|
Some("Bearer shared-oauth-proxy-secret")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code").map(String::as_str),
|
||||||
|
Some("fake_code")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code_verifier").map(String::as_str),
|
||||||
|
Some("test-code-verifier")
|
||||||
|
);
|
||||||
|
|
||||||
|
let access_token = secrets
|
||||||
|
.get_decrypted("test", "test_token")
|
||||||
|
.await
|
||||||
|
.expect("access token stored");
|
||||||
|
assert_eq!(access_token.expose(), "proxy-access-token");
|
||||||
|
|
||||||
|
let refresh_token = secrets
|
||||||
|
.get_decrypted("test", "test_token_refresh_token")
|
||||||
|
.await
|
||||||
|
.expect("refresh token stored");
|
||||||
|
assert_eq!(refresh_token.expose(), "proxy-refresh-token");
|
||||||
|
|
||||||
|
match receiver.recv().await.expect("auth_completed event").event {
|
||||||
|
crate::channels::web::types::AppEvent::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(extension_name, "test_tool");
|
||||||
|
assert!(success, "OAuth callback should broadcast success");
|
||||||
|
}
|
||||||
|
event => panic!("expected AuthCompleted event, got {event:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Slack relay OAuth CSRF tests ---
|
// --- Slack relay OAuth CSRF tests ---
|
||||||
|
|
||||||
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
||||||
|
|||||||
+1
-2
@@ -127,8 +127,7 @@ async fn list_settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let display_value = if value.len() > 60 {
|
let display_value = if value.len() > 60 {
|
||||||
let end = crate::util::floor_char_boundary(&value, 57);
|
format!("{}...", &value[..57])
|
||||||
format!("{}...", &value[..end])
|
|
||||||
} else {
|
} else {
|
||||||
value
|
value
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-15
@@ -256,8 +256,7 @@ fn truncate_content(s: &str, max_len: usize) -> String {
|
|||||||
if s.len() <= max_len {
|
if s.len() <= max_len {
|
||||||
s.to_string()
|
s.to_string()
|
||||||
} else {
|
} else {
|
||||||
let end = crate::util::floor_char_boundary(s, max_len);
|
format!("{}...", &s[..max_len])
|
||||||
format!("{}...", &s[..end])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,17 +292,4 @@ mod tests {
|
|||||||
assert_eq!(truncate_content("hello", 10), "hello");
|
assert_eq!(truncate_content("hello", 10), "hello");
|
||||||
assert_eq!(truncate_content("hello world", 5), "hello...");
|
assert_eq!(truncate_content("hello world", 5), "hello...");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncate_content_multibyte_does_not_panic() {
|
|
||||||
// \u{00e9} is precomposed 'é' (2 bytes in UTF-8)
|
|
||||||
let s = "caf\u{00e9} au lait"; // "café au lait", é starts at byte 3
|
|
||||||
let result = truncate_content(s, 4); // byte 4 is inside 2-byte é
|
|
||||||
assert_eq!(result, "caf...");
|
|
||||||
|
|
||||||
// 4-byte emoji: slicing mid-emoji must not panic
|
|
||||||
let emoji = "Hi \u{1F600} there"; // 😀 is 4 bytes, starts at byte 3
|
|
||||||
let result = truncate_content(emoji, 4); // byte 4 is inside 😀
|
|
||||||
assert_eq!(result, "Hi ...");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-5
@@ -473,7 +473,8 @@ pub struct PendingOAuthFlow {
|
|||||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||||
/// SSE broadcast manager for notifying the web UI.
|
/// SSE broadcast manager for notifying the web UI.
|
||||||
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
||||||
|
/// Kept as `gateway_token` for public API compatibility.
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Additional form params for the token exchange request.
|
/// Additional form params for the token exchange request.
|
||||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||||
@@ -496,6 +497,12 @@ impl std::fmt::Debug for PendingOAuthFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PendingOAuthFlow {
|
||||||
|
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
||||||
|
self.gateway_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
||||||
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
||||||
|
|
||||||
@@ -529,6 +536,22 @@ pub fn exchange_proxy_url() -> Option<String> {
|
|||||||
.filter(|url| !url.is_empty())
|
.filter(|url| !url.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the configured OAuth proxy auth token, if any.
|
||||||
|
///
|
||||||
|
/// New hosted infra can inject a dedicated shared proxy secret via
|
||||||
|
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
|
||||||
|
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
|
||||||
|
pub fn oauth_proxy_auth_token() -> Option<String> {
|
||||||
|
fn normalized_env_value(key: &str) -> Option<String> {
|
||||||
|
crate::config::helpers::env_or_override(key)
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
|
||||||
|
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
@@ -674,6 +697,8 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
|||||||
|
|
||||||
pub struct ProxyTokenExchangeRequest<'a> {
|
pub struct ProxyTokenExchangeRequest<'a> {
|
||||||
pub proxy_url: &'a str,
|
pub proxy_url: &'a str,
|
||||||
|
/// OAuth proxy auth token.
|
||||||
|
/// Kept as `gateway_token` for public API compatibility.
|
||||||
pub gateway_token: &'a str,
|
pub gateway_token: &'a str,
|
||||||
pub token_url: &'a str,
|
pub token_url: &'a str,
|
||||||
pub client_id: &'a str,
|
pub client_id: &'a str,
|
||||||
@@ -687,6 +712,8 @@ pub struct ProxyTokenExchangeRequest<'a> {
|
|||||||
|
|
||||||
pub struct ProxyRefreshTokenRequest<'a> {
|
pub struct ProxyRefreshTokenRequest<'a> {
|
||||||
pub proxy_url: &'a str,
|
pub proxy_url: &'a str,
|
||||||
|
/// OAuth proxy auth token.
|
||||||
|
/// Kept as `gateway_token` for public API compatibility.
|
||||||
pub gateway_token: &'a str,
|
pub gateway_token: &'a str,
|
||||||
pub token_url: &'a str,
|
pub token_url: &'a str,
|
||||||
pub client_id: &'a str,
|
pub client_id: &'a str,
|
||||||
@@ -729,7 +756,7 @@ fn oauth_token_response_from_json(
|
|||||||
|
|
||||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||||
///
|
///
|
||||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||||
/// the provider requires it.
|
/// the provider requires it.
|
||||||
///
|
///
|
||||||
@@ -741,7 +768,7 @@ pub async fn exchange_via_proxy(
|
|||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
if request.gateway_token.is_empty() {
|
if request.gateway_token.is_empty() {
|
||||||
return Err(OAuthCallbackError::Io(
|
return Err(OAuthCallbackError::Io(
|
||||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
"OAuth proxy auth token is required for proxy token exchange".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
||||||
@@ -796,7 +823,7 @@ pub async fn exchange_via_proxy(
|
|||||||
|
|
||||||
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
||||||
///
|
///
|
||||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
||||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||||
/// the provider requires it.
|
/// the provider requires it.
|
||||||
pub async fn refresh_token_via_proxy(
|
pub async fn refresh_token_via_proxy(
|
||||||
@@ -804,7 +831,7 @@ pub async fn refresh_token_via_proxy(
|
|||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
if request.gateway_token.is_empty() {
|
if request.gateway_token.is_empty() {
|
||||||
return Err(OAuthCallbackError::Io(
|
return Err(OAuthCallbackError::Io(
|
||||||
"Gateway auth token is required for proxy token refresh".to_string(),
|
"OAuth proxy auth token is required for proxy token refresh".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1010,6 +1037,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct EnvVarGuard {
|
||||||
|
key: &'static str,
|
||||||
|
original: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvVarGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
|
unsafe {
|
||||||
|
if let Some(ref value) = self.original {
|
||||||
|
std::env::set_var(self.key, value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var(self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
||||||
|
let original = std::env::var(key).ok();
|
||||||
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
|
unsafe {
|
||||||
|
if let Some(value) = value {
|
||||||
|
std::env::set_var(key, value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EnvVarGuard { key, original }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
||||||
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
||||||
@@ -1030,6 +1088,79 @@ mod tests {
|
|||||||
assert_eq!(result, client_secret);
|
assert_eq!(result, client_secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_exchange_via_proxy_sends_auth_and_form() {
|
||||||
|
let server = MockProxyServer::start().await;
|
||||||
|
let mut extra_token_params = HashMap::new();
|
||||||
|
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
|
||||||
|
|
||||||
|
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
|
||||||
|
proxy_url: &server.base_url(),
|
||||||
|
gateway_token: "shared-oauth-proxy-secret",
|
||||||
|
code: "auth-code-123",
|
||||||
|
redirect_uri: "https://oauth.example.com/oauth/callback",
|
||||||
|
token_url: "https://oauth2.googleapis.com/token",
|
||||||
|
client_id: TEST_OAUTH_CLIENT_ID,
|
||||||
|
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
||||||
|
access_token_field: "access_token",
|
||||||
|
code_verifier: Some("code-verifier-123"),
|
||||||
|
extra_token_params: &extra_token_params,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("proxy exchange succeeds");
|
||||||
|
|
||||||
|
assert_eq!(response.access_token, "proxy-access-token");
|
||||||
|
assert_eq!(
|
||||||
|
response.refresh_token.as_deref(),
|
||||||
|
Some("proxy-refresh-token")
|
||||||
|
);
|
||||||
|
assert_eq!(response.expires_in, Some(7200));
|
||||||
|
|
||||||
|
let requests = server.requests().await;
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].authorization.as_deref(),
|
||||||
|
Some("Bearer shared-oauth-proxy-secret")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code").map(String::as_str),
|
||||||
|
Some("auth-code-123")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("redirect_uri").map(String::as_str),
|
||||||
|
Some("https://oauth.example.com/oauth/callback")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("token_url").map(String::as_str),
|
||||||
|
Some("https://oauth2.googleapis.com/token")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("client_id").map(String::as_str),
|
||||||
|
Some(TEST_OAUTH_CLIENT_ID)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("client_secret").map(String::as_str),
|
||||||
|
Some(TEST_OAUTH_CLIENT_SECRET)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0]
|
||||||
|
.form
|
||||||
|
.get("access_token_field")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("access_token")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("code_verifier").map(String::as_str),
|
||||||
|
Some("code-verifier-123")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].form.get("resource").map(String::as_str),
|
||||||
|
Some("https://mcp.notion.com")
|
||||||
|
);
|
||||||
|
|
||||||
|
server.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
||||||
let server = MockProxyServer::start().await;
|
let server = MockProxyServer::start().await;
|
||||||
@@ -1535,6 +1666,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var(
|
||||||
|
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||||
|
Some("shared-proxy-secret"),
|
||||||
|
);
|
||||||
|
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||||
|
Some("shared-proxy-secret")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
|
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||||
|
Some("gateway-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
|
||||||
|
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
||||||
|
Some("gateway-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
|
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||||
|
|
||||||
|
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_strip_instance_prefix_with_colon() {
|
fn test_strip_instance_prefix_with_colon() {
|
||||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||||
|
|||||||
@@ -403,9 +403,10 @@ pub struct ExtensionManager {
|
|||||||
/// when running in gateway mode, consumed by the web gateway's
|
/// when running in gateway mode, consumed by the web gateway's
|
||||||
/// `/oauth/callback` handler.
|
/// `/oauth/callback` handler.
|
||||||
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
|
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
||||||
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
|
/// Resolved once at construction from `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`,
|
||||||
gateway_token: Option<String>,
|
/// then `GATEWAY_AUTH_TOKEN` as a backward-compatible fallback.
|
||||||
|
oauth_proxy_auth_token: Option<String>,
|
||||||
/// Relay config captured at startup. Used by `auth_channel_relay` and
|
/// Relay config captured at startup. Used by `auth_channel_relay` and
|
||||||
/// `activate_channel_relay` instead of re-reading env vars.
|
/// `activate_channel_relay` instead of re-reading env vars.
|
||||||
relay_config: Option<crate::config::RelayConfig>,
|
relay_config: Option<crate::config::RelayConfig>,
|
||||||
@@ -535,7 +536,7 @@ impl ExtensionManager {
|
|||||||
activation_errors: RwLock::new(HashMap::new()),
|
activation_errors: RwLock::new(HashMap::new()),
|
||||||
sse_manager: RwLock::new(None),
|
sse_manager: RwLock::new(None),
|
||||||
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
||||||
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
|
oauth_proxy_auth_token: crate::cli::oauth_defaults::oauth_proxy_auth_token(),
|
||||||
relay_config: crate::config::RelayConfig::from_env(),
|
relay_config: crate::config::RelayConfig::from_env(),
|
||||||
relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)),
|
relay_event_tx: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)),
|
relay_signing_secret_cache: Arc::new(std::sync::Mutex::new(None)),
|
||||||
@@ -2788,7 +2789,7 @@ impl ExtensionManager {
|
|||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
secrets: Arc::clone(&self.secrets),
|
secrets: Arc::clone(&self.secrets),
|
||||||
sse_manager: self.sse_manager.read().await.clone(),
|
sse_manager: self.sse_manager.read().await.clone(),
|
||||||
gateway_token: self.gateway_token.clone(),
|
gateway_token: self.oauth_proxy_auth_token.clone(),
|
||||||
token_exchange_extra_params,
|
token_exchange_extra_params,
|
||||||
client_id_secret_name: if server.oauth.is_none() {
|
client_id_secret_name: if server.oauth.is_none() {
|
||||||
Some(server.client_id_secret_name())
|
Some(server.client_id_secret_name())
|
||||||
@@ -3305,7 +3306,7 @@ impl ExtensionManager {
|
|||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
secrets: Arc::clone(&self.secrets),
|
secrets: Arc::clone(&self.secrets),
|
||||||
sse_manager: self.sse_manager.read().await.clone(),
|
sse_manager: self.sse_manager.read().await.clone(),
|
||||||
gateway_token: self.gateway_token.clone(),
|
gateway_token: self.oauth_proxy_auth_token.clone(),
|
||||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||||
client_id_secret_name: None,
|
client_id_secret_name: None,
|
||||||
created_at: std::time::Instant::now(),
|
created_at: std::time::Instant::now(),
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ impl NearAiChatProvider {
|
|||||||
provider: "nearai_chat".to_string(),
|
provider: "nearai_chat".to_string(),
|
||||||
reason: format!(
|
reason: format!(
|
||||||
"No model names found in response: {}",
|
"No model names found in response: {}",
|
||||||
&response_text[..crate::util::floor_char_boundary(&response_text, 300)]
|
&response_text[..response_text.len().min(300)]
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,16 +446,14 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
|
|||||||
builtin.as_ref(),
|
builtin.as_ref(),
|
||||||
exchange_proxy_url.is_some(),
|
exchange_proxy_url.is_some(),
|
||||||
);
|
);
|
||||||
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
|
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
|
||||||
.map(|token| token.trim().to_string())
|
|
||||||
.filter(|token| !token.is_empty());
|
|
||||||
|
|
||||||
Some(OAuthRefreshConfig {
|
Some(OAuthRefreshConfig {
|
||||||
token_url: oauth.token_url.clone(),
|
token_url: oauth.token_url.clone(),
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret,
|
||||||
exchange_proxy_url,
|
exchange_proxy_url,
|
||||||
gateway_token,
|
gateway_token: oauth_proxy_auth_token,
|
||||||
secret_name: auth.secret_name.clone(),
|
secret_name: auth.secret_name.clone(),
|
||||||
provider: auth.provider.clone(),
|
provider: auth.provider.clone(),
|
||||||
})
|
})
|
||||||
@@ -891,6 +889,11 @@ mod tests {
|
|||||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
||||||
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||||
|
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
|
|
||||||
let caps = CapabilitiesFile {
|
let caps = CapabilitiesFile {
|
||||||
auth: Some(AuthCapabilitySchema {
|
auth: Some(AuthCapabilitySchema {
|
||||||
secret_name: "google_oauth_token".to_string(),
|
secret_name: "google_oauth_token".to_string(),
|
||||||
@@ -982,6 +985,7 @@ mod tests {
|
|||||||
let _guard = lock_env();
|
let _guard = lock_env();
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||||
|
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
|
|
||||||
// google_oauth_token should fall back to built-in credentials
|
// google_oauth_token should fall back to built-in credentials
|
||||||
let caps = CapabilitiesFile {
|
let caps = CapabilitiesFile {
|
||||||
@@ -1021,6 +1025,7 @@ mod tests {
|
|||||||
Some("https://compose-api.example.com"),
|
Some("https://compose-api.example.com"),
|
||||||
);
|
);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
|
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
let _client_id_guard =
|
let _client_id_guard =
|
||||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||||
|
|
||||||
@@ -1061,6 +1066,7 @@ mod tests {
|
|||||||
Some("https://compose-api.example.com"),
|
Some("https://compose-api.example.com"),
|
||||||
);
|
);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
|
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
||||||
let _client_id_guard =
|
let _client_id_guard =
|
||||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||||
let _client_secret_guard =
|
let _client_secret_guard =
|
||||||
@@ -1095,6 +1101,47 @@ mod tests {
|
|||||||
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
|
||||||
|
use crate::tools::wasm::capabilities_schema::{
|
||||||
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _guard = lock_env();
|
||||||
|
let _proxy_guard = set_env_var(
|
||||||
|
"IRONCLAW_OAUTH_EXCHANGE_URL",
|
||||||
|
Some("https://compose-api.example.com"),
|
||||||
|
);
|
||||||
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
|
let _oauth_proxy_token_guard = set_env_var(
|
||||||
|
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
||||||
|
Some("shared-oauth-proxy-secret"),
|
||||||
|
);
|
||||||
|
let _client_id_guard =
|
||||||
|
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||||
|
|
||||||
|
let caps = CapabilitiesFile {
|
||||||
|
auth: Some(AuthCapabilitySchema {
|
||||||
|
secret_name: "google_oauth_token".to_string(),
|
||||||
|
provider: Some("google".to_string()),
|
||||||
|
oauth: Some(OAuthConfigSchema {
|
||||||
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||||
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||||
|
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
|
||||||
|
assert_eq!(
|
||||||
|
config.gateway_token.as_deref(),
|
||||||
|
Some("shared-oauth-proxy-secret")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Security regression tests
|
// Security regression tests
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ pub struct OAuthRefreshConfig {
|
|||||||
pub client_secret: Option<String>,
|
pub client_secret: Option<String>,
|
||||||
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
||||||
pub exchange_proxy_url: Option<String>,
|
pub exchange_proxy_url: Option<String>,
|
||||||
/// Gateway auth token for authenticating with the hosted OAuth proxy.
|
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
|
||||||
|
/// Kept as `gateway_token` for public API compatibility.
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Secret name of the access token (e.g., "google_oauth_token").
|
/// Secret name of the access token (e.g., "google_oauth_token").
|
||||||
/// The refresh token lives at `{secret_name}_refresh_token`.
|
/// The refresh token lives at `{secret_name}_refresh_token`.
|
||||||
@@ -71,6 +72,12 @@ pub struct OAuthRefreshConfig {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OAuthRefreshConfig {
|
||||||
|
fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
||||||
|
self.gateway_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pre-resolved credential for host-based injection.
|
/// Pre-resolved credential for host-based injection.
|
||||||
///
|
///
|
||||||
/// Built before each WASM execution by decrypting secrets from the store.
|
/// Built before each WASM execution by decrypting secrets from the store.
|
||||||
@@ -1218,9 +1225,9 @@ async fn refresh_oauth_token(
|
|||||||
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
||||||
|
|
||||||
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
||||||
let Some(gateway_token) = config.gateway_token.as_deref() else {
|
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"OAuth refresh proxy is configured, but no gateway auth token is available"
|
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -1235,7 +1242,7 @@ async fn refresh_oauth_token(
|
|||||||
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
||||||
oauth_defaults::ProxyRefreshTokenRequest {
|
oauth_defaults::ProxyRefreshTokenRequest {
|
||||||
proxy_url,
|
proxy_url,
|
||||||
gateway_token,
|
gateway_token: oauth_proxy_auth_token,
|
||||||
token_url: &config.token_url,
|
token_url: &config.token_url,
|
||||||
client_id: &config.client_id,
|
client_id: &config.client_id,
|
||||||
client_secret: config.client_secret.as_deref(),
|
client_secret: config.client_secret.as_deref(),
|
||||||
@@ -2704,7 +2711,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
|
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
|
||||||
|
{
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user