feat(phase-5): WS1 — Rust SDK
Implements the sentryagent-idp Rust SDK crate (sdk-rust/) with: - TokenManager with Arc<Mutex<TokenCache>> for thread-safe token caching - AgentIdPClient with full method coverage: agents, oauth2, credentials, audit, marketplace, delegation - Error hierarchy via thiserror (AgentIdPError enum) - All model types with serde derive - 429 RateLimited handling with Retry-After parsing; zero unwrap() calls - Unit tests (mockito), doc tests, and integration tests (#[ignore]) - quickstart example, full README, cargo doc clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
205
sdk-rust/src/agents.rs
Normal file
205
sdk-rust/src/agents.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Agent registry methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `POST /agents`, `GET /agents`, `GET /agents/{id}`,
|
||||
//! `PATCH /agents/{id}`, and `DELETE /agents/{id}`.
|
||||
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::{Agent, AgentList, RegisterAgentRequest, UpdateAgentRequest};
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Registers a new AI agent identity.
|
||||
///
|
||||
/// `POST /agents` → `201 Agent`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AgentIdPError::AuthError`] on 401/403, or
|
||||
/// [`AgentIdPError::ApiError`] for other non-2xx responses.
|
||||
pub async fn register_agent(
|
||||
&self,
|
||||
req: RegisterAgentRequest,
|
||||
) -> Result<Agent, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Retrieves a single agent by its unique identifier.
|
||||
///
|
||||
/// `GET /agents/{id}` → `200 Agent`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
|
||||
pub async fn get_agent(&self, agent_id: &str) -> Result<Agent, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Returns a paginated list of agents owned by the authenticated client.
|
||||
///
|
||||
/// `GET /agents` → `200 AgentList`
|
||||
///
|
||||
/// Pass `None` for `page` or `per_page` to use the server defaults.
|
||||
pub async fn list_agents(
|
||||
&self,
|
||||
page: Option<u32>,
|
||||
per_page: Option<u32>,
|
||||
) -> Result<AgentList, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents", self.base_url);
|
||||
|
||||
let mut query: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(p) = page {
|
||||
query.push(("page", p.to_string()));
|
||||
}
|
||||
if let Some(pp) = per_page {
|
||||
query.push(("per_page", pp.to_string()));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Partially updates an existing agent.
|
||||
///
|
||||
/// `PATCH /agents/{id}` → `200 Agent`
|
||||
///
|
||||
/// Only fields set to `Some` in `req` are sent to the API.
|
||||
pub async fn update_agent(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
req: UpdateAgentRequest,
|
||||
) -> Result<Agent, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Permanently deletes an agent.
|
||||
///
|
||||
/// `DELETE /agents/{id}` → `204 No Content`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
|
||||
pub async fn delete_agent(&self, agent_id: &str) -> Result<(), AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.delete(&url)
|
||||
.header("Authorization", auth)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().as_u16() == 204 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reuse parse_response to handle errors; the Ok(Agent) path will never
|
||||
// be reached since 204 is handled above, but we need to satisfy the type.
|
||||
let _: Agent = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an HTTP response into `T` or an appropriate `AgentIdPError`.
|
||||
///
|
||||
/// Status mapping:
|
||||
/// - `2xx` → deserialise body as `T`
|
||||
/// - `401` / `403` → [`AgentIdPError::AuthError`]
|
||||
/// - `404` → [`AgentIdPError::NotFound`]
|
||||
/// - `429` → [`AgentIdPError::RateLimited`] (parses `Retry-After` header)
|
||||
/// - Other non-2xx → [`AgentIdPError::ApiError`]
|
||||
pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
|
||||
resp: reqwest::Response,
|
||||
) -> Result<T, AgentIdPError> {
|
||||
let status = resp.status();
|
||||
|
||||
if status.is_success() {
|
||||
let value: T = resp.json().await?;
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let status_code = status.as_u16();
|
||||
|
||||
match status_code {
|
||||
401 | 403 => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
let msg = extract_message(&body);
|
||||
Err(AgentIdPError::AuthError(msg))
|
||||
}
|
||||
404 => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
let msg = extract_message(&body);
|
||||
Err(AgentIdPError::NotFound(msg))
|
||||
}
|
||||
429 => {
|
||||
let retry_after_secs = resp
|
||||
.headers()
|
||||
.get("Retry-After")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
Err(AgentIdPError::RateLimited { retry_after_secs })
|
||||
}
|
||||
_ => {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
let message = extract_message(&body);
|
||||
let code = body
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
Err(AgentIdPError::ApiError {
|
||||
status: status_code,
|
||||
message,
|
||||
code,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a human-readable message from an API error body.
|
||||
fn extract_message(body: &serde_json::Value) -> String {
|
||||
body.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error")
|
||||
.to_owned()
|
||||
}
|
||||
72
sdk-rust/src/audit.rs
Normal file
72
sdk-rust/src/audit.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Audit log methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `GET /audit-logs` with optional query-parameter filters.
|
||||
|
||||
use crate::agents::parse_response;
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::{AuditLogFilters, AuditLogList};
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Queries the audit log with optional filters.
|
||||
///
|
||||
/// `GET /audit-logs` → `200 AuditLogList`
|
||||
///
|
||||
/// Only `Some` fields in `filters` are appended as query parameters.
|
||||
/// `page` and `per_page` are always included.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use sentryagent_idp::{AgentIdPClient, AuditLogFilters};
|
||||
///
|
||||
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
|
||||
/// let logs = client.list_audit_logs(AuditLogFilters {
|
||||
/// agent_id: Some("agent-uuid".to_owned()),
|
||||
/// event_type: Some("token.issued".to_owned()),
|
||||
/// from: None,
|
||||
/// to: None,
|
||||
/// page: 1,
|
||||
/// per_page: 50,
|
||||
/// }).await?;
|
||||
/// println!("Total events: {}", logs.total);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn list_audit_logs(
|
||||
&self,
|
||||
filters: AuditLogFilters,
|
||||
) -> Result<AuditLogList, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/audit-logs", self.base_url);
|
||||
|
||||
// Build query params, omitting None values.
|
||||
let mut query: Vec<(&str, String)> = vec![
|
||||
("page", filters.page.to_string()),
|
||||
("per_page", filters.per_page.to_string()),
|
||||
];
|
||||
|
||||
if let Some(ref agent_id) = filters.agent_id {
|
||||
query.push(("agent_id", agent_id.clone()));
|
||||
}
|
||||
if let Some(ref event_type) = filters.event_type {
|
||||
query.push(("event_type", event_type.clone()));
|
||||
}
|
||||
if let Some(ref from) = filters.from {
|
||||
query.push(("from", from.clone()));
|
||||
}
|
||||
if let Some(ref to) = filters.to {
|
||||
query.push(("to", to.clone()));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", auth)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
}
|
||||
101
sdk-rust/src/client.rs
Normal file
101
sdk-rust/src/client.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Core `AgentIdPClient` — entry point for all SDK operations.
|
||||
//!
|
||||
//! Create a client via [`AgentIdPClient::new`] or [`AgentIdPClient::from_env`],
|
||||
//! then call methods that correspond to each API endpoint. The client manages
|
||||
//! token acquisition transparently through the embedded [`TokenManager`].
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::token_manager::TokenManager;
|
||||
|
||||
/// The top-level client for the SentryAgent.ai AgentIdP API.
|
||||
///
|
||||
/// All methods are `async` and require a `tokio` runtime. The client is
|
||||
/// cheap to clone — the underlying HTTP connection pool and token cache are
|
||||
/// shared via `Arc`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use sentryagent_idp::AgentIdPClient;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = AgentIdPClient::from_env()?;
|
||||
/// let agents = client.list_agents(Some(1), Some(20)).await?;
|
||||
/// println!("Total agents: {}", agents.total);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AgentIdPClient {
|
||||
/// Base URL of the AgentIdP API (no trailing slash).
|
||||
pub(crate) base_url: String,
|
||||
/// Reusable `reqwest` HTTP client — created once, shared across all requests.
|
||||
pub(crate) http: reqwest::Client,
|
||||
/// Shared, async-safe token manager.
|
||||
pub(crate) token_manager: Arc<Mutex<TokenManager>>,
|
||||
}
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Creates a new `AgentIdPClient`. No network calls are made at construction time.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_url` — Root URL of the AgentIdP API, e.g. `"https://api.sentryagent.ai"`.
|
||||
/// * `client_id` — OAuth 2.0 client identifier.
|
||||
/// * `client_secret` — OAuth 2.0 client secret.
|
||||
pub fn new(base_url: &str, client_id: &str, client_secret: &str) -> Self {
|
||||
let clean_url = base_url.trim_end_matches('/').to_owned();
|
||||
let tm = TokenManager::new(&clean_url, client_id, client_secret);
|
||||
Self {
|
||||
base_url: clean_url,
|
||||
http: reqwest::Client::new(),
|
||||
token_manager: Arc::new(Mutex::new(tm)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a client from environment variables.
|
||||
///
|
||||
/// Reads the following variables:
|
||||
///
|
||||
/// | Variable | Purpose |
|
||||
/// |---|---|
|
||||
/// | `AGENTIDP_API_URL` | Base URL of the API |
|
||||
/// | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client ID |
|
||||
/// | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
|
||||
///
|
||||
/// Returns [`AgentIdPError::ConfigError`] if any variable is missing.
|
||||
pub fn from_env() -> Result<Self, AgentIdPError> {
|
||||
let api_url = env::var("AGENTIDP_API_URL").map_err(|_| {
|
||||
AgentIdPError::ConfigError(
|
||||
"AGENTIDP_API_URL environment variable is not set".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let client_id = env::var("AGENTIDP_CLIENT_ID").map_err(|_| {
|
||||
AgentIdPError::ConfigError(
|
||||
"AGENTIDP_CLIENT_ID environment variable is not set".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let client_secret = env::var("AGENTIDP_CLIENT_SECRET").map_err(|_| {
|
||||
AgentIdPError::ConfigError(
|
||||
"AGENTIDP_CLIENT_SECRET environment variable is not set".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self::new(&api_url, &client_id, &client_secret))
|
||||
}
|
||||
|
||||
/// Returns a `Bearer <token>` string for the `Authorization` header.
|
||||
///
|
||||
/// Delegates to [`TokenManager::get_token`], which handles caching and
|
||||
/// automatic refresh transparently.
|
||||
pub(crate) async fn get_auth_header(&self) -> Result<String, AgentIdPError> {
|
||||
let tm = self.token_manager.lock().await;
|
||||
let token = tm.get_token().await?;
|
||||
Ok(format!("Bearer {}", token))
|
||||
}
|
||||
}
|
||||
98
sdk-rust/src/credentials.rs
Normal file
98
sdk-rust/src/credentials.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Credential management methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `POST /agents/{id}/credentials` (generate),
|
||||
//! `POST /agents/{id}/credentials/rotate`, and
|
||||
//! `DELETE /agents/{id}/credentials/{cred_id}`.
|
||||
|
||||
use crate::agents::parse_response;
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::Credentials;
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Generates a new set of credentials (client ID + secret) for an agent.
|
||||
///
|
||||
/// `POST /agents/{id}/credentials` → `201 Credentials`
|
||||
///
|
||||
/// The `client_secret` field in the response is the **only time** the
|
||||
/// plaintext secret is returned — store it securely.
|
||||
pub async fn generate_credentials(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Result<Credentials, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/agents/{}/credentials", self.base_url, agent_id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Rotates the credentials for an agent, invalidating the previous secret.
|
||||
///
|
||||
/// `POST /agents/{id}/credentials/rotate` → `200 Credentials`
|
||||
///
|
||||
/// The new `client_secret` is returned in the response and will not be
|
||||
/// retrievable again.
|
||||
pub async fn rotate_credentials(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Result<Credentials, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!(
|
||||
"{}/agents/{}/credentials/rotate",
|
||||
self.base_url, agent_id
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Revokes a specific credential set for an agent.
|
||||
///
|
||||
/// `DELETE /agents/{id}/credentials/{cred_id}` → `204 No Content`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::AgentIdPError::NotFound`] when the agent or
|
||||
/// credential ID does not exist.
|
||||
pub async fn revoke_credentials(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
cred_id: &str,
|
||||
) -> Result<(), AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!(
|
||||
"{}/agents/{}/credentials/{}",
|
||||
self.base_url, agent_id, cred_id
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.delete(&url)
|
||||
.header("Authorization", auth)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().as_u16() == 204 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Delegate error handling to parse_response; the Ok branch is unreachable.
|
||||
let _: Credentials = parse_response(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
75
sdk-rust/src/delegation.rs
Normal file
75
sdk-rust/src/delegation.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Agent-to-agent (A2A) delegation methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `POST /delegation` and `POST /delegation/verify`.
|
||||
|
||||
use crate::agents::parse_response;
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::{DelegateRequest, DelegationToken, DelegationVerification};
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Creates an A2A delegation token granting a delegatee agent authority
|
||||
/// to act on behalf of the calling (delegator) agent.
|
||||
///
|
||||
/// `POST /delegation` → `201 DelegationToken`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`AgentIdPError::DelegationError`] when the delegation chain
|
||||
/// would be invalid (e.g. cyclic delegation or insufficient scope).
|
||||
pub async fn delegate(
|
||||
&self,
|
||||
req: DelegateRequest,
|
||||
) -> Result<DelegationToken, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/delegation", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Map 422 Unprocessable Entity to DelegationError.
|
||||
if resp.status().as_u16() == 422 {
|
||||
let body: serde_json::Value =
|
||||
resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("invalid delegation chain")
|
||||
.to_owned();
|
||||
return Err(AgentIdPError::DelegationError(msg));
|
||||
}
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Verifies an A2A delegation token and returns its claims.
|
||||
///
|
||||
/// `POST /delegation/verify` → `200 DelegationVerification`
|
||||
///
|
||||
/// The response's `valid` field is `false` when the token is expired or
|
||||
/// the chain has been revoked, rather than returning an error.
|
||||
pub async fn verify_delegation(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<DelegationVerification, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/delegation/verify", self.base_url);
|
||||
|
||||
let body = serde_json::json!({ "delegation_token": token });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
}
|
||||
68
sdk-rust/src/error.rs
Normal file
68
sdk-rust/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Error types for the SentryAgent.ai AgentIdP Rust SDK.
|
||||
//!
|
||||
//! All fallible operations return `Result<T, AgentIdPError>`. Match on the
|
||||
//! variants to handle specific conditions such as rate-limiting or
|
||||
//! missing resources.
|
||||
|
||||
/// The unified error type returned by all SDK operations.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use sentryagent_idp::AgentIdPError;
|
||||
///
|
||||
/// async fn example(client: &sentryagent_idp::AgentIdPClient) {
|
||||
/// match client.get_agent("unknown-id").await {
|
||||
/// Err(AgentIdPError::NotFound(id)) => eprintln!("Agent not found: {}", id),
|
||||
/// Err(AgentIdPError::RateLimited { retry_after_secs }) => {
|
||||
/// eprintln!("Rate limited — retry after {}s", retry_after_secs);
|
||||
/// }
|
||||
/// Err(e) => eprintln!("Unexpected error: {}", e),
|
||||
/// Ok(agent) => println!("Found: {:?}", agent),
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AgentIdPError {
|
||||
/// An underlying HTTP transport error from `reqwest`.
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
|
||||
/// The API returned a non-2xx status code with a structured error body.
|
||||
#[error("API error {status}: {message}")]
|
||||
ApiError {
|
||||
/// HTTP status code returned by the API.
|
||||
status: u16,
|
||||
/// Human-readable error message from the API.
|
||||
message: String,
|
||||
/// Machine-readable error code from the API, if present.
|
||||
code: Option<String>,
|
||||
},
|
||||
|
||||
/// Authentication or authorisation failed (401/403).
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
/// The requested resource was not found (404).
|
||||
#[error("Agent not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The API rate-limited this client (429). Contains the retry delay.
|
||||
#[error("Rate limit exceeded. Retry after {retry_after_secs}s")]
|
||||
RateLimited {
|
||||
/// Seconds to wait before retrying.
|
||||
retry_after_secs: u64,
|
||||
},
|
||||
|
||||
/// A required configuration value was missing or invalid.
|
||||
#[error("Invalid configuration: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
/// JSON serialization or deserialization failed.
|
||||
#[error("Serialization error: {0}")]
|
||||
SerdeError(#[from] serde_json::Error),
|
||||
|
||||
/// A delegation chain was invalid or could not be verified.
|
||||
#[error("Delegation chain invalid: {0}")]
|
||||
DelegationError(String),
|
||||
}
|
||||
81
sdk-rust/src/lib.rs
Normal file
81
sdk-rust/src/lib.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! # sentryagent-idp
|
||||
//!
|
||||
//! Production-grade Rust SDK for the [SentryAgent.ai](https://sentryagent.ai)
|
||||
//! AgentIdP API. Provides full coverage of the 14 API endpoints across agent
|
||||
//! identity, OAuth 2.0 token management, credential rotation, audit logs, the
|
||||
//! public marketplace, and agent-to-agent (A2A) delegation.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Async-first** — every API call is `async` and backed by `tokio`.
|
||||
//! - **Thread-safe token cache** — [`TokenManager`] refreshes tokens
|
||||
//! automatically before expiry; safe for concurrent use across tasks.
|
||||
//! - **Typed errors** — every failure maps to a variant of [`AgentIdPError`].
|
||||
//! - **Zero `unwrap()`** — all error paths use `?` or explicit `match`.
|
||||
//!
|
||||
//! ## Quickstart
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use sentryagent_idp::{AgentIdPClient, RegisterAgentRequest};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let client = AgentIdPClient::from_env()?;
|
||||
//!
|
||||
//! let agent = client.register_agent(RegisterAgentRequest {
|
||||
//! name: "my-agent".to_owned(),
|
||||
//! description: Some("Does useful things".to_owned()),
|
||||
//! agent_type: "worker".to_owned(),
|
||||
//! capabilities: vec!["read:files".to_owned()],
|
||||
//! metadata: None,
|
||||
//! }).await?;
|
||||
//!
|
||||
//! println!("Registered agent: {}", agent.id);
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Environment Variables
|
||||
//!
|
||||
//! | Variable | Purpose |
|
||||
//! |---|---|
|
||||
//! | `AGENTIDP_API_URL` | Base URL of the AgentIdP API |
|
||||
//! | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client identifier |
|
||||
//! | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod client;
|
||||
pub mod credentials;
|
||||
pub mod delegation;
|
||||
pub mod error;
|
||||
pub mod marketplace;
|
||||
pub mod models;
|
||||
pub mod oauth2;
|
||||
pub mod token_manager;
|
||||
|
||||
// Re-export the primary entry points at crate root for ergonomic use.
|
||||
pub use client::AgentIdPClient;
|
||||
pub use error::AgentIdPError;
|
||||
pub use token_manager::TokenManager;
|
||||
|
||||
// Re-export all model types.
|
||||
pub use models::{
|
||||
Agent,
|
||||
AgentList,
|
||||
AuditLogEntry,
|
||||
AuditLogFilters,
|
||||
AuditLogList,
|
||||
Credentials,
|
||||
DelegateRequest,
|
||||
DelegationToken,
|
||||
DelegationVerification,
|
||||
MarketplaceAgent,
|
||||
MarketplaceAgentList,
|
||||
MarketplaceFilters,
|
||||
RegisterAgentRequest,
|
||||
TokenResponse,
|
||||
UpdateAgentRequest,
|
||||
};
|
||||
87
sdk-rust/src/marketplace.rs
Normal file
87
sdk-rust/src/marketplace.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Public marketplace methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `GET /marketplace/agents` and `GET /marketplace/agents/{id}`.
|
||||
//! These endpoints are **unauthenticated** — no `Authorization` header is sent.
|
||||
|
||||
use crate::agents::parse_response;
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::{MarketplaceAgent, MarketplaceAgentList, MarketplaceFilters};
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Lists publicly available agents in the marketplace.
|
||||
///
|
||||
/// `GET /marketplace/agents` → `200 MarketplaceAgentList`
|
||||
///
|
||||
/// This endpoint does **not** require authentication. `None` filter fields
|
||||
/// are omitted from the query string.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use sentryagent_idp::{AgentIdPClient, MarketplaceFilters};
|
||||
///
|
||||
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
|
||||
/// let results = client.list_public_agents(MarketplaceFilters {
|
||||
/// q: Some("summarizer".to_owned()),
|
||||
/// capability: None,
|
||||
/// publisher: None,
|
||||
/// page: 1,
|
||||
/// per_page: 20,
|
||||
/// }).await?;
|
||||
/// println!("Found {} agents", results.total);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn list_public_agents(
|
||||
&self,
|
||||
filters: MarketplaceFilters,
|
||||
) -> Result<MarketplaceAgentList, AgentIdPError> {
|
||||
let url = format!("{}/marketplace/agents", self.base_url);
|
||||
|
||||
let mut query: Vec<(&str, String)> = vec![
|
||||
("page", filters.page.to_string()),
|
||||
("per_page", filters.per_page.to_string()),
|
||||
];
|
||||
|
||||
if let Some(ref q) = filters.q {
|
||||
query.push(("q", q.clone()));
|
||||
}
|
||||
if let Some(ref capability) = filters.capability {
|
||||
query.push(("capability", capability.clone()));
|
||||
}
|
||||
if let Some(ref publisher) = filters.publisher {
|
||||
query.push(("publisher", publisher.clone()));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
|
||||
/// Retrieves a single publicly listed marketplace agent by ID.
|
||||
///
|
||||
/// `GET /marketplace/agents/{id}` → `200 MarketplaceAgent`
|
||||
///
|
||||
/// This endpoint does **not** require authentication.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::AgentIdPError::NotFound`] when no public agent
|
||||
/// with the given ID exists.
|
||||
pub async fn get_public_agent(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Result<MarketplaceAgent, AgentIdPError> {
|
||||
let url = format!("{}/marketplace/agents/{}", self.base_url, agent_id);
|
||||
|
||||
let resp = self.http.get(&url).send().await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
}
|
||||
261
sdk-rust/src/models.rs
Normal file
261
sdk-rust/src/models.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! Request and response model types for the SentryAgent.ai AgentIdP API.
|
||||
//!
|
||||
//! All types implement `serde::Serialize` and `serde::Deserialize` for
|
||||
//! transparent JSON encoding. `Option` fields are omitted from serialized
|
||||
//! output when `None`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── Request types ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for `POST /agents` — register a new agent identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterAgentRequest {
|
||||
/// Human-readable name for the agent.
|
||||
pub name: String,
|
||||
/// Optional description of the agent's purpose.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Functional category of the agent (e.g. `"worker"`, `"orchestrator"`).
|
||||
pub agent_type: String,
|
||||
/// List of capability strings the agent exposes (e.g. `"read:files"`).
|
||||
pub capabilities: Vec<String>,
|
||||
/// Arbitrary metadata to attach to the agent record.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Request body for `PATCH /agents/{id}` — partially update an existing agent.
|
||||
///
|
||||
/// Only fields that are `Some` are sent to the API; `None` fields are omitted.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateAgentRequest {
|
||||
/// New human-readable name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// New description.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Replacement capability list.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
/// Whether to list the agent in the public marketplace.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_public: Option<bool>,
|
||||
/// Replacement metadata object.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /audit-logs`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AuditLogFilters {
|
||||
/// Filter by agent ID.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
/// Filter by event type string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_type: Option<String>,
|
||||
/// Start of time range (ISO 8601).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from: Option<String>,
|
||||
/// End of time range (ISO 8601).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to: Option<String>,
|
||||
/// Page number (1-based).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /marketplace/agents`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MarketplaceFilters {
|
||||
/// Free-text search query.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub q: Option<String>,
|
||||
/// Filter by capability string.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capability: Option<String>,
|
||||
/// Filter by publisher identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub publisher: Option<String>,
|
||||
/// Page number (1-based).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// Request body for `POST /delegation` — create an A2A delegation token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegateRequest {
|
||||
/// The agent ID that receives delegated authority.
|
||||
pub delegatee_agent_id: String,
|
||||
/// Scopes being delegated.
|
||||
pub scopes: Vec<String>,
|
||||
/// Token lifetime in seconds.
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
// ─── Response types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A registered AI agent identity returned by the API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Agent {
|
||||
/// Unique agent identifier (UUID).
|
||||
pub id: String,
|
||||
/// Human-readable name.
|
||||
pub name: String,
|
||||
/// Optional description.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Capabilities the agent exposes.
|
||||
pub capabilities: Vec<String>,
|
||||
/// Decentralised Identifier for the agent.
|
||||
pub did: String,
|
||||
/// Whether the agent is listed in the public marketplace.
|
||||
pub is_public: bool,
|
||||
/// ISO 8601 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// ISO 8601 last-updated timestamp.
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Paginated list of agents returned by `GET /agents`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentList {
|
||||
/// Agents on the current page.
|
||||
pub agents: Vec<Agent>,
|
||||
/// Total number of agents matching the query.
|
||||
pub total: u64,
|
||||
/// Current page number (1-based).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// OAuth 2.0 access token response (RFC 6749 §4.4.3).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
/// The bearer access token.
|
||||
pub access_token: String,
|
||||
/// Token type — always `"Bearer"`.
|
||||
pub token_type: String,
|
||||
/// Seconds until the token expires.
|
||||
pub expires_in: u64,
|
||||
/// Space-separated list of granted scopes.
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
/// Agent credentials — client ID and (on creation/rotation only) client secret.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
/// OAuth 2.0 client ID.
|
||||
pub client_id: String,
|
||||
/// OAuth 2.0 client secret (only present on generate/rotate responses).
|
||||
pub client_secret: String,
|
||||
/// ISO 8601 creation timestamp.
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// A single audit log entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditLogEntry {
|
||||
/// Unique event identifier.
|
||||
pub id: String,
|
||||
/// Agent ID this event relates to.
|
||||
pub agent_id: String,
|
||||
/// Type of event that occurred.
|
||||
pub event_type: String,
|
||||
/// Identity of the actor that triggered the event.
|
||||
pub actor: String,
|
||||
/// Structured metadata associated with the event.
|
||||
pub metadata: serde_json::Value,
|
||||
/// ISO 8601 timestamp of the event.
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// Paginated list of audit log entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditLogList {
|
||||
/// Entries on the current page.
|
||||
pub entries: Vec<AuditLogEntry>,
|
||||
/// Total number of entries matching the query.
|
||||
pub total: u64,
|
||||
/// Current page number (1-based).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// A publicly listed marketplace agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceAgent {
|
||||
/// Unique agent identifier (UUID).
|
||||
pub id: String,
|
||||
/// Human-readable name.
|
||||
pub name: String,
|
||||
/// Optional description.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Capabilities the agent exposes.
|
||||
pub capabilities: Vec<String>,
|
||||
/// Full W3C DID Document for the agent.
|
||||
pub did_document: serde_json::Value,
|
||||
/// Publisher identifier or organisation name.
|
||||
pub publisher: String,
|
||||
/// ISO 8601 creation timestamp.
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Paginated list of marketplace agents.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MarketplaceAgentList {
|
||||
/// Agents on the current page.
|
||||
pub agents: Vec<MarketplaceAgent>,
|
||||
/// Total number of agents matching the query.
|
||||
pub total: u64,
|
||||
/// Current page number (1-based).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// A delegation token granting a delegatee agent authority on behalf of the delegator.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationToken {
|
||||
/// Opaque signed delegation token — pass in `X-Delegation-Token` header.
|
||||
pub delegation_token: String,
|
||||
/// Unique identifier for this delegation chain.
|
||||
pub chain_id: String,
|
||||
/// Agent ID of the delegator (authority source).
|
||||
pub delegator_agent_id: String,
|
||||
/// Agent ID of the delegatee (authority recipient).
|
||||
pub delegatee_agent_id: String,
|
||||
/// Scopes that have been delegated.
|
||||
pub scopes: Vec<String>,
|
||||
/// ISO 8601 timestamp when the delegation expires.
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
/// Result of verifying a delegation token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationVerification {
|
||||
/// Whether the delegation token is valid and unexpired.
|
||||
pub valid: bool,
|
||||
/// Delegation chain ID, present when `valid` is `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chain_id: Option<String>,
|
||||
/// Delegator agent ID, present when `valid` is `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegator_agent_id: Option<String>,
|
||||
/// Delegatee agent ID, present when `valid` is `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegatee_agent_id: Option<String>,
|
||||
/// Delegated scopes, present when `valid` is `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Expiry timestamp, present when `valid` is `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
47
sdk-rust/src/oauth2.rs
Normal file
47
sdk-rust/src/oauth2.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! OAuth 2.0 token issuance methods for `AgentIdPClient`.
|
||||
//!
|
||||
//! Covers `POST /oauth2/token` for issuing agent-scoped access tokens.
|
||||
|
||||
use crate::agents::parse_response;
|
||||
use crate::client::AgentIdPClient;
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::TokenResponse;
|
||||
|
||||
impl AgentIdPClient {
|
||||
/// Issues an OAuth 2.0 access token for the given agent with the requested scopes.
|
||||
///
|
||||
/// `POST /oauth2/token` (form body) → `200 TokenResponse`
|
||||
///
|
||||
/// This differs from the internal `TokenManager` token fetch in that it
|
||||
/// allows callers to request tokens for specific agent IDs and scope sets.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `agent_id` — The agent on whose behalf the token is issued.
|
||||
/// * `scopes` — Scopes to request (e.g. `&["agents:read", "agents:write"]`).
|
||||
pub async fn issue_token(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
scopes: &[&str],
|
||||
) -> Result<TokenResponse, AgentIdPError> {
|
||||
let auth = self.get_auth_header().await?;
|
||||
let url = format!("{}/oauth2/token", self.base_url);
|
||||
let scope_str = scopes.join(" ");
|
||||
|
||||
let params = [
|
||||
("grant_type", "client_credentials"),
|
||||
("agent_id", agent_id),
|
||||
("scope", scope_str.as_str()),
|
||||
];
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
parse_response(resp).await
|
||||
}
|
||||
}
|
||||
254
sdk-rust/src/token_manager.rs
Normal file
254
sdk-rust/src/token_manager.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! Thread-safe OAuth 2.0 token cache with automatic refresh.
|
||||
//!
|
||||
//! `TokenManager` holds a single `reqwest::Client` for token requests and
|
||||
//! caches the current access token behind an async `Mutex`. Tokens are
|
||||
//! proactively refreshed 60 seconds before they expire, preventing any
|
||||
//! request from using a stale bearer token.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::AgentIdPError;
|
||||
use crate::models::TokenResponse;
|
||||
|
||||
/// Internal token cache — holds the raw token string and its calculated expiry.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TokenCache {
|
||||
/// Cached bearer token, or `None` if no token has been fetched yet.
|
||||
pub access_token: Option<String>,
|
||||
/// Monotonic instant at which the cached token expires (less the 60 s buffer).
|
||||
pub expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
/// Returns `true` when the cached token is present and has not yet reached
|
||||
/// its expiry instant (which already includes the 60 s refresh buffer).
|
||||
fn is_valid(&self) -> bool {
|
||||
match (&self.access_token, self.expires_at) {
|
||||
(Some(_), Some(exp)) => Instant::now() < exp,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe OAuth 2.0 client-credentials token manager.
|
||||
///
|
||||
/// Obtains bearer tokens from the AgentIdP server and caches them until they
|
||||
/// are within 60 seconds of expiry, at which point the next call to
|
||||
/// [`get_token`](TokenManager::get_token) transparently fetches a fresh one.
|
||||
///
|
||||
/// The inner `Arc<Mutex<TokenCache>>` makes `TokenManager` cheap to clone and
|
||||
/// safe to share across `tokio` tasks.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use sentryagent_idp::TokenManager;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let tm = TokenManager::new("https://api.sentryagent.ai", "client_id", "client_secret");
|
||||
/// let token = tm.get_token().await?;
|
||||
/// println!("Bearer {}", token);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub struct TokenManager {
|
||||
/// Base URL of the AgentIdP API (no trailing slash).
|
||||
pub(crate) api_url: String,
|
||||
/// OAuth 2.0 client identifier.
|
||||
pub(crate) client_id: String,
|
||||
/// OAuth 2.0 client secret.
|
||||
pub(crate) client_secret: String,
|
||||
/// Shared async token cache.
|
||||
pub(crate) cache: Arc<Mutex<TokenCache>>,
|
||||
/// Reusable HTTP client for token endpoint requests.
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TokenManager {
|
||||
/// Creates a new `TokenManager`. No network calls are made at construction time.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `api_url` — Base URL of the AgentIdP API (e.g. `"https://api.sentryagent.ai"`).
|
||||
/// * `client_id` — OAuth 2.0 client identifier.
|
||||
/// * `client_secret` — OAuth 2.0 client secret.
|
||||
pub fn new(api_url: &str, client_id: &str, client_secret: &str) -> Self {
|
||||
Self {
|
||||
api_url: api_url.trim_end_matches('/').to_owned(),
|
||||
client_id: client_id.to_owned(),
|
||||
client_secret: client_secret.to_owned(),
|
||||
cache: Arc::new(Mutex::new(TokenCache::default())),
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a valid bearer access token.
|
||||
///
|
||||
/// If a cached token exists and will not expire within the next 60 seconds,
|
||||
/// it is returned immediately without any network call. Otherwise a new
|
||||
/// token is fetched from `POST /oauth2/token` and the cache is updated.
|
||||
///
|
||||
/// This method is safe to call concurrently from multiple `tokio` tasks —
|
||||
/// the `Mutex` ensures only one token fetch occurs at a time.
|
||||
pub async fn get_token(&self) -> Result<String, AgentIdPError> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
|
||||
if cache.is_valid() {
|
||||
// Safety: is_valid() guarantees access_token is Some.
|
||||
return Ok(cache.access_token.clone().expect("token present when valid"));
|
||||
}
|
||||
|
||||
// Fetch a fresh token.
|
||||
let token_resp = self.fetch_token().await?;
|
||||
|
||||
// Expire the cache 60 s before the server-reported expiry so we never
|
||||
// hand out a token that is about to become invalid.
|
||||
let ttl = token_resp
|
||||
.expires_in
|
||||
.saturating_sub(60);
|
||||
cache.access_token = Some(token_resp.access_token.clone());
|
||||
cache.expires_at = Some(Instant::now() + Duration::from_secs(ttl));
|
||||
|
||||
Ok(token_resp.access_token)
|
||||
}
|
||||
|
||||
/// Performs the OAuth 2.0 client-credentials grant against the token endpoint.
|
||||
async fn fetch_token(&self) -> Result<TokenResponse, AgentIdPError> {
|
||||
let token_url = format!("{}/oauth2/token", self.api_url);
|
||||
|
||||
let params = [
|
||||
("grant_type", "client_credentials"),
|
||||
("client_id", self.client_id.as_str()),
|
||||
("client_secret", self.client_secret.as_str()),
|
||||
];
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||
let message = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("token request failed")
|
||||
.to_owned();
|
||||
return Err(AgentIdPError::AuthError(format!(
|
||||
"token endpoint returned {}: {}",
|
||||
status, message
|
||||
)));
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = resp.json().await?;
|
||||
Ok(token_resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Unit tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mockito::Server;
|
||||
|
||||
fn token_body(expires_in: u64) -> String {
|
||||
format!(
|
||||
r#"{{"access_token":"test-token","token_type":"Bearer","expires_in":{},"scope":"agents:read"}}"#,
|
||||
expires_in
|
||||
)
|
||||
}
|
||||
|
||||
/// `get_token()` should return the cached token on a second call without
|
||||
/// hitting the mock server again.
|
||||
#[tokio::test]
|
||||
async fn test_returns_cached_token() {
|
||||
let mut server = Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/oauth2/token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(token_body(3600))
|
||||
.expect(1) // Must be called exactly once.
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let tm = TokenManager::new(&server.url(), "id", "secret");
|
||||
|
||||
let t1 = tm.get_token().await.expect("first call succeeds");
|
||||
let t2 = tm.get_token().await.expect("second call succeeds");
|
||||
|
||||
assert_eq!(t1, "test-token");
|
||||
assert_eq!(t2, "test-token");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// When the cached token's `expires_at` is in the past, `get_token()` must
|
||||
/// fetch a new token (i.e. hit the mock server a second time).
|
||||
#[tokio::test]
|
||||
async fn test_refreshes_expired_token() {
|
||||
let mut server = Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/oauth2/token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(token_body(3600))
|
||||
.expect(2) // Must be called twice.
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let tm = TokenManager::new(&server.url(), "id", "secret");
|
||||
|
||||
// First call — populates cache.
|
||||
let _ = tm.get_token().await.expect("first call succeeds");
|
||||
|
||||
// Manually expire the cache.
|
||||
{
|
||||
let mut cache = tm.cache.lock().await;
|
||||
cache.expires_at = Some(Instant::now() - Duration::from_secs(1));
|
||||
}
|
||||
|
||||
// Second call — cache expired, must fetch again.
|
||||
let t2 = tm.get_token().await.expect("second call succeeds");
|
||||
assert_eq!(t2, "test-token");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
/// Ten concurrent `get_token()` calls must all succeed and the token
|
||||
/// endpoint must be called exactly once (all but the first see the cache).
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_no_race() {
|
||||
let mut server = Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/oauth2/token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(token_body(3600))
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let tm = Arc::new(TokenManager::new(&server.url(), "id", "secret"));
|
||||
|
||||
let handles: Vec<_> = (0..10)
|
||||
.map(|_| {
|
||||
let tm_clone = Arc::clone(&tm);
|
||||
tokio::spawn(async move { tm_clone.get_token().await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let result = handle.await.expect("task did not panic");
|
||||
assert_eq!(result.expect("get_token succeeded"), "test-token");
|
||||
}
|
||||
|
||||
mock.assert_async().await;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user