Compare commits

...
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 810ba58fd2 feat: Add Okta SSO WASM tool for profile management and app catalog
Sandboxed WASM tool that integrates with Okta's Management API and
MyAccount API. Supports user profile CRUD, listing all SSO app
chiclets, searching apps by name, retrieving SSO launch links, and
fetching org info. Uses OAuth2 with PKCE against the Org Authorization
Server, with the domain stored in workspace at okta/domain.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-09 23:31:29 -08:00
5 changed files with 662 additions and 0 deletions
+21
View File
@@ -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"
}
}
+281
View File
@@ -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())
}
+148
View File
@@ -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);
+119
View File
@@ -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>,
}