mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810ba58fd2 |
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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())?;
|
||||||
|
|
||||||
|
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())?;
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -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<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);
|
||||||
@@ -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<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