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
+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(""), "");
}
}