mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
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:
co-authored by
Claude Opus 4.6
parent
902492bcdb
commit
704d63f16a
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(¶ms)
|
||||
.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
File diff suppressed because it is too large
Load Diff
+379
-18
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user