Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | |
Expand Down
163 changes: 130 additions & 33 deletions src/channels/web/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use axum::{
routing::{get, post},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer};
Expand Down Expand Up @@ -63,6 +64,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot =
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;

fn redact_oauth_state_for_logs(state: &str) -> String {
let digest = Sha256::digest(state.as_bytes());
let mut short_hash = String::with_capacity(12);
for byte in &digest[..6] {
use std::fmt::Write as _;
let _ = write!(&mut short_hash, "{byte:02x}");
}
format!("sha256:{short_hash}:len={}", state.len())
}

/// Simple sliding-window rate limiter.
///
/// Tracks the number of requests in the current window. Resets when the window expires.
Expand Down Expand Up @@ -566,22 +577,35 @@ async fn oauth_callback_handler(
}
};

// Strip instance prefix from state for registry lookup.
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
Ok(decoded) => decoded,
Err(error) => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
tracing::warn!(
state = %redacted_state,
error = %error,
"OAuth callback received with malformed state"
);
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
let lookup_key = decoded_state.flow_id.clone();

let flow = ext_mgr
.pending_oauth_flows()
.write()
.await
.remove(lookup_key);
.remove(&lookup_key);

let flow = match flow {
Some(f) => f,
None => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!(
state = %state_param,
lookup_key = %lookup_key,
state = %redacted_state,
lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state"
);
clear_auth_mode(&state).await;
Expand All @@ -608,41 +632,37 @@ async fn oauth_callback_handler(
}

// Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container),
// otherwise call the provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
// Use the platform exchange proxy when configured, otherwise call the
// provider's token URL directly.
let exchange_proxy_url = oauth_defaults::exchange_proxy_url();

let result: Result<(), String> = async {
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
{
// Use the platform exchange proxy when configured and no resource
// parameter is needed. The proxy holds client_secret server-side so
// the container never sees it. MCP flows (resource.is_some()) bypass
// the proxy because it doesn't forward the RFC 8707 resource param.
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
token_url: &flow.token_url,
client_id: &flow.client_id,
client_secret: flow.client_secret.as_deref(),
code: &code,
redirect_uri: &flow.redirect_uri,
code_verifier: flow.code_verifier.as_deref(),
access_token_field: &flow.access_token_field,
extra_token_params: &flow.token_exchange_extra_params,
})
.await
.map_err(|e| e.to_string())?
} else {
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
// flows can include the RFC 8707 `resource` parameter to scope the
// issued token to the specific MCP server.
oauth_defaults::exchange_oauth_code_with_resource(
oauth_defaults::exchange_oauth_code_with_params(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
flow.resource.as_deref(),
&flow.token_exchange_extra_params,
)
.await
.map_err(|e| e.to_string())?
Expand All @@ -669,10 +689,8 @@ async fn oauth_callback_handler(
.await
.map_err(|e| e.to_string())?;

// For MCP OAuth flows (identified by resource field), persist the
// client_id so token refresh works without re-authentication.
// The CLI flow stores this in authorize_mcp_server(); the gateway
// callback must do the same.
// Persist the client_id for flows that need it after the session ends
// (for example DCR-based MCP refresh).
if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
Expand Down Expand Up @@ -3311,7 +3329,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
Expand Down Expand Up @@ -3379,7 +3397,7 @@ mod tests {
secrets,
sse_sender: Some(sender),
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
Expand Down Expand Up @@ -3482,7 +3500,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
Expand Down Expand Up @@ -3534,6 +3552,85 @@ mod tests {
);
}

#[tokio::test]
async fn test_oauth_callback_accepts_versioned_hosted_state() {
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 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_sender: 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", 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("Authorization Failed"));
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}

// --- Slack relay OAuth CSRF tests ---

fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
Expand Down
Loading
Loading