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
+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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user