mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +00:00
Fix hosted OAuth refresh via proxy (#1602)
* Fix hosted OAuth refresh via proxy * Address OAuth refresh review feedback * Address new OAuth refresh review comments * Address additional OAuth refresh review feedback * Harden proxy exchange redirects
This commit is contained in:
@@ -418,6 +418,7 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
|
||||
let oauth = auth.oauth.as_ref()?;
|
||||
|
||||
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
|
||||
let exchange_proxy_url = crate::cli::oauth_defaults::exchange_proxy_url();
|
||||
|
||||
let client_id = oauth
|
||||
.client_id
|
||||
@@ -440,11 +441,21 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
|
||||
let client_secret = crate::cli::oauth_defaults::hosted_proxy_client_secret(
|
||||
&client_secret,
|
||||
builtin.as_ref(),
|
||||
exchange_proxy_url.is_some(),
|
||||
);
|
||||
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
Some(OAuthRefreshConfig {
|
||||
token_url: oauth.token_url.clone(),
|
||||
client_id,
|
||||
client_secret,
|
||||
exchange_proxy_url,
|
||||
gateway_token,
|
||||
secret_name: auth.secret_name.clone(),
|
||||
provider: auth.provider.clone(),
|
||||
})
|
||||
@@ -711,9 +722,44 @@ mod tests {
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::config::helpers::lock_env;
|
||||
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
|
||||
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
||||
|
||||
/// Restores a test-scoped env var override on drop.
|
||||
struct EnvVarGuard {
|
||||
key: String,
|
||||
previous: 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.previous {
|
||||
std::env::set_var(&self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_env_var(key: &str, value: Option<&str>) -> EnvVarGuard {
|
||||
let previous = std::env::var(key).ok();
|
||||
// SAFETY: Tests use lock_env() to serialize environment access.
|
||||
unsafe {
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
EnvVarGuard {
|
||||
key: key.to_string(),
|
||||
previous,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_none_is_ok() {
|
||||
// Pre-versioning extensions (no wit_version declared) should always pass
|
||||
@@ -871,6 +917,8 @@ mod tests {
|
||||
config.client_secret,
|
||||
Some(TEST_OAUTH_CLIENT_SECRET.to_string())
|
||||
);
|
||||
assert_eq!(config.exchange_proxy_url, None);
|
||||
assert_eq!(config.gateway_token, None);
|
||||
assert_eq!(config.secret_name, "google_oauth_token");
|
||||
assert_eq!(config.provider, Some("google".to_string()));
|
||||
}
|
||||
@@ -931,6 +979,10 @@ mod tests {
|
||||
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);
|
||||
|
||||
// google_oauth_token should fall back to built-in credentials
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
@@ -952,6 +1004,95 @@ mod tests {
|
||||
let config = config.unwrap();
|
||||
assert!(!config.client_id.is_empty());
|
||||
assert!(config.client_secret.is_some());
|
||||
assert_eq!(config.exchange_proxy_url, None);
|
||||
assert_eq!(config.gateway_token, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_hosted_proxy_populates_env_and_suppresses_builtin_secret()
|
||||
{
|
||||
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 _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.client_id, "hosted-google-client-id");
|
||||
assert_eq!(config.client_secret, None);
|
||||
assert_eq!(
|
||||
config.exchange_proxy_url.as_deref(),
|
||||
Some("https://compose-api.example.com")
|
||||
);
|
||||
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_hosted_proxy_preserves_explicit_secret() {
|
||||
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 _client_id_guard =
|
||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||
let _client_secret_guard =
|
||||
set_env_var("GOOGLE_OAUTH_CLIENT_SECRET", Some("hosted-server-secret"));
|
||||
|
||||
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()),
|
||||
client_secret_env: Some("GOOGLE_OAUTH_CLIENT_SECRET".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
|
||||
assert_eq!(config.client_id, "hosted-google-client-id");
|
||||
assert_eq!(
|
||||
config.client_secret.as_deref(),
|
||||
Some("hosted-server-secret")
|
||||
);
|
||||
assert_eq!(
|
||||
config.exchange_proxy_url.as_deref(),
|
||||
Some("https://compose-api.example.com")
|
||||
);
|
||||
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+443
-38
@@ -19,7 +19,7 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
|
||||
use crate::context::JobContext;
|
||||
use crate::llm::recording::{HttpExchangeRequest, HttpExchangeResponse, HttpInterceptor};
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::secrets::{DecryptedSecret, SecretsStore};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::wasm::capabilities::Capabilities;
|
||||
use crate::tools::wasm::credential_injector::{
|
||||
@@ -44,6 +44,7 @@ wasmtime::component::bindgen!({
|
||||
});
|
||||
|
||||
// Alias the export interface types for convenience.
|
||||
use crate::cli::oauth_defaults;
|
||||
use exports::near::agent::tool as wit_tool;
|
||||
|
||||
/// Configuration needed to refresh an expired OAuth access token.
|
||||
@@ -59,6 +60,10 @@ pub struct OAuthRefreshConfig {
|
||||
pub client_id: String,
|
||||
/// OAuth client_secret (optional, some providers use PKCE without a secret).
|
||||
pub client_secret: Option<String>,
|
||||
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
||||
pub exchange_proxy_url: Option<String>,
|
||||
/// Gateway auth token for authenticating with the hosted OAuth proxy.
|
||||
pub gateway_token: Option<String>,
|
||||
/// Secret name of the access token (e.g., "google_oauth_token").
|
||||
/// The refresh token lives at `{secret_name}_refresh_token`.
|
||||
pub secret_name: String,
|
||||
@@ -1210,6 +1215,53 @@ async fn refresh_oauth_token(
|
||||
user_id: &str,
|
||||
config: &OAuthRefreshConfig,
|
||||
) -> bool {
|
||||
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
||||
|
||||
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
||||
let Some(gateway_token) = config.gateway_token.as_deref() else {
|
||||
tracing::warn!(
|
||||
"OAuth refresh proxy is configured, but no gateway auth token is available"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
// In hosted mode, the configured exchange proxy owns the outbound token
|
||||
// refresh and validation policy for the provider token_url. Direct-mode
|
||||
// HTTPS/private-IP checks remain in place for self-hosted refreshes below.
|
||||
let refresh_secret = match load_oauth_refresh_secret(store, user_id, &refresh_name).await {
|
||||
Some(secret) => secret,
|
||||
None => return false,
|
||||
};
|
||||
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
||||
oauth_defaults::ProxyRefreshTokenRequest {
|
||||
proxy_url,
|
||||
gateway_token,
|
||||
token_url: &config.token_url,
|
||||
client_id: &config.client_id,
|
||||
client_secret: config.client_secret.as_deref(),
|
||||
refresh_token: refresh_secret.expose(),
|
||||
provider: config.provider.as_deref(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "OAuth token refresh via proxy failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return persist_refreshed_oauth_tokens(
|
||||
store,
|
||||
user_id,
|
||||
config,
|
||||
&refresh_name,
|
||||
token_response,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// SSRF defense: token_url comes from the tool's capabilities file.
|
||||
if !config.token_url.starts_with("https://") {
|
||||
tracing::warn!(
|
||||
@@ -1227,19 +1279,6 @@ async fn refresh_oauth_token(
|
||||
return false;
|
||||
}
|
||||
|
||||
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
||||
let refresh_secret = match store.get_decrypted(user_id, &refresh_name).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
secret_name = %refresh_name,
|
||||
error = %e,
|
||||
"No refresh token available, skipping token refresh"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
@@ -1252,6 +1291,10 @@ async fn refresh_oauth_token(
|
||||
}
|
||||
};
|
||||
|
||||
let refresh_secret = match load_oauth_refresh_secret(store, user_id, &refresh_name).await {
|
||||
Some(secret) => secret,
|
||||
None => return false,
|
||||
};
|
||||
let mut params = vec![
|
||||
("grant_type", "refresh_token".to_string()),
|
||||
("refresh_token", refresh_secret.expose().to_string()),
|
||||
@@ -1287,22 +1330,55 @@ async fn refresh_oauth_token(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let new_access_token = match token_data.get("access_token").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
let token_response = match token_data.get("access_token").and_then(|v| v.as_str()) {
|
||||
Some(access_token) => oauth_defaults::OAuthTokenResponse {
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: token_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
expires_in: token_data.get("expires_in").and_then(|v| v.as_u64()),
|
||||
},
|
||||
None => {
|
||||
tracing::warn!("Token refresh response missing access_token field");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Store the new access token with expiry
|
||||
persist_refreshed_oauth_tokens(store, user_id, config, &refresh_name, token_response).await
|
||||
}
|
||||
|
||||
async fn load_oauth_refresh_secret(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
refresh_name: &str,
|
||||
) -> Option<DecryptedSecret> {
|
||||
match store.get_decrypted(user_id, refresh_name).await {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
secret_name = %refresh_name,
|
||||
error = %error,
|
||||
"No refresh token available, skipping token refresh"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_refreshed_oauth_tokens(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
config: &OAuthRefreshConfig,
|
||||
refresh_name: &str,
|
||||
token_response: oauth_defaults::OAuthTokenResponse,
|
||||
) -> bool {
|
||||
let mut access_params =
|
||||
crate::secrets::CreateSecretParams::new(&config.secret_name, new_access_token);
|
||||
crate::secrets::CreateSecretParams::new(&config.secret_name, &token_response.access_token);
|
||||
if let Some(ref provider) = config.provider {
|
||||
access_params = access_params.with_provider(provider);
|
||||
}
|
||||
if let Some(expires_in) = token_data.get("expires_in").and_then(|v| v.as_u64()) {
|
||||
if let Some(expires_in) = token_response.expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
|
||||
access_params = access_params.with_expiry(expires_at);
|
||||
}
|
||||
@@ -1312,10 +1388,8 @@ async fn refresh_oauth_token(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store rotated refresh token if the provider sent a new one
|
||||
if let Some(new_refresh) = token_data.get("refresh_token").and_then(|v| v.as_str()) {
|
||||
let mut refresh_params =
|
||||
crate::secrets::CreateSecretParams::new(&refresh_name, new_refresh);
|
||||
if let Some(new_refresh) = token_response.refresh_token.as_deref() {
|
||||
let mut refresh_params = crate::secrets::CreateSecretParams::new(refresh_name, new_refresh);
|
||||
if let Some(ref provider) = config.provider {
|
||||
refresh_params = refresh_params.with_provider(provider);
|
||||
}
|
||||
@@ -1664,9 +1738,18 @@ fn build_tool_usage_hint(tool_name: &str, schema: &serde_json::Value) -> String
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::{Form, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex as AsyncMutex, oneshot};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::JobContext;
|
||||
@@ -1756,6 +1839,95 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct RecordedProxyRequest {
|
||||
authorization: Option<String>,
|
||||
form: HashMap<String, String>,
|
||||
}
|
||||
|
||||
struct MockProxyServer {
|
||||
addr: SocketAddr,
|
||||
requests: Arc<AsyncMutex<Vec<RecordedProxyRequest>>>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MockProxyServer {
|
||||
async fn start() -> Self {
|
||||
async fn refresh_handler(
|
||||
State(requests): State<Arc<AsyncMutex<Vec<RecordedProxyRequest>>>>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Json<serde_json::Value> {
|
||||
requests.lock().await.push(RecordedProxyRequest {
|
||||
authorization: headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string),
|
||||
form,
|
||||
});
|
||||
Json(json!({
|
||||
"access_token": "mock-refreshed-access-token",
|
||||
"refresh_token": "mock-rotated-refresh-token",
|
||||
"expires_in": 3600
|
||||
}))
|
||||
}
|
||||
|
||||
let requests = Arc::new(AsyncMutex::new(Vec::new()));
|
||||
let app = Router::new()
|
||||
.route("/oauth/refresh", post(refresh_handler))
|
||||
.with_state(Arc::clone(&requests));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock proxy");
|
||||
let addr = listener.local_addr().expect("read mock proxy addr");
|
||||
let (shutdown_tx, shutdown_rx) = 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<RecordedProxyRequest> {
|
||||
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 MockProxyServer {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrapper_creation() {
|
||||
// This test verifies the runtime can be created
|
||||
@@ -2094,8 +2266,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_bearer() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
@@ -2141,8 +2311,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_owner_scope_bearer() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
@@ -2188,8 +2356,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_resolves_host_credentials_from_owner_scope_context() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
|
||||
@@ -2239,8 +2405,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_missing_secret() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
@@ -2272,8 +2436,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_when_not_expired() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
@@ -2315,6 +2477,8 @@ mod tests {
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
exchange_proxy_url: None,
|
||||
gateway_token: None,
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
@@ -2331,8 +2495,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_no_config() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
@@ -2376,8 +2538,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_no_expires_at() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
@@ -2417,6 +2577,8 @@ mod tests {
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
exchange_proxy_url: None,
|
||||
gateway_token: None,
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
@@ -2431,6 +2593,249 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_refreshes_via_proxy_without_direct_token_url_validation()
|
||||
{
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||
|
||||
let proxy = MockProxyServer::start().await;
|
||||
let store = test_secrets_store();
|
||||
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "expired-access-token")
|
||||
.with_expiry(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token_refresh_token", "stored-refresh-token"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut credentials = HashMap::new();
|
||||
credentials.insert(
|
||||
"google_oauth_token".to_string(),
|
||||
CredentialMapping {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec!["www.googleapis.com".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
let caps = Capabilities {
|
||||
http: Some(HttpCapability {
|
||||
credentials,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let oauth_config = OAuthRefreshConfig {
|
||||
token_url: "http://127.0.0.1:9/provider-token-endpoint".to_string(),
|
||||
client_id: "hosted-google-client-id".to_string(),
|
||||
client_secret: None,
|
||||
exchange_proxy_url: Some(proxy.base_url()),
|
||||
gateway_token: Some("gateway-test-token".to_string()),
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
|
||||
let resolved =
|
||||
resolve_host_credentials(&caps, Some(&store), "user1", Some(&oauth_config)).await;
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(
|
||||
resolved[0].headers.get("Authorization"),
|
||||
Some(&"Bearer mock-refreshed-access-token".to_string())
|
||||
);
|
||||
|
||||
let access_secret = store.get("user1", "google_oauth_token").await.unwrap();
|
||||
assert!(
|
||||
access_secret
|
||||
.expires_at
|
||||
.expect("refreshed access token expiry")
|
||||
> chrono::Utc::now()
|
||||
);
|
||||
let access_value = store
|
||||
.get_decrypted("user1", "google_oauth_token")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(access_value.expose(), "mock-refreshed-access-token");
|
||||
|
||||
let refresh_value = store
|
||||
.get_decrypted("user1", "google_oauth_token_refresh_token")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(refresh_value.expose(), "mock-rotated-refresh-token");
|
||||
|
||||
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("client_id").map(String::as_str),
|
||||
Some("hosted-google-client-id")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("token_url").map(String::as_str),
|
||||
Some("http://127.0.0.1:9/provider-token-endpoint")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("refresh_token").map(String::as_str),
|
||||
Some("stored-refresh-token")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].form.get("provider").map(String::as_str),
|
||||
Some("google")
|
||||
);
|
||||
assert!(!requests[0].form.contains_key("client_secret"));
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||
|
||||
let store = RecordingSecretsStore::new();
|
||||
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "expired-access-token")
|
||||
.with_expiry(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token_refresh_token", "stored-refresh-token"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut credentials = HashMap::new();
|
||||
credentials.insert(
|
||||
"google_oauth_token".to_string(),
|
||||
CredentialMapping {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec!["www.googleapis.com".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
let caps = Capabilities {
|
||||
http: Some(HttpCapability {
|
||||
credentials,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let oauth_config = OAuthRefreshConfig {
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: "hosted-google-client-id".to_string(),
|
||||
client_secret: None,
|
||||
exchange_proxy_url: Some("https://compose-api.example.com".to_string()),
|
||||
gateway_token: None,
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
|
||||
let resolved =
|
||||
resolve_host_credentials(&caps, Some(&store), "user1", Some(&oauth_config)).await;
|
||||
assert!(resolved.is_empty());
|
||||
|
||||
let lookups = store.decrypted_lookups();
|
||||
assert!(lookups.contains(&("user1".to_string(), "google_oauth_token".to_string())));
|
||||
assert!(!lookups.contains(&(
|
||||
"user1".to_string(),
|
||||
"google_oauth_token_refresh_token".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_for_invalid_direct_token_url()
|
||||
{
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||
|
||||
let store = RecordingSecretsStore::new();
|
||||
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "expired-access-token")
|
||||
.with_expiry(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token_refresh_token", "stored-refresh-token"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut credentials = HashMap::new();
|
||||
credentials.insert(
|
||||
"google_oauth_token".to_string(),
|
||||
CredentialMapping {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec!["www.googleapis.com".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
let caps = Capabilities {
|
||||
http: Some(HttpCapability {
|
||||
credentials,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let oauth_config = OAuthRefreshConfig {
|
||||
token_url: "http://127.0.0.1:9/provider-token-endpoint".to_string(),
|
||||
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
exchange_proxy_url: None,
|
||||
gateway_token: None,
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
|
||||
let resolved =
|
||||
resolve_host_credentials(&caps, Some(&store), "user1", Some(&oauth_config)).await;
|
||||
assert!(resolved.is_empty());
|
||||
|
||||
let lookups = store.decrypted_lookups();
|
||||
assert!(lookups.contains(&("user1".to_string(), "google_oauth_token".to_string())));
|
||||
assert!(!lookups.contains(&(
|
||||
"user1".to_string(),
|
||||
"google_oauth_token_refresh_token".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_private_ip_v4() {
|
||||
use std::net::IpAddr;
|
||||
|
||||
Reference in New Issue
Block a user