feat(oauth): route callbacks through web gateway for hosted instances (#555)

* feat: route OAuth callbacks through web gateway for hosted instances

On hosted instances (e.g., NEAR AI), OAuth callbacks can't reach the
local TCP listener on port 9876. This adds a gateway-routed OAuth flow
that works behind reverse proxies and load balancers.

Backend changes:
- Add /oauth/callback as a public route on the web gateway
- PendingOAuthFlow registry shared between ExtensionManager and handler
- Gateway mode auto-detected via IRONCLAW_OAUTH_CALLBACK_URL env var
- Platform state format (instance:nonce) for nginx routing
- Token exchange proxy support via IRONCLAW_OAUTH_EXCHANGE_URL
- Local TCP listener mode preserved as backward-compatible fallback

UX improvements:
- Hide Configure button for tools with auto-resolved OAuth credentials
  (builtin defaults or platform-injected env vars)
- Skip client_id/client_secret fields in setup schema when auto-resolved
- Show Reconfigure only after successful authentication

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(oauth): harden gateway callback and refactor AuthResult

- Add 60s timeout to exchange_via_proxy HTTP client (matching exchange_oauth_code)
- Read GATEWAY_AUTH_TOKEN once at ExtensionManager construction instead of
  per-flow from env (prevents coupling and clarifies token provenance)
- Extract oauth_error_page() helper to deduplicate error landing pages
- Remove IRONCLAW_FORCE_GATEWAY_CALLBACK env var (auto-detection suffices)
- Refactor AuthResult into typed AuthStatus enum with constructors,
  eliminating stringly-typed status and Option fields that were always None
- Adapt all handlers (chat, extensions, ws) to new AuthResult/AuthStatus API
- Use setup_url (not validation_endpoint) for awaiting_token responses

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(oauth): address review feedback — empty token guard, test flakiness, doc typos

- Fail early in exchange_via_proxy() when gateway_token is empty instead
  of sending an unauthenticated request to the exchange proxy
- Fix test_oauth_callback_strips_instance_prefix to use an expired flow
  so it never attempts a real HTTP token exchange (prevents CI flakiness)
- Fix doc comments: /auth/callback → /oauth/callback in PendingOAuthFlow
  and ExtensionManager pending_oauth_flows docs

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: clarify strip_instance_prefix safety, wrapper credential fix, test assertion

- Add comment to strip_instance_prefix noting nonces are base64url (no colons)
- Expand wrapper.rs comment explaining the credential_user_id bug fix
- Fix test_oauth_callback_strips_instance_prefix assertion: landing_html
  does not include provider_name on error pages

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-03-04 23:47:45 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 902492bcdb
commit 704d63f16a
11 changed files with 1759 additions and 465 deletions
+5 -5
View File
@@ -1295,7 +1295,7 @@ impl Agent {
};
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
Ok(result) if result.status == "authenticated" => {
Ok(result) if result.is_authenticated() => {
tracing::info!(
"Extension '{}' authenticated via auth mode",
pending.extension_name
@@ -1364,8 +1364,8 @@ impl Agent {
}
}
let msg = result
.instructions
.clone()
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
@@ -1375,8 +1375,8 @@ impl Agent {
StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url,
setup_url: result.setup_url,
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
},
&message.metadata,
)
+6 -5
View File
@@ -142,7 +142,7 @@ pub async fn chat_auth_token_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.status == "authenticated" {
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
@@ -170,13 +170,14 @@ pub async fn chat_auth_token_handler(
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions.clone(),
auth_url: result.auth_url.clone(),
setup_url: result.setup_url.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
}
+6 -6
View File
@@ -141,7 +141,7 @@ pub async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
@@ -152,13 +152,13 @@ pub async fn extensions_activate_handler(
// Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail(
auth_result
.instructions
.clone()
.instructions()
.map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
);
resp.auth_url = auth_result.auth_url;
resp.awaiting_token = Some(auth_result.awaiting_token);
resp.instructions = auth_result.instructions;
resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp))
}
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
+532 -20
View File
@@ -192,7 +192,9 @@ pub async fn start_server(
})?;
// Public routes (no auth)
let public = Router::new().route("/api/health", get(health_handler));
let public = Router::new()
.route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler));
// Protected routes (require auth)
let auth_state = AuthState { token: auth_token };
@@ -424,6 +426,180 @@ async fn health_handler() -> Json<HealthResponse> {
})
}
/// Return an OAuth error landing page response.
fn oauth_error_page(label: &str) -> axum::response::Response {
let html = crate::cli::oauth_defaults::landing_html(label, false);
axum::response::Html(html).into_response()
}
/// OAuth callback handler for the web gateway.
///
/// This is a PUBLIC route (no Bearer token required) because OAuth providers
/// redirect the user's browser here. The `state` query parameter correlates
/// the callback with a pending OAuth flow registered by `start_wasm_oauth()`.
///
/// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to
/// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`).
/// Local/desktop mode continues to use the TCP listener on port 9876.
async fn oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
use crate::cli::oauth_defaults;
// Check for error from OAuth provider (e.g., user denied consent)
if let Some(error) = params.get("error") {
let description = params
.get("error_description")
.cloned()
.unwrap_or_else(|| error.clone());
return oauth_error_page(&description);
}
let state_param = match params.get("state") {
Some(s) if !s.is_empty() => s.clone(),
_ => return oauth_error_page("IronClaw"),
};
let code = match params.get("code") {
Some(c) if !c.is_empty() => c.clone(),
_ => return oauth_error_page("IronClaw"),
};
// Look up the pending flow by CSRF state (atomic remove prevents replay)
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => return oauth_error_page("IronClaw"),
};
// 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 flow = ext_mgr
.pending_oauth_flows()
.write()
.await
.remove(lookup_key);
let flow = match flow {
Some(f) => f,
None => {
tracing::warn!(
state = %state_param,
lookup_key = %lookup_key,
"OAuth callback received with unknown or expired state"
);
return oauth_error_page("IronClaw");
}
};
// Check flow expiry (5 minutes, matching TCP listener timeout)
if flow.created_at.elapsed() > oauth_defaults::OAUTH_FLOW_EXPIRY {
tracing::warn!(
extension = %flow.extension_name,
"OAuth flow expired"
);
return oauth_error_page(&flow.display_name);
}
// 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();
let result: Result<(), String> = async {
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
.await
.map_err(|e| e.to_string())?
} else {
oauth_defaults::exchange_oauth_code(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
.await
.map_err(|e| e.to_string())?
};
// Validate the token before storing (catches wrong account, etc.)
if let Some(ref validation) = flow.validation_endpoint {
oauth_defaults::validate_oauth_token(&token_response.access_token, validation)
.await
.map_err(|e| e.to_string())?;
}
// Store tokens encrypted in the secrets store
oauth_defaults::store_oauth_tokens(
flow.secrets.as_ref(),
&flow.user_id,
&flow.secret_name,
flow.provider.as_deref(),
&token_response.access_token,
token_response.refresh_token.as_deref(),
token_response.expires_in,
&flow.scopes,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
.await;
let (success, message) = match &result {
Ok(()) => (
true,
format!("{} authenticated successfully", flow.display_name),
),
Err(e) => (
false,
format!("{} authentication failed: {}", flow.display_name, e),
),
};
match &result {
Ok(()) => {
tracing::info!(
extension = %flow.extension_name,
"OAuth completed successfully via gateway callback"
);
}
Err(e) => {
tracing::warn!(
extension = %flow.extension_name,
error = %e,
"OAuth failed via gateway callback"
);
}
}
// Broadcast SSE event to notify the web UI
if let Some(ref sender) = flow.sse_sender {
let _ = sender.send(SseEvent::AuthCompleted {
extension_name: flow.extension_name,
success,
message,
});
}
let html = oauth_defaults::landing_html(&flow.display_name, success);
axum::response::Html(html).into_response()
}
// --- Chat handlers ---
async fn chat_send_handler(
@@ -552,7 +728,7 @@ async fn chat_auth_token_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.status == "authenticated" {
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
@@ -580,13 +756,14 @@ async fn chat_auth_token_handler(
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions.clone(),
auth_url: result.auth_url.clone(),
setup_url: result.setup_url.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
}
@@ -1332,12 +1509,9 @@ async fn extensions_install_handler(
// configured (e.g., built-in providers). We only surface an auth_url
// when the extension reports it is awaiting authorization.
match ext_mgr.auth(&req.name, None).await {
Ok(auth_result)
if auth_result.auth_url.is_some()
&& auth_result.status == "awaiting_authorization" =>
{
Ok(auth_result) if auth_result.auth_url().is_some() => {
// Scope expansion or initial OAuth: user needs to authorize
resp.auth_url = auth_result.auth_url;
resp.auth_url = auth_result.auth_url().map(String::from);
}
_ => {}
}
@@ -1366,10 +1540,9 @@ async fn extensions_activate_handler(
// Initial OAuth setup is triggered via save_setup_secrets.
let mut resp = ActionResponse::ok(result.message);
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
&& auth_result.auth_url.is_some()
&& auth_result.status == "awaiting_authorization"
&& auth_result.auth_url().is_some()
{
resp.auth_url = auth_result.auth_url;
resp.auth_url = auth_result.auth_url().map(String::from);
}
Ok(Json(resp))
}
@@ -1385,7 +1558,7 @@ async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
@@ -1396,13 +1569,13 @@ async fn extensions_activate_handler(
// Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail(
auth_result
.instructions
.clone()
.instructions()
.map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
);
resp.auth_url = auth_result.auth_url;
resp.awaiting_token = Some(auth_result.awaiting_token);
resp.instructions = auth_result.instructions;
resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp))
}
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
@@ -2239,4 +2412,343 @@ mod tests {
let turns = build_turns_from_db_messages(&[]);
assert!(turns.is_empty());
}
// --- OAuth callback handler tests ---
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: ext_mgr,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
skill_registry: None,
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
registry_entries: vec![],
cost_guard: None,
startup_time: std::time::Instant::now(),
})
}
/// Build a test router with just the OAuth callback route.
fn test_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route("/oauth/callback", get(oauth_callback_handler))
.with_state(state)
}
#[tokio::test]
async fn test_oauth_callback_missing_params() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback")
.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"));
}
#[tokio::test]
async fn test_oauth_callback_error_from_provider() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?error=access_denied&error_description=access_denied")
.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"));
}
#[tokio::test]
async fn test_oauth_callback_unknown_state() {
use axum::body::Body;
use tower::ServiceExt;
// Build an ExtensionManager so the handler can look up flows
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets,
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
let state = test_gateway_state(Some(ext_mgr));
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=unknown_state_value")
.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"));
}
#[tokio::test]
async fn test_oauth_callback_expired_flow() {
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-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets.clone(),
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
// Insert an expired flow (created 10 minutes ago)
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,
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("expired_state".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr));
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=expired_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);
// Expired flow → error landing page
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_no_extension_manager() {
use axum::body::Body;
use tower::ServiceExt;
// No extension manager set → graceful error
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=some_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"));
}
#[tokio::test]
async fn test_oauth_callback_strips_instance_prefix() {
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-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets.clone(),
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
// Insert a flow keyed by raw nonce "test_nonce" (without instance prefix).
// Use an expired flow so the handler exits before attempting a real HTTP
// token exchange — we only need to verify that the instance prefix was
// stripped and the flow was found by the raw nonce.
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,
// Expired — handler will reject after lookup (no network I/O)
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
};
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);
// Send callback with instance prefix: "myinstance:test_nonce"
// The handler should strip "myinstance:" and find the flow keyed by "test_nonce"
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=fake_code&state=myinstance:test_nonce")
.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);
// The flow was found (stripped prefix matched) but is expired, so the
// handler returns an error landing page. The flow being consumed from
// the registry (checked below) proves the prefix was stripped correctly.
assert!(
html.contains("Authorization Failed"),
"Expected error page, html was: {}",
&html[..html.len().min(500)]
);
// Verify the flow was consumed (removed from registry)
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
}
+5 -1
View File
@@ -2013,7 +2013,11 @@ function renderExtensionCard(ext) {
actions.appendChild(activateBtn);
}
if (ext.needs_setup || ext.has_auth) {
// Show Configure/Reconfigure button when there are secrets to enter.
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
// this means OAuth credentials resolve automatically (builtin/env) and the user
// just needs to complete the OAuth flow, not fill in a config form.
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
+4 -4
View File
@@ -242,7 +242,7 @@ async fn handle_client_message(
} => {
if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.auth(&extension_name, Some(&token)).await {
Ok(result) if result.status == "authenticated" => {
Ok(result) if result.is_authenticated() => {
let msg = match ext_mgr.activate(&extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
@@ -268,9 +268,9 @@ async fn handle_client_message(
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions,
auth_url: result.auth_url,
setup_url: result.setup_url,
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
}
Err(e) => {
+372
View File
@@ -18,6 +18,7 @@
//! env vars, which take priority over built-in defaults.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -25,6 +26,7 @@ use rand::RngCore;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use crate::secrets::{CreateSecretParams, SecretsStore};
@@ -683,6 +685,219 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
)
}
// ── Gateway callback support ─────────────────────────────────────────
/// State for an in-progress OAuth flow, keyed by CSRF `state` parameter.
///
/// Created by `start_wasm_oauth()` and consumed by the web gateway's
/// `/oauth/callback` handler when running in hosted mode.
pub struct PendingOAuthFlow {
/// Extension name (e.g., "google_calendar").
pub extension_name: String,
/// Human-readable display name (e.g., "Google Calendar").
pub display_name: String,
/// OAuth token exchange URL.
pub token_url: String,
/// OAuth client ID.
pub client_id: String,
/// OAuth client secret (optional for PKCE-only flows).
pub client_secret: Option<String>,
/// The redirect_uri used in the authorization request.
pub redirect_uri: String,
/// PKCE code verifier (must match the code_challenge sent in the auth URL).
pub code_verifier: Option<String>,
/// Field name in token response containing the access token.
pub access_token_field: String,
/// Secret name for storage (e.g., "google_oauth_token").
pub secret_name: String,
/// Provider hint (e.g., "google").
pub provider: Option<String>,
/// Token validation endpoint (optional).
pub validation_endpoint: Option<crate::tools::wasm::ValidationEndpointSchema>,
/// Scopes that were requested.
pub scopes: Vec<String>,
/// User ID for secret storage.
pub user_id: String,
/// Secrets store reference for token persistence.
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
/// SSE broadcast sender for notifying the web UI.
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// When this flow was created (for expiry).
pub created_at: std::time::Instant,
}
impl std::fmt::Debug for PendingOAuthFlow {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingOAuthFlow")
.field("extension_name", &self.extension_name)
.field("display_name", &self.display_name)
.field("secret_name", &self.secret_name)
.field("created_at", &self.created_at)
.finish_non_exhaustive()
}
}
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
/// Create a new empty pending OAuth flow registry.
pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
Arc::new(RwLock::new(HashMap::new()))
}
/// Returns `true` if OAuth callbacks should be routed through the web gateway
/// instead of the local TCP listener.
///
/// This is the case when `IRONCLAW_OAUTH_CALLBACK_URL` is set to a non-loopback
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost.
pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.map(|raw| {
url::Url::parse(&raw)
.ok()
.and_then(|u| u.host_str().map(String::from))
.map(|host| !is_loopback_host(&host))
.unwrap_or(false)
})
.unwrap_or(false)
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
/// Remove expired flows from the registry.
///
/// Called when inserting new flows to prevent accumulation from abandoned
/// OAuth attempts.
pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
let mut flows = registry.write().await;
flows.retain(|_, flow| flow.created_at.elapsed() < OAUTH_FLOW_EXPIRY);
}
// ── Platform routing helpers ────────────────────────────────────────
/// Prepend instance name to CSRF state for platform routing.
///
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
/// from the `state` query parameter (format: `instance:nonce`) to route the
/// OAuth callback to the correct container.
///
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
/// (local/non-platform mode).
pub fn build_platform_state(nonce: &str) -> String {
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
.ok()
.filter(|v| !v.is_empty());
match instance {
Some(name) => format!("{}:{}", name, nonce),
None => nonce.to_string(),
}
}
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
///
/// `"myinstance:abc123"` → `"abc123"`, `"abc123"` → `"abc123"` (no prefix).
///
/// Safe because nonces are base64url-encoded (`[A-Za-z0-9_-]`, no colons).
pub fn strip_instance_prefix(state: &str) -> &str {
state
.split_once(':')
.map(|(_, nonce)| nonce)
.unwrap_or(state)
}
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// The proxy holds `client_secret` server-side so the container never sees it.
/// Authenticated via the gateway auth token (Bearer header).
///
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy(
proxy_url: &str,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
];
if let Some(verifier) = code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
let response = client
.post(&exchange_url)
.bearer_auth(gateway_token)
.form(&params)
.send()
.await
.map_err(|e| {
OAuthCallbackError::Io(format!("Token exchange proxy request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(OAuthCallbackError::Io(format!(
"Token exchange proxy failed: {} - {}",
status, body
)));
}
let token_data: serde_json::Value = response
.json()
.await
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data
.get(access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
let fields: Vec<&str> = token_data
.as_object()
.map(|o| o.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields
))
})?
.to_string();
let refresh_token = token_data
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
Ok(OAuthTokenResponse {
access_token,
refresh_token,
expires_in,
})
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
@@ -939,4 +1154,161 @@ mod tests {
// State should be different each time (random)
assert_ne!(result1.state, result2.state);
}
#[test]
fn test_use_gateway_callback_false_by_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
}
}
#[test]
fn test_use_gateway_callback_true_for_hosted() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
"IRONCLAW_OAUTH_CALLBACK_URL",
"https://kind-deer.agent1.near.ai",
);
}
assert!(crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_use_gateway_callback_false_for_localhost() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "http://127.0.0.1:3001");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_use_gateway_callback_false_for_empty() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
}
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
} else {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
}
}
}
#[test]
fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
assert_eq!(build_platform_state("abc123"), "abc123");
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
}
if let Some(val) = original_oc {
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
}
}
}
#[test]
fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
}
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
unsafe {
if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
}
if let Some(val) = original_oc {
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
} else {
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
}
}
#[test]
fn test_strip_instance_prefix_with_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
assert_eq!(strip_instance_prefix("kind-deer:abc123"), "abc123");
assert_eq!(strip_instance_prefix("my-instance:xyz"), "xyz");
}
#[test]
fn test_strip_instance_prefix_without_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
assert_eq!(strip_instance_prefix("abc123"), "abc123");
assert_eq!(strip_instance_prefix(""), "");
}
}
+440 -403
View File
File diff suppressed because it is too large Load Diff
+379 -18
View File
@@ -24,6 +24,7 @@ pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
pub use registry::ExtensionRegistry;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
/// The kind of extension, determining how it's installed, authenticated, and activated.
@@ -145,28 +146,267 @@ pub struct InstallResult {
pub message: String,
}
/// Auth readiness state for the extensions list UI.
///
/// Used by `check_tool_auth_status` and `check_channel_auth_status` to
/// communicate a tool's credential state to the list handler without
/// ambiguous `(bool, bool)` tuples.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolAuthState {
/// Token/credentials are present — ready to use.
Ready,
/// Auth section exists but the access token is missing (OAuth not completed).
NeedsAuth,
/// Setup credentials (client_id/secret) must be configured before OAuth can start.
NeedsSetup,
/// No auth configuration at all (no capabilities or auth section).
NoAuth,
}
/// The typed auth status, carrying only the data relevant to each state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthStatus {
/// Authentication is complete; no further action needed.
Authenticated,
/// No authentication is required for this extension.
NoAuthRequired,
/// OAuth flow started — user must open `auth_url` in their browser.
AwaitingAuthorization {
auth_url: String,
callback_type: String,
},
/// Waiting for user to provide a token/key manually.
AwaitingToken {
instructions: String,
setup_url: Option<String>,
},
/// OAuth client credentials need to be configured before auth can proceed.
NeedsSetup {
instructions: String,
setup_url: Option<String>,
},
}
impl AuthStatus {
/// The wire-format status string (backward-compatible with JS consumers).
pub fn as_str(&self) -> &'static str {
match self {
AuthStatus::Authenticated => "authenticated",
AuthStatus::NoAuthRequired => "no_auth_required",
AuthStatus::AwaitingAuthorization { .. } => "awaiting_authorization",
AuthStatus::AwaitingToken { .. } => "awaiting_token",
AuthStatus::NeedsSetup { .. } => "needs_setup",
}
}
}
/// Result of authenticating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct AuthResult {
pub name: String,
pub kind: ExtensionKind,
/// OAuth URL to open (for OAuth flows).
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_url: Option<String>,
/// Whether using local or remote callback.
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_type: Option<String>,
/// Instructions for manual token entry (for WASM tools).
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// URL for manual token setup.
#[serde(skip_serializing_if = "Option::is_none")]
pub setup_url: Option<String>,
/// Whether the tool is waiting for a token from the user.
#[serde(default)]
pub awaiting_token: bool,
/// Current auth status.
pub status: String,
pub status: AuthStatus,
}
impl AuthResult {
// ── Constructors ──────────────────────────────────────────────────
pub fn authenticated(name: impl Into<String>, kind: ExtensionKind) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::Authenticated,
}
}
pub fn no_auth_required(name: impl Into<String>, kind: ExtensionKind) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::NoAuthRequired,
}
}
pub fn awaiting_authorization(
name: impl Into<String>,
kind: ExtensionKind,
auth_url: String,
callback_type: String,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingAuthorization {
auth_url,
callback_type,
},
}
}
pub fn awaiting_token(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingToken {
instructions,
setup_url,
},
}
}
pub fn needs_setup(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::NeedsSetup {
instructions,
setup_url,
},
}
}
// ── Accessors ─────────────────────────────────────────────────────
pub fn is_authenticated(&self) -> bool {
matches!(self.status, AuthStatus::Authenticated)
}
pub fn auth_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { auth_url, .. } => Some(auth_url),
_ => None,
}
}
pub fn callback_type(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { callback_type, .. } => Some(callback_type),
_ => None,
}
}
pub fn instructions(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { instructions, .. }
| AuthStatus::NeedsSetup { instructions, .. } => Some(instructions),
_ => None,
}
}
pub fn setup_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { setup_url, .. }
| AuthStatus::NeedsSetup { setup_url, .. } => setup_url.as_deref(),
_ => None,
}
}
pub fn is_awaiting_token(&self) -> bool {
matches!(self.status, AuthStatus::AwaitingToken { .. })
}
pub fn status_str(&self) -> &'static str {
self.status.as_str()
}
}
/// Serialize `AuthResult` to the same flat JSON shape the JS frontend expects.
impl Serialize for AuthResult {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Count fields: name + kind + status + optional fields
let optional_count = self.auth_url().is_some() as usize
+ self.callback_type().is_some() as usize
+ self.instructions().is_some() as usize
+ self.setup_url().is_some() as usize;
let mut map = serializer.serialize_map(Some(4 + optional_count))?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("kind", &self.kind)?;
if let Some(url) = self.auth_url() {
map.serialize_entry("auth_url", url)?;
}
if let Some(cb) = self.callback_type() {
map.serialize_entry("callback_type", cb)?;
}
if let Some(inst) = self.instructions() {
map.serialize_entry("instructions", inst)?;
}
if let Some(url) = self.setup_url() {
map.serialize_entry("setup_url", url)?;
}
map.serialize_entry("awaiting_token", &self.is_awaiting_token())?;
map.serialize_entry("status", self.status_str())?;
map.end()
}
}
/// Deserialize from the flat JSON shape back into the typed enum.
impl<'de> Deserialize<'de> for AuthResult {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
/// Flat helper matching the old JSON shape.
#[derive(Deserialize)]
#[allow(dead_code)]
struct Raw {
name: String,
kind: ExtensionKind,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
callback_type: Option<String>,
#[serde(default)]
instructions: Option<String>,
#[serde(default)]
setup_url: Option<String>,
#[serde(default)]
awaiting_token: bool,
status: String,
}
let raw = Raw::deserialize(deserializer)?;
let status = match raw.status.as_str() {
"authenticated" => AuthStatus::Authenticated,
"no_auth_required" => AuthStatus::NoAuthRequired,
"awaiting_authorization" => AuthStatus::AwaitingAuthorization {
auth_url: raw.auth_url.unwrap_or_default(),
callback_type: raw.callback_type.unwrap_or_default(),
},
"awaiting_token" => AuthStatus::AwaitingToken {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
"needs_setup" => AuthStatus::NeedsSetup {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
other => {
return Err(serde::de::Error::unknown_variant(
other,
&[
"authenticated",
"no_auth_required",
"awaiting_authorization",
"awaiting_token",
"needs_setup",
],
));
}
};
Ok(AuthResult {
name: raw.name,
kind: raw.kind,
status,
})
}
}
/// Result of activating an extension.
@@ -257,3 +497,124 @@ pub enum ExtensionError {
#[error("{0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_result_authenticated_round_trip() {
let result = AuthResult::authenticated("gmail", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "authenticated");
assert_eq!(json["name"], "gmail");
assert_eq!(json["kind"], "wasm_tool");
assert_eq!(json["awaiting_token"], false);
assert!(json.get("auth_url").is_none());
assert!(json.get("instructions").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_authenticated());
assert!(back.auth_url().is_none());
}
#[test]
fn auth_result_awaiting_authorization_round_trip() {
let result = AuthResult::awaiting_authorization(
"google-drive",
ExtensionKind::WasmTool,
"https://accounts.google.com/o/oauth2/v2/auth?state=abc".to_string(),
"local".to_string(),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_authorization");
assert_eq!(
json["auth_url"],
"https://accounts.google.com/o/oauth2/v2/auth?state=abc"
);
assert_eq!(json["callback_type"], "local");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert_eq!(
back.auth_url(),
Some("https://accounts.google.com/o/oauth2/v2/auth?state=abc")
);
assert_eq!(back.callback_type(), Some("local"));
assert!(!back.is_authenticated());
}
#[test]
fn auth_result_awaiting_token_round_trip() {
let result = AuthResult::awaiting_token(
"telegram",
ExtensionKind::WasmChannel,
"Enter your bot token".to_string(),
None,
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_token");
assert_eq!(json["instructions"], "Enter your bot token");
assert_eq!(json["awaiting_token"], true);
assert!(json.get("auth_url").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_awaiting_token());
assert_eq!(back.instructions(), Some("Enter your bot token"));
}
#[test]
fn auth_result_needs_setup_round_trip() {
let result = AuthResult::needs_setup(
"custom-tool",
ExtensionKind::WasmTool,
"Configure OAuth credentials in the Setup tab.".to_string(),
Some("https://console.cloud.google.com".to_string()),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "needs_setup");
assert_eq!(json["setup_url"], "https://console.cloud.google.com");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert!(!back.is_awaiting_token());
assert_eq!(back.setup_url(), Some("https://console.cloud.google.com"));
}
#[test]
fn auth_result_no_auth_required_round_trip() {
let result = AuthResult::no_auth_required("echo", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "no_auth_required");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert_eq!(back.status, AuthStatus::NoAuthRequired);
}
#[test]
fn auth_status_type_safety() {
// AwaitingAuthorization always has auth_url
let result = AuthResult::awaiting_authorization(
"test",
ExtensionKind::WasmTool,
"https://example.com".to_string(),
"local".to_string(),
);
assert!(result.auth_url().is_some());
assert!(!result.is_awaiting_token());
// Authenticated never has auth_url
let result = AuthResult::authenticated("test", ExtensionKind::WasmTool);
assert!(result.auth_url().is_none());
assert!(result.instructions().is_none());
assert!(result.setup_url().is_none());
}
}
+2 -2
View File
@@ -218,7 +218,7 @@ impl Tool for ToolAuthTool {
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
// Auto-activate after successful auth so tools are available immediately
if result.status == "authenticated" {
if result.is_authenticated() {
match self.manager.activate(name).await {
Ok(activate_result) => {
let output = serde_json::json!({
@@ -324,7 +324,7 @@ impl Tool for ToolActivateTool {
// Activation failed due to missing auth; initiate auth flow
// so the agent loop can show the auth card.
match self.manager.auth(name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded (e.g. env var was set); retry activation.
let result = self
.manager
+8 -1
View File
@@ -656,10 +656,17 @@ impl Tool for WasmToolWrapper {
// Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access.
//
// BUG FIX: ExtensionManager stores OAuth tokens under user_id "default"
// (hardcoded at construction in app.rs), but this was previously looking
// them up under ctx.user_id — which could be a Telegram user ID, web
// gateway user, etc. — causing credential resolution to silently fail.
// Must match the storage key until per-user credential isolation is added.
let credential_user_id = "default";
let host_credentials = resolve_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&ctx.user_id,
credential_user_id,
self.oauth_refresh.as_ref(),
)
.await;