Fix: allow OAuth callback to work on remote servers (fixes #186) (#212)

* fix: allow OAuth callback to work on remote servers via OAUTH_CALLBACK_HOST

Fixes #186.

The OAuth callback URL was hardcoded to `http://127.0.0.1:9876` in two
places (NEAR AI login and MCP server auth). On a remote server this URL
is unreachable from the user's browser, making authentication impossible.

Changes:
- Add `callback_host()` to `oauth_defaults` that reads `OAUTH_CALLBACK_HOST`
  (default: `127.0.0.1`)
- Update `bind_callback_listener()` to bind to `0.0.0.0` when a non-loopback
  host is configured, so the port is reachable from outside the machine
- Update `session.rs` and `mcp/auth.rs` to use `callback_host()` instead
  of hardcoded `127.0.0.1` / `localhost`

Usage on a remote server:
  export OAUTH_CALLBACK_HOST=<your-server-ip>
  ironclaw login

* fix: address PR review comments for OAuth callback security

* fix: address serrrfirat review comments on PR #212

---------

Co-authored-by: firat.sertgoz <[email protected]>
This commit is contained in:
Nitanshu Lokhande
2026-02-21 15:39:47 +04:00
committed by GitHub
co-authored by firat.sertgoz
parent 0a30c95ee1
commit 37c0158765
3 changed files with 172 additions and 32 deletions
+145 -31
View File
@@ -66,12 +66,47 @@ pub const OAUTH_CALLBACK_PORT: u16 = 9876;
///
/// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`.
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT))
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
}
/// Returns the hostname used in OAuth callback URLs.
///
/// Reads `OAUTH_CALLBACK_HOST` from the environment (default: `127.0.0.1`).
///
/// **Remote server usage:** set `OAUTH_CALLBACK_HOST` to the network interface
/// address you want to listen on (e.g. the server's LAN IP or `0.0.0.0`).
/// The callback listener will bind to that specific address instead of the
/// loopback interface, so the OAuth redirect can reach an external browser.
/// Note: this transmits the session token over plain HTTP — prefer SSH port
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
///
/// # Example
///
/// ```bash
/// export OAUTH_CALLBACK_HOST=203.0.113.10
/// ironclaw login
/// # Opens: http://203.0.113.10:9876/auth/callback
/// ```
pub fn callback_host() -> String {
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
}
/// Returns `true` if `host` is a loopback address that only accepts local connections.
///
/// Covers `localhost` (case-insensitive), the full `127.0.0.0/8` IPv4 loopback
/// range, and `::1` for IPv6.
pub fn is_loopback_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
host.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
/// Error from the OAuth callback listener.
@@ -90,35 +125,50 @@ pub enum OAuthCallbackError {
Io(String),
}
/// Map a `std::io::Error` from a bind attempt to an `OAuthCallbackError`.
fn bind_error(e: std::io::Error) -> OAuthCallbackError {
if e.kind() == std::io::ErrorKind::AddrInUse {
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
} else {
OAuthCallbackError::Io(e.to_string())
}
}
/// Bind the OAuth callback listener on the fixed port.
///
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
/// than `AddrInUse`. If the port is already occupied, fails immediately.
/// When `OAUTH_CALLBACK_HOST` is a loopback address (the default `127.0.0.1`),
/// binds to `127.0.0.1` first and falls back to `[::1]` so local-only auth
/// flows remain restricted to the local machine.
///
/// When `OAUTH_CALLBACK_HOST` is set to a remote address, binds to that
/// specific address so only connections directed to it are accepted.
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
match TcpListener::bind(&ipv4_addr).await {
Ok(listener) => return Ok(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
return Err(OAuthCallbackError::PortInUse(
OAUTH_CALLBACK_PORT,
e.to_string(),
));
}
Err(_) => {
// IPv4 not available, fall back to IPv6
}
}
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AddrInUse {
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
} else {
OAuthCallbackError::Io(e.to_string())
let host = callback_host();
if is_loopback_host(&host) {
// Local mode: prefer IPv4 loopback, fall back to IPv6.
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
match TcpListener::bind(&ipv4_addr).await {
Ok(listener) => return Ok(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
return Err(OAuthCallbackError::PortInUse(
OAUTH_CALLBACK_PORT,
e.to_string(),
));
}
})
Err(_) => {
// IPv4 not available, fall back to IPv6
}
}
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
.await
.map_err(bind_error)
} else {
// Remote mode: bind to the specific configured host address only,
// not 0.0.0.0, to limit exposure to the intended interface.
let addr = format!("{}:{}", host, OAUTH_CALLBACK_PORT);
TcpListener::bind(&addr).await.map_err(bind_error)
}
}
/// Wait for an OAuth callback and extract a query parameter value.
@@ -311,27 +361,91 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
mod tests {
use std::sync::Mutex;
use crate::cli::oauth_defaults::{builtin_credentials, callback_url, landing_html};
use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
};
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_is_loopback_host() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.2")); // full 127.0.0.0/8 range
assert!(is_loopback_host("127.255.255.254"));
assert!(is_loopback_host("::1"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("LOCALHOST"));
assert!(!is_loopback_host("203.0.113.10"));
assert!(!is_loopback_host("my-server.example.com"));
assert!(!is_loopback_host("0.0.0.0"));
}
#[test]
fn test_callback_host_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("OAUTH_CALLBACK_HOST");
}
assert_eq!(callback_host(), "127.0.0.1");
// Restore
unsafe {
if let Some(val) = original {
std::env::set_var("OAUTH_CALLBACK_HOST", val);
}
}
}
#[test]
fn test_callback_host_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("OAUTH_CALLBACK_HOST", "203.0.113.10");
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
assert_eq!(callback_host(), "203.0.113.10");
// callback_url() fallback should incorporate the custom host
let url = callback_url();
assert!(url.contains("203.0.113.10"), "url was: {url}");
// Restore
unsafe {
if let Some(val) = original_host {
std::env::set_var("OAUTH_CALLBACK_HOST", val);
} else {
std::env::remove_var("OAUTH_CALLBACK_HOST");
}
if let Some(val) = original_url {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
}
}
#[test]
fn test_callback_url_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// Clear the env var to test default behavior
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// Clear both env vars to test default behavior
let original_url = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
let original_host = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
std::env::remove_var("OAUTH_CALLBACK_HOST");
}
let url = callback_url();
assert_eq!(url, "http://127.0.0.1:9876");
// Restore
unsafe {
if let Some(val) = original {
if let Some(val) = original_url {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
if let Some(val) = original_host {
std::env::set_var("OAUTH_CALLBACK_HOST", val);
}
}
}
+15
View File
@@ -7,6 +7,8 @@
use std::path::PathBuf;
use std::sync::Arc;
use crate::cli::oauth_defaults::OAUTH_CALLBACK_PORT;
use chrono::{DateTime, Utc};
use reqwest::Client;
use secrecy::SecretString;
@@ -238,6 +240,7 @@ impl SessionManager {
use crate::cli::oauth_defaults;
let cb_url = oauth_defaults::callback_url();
let host = oauth_defaults::callback_host();
// Show auth provider menu BEFORE binding the listener
println!();
@@ -288,6 +291,18 @@ impl SessionManager {
}
}
// Warn about plain-HTTP token transmission only for OAuth paths (1, 2)
// where the callback URL actually carries the session token.
if !oauth_defaults::is_loopback_host(&host) {
println!();
println!("Warning: OAuth callback is using plain HTTP to a remote host ({host}).");
println!(" The session token will be transmitted unencrypted.");
println!(" Consider SSH port forwarding instead:");
println!(
" ssh -L {OAUTH_CALLBACK_PORT}:127.0.0.1:{OAUTH_CALLBACK_PORT} user@{host}"
);
}
// OAuth paths: bind the callback listener now
let listener = oauth_defaults::bind_callback_listener()
.await
+12 -1
View File
@@ -380,7 +380,18 @@ pub async fn authorize_mcp_server(
) -> Result<AccessToken, AuthError> {
// Find an available port for the callback first (needed for DCR)
let (listener, port) = find_available_port().await?;
let redirect_uri = format!("http://localhost:{}/callback", port);
let host = oauth_defaults::callback_host();
let redirect_uri = format!("http://{}:{}/callback", host, port);
// Warn when the callback is served over plain HTTP to a remote host.
// Authorization codes travel unencrypted; SSH port forwarding is safer:
// ssh -L <port>:127.0.0.1:<port> user@your-server
if !oauth_defaults::is_loopback_host(&host) {
println!("Warning: MCP OAuth callback is using plain HTTP to a remote host ({host}).");
println!(" Authorization codes will be transmitted unencrypted.");
println!(" Consider SSH port forwarding instead:");
println!(" ssh -L {port}:127.0.0.1:{port} user@{host}");
}
// Determine client_id and endpoints
let (client_id, authorization_url, token_url, use_pkce, scopes, extra_params) =