diff --git a/tools-src/okta/Cargo.toml b/tools-src/okta/Cargo.toml new file mode 100644 index 00000000..e399d494 --- /dev/null +++ b/tools-src/okta/Cargo.toml @@ -0,0 +1,21 @@ +[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 diff --git a/tools-src/okta/okta-tool.capabilities.json b/tools-src/okta/okta-tool.capabilities.json new file mode 100644 index 00000000..14a0879d --- /dev/null +++ b/tools-src/okta/okta-tool.capabilities.json @@ -0,0 +1,93 @@ +{ + "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" + } +} diff --git a/tools-src/okta/src/api.rs b/tools-src/okta/src/api.rs new file mode 100644 index 00000000..684947fd --- /dev/null +++ b/tools-src/okta/src/api.rs @@ -0,0 +1,281 @@ +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())?; + + 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())?; + + 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 new file mode 100644 index 00000000..fed25f35 --- /dev/null +++ b/tools-src/okta/src/lib.rs @@ -0,0 +1,148 @@ +//! 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"], + "oneOf": [ + { + "properties": { + "action": { "const": "get_profile" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "update_profile" }, + "fields": { + "type": "object", + "description": "Profile fields to update. Common: firstName, lastName, email, mobilePhone, displayName, nickName, title, department, organization" + } + }, + "required": ["action", "fields"] + }, + { + "properties": { + "action": { "const": "list_apps" } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "search_apps" }, + "query": { + "type": "string", + "description": "Case-insensitive search query to match against app labels and names" + } + }, + "required": ["action", "query"] + }, + { + "properties": { + "action": { "const": "get_app_sso_link" }, + "app": { + "type": "string", + "description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace')" + } + }, + "required": ["action", "app"] + }, + { + "properties": { + "action": { "const": "get_org_info" } + }, + "required": ["action"] + } + ] + }"# + .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 new file mode 100644 index 00000000..63e40ab9 --- /dev/null +++ b/tools-src/okta/src/types.rs @@ -0,0 +1,119 @@ +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, +}