diff --git a/Cargo.toml b/Cargo.toml index a3804e5d..49e652da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ exclude = [ "tools-src/google-drive", "tools-src/google-sheets", "tools-src/google-slides", - "tools-src/okta", "tools-src/slack", "tools-src/telegram", ] diff --git a/registry/tools/okta.json b/registry/tools/okta.json deleted file mode 100644 index 26d17675..00000000 --- a/registry/tools/okta.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "okta", - "display_name": "Okta", - "kind": "tool", - "version": "0.1.0", - "description": "Okta SSO for user profile, app catalog, and SSO launch links", - "keywords": ["sso", "identity", "authentication", "okta"], - - "source": { - "dir": "tools-src/okta", - "capabilities": "okta-tool.capabilities.json", - "crate_name": "okta-tool" - }, - - "artifacts": { - "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/okta-wasm32-wasip2.tar.gz", - "sha256": null - } - }, - - "auth_summary": { - "method": "oauth", - "provider": "Okta", - "secrets": ["okta_oauth_token"], - "shared_auth": null, - "setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/" - }, - - "tags": ["identity"] -} diff --git a/tools-src/okta/Cargo.toml b/tools-src/okta/Cargo.toml deleted file mode 100644 index 5265cf4d..00000000 --- a/tools-src/okta/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "okta-tool" -version = "0.1.0" -edition = "2021" -description = "Okta SSO tool for IronClaw (WASM component) — user profile, app catalog, and SSO launch links" -license = "MIT OR Apache-2.0" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = "=0.36" -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -[profile.release] -opt-level = "s" -lto = true -strip = true -codegen-units = 1 - -[workspace] diff --git a/tools-src/okta/okta-tool.capabilities.json b/tools-src/okta/okta-tool.capabilities.json deleted file mode 100644 index 1badf9d3..00000000 --- a/tools-src/okta/okta-tool.capabilities.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "http": { - "allowlist": [ - { - "host": "*.okta.com", - "path_prefix": "/api/v1/", - "methods": ["GET", "POST", "PUT"] - }, - { - "host": "*.okta.com", - "path_prefix": "/idp/myaccount/", - "methods": ["GET", "PUT"] - }, - { - "host": "*.okta.com", - "path_prefix": "/oauth2/v1/", - "methods": ["POST"] - }, - { - "host": "*.oktapreview.com", - "path_prefix": "/api/v1/", - "methods": ["GET", "POST", "PUT"] - }, - { - "host": "*.oktapreview.com", - "path_prefix": "/idp/myaccount/", - "methods": ["GET", "PUT"] - }, - { - "host": "*.oktapreview.com", - "path_prefix": "/oauth2/v1/", - "methods": ["POST"] - }, - { - "host": "*.okta-emea.com", - "path_prefix": "/api/v1/", - "methods": ["GET", "POST", "PUT"] - }, - { - "host": "*.okta-emea.com", - "path_prefix": "/idp/myaccount/", - "methods": ["GET", "PUT"] - }, - { - "host": "*.okta-emea.com", - "path_prefix": "/oauth2/v1/", - "methods": ["POST"] - } - ], - "credentials": { - "okta_oauth_token": { - "secret_name": "okta_oauth_token", - "location": { "type": "bearer" }, - "host_patterns": ["*.okta.com", "*.oktapreview.com", "*.okta-emea.com"] - } - }, - "rate_limit": { - "requests_per_minute": 30, - "requests_per_hour": 500 - }, - "timeout_secs": 30 - }, - "workspace": { - "allowed_prefixes": ["okta/"] - }, - "secrets": { - "allowed_names": ["okta_oauth_token"] - }, - "auth": { - "secret_name": "okta_oauth_token", - "display_name": "Okta", - "oauth": { - "authorization_url": "https://{okta_domain}/oauth2/v1/authorize", - "token_url": "https://{okta_domain}/oauth2/v1/token", - "client_id_env": "OKTA_OAUTH_CLIENT_ID", - "client_secret_env": "OKTA_OAUTH_CLIENT_SECRET", - "scopes": [ - "openid", - "profile", - "email", - "offline_access", - "okta.users.read.self", - "okta.users.manage.self", - "okta.apps.read" - ], - "use_pkce": true - }, - "instructions": "1. In your Okta Admin Console, go to Applications > Create App Integration\n2. Select 'OIDC - OpenID Connect', then 'Web Application'\n3. Set Sign-in redirect URI to http://localhost:9876/callback (through :9886)\n4. Under Okta API Scopes, grant: okta.users.read.self, okta.users.manage.self, okta.apps.read\n5. Copy the Client ID and Client Secret\n6. IMPORTANT: You must use the Org Authorization Server (not a custom one)\n7. Store your Okta domain in workspace at 'okta/domain' (e.g., 'mycompany.okta.com')\n8. For custom domains, add them to okta-tool.capabilities.json allowlist", - "setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/", - "token_hint": "OAuth2 access token (JWT)", - "env_var": "OKTA_OAUTH_TOKEN" - }, - "setup": { - "required_secrets": [ - { - "name": "okta_oauth_client_id", - "prompt": "Okta OAuth Client ID" - }, - { - "name": "okta_oauth_client_secret", - "prompt": "Okta OAuth Client Secret" - } - ] - } -} diff --git a/tools-src/okta/src/api.rs b/tools-src/okta/src/api.rs deleted file mode 100644 index 1d28248f..00000000 --- a/tools-src/okta/src/api.rs +++ /dev/null @@ -1,281 +0,0 @@ -use crate::near::agent::host; -use crate::types::*; - -const WORKSPACE_DOMAIN_PATH: &str = "okta/domain"; - -/// Read the configured Okta domain from workspace, or return a helpful error. -fn get_domain() -> Result { - host::workspace_read(WORKSPACE_DOMAIN_PATH).ok_or_else(|| { - "Okta domain not configured. Write your Okta domain to workspace path 'okta/domain' \ - using the memory_write tool (e.g., memory_write with path='okta/domain' and \ - content='mycompany.okta.com')." - .to_string() - }) -} - -/// Build the base URL for the Okta Management API. -fn management_base(domain: &str) -> String { - format!("https://{}/api/v1", domain) -} - -/// Make an Okta API call. -fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result { - let headers = if body.is_some() { - r#"{"Content-Type": "application/json", "Accept": "application/json"}"# - } else { - r#"{"Accept": "application/json"}"# - }; - - let body_bytes = body.map(|b| b.as_bytes().to_vec()); - - host::log( - host::LogLevel::Debug, - &format!("Okta API: {} {}", method, url), - ); - - let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?; - - if response.status < 200 || response.status >= 300 { - let body_text = String::from_utf8_lossy(&response.body); - // Try to extract Okta's error summary for a better message. - if let Ok(parsed) = serde_json::from_str::(&body_text) { - if let Some(summary) = parsed["errorSummary"].as_str() { - return Err(format!("Okta API error ({}): {}", response.status, summary)); - } - } - return Err(format!( - "Okta API returned status {}: {}", - response.status, body_text - )); - } - - String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e)) -} - -// --------------------------------------------------------------------------- -// Action implementations -// --------------------------------------------------------------------------- - -/// GET /api/v1/users/me -pub fn get_profile() -> Result { - let domain = get_domain()?; - let url = format!("{}/users/me", management_base(&domain)); - let response = okta_api_call("GET", &url, None)?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let profile = parse_user_profile(&parsed)?; - serde_json::to_string(&profile).map_err(|e| e.to_string()) -} - -/// POST /api/v1/users/me (partial update via Management API) -pub fn update_profile(fields: &serde_json::Value) -> Result { - let domain = get_domain()?; - let url = format!("{}/users/me", management_base(&domain)); - - // Wrap fields under "profile" key for Okta's expected format. - let payload = serde_json::json!({ "profile": fields }); - let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; - - let response = okta_api_call("POST", &url, Some(&body))?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let profile = parse_user_profile(&parsed)?; - let result = UpdateProfileResult { - success: true, - profile, - }; - serde_json::to_string(&result).map_err(|e| e.to_string()) -} - -/// GET /api/v1/users/me/appLinks -pub fn list_apps() -> Result { - let domain = get_domain()?; - let url = format!("{}/users/me/appLinks", management_base(&domain)); - let response = okta_api_call("GET", &url, None)?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let apps = parse_app_links(&parsed)?; - let count = apps.len(); - let result = ListAppsResult { apps, count }; - serde_json::to_string(&result).map_err(|e| e.to_string()) -} - -/// Search apps by label (case-insensitive substring match). -pub fn search_apps(query: &str) -> Result { - let domain = get_domain()?; - let url = format!("{}/users/me/appLinks", management_base(&domain)); - let response = okta_api_call("GET", &url, None)?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let all_apps = parse_app_links(&parsed)?; - let query_lower = query.to_lowercase(); - - let apps: Vec = all_apps - .into_iter() - .filter(|app| { - app.label.to_lowercase().contains(&query_lower) - || app.app_name.to_lowercase().contains(&query_lower) - }) - .collect(); - - let count = apps.len(); - let result = ListAppsResult { apps, count }; - serde_json::to_string(&result).map_err(|e| e.to_string()) -} - -/// Find an app by ID or label and return its SSO launch link. -pub fn get_app_sso_link(app: &str) -> Result { - let domain = get_domain()?; - let url = format!("{}/users/me/appLinks", management_base(&domain)); - let response = okta_api_call("GET", &url, None)?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let all_apps = parse_app_links(&parsed)?; - let app_lower = app.to_lowercase(); - - // Try exact ID match first, then case-insensitive label match. - let found = all_apps - .iter() - .find(|a| a.app_instance_id == app) - .or_else(|| { - all_apps - .iter() - .find(|a| a.label.to_lowercase() == app_lower) - }) - .or_else(|| { - all_apps - .iter() - .find(|a| a.label.to_lowercase().contains(&app_lower)) - }); - - match found { - Some(app_link) => { - let result = AppSsoLinkResult { - label: app_link.label.clone(), - link_url: app_link.link_url.clone(), - app_instance_id: app_link.app_instance_id.clone(), - app_name: app_link.app_name.clone(), - }; - serde_json::to_string(&result).map_err(|e| e.to_string()) - } - None => { - let available: Vec = all_apps.iter().map(|a| a.label.clone()).collect(); - Err(format!( - "App '{}' not found. Available apps: {}", - app, - available.join(", ") - )) - } - } -} - -/// GET /idp/myaccount/organization -pub fn get_org_info() -> Result { - let domain = get_domain()?; - let url = format!("https://{}/idp/myaccount/organization", domain); - - // MyAccount API requires the okta-version header. - let response = okta_api_call_with_headers( - "GET", - &url, - None, - r#"{"Accept": "application/json; okta-version=1.0.0"}"#, - )?; - - let parsed: serde_json::Value = - serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; - - let result = OrgInfo { - id: parsed["id"].as_str().unwrap_or("").to_string(), - name: parsed["name"].as_str().unwrap_or("").to_string(), - subdomain: parsed["subdomain"].as_str().map(|s| s.to_string()), - website: parsed["website"].as_str().map(|s| s.to_string()), - support_phone: parsed["supportPhoneNumber"].as_str().map(|s| s.to_string()), - technical_contact: parsed["technicalContact"].as_str().map(|s| s.to_string()), - }; - serde_json::to_string(&result).map_err(|e| e.to_string()) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Like `okta_api_call` but with custom headers (for MyAccount API versioning). -fn okta_api_call_with_headers( - method: &str, - url: &str, - body: Option<&str>, - headers: &str, -) -> Result { - let body_bytes = body.map(|b| b.as_bytes().to_vec()); - - host::log( - host::LogLevel::Debug, - &format!("Okta API: {} {}", method, url), - ); - - let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?; - - if response.status < 200 || response.status >= 300 { - let body_text = String::from_utf8_lossy(&response.body); - if let Ok(parsed) = serde_json::from_str::(&body_text) { - if let Some(summary) = parsed["errorSummary"].as_str() { - return Err(format!("Okta API error ({}): {}", response.status, summary)); - } - } - return Err(format!( - "Okta API returned status {}: {}", - response.status, body_text - )); - } - - String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e)) -} - -fn parse_user_profile(v: &serde_json::Value) -> Result { - let p = &v["profile"]; - Ok(UserProfile { - id: v["id"].as_str().unwrap_or("").to_string(), - status: v["status"].as_str().unwrap_or("").to_string(), - first_name: p["firstName"].as_str().unwrap_or("").to_string(), - last_name: p["lastName"].as_str().unwrap_or("").to_string(), - email: p["email"].as_str().unwrap_or("").to_string(), - login: p["login"].as_str().unwrap_or("").to_string(), - mobile_phone: p["mobilePhone"].as_str().map(|s| s.to_string()), - display_name: p["displayName"].as_str().map(|s| s.to_string()), - nick_name: p["nickName"].as_str().map(|s| s.to_string()), - title: p["title"].as_str().map(|s| s.to_string()), - department: p["department"].as_str().map(|s| s.to_string()), - organization: p["organization"].as_str().map(|s| s.to_string()), - timezone: p["timezone"].as_str().map(|s| s.to_string()), - locale: p["locale"].as_str().map(|s| s.to_string()), - }) -} - -fn parse_app_links(v: &serde_json::Value) -> Result, String> { - let arr = v - .as_array() - .ok_or_else(|| "Expected array of app links from Okta".to_string())?; - - Ok(arr - .iter() - .map(|a| AppLink { - app_instance_id: a["appInstanceId"].as_str().unwrap_or("").to_string(), - label: a["label"].as_str().unwrap_or("").to_string(), - link_url: a["linkUrl"].as_str().unwrap_or("").to_string(), - logo_url: a["logoUrl"].as_str().map(|s| s.to_string()), - app_name: a["appName"].as_str().unwrap_or("").to_string(), - hidden: a["hidden"].as_bool().unwrap_or(false), - }) - .collect()) -} diff --git a/tools-src/okta/src/lib.rs b/tools-src/okta/src/lib.rs deleted file mode 100644 index 296e3af3..00000000 --- a/tools-src/okta/src/lib.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Okta WASM Tool for IronClaw. -//! -//! Provides user profile management, SSO app catalog browsing, and -//! launch links for all applications under Okta single sign-on. -//! -//! # Setup -//! -//! 1. Configure OAuth2 with PKCE (see capabilities.json instructions) -//! 2. Write your Okta domain to workspace: `memory_write(path="okta/domain", content="mycompany.okta.com")` -//! 3. All actions read the domain from workspace automatically -//! -//! # Capabilities Required -//! -//! - HTTP: `*.okta.com/api/v1/*`, `*.okta.com/idp/myaccount/*` (GET, POST, PUT) -//! - Secrets: `okta_oauth_token` (injected as Bearer token) -//! - Workspace: `okta/` prefix (read-only, for domain config) -//! -//! # Supported Actions -//! -//! - `get_profile`: Fetch the current user's profile -//! - `update_profile`: Update profile fields -//! - `list_apps`: List all SSO apps assigned to the user -//! - `search_apps`: Search apps by name -//! - `get_app_sso_link`: Get the SSO launch URL for a specific app -//! - `get_org_info`: Get organization details - -mod api; -mod types; - -use types::OktaAction; - -wit_bindgen::generate!({ - world: "sandboxed-tool", - path: "../../wit/tool.wit", -}); - -struct OktaTool; - -impl exports::near::agent::tool::Guest for OktaTool { - fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { - match execute_inner(&req.params) { - Ok(result) => exports::near::agent::tool::Response { - output: Some(result), - error: None, - }, - Err(e) => exports::near::agent::tool::Response { - output: None, - error: Some(e), - }, - } - } - - fn schema() -> String { - r#"{ - "type": "object", - "required": ["action"], - "properties": { - "action": { - "type": "string", - "enum": ["get_profile", "update_profile", "list_apps", "search_apps", "get_app_sso_link", "get_org_info"], - "description": "The Okta operation to perform" - }, - "fields": { - "type": "object", - "description": "Profile fields to update (e.g., firstName, lastName, email, mobilePhone, displayName, title, department). Required for: update_profile" - }, - "query": { - "type": "string", - "description": "Case-insensitive search query to match against app labels and names. Required for: search_apps" - }, - "app": { - "type": "string", - "description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace'). Required for: get_app_sso_link" - } - } - }"# - .to_string() - } - - fn description() -> String { - "Okta SSO tool for managing your profile and accessing all applications under \ - single sign-on. Supports viewing/updating your Okta profile, listing all assigned \ - SSO apps, searching apps by name, and getting direct SSO launch links. Requires \ - Okta domain in workspace at 'okta/domain' and an OAuth token with \ - okta.users.read.self, okta.users.manage.self, and okta.apps.read scopes." - .to_string() - } -} - -fn execute_inner(params: &str) -> Result { - if !crate::near::agent::host::secret_exists("okta_oauth_token") { - return Err( - "Okta OAuth token not configured. Please add the 'okta_oauth_token' secret \ - via OAuth2 flow or set the OKTA_OAUTH_TOKEN environment variable." - .to_string(), - ); - } - - let action: OktaAction = - serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; - - crate::near::agent::host::log( - crate::near::agent::host::LogLevel::Info, - &format!("Executing Okta action: {:?}", action), - ); - - match action { - OktaAction::GetProfile => api::get_profile(), - OktaAction::UpdateProfile { fields } => api::update_profile(&fields), - OktaAction::ListApps => api::list_apps(), - OktaAction::SearchApps { query } => api::search_apps(&query), - OktaAction::GetAppSsoLink { app } => api::get_app_sso_link(&app), - OktaAction::GetOrgInfo => api::get_org_info(), - } -} - -export!(OktaTool); diff --git a/tools-src/okta/src/types.rs b/tools-src/okta/src/types.rs deleted file mode 100644 index 63e40ab9..00000000 --- a/tools-src/okta/src/types.rs +++ /dev/null @@ -1,119 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Input parameters for the Okta tool. -/// -/// Actions map to Okta Management API (/api/v1/) and MyAccount API (/idp/myaccount/). -/// The tool reads the Okta domain from workspace at `okta/domain`. -#[derive(Debug, Deserialize)] -#[serde(tag = "action", rename_all = "snake_case")] -pub enum OktaAction { - /// Get the current user's Okta profile. - GetProfile, - - /// Update fields on the current user's profile (partial update). - UpdateProfile { - /// Key-value pairs of profile fields to update. - /// Common fields: firstName, lastName, email, mobilePhone, displayName, - /// nickName, title, department, organization. - fields: serde_json::Value, - }, - - /// List all SSO applications assigned to the current user. - ListApps, - - /// Search assigned apps by name (case-insensitive substring match). - SearchApps { - /// Search query to match against app labels. - query: String, - }, - - /// Get the SSO launch link for a specific app by its instance ID or label. - GetAppSsoLink { - /// App instance ID (e.g., "0oa1xxx") or app label to search for. - app: String, - }, - - /// Get information about the Okta organization. - GetOrgInfo, -} - -// --------------------------------------------------------------------------- -// Response types -// --------------------------------------------------------------------------- - -/// User profile from Okta. -#[derive(Debug, Serialize)] -pub struct UserProfile { - pub id: String, - pub status: String, - pub first_name: String, - pub last_name: String, - pub email: String, - pub login: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub mobile_phone: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub nick_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub department: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub organization: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timezone: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub locale: Option, -} - -/// Result of a profile update. -#[derive(Debug, Serialize)] -pub struct UpdateProfileResult { - pub success: bool, - pub profile: UserProfile, -} - -/// An SSO app link (chiclet) assigned to the user. -#[derive(Debug, Serialize)] -pub struct AppLink { - pub app_instance_id: String, - pub label: String, - pub link_url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub logo_url: Option, - pub app_name: String, - pub hidden: bool, -} - -/// Result of listing or searching apps. -#[derive(Debug, Serialize)] -pub struct ListAppsResult { - pub apps: Vec, - pub count: usize, -} - -/// SSO launch link for a specific app. -#[derive(Debug, Serialize)] -pub struct AppSsoLinkResult { - pub label: String, - pub link_url: String, - pub app_instance_id: String, - pub app_name: String, -} - -/// Okta organization info. -#[derive(Debug, Serialize)] -pub struct OrgInfo { - pub id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub subdomain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub website: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub support_phone: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub technical_contact: Option, -}