mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: remove the okta tool (#506)
This commit is contained in:
@@ -12,7 +12,6 @@ exclude = [
|
|||||||
"tools-src/google-drive",
|
"tools-src/google-drive",
|
||||||
"tools-src/google-sheets",
|
"tools-src/google-sheets",
|
||||||
"tools-src/google-slides",
|
"tools-src/google-slides",
|
||||||
"tools-src/okta",
|
|
||||||
"tools-src/slack",
|
"tools-src/slack",
|
||||||
"tools-src/telegram",
|
"tools-src/telegram",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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::<serde_json::Value>(&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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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<AppLink> = 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<String, String> {
|
|
||||||
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<String> = 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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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::<serde_json::Value>(&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<UserProfile, String> {
|
|
||||||
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<Vec<AppLink>, 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())
|
|
||||||
}
|
|
||||||
@@ -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<String, String> {
|
|
||||||
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);
|
|
||||||
@@ -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<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub nick_name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub department: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub organization: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub timezone: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub locale: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
pub app_name: String,
|
|
||||||
pub hidden: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of listing or searching apps.
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ListAppsResult {
|
|
||||||
pub apps: Vec<AppLink>,
|
|
||||||
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<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub website: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub support_phone: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub technical_contact: Option<String>,
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user