| 1 | //! A small client for the Onde Inference account API at ondeinference.com. |
| 2 | //! |
| 3 | //! It signs a user in, finds or creates their app, activates it and returns the Onde Cloud |
| 4 | //! API key (`app_id:app_secret`) an agent authenticates with. Every call goes through |
| 5 | //! onde-web's own API, which adds the smbCloud client credentials on the server, so no |
| 6 | //! Onde secret ships in whatever embeds this crate. |
| 7 | //! |
| 8 | //! ```no_run |
| 9 | //! # async fn run() -> Result<(), ed_onde_account::Error> { |
| 10 | //! use ed_onde_account::{OndeAccount, SignIn}; |
| 11 | //! |
| 12 | //! let account = OndeAccount::new(); |
| 13 | //! if let SignIn::Ready(token) = account.sign_in("me@example.com", "password").await? { |
| 14 | //! let key = account.ensure_key(&token, "My Agent").await?; |
| 15 | //! // key.as_str() is "app_id:app_secret" |
| 16 | //! } |
| 17 | //! # Ok(()) |
| 18 | //! # } |
| 19 | //! ``` |
| 20 | |
| 21 | use std::fmt; |
| 22 | |
| 23 | use reqwest::{Method, RequestBuilder, StatusCode, Url}; |
| 24 | use serde::{Deserialize, Deserializer}; |
| 25 | use serde_json::{Value, json}; |
| 26 | |
| 27 | /// The production API host. |
| 28 | pub const DEFAULT_BASE_URL: &str = "https://ondeinference.com"; |
| 29 | |
| 30 | pub type Result<T, E = Error> = std::result::Result<T, E>; |
| 31 | |
| 32 | #[derive(Debug, thiserror::Error)] |
| 33 | pub enum Error { |
| 34 | /// The access token is missing, expired or revoked. Sign in again. |
| 35 | #[error("not signed in, or the session expired")] |
| 36 | Unauthorized, |
| 37 | /// Sign-in was refused: the email and password don't match. |
| 38 | #[error("incorrect email or password")] |
| 39 | InvalidCredentials, |
| 40 | /// Signed in, but this account may not do that. |
| 41 | #[error("this Onde account is not allowed to do that")] |
| 42 | Forbidden, |
| 43 | /// The user's app with this name exists but is suspended, so it cannot get a key. |
| 44 | #[error("the app \"{0}\" is suspended")] |
| 45 | Suspended(String), |
| 46 | /// The API answered with another failure status. |
| 47 | #[error("Onde API error {status}: {message}")] |
| 48 | Http { status: u16, message: String }, |
| 49 | /// The API answered, but not with the shape this client expects. |
| 50 | #[error("unexpected response from the Onde API: {0}")] |
| 51 | Unexpected(String), |
| 52 | /// The request never completed. |
| 53 | #[error("could not reach the Onde API: {0}")] |
| 54 | Transport(#[from] reqwest::Error), |
| 55 | } |
| 56 | |
| 57 | /// Which Onde backend to talk to. Production unless a host is testing against the dev stack. |
| 58 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 59 | pub enum OndeEnv { |
| 60 | #[default] |
| 61 | Production, |
| 62 | Development, |
| 63 | } |
| 64 | |
| 65 | impl OndeEnv { |
| 66 | fn as_str(self) -> &'static str { |
| 67 | match self { |
| 68 | Self::Production => "production", |
| 69 | Self::Development => "development", |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// The smbCloud access token for a signed-in user. Debug output is redacted. |
| 75 | #[derive(Clone, PartialEq, Eq)] |
| 76 | pub struct AccessToken(String); |
| 77 | |
| 78 | impl AccessToken { |
| 79 | /// Wrap a token, stripping surrounding whitespace and any `Bearer ` prefixes. |
| 80 | pub fn new(token: impl AsRef<str>) -> Self { |
| 81 | let mut token = token.as_ref().trim(); |
| 82 | while let Some(prefix) = token.get(..7) { |
| 83 | if !prefix.eq_ignore_ascii_case("bearer ") { |
| 84 | break; |
| 85 | } |
| 86 | token = token[7..].trim_start(); |
| 87 | } |
| 88 | Self(token.to_owned()) |
| 89 | } |
| 90 | |
| 91 | pub fn as_str(&self) -> &str { |
| 92 | &self.0 |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | impl fmt::Debug for AccessToken { |
| 97 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 98 | f.write_str("AccessToken(<redacted>)") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// An Onde Cloud API key, `app_id:app_secret`. Debug output is redacted. |
| 103 | #[derive(Clone, PartialEq, Eq)] |
| 104 | pub struct ApiKey(String); |
| 105 | |
| 106 | impl ApiKey { |
| 107 | pub fn new(app_id: &str, app_secret: &str) -> Self { |
| 108 | Self(format!("{app_id}:{app_secret}")) |
| 109 | } |
| 110 | |
| 111 | pub fn as_str(&self) -> &str { |
| 112 | &self.0 |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | impl fmt::Debug for ApiKey { |
| 117 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 118 | f.write_str("ApiKey(<redacted>)") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// How a sign-in attempt ended. |
| 123 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 124 | pub enum SignIn { |
| 125 | Ready(AccessToken), |
| 126 | /// No account for this email. |
| 127 | NotFound, |
| 128 | /// The account exists but cannot sign in yet, for instance an unconfirmed email. |
| 129 | Incomplete { |
| 130 | error_code: Option<i64>, |
| 131 | }, |
| 132 | } |
| 133 | |
| 134 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] |
| 135 | #[serde(rename_all = "lowercase")] |
| 136 | pub enum AppStatus { |
| 137 | Provisioning, |
| 138 | Active, |
| 139 | Suspended, |
| 140 | #[serde(other)] |
| 141 | Unknown, |
| 142 | } |
| 143 | |
| 144 | /// An Onde app, the thing an API key belongs to. |
| 145 | #[derive(Clone, Deserialize)] |
| 146 | pub struct OndeApp { |
| 147 | #[serde(deserialize_with = "string_or_number")] |
| 148 | pub id: String, |
| 149 | pub name: String, |
| 150 | pub status: AppStatus, |
| 151 | #[serde(default, deserialize_with = "opt_string_or_number")] |
| 152 | pub app_id: Option<String>, |
| 153 | pub app_secret: Option<String>, |
| 154 | } |
| 155 | |
| 156 | impl fmt::Debug for OndeApp { |
| 157 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 158 | f.debug_struct("OndeApp") |
| 159 | .field("id", &self.id) |
| 160 | .field("name", &self.name) |
| 161 | .field("status", &self.status) |
| 162 | .field( |
| 163 | "app_secret", |
| 164 | &self.app_secret.as_ref().map(|_| "<redacted>"), |
| 165 | ) |
| 166 | .finish() |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | impl OndeApp { |
| 171 | /// The key for this app, if the secret is present. |
| 172 | pub fn api_key(&self) -> Option<ApiKey> { |
| 173 | let secret = self.app_secret.as_deref().filter(|s| !s.is_empty())?; |
| 174 | Some(ApiKey::new( |
| 175 | self.app_id.as_deref().unwrap_or(&self.id), |
| 176 | secret, |
| 177 | )) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | /// The signed-in user. |
| 182 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 183 | pub struct Profile { |
| 184 | pub email: String, |
| 185 | pub name: Option<String>, |
| 186 | /// Whether the account may create apps (and so keys). |
| 187 | pub can_create: bool, |
| 188 | } |
| 189 | |
| 190 | #[derive(Debug, Clone)] |
| 191 | pub struct OndeAccount { |
| 192 | http: reqwest::Client, |
| 193 | base_url: Url, |
| 194 | env: OndeEnv, |
| 195 | } |
| 196 | |
| 197 | impl Default for OndeAccount { |
| 198 | fn default() -> Self { |
| 199 | Self::new() |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | impl OndeAccount { |
| 204 | /// Production at ondeinference.com. |
| 205 | pub fn new() -> Self { |
| 206 | Self::with_base_url(DEFAULT_BASE_URL).expect("the default base URL is valid") |
| 207 | } |
| 208 | |
| 209 | /// Point at another host, such as a local onde-web or a test server. |
| 210 | pub fn with_base_url(base_url: &str) -> Result<Self> { |
| 211 | let base_url = Url::parse(base_url) |
| 212 | .map_err(|e| Error::Unexpected(format!("invalid base URL: {e}")))?; |
| 213 | Ok(Self { |
| 214 | http: reqwest::Client::new(), |
| 215 | base_url, |
| 216 | env: OndeEnv::default(), |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | pub fn with_env(mut self, env: OndeEnv) -> Self { |
| 221 | self.env = env; |
| 222 | self |
| 223 | } |
| 224 | |
| 225 | pub async fn sign_in(&self, email: &str, password: &str) -> Result<SignIn> { |
| 226 | let body = json!({ "email": email.trim(), "password": password }); |
| 227 | let value = self |
| 228 | .send(self.request(Method::POST, "/api/auth/sign-in").json(&body)) |
| 229 | .await |
| 230 | // There is no token to expire yet, so a 401 here is a wrong password. |
| 231 | .map_err(|e| match e { |
| 232 | Error::Unauthorized => Error::InvalidCredentials, |
| 233 | e => e, |
| 234 | })?; |
| 235 | match value.get("status").and_then(Value::as_str) { |
| 236 | Some("ready") => match value.get("accessToken").and_then(Value::as_str) { |
| 237 | Some(token) if !AccessToken::new(token).as_str().is_empty() => { |
| 238 | Ok(SignIn::Ready(AccessToken::new(token))) |
| 239 | } |
| 240 | _ => Err(Error::Unexpected( |
| 241 | "sign-in was ready without a token".into(), |
| 242 | )), |
| 243 | }, |
| 244 | Some("notFound") => Ok(SignIn::NotFound), |
| 245 | Some("incomplete") => Ok(SignIn::Incomplete { |
| 246 | error_code: value.get("errorCode").and_then(Value::as_i64), |
| 247 | }), |
| 248 | _ => Err(Error::Unexpected("unknown sign-in status".into())), |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | pub async fn me(&self, token: &AccessToken) -> Result<Profile> { |
| 253 | let value = self |
| 254 | .send( |
| 255 | self.request(Method::GET, "/api/auth/me") |
| 256 | .bearer_auth(token.as_str()), |
| 257 | ) |
| 258 | .await?; |
| 259 | let flag = |key: &str| value.get(key).and_then(Value::as_bool); |
| 260 | Ok(Profile { |
| 261 | email: value |
| 262 | .get("email") |
| 263 | .and_then(Value::as_str) |
| 264 | .unwrap_or_default() |
| 265 | .to_owned(), |
| 266 | name: value.get("name").and_then(Value::as_str).map(str::to_owned), |
| 267 | can_create: flag("can_create_gresiq_apps") |
| 268 | .or_else(|| flag("can_manage_all_gresiq_apps")) |
| 269 | .unwrap_or(false), |
| 270 | }) |
| 271 | } |
| 272 | |
| 273 | pub async fn apps(&self, token: &AccessToken) -> Result<Vec<OndeApp>> { |
| 274 | let value = self |
| 275 | .send( |
| 276 | self.request(Method::GET, "/api/gresiq/apps") |
| 277 | .bearer_auth(token.as_str()), |
| 278 | ) |
| 279 | .await?; |
| 280 | decode(value) |
| 281 | } |
| 282 | |
| 283 | pub async fn create_app(&self, token: &AccessToken, name: &str) -> Result<OndeApp> { |
| 284 | let value = self |
| 285 | .send( |
| 286 | self.request(Method::POST, "/api/gresiq/apps") |
| 287 | .bearer_auth(token.as_str()) |
| 288 | .json(&json!({ "gresiq_app": { "name": name } })), |
| 289 | ) |
| 290 | .await?; |
| 291 | decode(value) |
| 292 | } |
| 293 | |
| 294 | /// Move a provisioning app to `active`. Onde Cloud refuses its key until then. |
| 295 | pub async fn activate(&self, token: &AccessToken, id: &str) -> Result<OndeApp> { |
| 296 | let value = self |
| 297 | .send( |
| 298 | self.request(Method::PATCH, &format!("/api/gresiq/apps/{id}")) |
| 299 | .bearer_auth(token.as_str()) |
| 300 | .json(&json!({ "gresiq_app": { "status": "active" } })), |
| 301 | ) |
| 302 | .await?; |
| 303 | decode(value) |
| 304 | } |
| 305 | |
| 306 | /// Reuse the user's app named `name` (or create it), activate it if needed and return |
| 307 | /// its key. Calls made one after another reuse the app. Nothing makes list-then-create |
| 308 | /// atomic, so two concurrent first calls can each create one; both then list again and |
| 309 | /// settle on the same (oldest) app, so they return the same key. The extra app stays in |
| 310 | /// the account, because this client has no delete call. |
| 311 | pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result<ApiKey> { |
| 312 | let apps = self.apps(token).await?; |
| 313 | let mut app = match pick(&apps, name) { |
| 314 | Some(app) => app.clone(), |
| 315 | // Whatever is left under this name is suspended. |
| 316 | None if apps.iter().any(|a| a.name == name) => { |
| 317 | return Err(Error::Suspended(name.to_owned())); |
| 318 | } |
| 319 | None => { |
| 320 | let created = self.create_app(token, name).await?; |
| 321 | // Another caller may have created one too: settle on the same app they do. |
| 322 | let apps = self.apps(token).await?; |
| 323 | pick(&apps, name).cloned().unwrap_or(created) |
| 324 | } |
| 325 | }; |
| 326 | if app.status != AppStatus::Active { |
| 327 | app = self.activate(token, &app.id).await?; |
| 328 | } |
| 329 | app.api_key() |
| 330 | .ok_or_else(|| Error::Unexpected("the app came back without a secret".into())) |
| 331 | } |
| 332 | |
| 333 | fn request(&self, method: Method, path: &str) -> RequestBuilder { |
| 334 | let mut url = self.base_url.clone(); |
| 335 | url.set_path(path); |
| 336 | url.query_pairs_mut().append_pair("env", self.env.as_str()); |
| 337 | self.http.request(method, url) |
| 338 | } |
| 339 | |
| 340 | async fn send(&self, request: RequestBuilder) -> Result<Value> { |
| 341 | let response = request.send().await?; |
| 342 | let status = response.status(); |
| 343 | let text = response.text().await?; |
| 344 | let body: Value = serde_json::from_str(&text).unwrap_or(Value::Null); |
| 345 | if status.is_success() { |
| 346 | return Ok(body); |
| 347 | } |
| 348 | Err(match status { |
| 349 | StatusCode::UNAUTHORIZED => Error::Unauthorized, |
| 350 | StatusCode::FORBIDDEN => Error::Forbidden, |
| 351 | _ => Error::Http { |
| 352 | status: status.as_u16(), |
| 353 | message: ["message", "error"] |
| 354 | .iter() |
| 355 | .find_map(|k| body.get(*k).and_then(Value::as_str)) |
| 356 | .map(str::to_owned) |
| 357 | .or_else(|| { |
| 358 | body.get("errors") |
| 359 | .and_then(Value::as_array) |
| 360 | .map(|e| { |
| 361 | e.iter() |
| 362 | .filter_map(Value::as_str) |
| 363 | .collect::<Vec<_>>() |
| 364 | .join("; ") |
| 365 | }) |
| 366 | .filter(|m| !m.is_empty()) |
| 367 | }) |
| 368 | .unwrap_or_else(|| "request failed".to_owned()), |
| 369 | }, |
| 370 | }) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | fn decode<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T> { |
| 375 | serde_json::from_value(value).map_err(|e| Error::Unexpected(e.to_string())) |
| 376 | } |
| 377 | |
| 378 | fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> { |
| 379 | match Value::deserialize(d)? { |
| 380 | Value::String(s) => Ok(s), |
| 381 | Value::Number(n) => Ok(n.to_string()), |
| 382 | other => Err(serde::de::Error::custom(format!( |
| 383 | "expected an id, got {other}" |
| 384 | ))), |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> { |
| 389 | match Option::<Value>::deserialize(d)? { |
| 390 | None | Some(Value::Null) => Ok(None), |
| 391 | Some(Value::String(s)) => Ok(Some(s)), |
| 392 | Some(Value::Number(n)) => Ok(Some(n.to_string())), |
| 393 | Some(other) => Err(serde::de::Error::custom(format!( |
| 394 | "expected an id, got {other}" |
| 395 | ))), |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// The app every caller agrees on among those named `name`: an active one before a |
| 400 | /// provisioning one, then the lowest id (numeric when it parses). Suspended apps never win. |
| 401 | fn pick<'a>(apps: &'a [OndeApp], name: &str) -> Option<&'a OndeApp> { |
| 402 | apps.iter() |
| 403 | .filter(|a| a.name == name && a.status != AppStatus::Suspended) |
| 404 | .min_by_key(|a| { |
| 405 | let numeric = a.id.parse::<u64>().ok(); |
| 406 | ( |
| 407 | a.status != AppStatus::Active, |
| 408 | numeric.is_none(), |
| 409 | numeric, |
| 410 | a.id.as_str(), |
| 411 | ) |
| 412 | }) |
| 413 | } |