//! # mcias-client //! //! Async Rust client for the MCIAS (Metacircular Identity and Access System) //! REST API. //! //! ## Usage //! //! ```rust,no_run //! use mcias_client::{Client, ClientOptions}; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = Client::new("https://auth.example.com", ClientOptions::default())?; //! //! let (token, expires_at) = client.login("alice", "s3cret", None).await?; //! println!("Logged in, token expires at {expires_at}"); //! //! client.logout().await?; //! Ok(()) //! } //! ``` //! //! ## Thread Safety //! //! [`Client`] is `Clone + Send + Sync`. The internally stored bearer token is //! protected by an `Arc>` so concurrent async tasks //! may share a single client safely. use std::sync::Arc; use reqwest::{header, Certificate, StatusCode}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; // ---- Error types ---- /// All errors returned by the MCIAS client. #[derive(Debug, thiserror::Error)] pub enum MciasError { /// HTTP 401 — authentication required or credentials invalid. #[error("authentication error: {0}")] Auth(String), /// HTTP 403 — caller lacks required role. #[error("permission denied: {0}")] Forbidden(String), /// HTTP 404 — requested resource does not exist. #[error("not found: {0}")] NotFound(String), /// HTTP 400 — the request payload was invalid. #[error("invalid input: {0}")] InvalidInput(String), /// HTTP 409 — resource conflict (e.g. duplicate username). #[error("conflict: {0}")] Conflict(String), /// HTTP 5xx — the server returned an internal error. #[error("server error ({status}): {message}")] Server { status: u16, message: String }, /// Transport-level error (DNS failure, connection refused, timeout, etc.). #[error("transport error: {0}")] Transport(#[from] reqwest::Error), /// Response body could not be decoded. #[error("decode error: {0}")] Decode(String), } // ---- Data types ---- /// Account information returned by the server. #[derive(Debug, Clone, Deserialize)] pub struct Account { pub id: String, pub username: String, pub account_type: String, pub status: String, pub created_at: String, pub updated_at: String, pub totp_enabled: bool, } /// Result of a token validation request. #[derive(Debug, Clone, Deserialize)] pub struct TokenClaims { pub valid: bool, #[serde(default)] pub sub: String, #[serde(default)] pub roles: Vec, #[serde(default)] pub expires_at: String, } /// The server's Ed25519 public key in JWK format. #[derive(Debug, Clone, Deserialize)] pub struct PublicKey { pub kty: String, pub crv: String, pub x: String, } /// Postgres credentials returned by the server. #[derive(Debug, Clone, Deserialize)] pub struct PgCreds { pub host: String, pub port: u16, pub database: String, pub username: String, pub password: String, } // ---- Internal request/response types ---- #[derive(Serialize)] struct LoginRequest<'a> { username: &'a str, password: &'a str, #[serde(skip_serializing_if = "Option::is_none")] totp_code: Option<&'a str>, } #[derive(Deserialize)] struct TokenResponse { token: String, expires_at: String, } #[derive(Deserialize)] struct ErrorResponse { #[serde(default)] error: String, } // ---- Client options ---- /// Configuration options for the MCIAS client. #[derive(Debug, Default, Clone)] pub struct ClientOptions { /// Optional PEM-encoded CA certificate for TLS verification. /// Use when connecting to a server with a self-signed or private-CA cert. pub ca_cert_pem: Option>, /// Optional pre-existing bearer token. pub token: Option, } // ---- Client ---- /// Async MCIAS REST API client. /// /// `Client` is cheaply cloneable — the internal HTTP client and token storage /// are reference-counted. All clones share the same token. #[derive(Clone)] pub struct Client { base_url: String, http: reqwest::Client, /// Bearer token storage. `Arc>` so clones share the token. token: Arc>>, } impl Client { /// Create a new client. /// /// `base_url` must be an HTTPS URL (e.g. `"https://auth.example.com"`). /// TLS 1.2+ is enforced by the underlying `reqwest` / `rustls` stack. pub fn new(base_url: &str, opts: ClientOptions) -> Result { let mut builder = reqwest::ClientBuilder::new() // Security: enforce TLS 1.2+ minimum. .min_tls_version(reqwest::tls::Version::TLS_1_2) .use_rustls_tls(); if let Some(pem) = opts.ca_cert_pem { let cert = Certificate::from_pem(&pem) .map_err(|e| MciasError::Decode(format!("parse CA cert: {e}")))?; builder = builder.add_root_certificate(cert); } let http = builder.build()?; Ok(Self { base_url: base_url.trim_end_matches('/').to_owned(), http, token: Arc::new(RwLock::new(opts.token)), }) } /// Return the currently stored bearer token, if any. pub async fn token(&self) -> Option { self.token.read().await.clone() } /// Replace the stored bearer token. pub async fn set_token(&self, tok: Option) { *self.token.write().await = tok; } // ---- Authentication ---- /// Login with username and password. On success stores the returned token /// and returns `(token, expires_at)`. /// /// `totp_code` may be `None` when TOTP is not enrolled. pub async fn login( &self, username: &str, password: &str, totp_code: Option<&str>, ) -> Result<(String, String), MciasError> { let body = LoginRequest { username, password, totp_code, }; let resp: TokenResponse = self.post("/v1/auth/login", &body).await?; *self.token.write().await = Some(resp.token.clone()); Ok((resp.token, resp.expires_at)) } /// Logout — revoke the current token on the server. Clears the stored token. pub async fn logout(&self) -> Result<(), MciasError> { self.post_empty("/v1/auth/logout").await?; *self.token.write().await = None; Ok(()) } /// Renew the current token. The old token is revoked server-side; the new /// token is stored and returned as `(token, expires_at)`. pub async fn renew_token(&self) -> Result<(String, String), MciasError> { let resp: TokenResponse = self.post("/v1/auth/renew", &serde_json::json!({})).await?; *self.token.write().await = Some(resp.token.clone()); Ok((resp.token, resp.expires_at)) } /// Validate a token. Returns [`TokenClaims`] with `valid: false` (no error) /// if the token is invalid or revoked. pub async fn validate_token(&self, token: &str) -> Result { let body = serde_json::json!({ "token": token }); self.post("/v1/token/validate", &body).await } // ---- Server information ---- /// Call the health endpoint. Returns `Ok(())` on HTTP 200. pub async fn health(&self) -> Result<(), MciasError> { self.get_empty("/v1/health").await } /// Return the server's Ed25519 public key in JWK format. pub async fn get_public_key(&self) -> Result { self.get("/v1/keys/public").await } // ---- Account management (admin only) ---- /// Create a new account. `account_type` must be `"human"` or `"system"`. pub async fn create_account( &self, username: &str, password: Option<&str>, account_type: &str, ) -> Result { let mut body = serde_json::json!({ "username": username, "account_type": account_type, }); if let Some(pw) = password { body["password"] = serde_json::Value::String(pw.to_owned()); } self.post_expect_status("/v1/accounts", &body, StatusCode::CREATED) .await } /// List all accounts. pub async fn list_accounts(&self) -> Result, MciasError> { self.get("/v1/accounts").await } /// Get a single account by UUID. pub async fn get_account(&self, id: &str) -> Result { self.get(&format!("/v1/accounts/{id}")).await } /// Update an account's status. Allowed values: `"active"`, `"inactive"`. pub async fn update_account(&self, id: &str, status: &str) -> Result { let body = serde_json::json!({ "status": status }); self.patch(&format!("/v1/accounts/{id}"), &body).await } /// Soft-delete an account and revoke all its tokens. pub async fn delete_account(&self, id: &str) -> Result<(), MciasError> { self.delete(&format!("/v1/accounts/{id}")).await } // ---- Role management (admin only) ---- /// Get all roles assigned to an account. pub async fn get_roles(&self, account_id: &str) -> Result, MciasError> { self.get(&format!("/v1/accounts/{account_id}/roles")).await } /// Replace the complete role set for an account. pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> { let url = format!("/v1/accounts/{account_id}/roles"); self.put_no_content(&url, roles).await } // ---- Token management (admin only) ---- /// Issue a long-lived token for a system account. pub async fn issue_service_token( &self, account_id: &str, ) -> Result<(String, String), MciasError> { let body = serde_json::json!({ "account_id": account_id }); let resp: TokenResponse = self.post("/v1/token/issue", &body).await?; Ok((resp.token, resp.expires_at)) } /// Revoke a token by JTI. pub async fn revoke_token(&self, jti: &str) -> Result<(), MciasError> { self.delete(&format!("/v1/token/{jti}")).await } // ---- PG credentials (admin only) ---- /// Get decrypted Postgres credentials for an account. pub async fn get_pg_creds(&self, account_id: &str) -> Result { self.get(&format!("/v1/accounts/{account_id}/pgcreds")) .await } /// Store Postgres credentials for an account. pub async fn set_pg_creds( &self, account_id: &str, host: &str, port: u16, database: &str, username: &str, password: &str, ) -> Result<(), MciasError> { let body = serde_json::json!({ "host": host, "port": port, "database": database, "username": username, "password": password, }); self.put_no_content(&format!("/v1/accounts/{account_id}/pgcreds"), &body) .await } // ---- HTTP helpers ---- /// Build a request with the Authorization header set from the stored token. /// Security: the token is read under a read-lock and is not logged. async fn auth_header(&self) -> Option { let guard = self.token.read().await; guard.as_deref().and_then(|tok| { header::HeaderValue::from_str(&format!("Bearer {tok}")).ok() }) } async fn get Deserialize<'de>>(&self, path: &str) -> Result { let mut req = self.http.get(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn get_empty(&self, path: &str) -> Result<(), MciasError> { let mut req = self.http.get(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn post Deserialize<'de>>( &self, path: &str, body: &B, ) -> Result { let mut req = self .http .post(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn post_expect_status Deserialize<'de>>( &self, path: &str, body: &B, expected: StatusCode, ) -> Result { let mut req = self .http .post(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; if resp.status() == expected { return resp .json::() .await .map_err(|e| MciasError::Decode(e.to_string())); } Err(self.error_from_response(resp).await) } async fn post_empty(&self, path: &str) -> Result<(), MciasError> { let mut req = self .http .post(format!("{}{path}", self.base_url)) .header(header::CONTENT_LENGTH, "0"); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn patch Deserialize<'de>>( &self, path: &str, body: &B, ) -> Result { let mut req = self .http .patch(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn put_no_content(&self, path: &str, body: &B) -> Result<(), MciasError> { let mut req = self .http .put(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn delete(&self, path: &str) -> Result<(), MciasError> { let mut req = self.http.delete(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn decode Deserialize<'de>>( &self, resp: reqwest::Response, ) -> Result { if resp.status().is_success() { return resp .json::() .await .map_err(|e| MciasError::Decode(e.to_string())); } Err(self.error_from_response(resp).await) } async fn expect_success(&self, resp: reqwest::Response) -> Result<(), MciasError> { if resp.status().is_success() { return Ok(()); } Err(self.error_from_response(resp).await) } async fn error_from_response(&self, resp: reqwest::Response) -> MciasError { let status = resp.status(); let message = resp .json::() .await .map(|e| if e.error.is_empty() { status.to_string() } else { e.error }) .unwrap_or_else(|_| status.to_string()); match status { StatusCode::UNAUTHORIZED => MciasError::Auth(message), StatusCode::FORBIDDEN => MciasError::Forbidden(message), StatusCode::NOT_FOUND => MciasError::NotFound(message), StatusCode::BAD_REQUEST => MciasError::InvalidInput(message), StatusCode::CONFLICT => MciasError::Conflict(message), s => MciasError::Server { status: s.as_u16(), message, }, } } }