diff --git a/Cargo.lock b/Cargo.lock index 0b19fff..dbd4a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,6 +2062,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "ed-onde-account" +version = "1.2.1" +dependencies = [ + "axum", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", +] + [[package]] name = "ed-uniffi-bindgen" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index da0a8c1..0075ce4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ed-acp", "crates/ed-acp-tui", "crates/ed-acp-testkit", + "crates/ed-onde-account", "uniffi-bindgen", ] @@ -62,6 +63,7 @@ ed-mcp = { version = "1.2.1", path = "crates/ed-mcp" } ed-acp = { version = "1.2.1", path = "crates/ed-acp" } ed-acp-tui = { version = "1.2.1", path = "crates/ed-acp-tui" } ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.2.1", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/README.md b/README.md index b342736..fba4de1 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The Onde Agent Platform crates, for agents that editors drive over the Agent Cli | [`ed-acp`](crates/ed-acp) | An ACP v1 server: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a `Profile`. | | [`ed-acp-tui`](crates/ed-acp-tui) | A terminal UI for any ACP agent. | | [`ed-mcp`](crates/ed-mcp) | MCP client glue over `rmcp`, the official Rust SDK: stdio and streamable HTTP servers, bearer tokens, tool namespacing, timeouts, progress and cancellation. With the `agent` feature, `McpExecutor` serves a toolset to Ed as tools. | +| [`ed-onde-account`](crates/ed-onde-account) | Onde Inference account client: sign in, activate an app and get its Onde Cloud API key through ondeinference.com, with no Onde secrets in the host app. | | [`ed-acp-testkit`](crates/ed-acp-testkit) | Mock Onde Cloud endpoint, raw ACP client, the ACP registry probe and the ACP v1 conformance suite. | Free the intelligence. diff --git a/crates/ed-onde-account/Cargo.toml b/crates/ed-onde-account/Cargo.toml new file mode 100644 index 0000000..76f551f --- /dev/null +++ b/crates/ed-onde-account/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ed-onde-account" +description = "Onde Inference account client for Onde agents: sign in, list and activate apps, and mint an Onde Cloud API key through the ondeinference.com API. Holds no Onde secrets." +keywords = ["onde", "agent", "auth", "api-key"] +categories = ["api-bindings", "authentication"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +axum = "0.8" +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } diff --git a/crates/ed-onde-account/LICENSE-APACHE b/crates/ed-onde-account/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-onde-account/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-onde-account/LICENSE-MIT b/crates/ed-onde-account/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-onde-account/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-onde-account/README.md b/crates/ed-onde-account/README.md new file mode 100644 index 0000000..b78c05b --- /dev/null +++ b/crates/ed-onde-account/README.md @@ -0,0 +1,35 @@ +# ed-onde-account + +An Onde Inference account client for [Ed](https://crates.io/crates/ed-agent) agents. + +It signs a user in to their Onde account, finds or creates their app, activates it, and returns +the Onde Cloud API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +the ondeinference.com API, which keeps Onde's smbCloud credentials on the server, so nothing +secret ships in the app that embeds this crate. + +```rust +use ed_onde_account::{OndeAccount, SignIn}; + +let account = OndeAccount::new(); // https://ondeinference.com, production +match account.sign_in("me@example.com", "password").await? { + SignIn::Ready(token) => { + // Reuses the app named "My Agent", or creates and activates it. + let key = account.ensure_key(&token, "My Agent").await?; + println!("{}", key.as_str()); // app_id:app_secret + } + SignIn::NotFound => { /* no account yet */ } + SignIn::Incomplete { .. } => { /* e.g. the email isn't confirmed */ } +} +``` + +- `sign_in`, `me`, `apps`, `create_app`, `activate` and `ensure_key`. +- `ensure_key` is idempotent: it never creates a second app with the same name. +- Errors are typed. `Error::Unauthorized` means the token expired and the user must sign in again; + `Error::Forbidden` means the account may not create apps. +- `AccessToken` and `ApiKey` redact themselves in `Debug` output. + +Creating apps needs an Onde account that is allowed to, which every signed-up user is. + +## License + +MIT OR Apache-2.0 diff --git a/crates/ed-onde-account/src/lib.rs b/crates/ed-onde-account/src/lib.rs new file mode 100644 index 0000000..57fe3e4 --- /dev/null +++ b/crates/ed-onde-account/src/lib.rs @@ -0,0 +1,413 @@ +//! A small client for the Onde Inference account API at ondeinference.com. +//! +//! It signs a user in, finds or creates their app, activates it and returns the Onde Cloud +//! API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +//! onde-web's own API, which adds the smbCloud client credentials on the server, so no +//! Onde secret ships in whatever embeds this crate. +//! +//! ```no_run +//! # async fn run() -> Result<(), ed_onde_account::Error> { +//! use ed_onde_account::{OndeAccount, SignIn}; +//! +//! let account = OndeAccount::new(); +//! if let SignIn::Ready(token) = account.sign_in("me@example.com", "password").await? { +//! let key = account.ensure_key(&token, "My Agent").await?; +//! // key.as_str() is "app_id:app_secret" +//! } +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; + +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Value, json}; + +/// The production API host. +pub const DEFAULT_BASE_URL: &str = "https://ondeinference.com"; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The access token is missing, expired or revoked. Sign in again. + #[error("not signed in, or the session expired")] + Unauthorized, + /// Sign-in was refused: the email and password don't match. + #[error("incorrect email or password")] + InvalidCredentials, + /// Signed in, but this account may not do that. + #[error("this Onde account is not allowed to do that")] + Forbidden, + /// The user's app with this name exists but is suspended, so it cannot get a key. + #[error("the app \"{0}\" is suspended")] + Suspended(String), + /// The API answered with another failure status. + #[error("Onde API error {status}: {message}")] + Http { status: u16, message: String }, + /// The API answered, but not with the shape this client expects. + #[error("unexpected response from the Onde API: {0}")] + Unexpected(String), + /// The request never completed. + #[error("could not reach the Onde API: {0}")] + Transport(#[from] reqwest::Error), +} + +/// Which Onde backend to talk to. Production unless a host is testing against the dev stack. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OndeEnv { + #[default] + Production, + Development, +} + +impl OndeEnv { + fn as_str(self) -> &'static str { + match self { + Self::Production => "production", + Self::Development => "development", + } + } +} + +/// The smbCloud access token for a signed-in user. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct AccessToken(String); + +impl AccessToken { + /// Wrap a token, stripping surrounding whitespace and any `Bearer ` prefixes. + pub fn new(token: impl AsRef) -> Self { + let mut token = token.as_ref().trim(); + while let Some(prefix) = token.get(..7) { + if !prefix.eq_ignore_ascii_case("bearer ") { + break; + } + token = token[7..].trim_start(); + } + Self(token.to_owned()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AccessToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AccessToken()") + } +} + +/// An Onde Cloud API key, `app_id:app_secret`. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct ApiKey(String); + +impl ApiKey { + pub fn new(app_id: &str, app_secret: &str) -> Self { + Self(format!("{app_id}:{app_secret}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApiKey()") + } +} + +/// How a sign-in attempt ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignIn { + Ready(AccessToken), + /// No account for this email. + NotFound, + /// The account exists but cannot sign in yet, for instance an unconfirmed email. + Incomplete { + error_code: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppStatus { + Provisioning, + Active, + Suspended, + #[serde(other)] + Unknown, +} + +/// An Onde app, the thing an API key belongs to. +#[derive(Clone, Deserialize)] +pub struct OndeApp { + #[serde(deserialize_with = "string_or_number")] + pub id: String, + pub name: String, + pub status: AppStatus, + #[serde(default, deserialize_with = "opt_string_or_number")] + pub app_id: Option, + pub app_secret: Option, +} + +impl fmt::Debug for OndeApp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OndeApp") + .field("id", &self.id) + .field("name", &self.name) + .field("status", &self.status) + .field( + "app_secret", + &self.app_secret.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl OndeApp { + /// The key for this app, if the secret is present. + pub fn api_key(&self) -> Option { + let secret = self.app_secret.as_deref().filter(|s| !s.is_empty())?; + Some(ApiKey::new( + self.app_id.as_deref().unwrap_or(&self.id), + secret, + )) + } +} + +/// The signed-in user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub email: String, + pub name: Option, + /// Whether the account may create apps (and so keys). + pub can_create: bool, +} + +#[derive(Debug, Clone)] +pub struct OndeAccount { + http: reqwest::Client, + base_url: Url, + env: OndeEnv, +} + +impl Default for OndeAccount { + fn default() -> Self { + Self::new() + } +} + +impl OndeAccount { + /// Production at ondeinference.com. + pub fn new() -> Self { + Self::with_base_url(DEFAULT_BASE_URL).expect("the default base URL is valid") + } + + /// Point at another host, such as a local onde-web or a test server. + pub fn with_base_url(base_url: &str) -> Result { + let base_url = Url::parse(base_url) + .map_err(|e| Error::Unexpected(format!("invalid base URL: {e}")))?; + Ok(Self { + http: reqwest::Client::new(), + base_url, + env: OndeEnv::default(), + }) + } + + pub fn with_env(mut self, env: OndeEnv) -> Self { + self.env = env; + self + } + + pub async fn sign_in(&self, email: &str, password: &str) -> Result { + let body = json!({ "email": email.trim(), "password": password }); + let value = self + .send(self.request(Method::POST, "/api/auth/sign-in").json(&body)) + .await + // There is no token to expire yet, so a 401 here is a wrong password. + .map_err(|e| match e { + Error::Unauthorized => Error::InvalidCredentials, + e => e, + })?; + match value.get("status").and_then(Value::as_str) { + Some("ready") => match value.get("accessToken").and_then(Value::as_str) { + Some(token) if !AccessToken::new(token).as_str().is_empty() => { + Ok(SignIn::Ready(AccessToken::new(token))) + } + _ => Err(Error::Unexpected( + "sign-in was ready without a token".into(), + )), + }, + Some("notFound") => Ok(SignIn::NotFound), + Some("incomplete") => Ok(SignIn::Incomplete { + error_code: value.get("errorCode").and_then(Value::as_i64), + }), + _ => Err(Error::Unexpected("unknown sign-in status".into())), + } + } + + pub async fn me(&self, token: &AccessToken) -> Result { + let value = self + .send( + self.request(Method::GET, "/api/auth/me") + .bearer_auth(token.as_str()), + ) + .await?; + let flag = |key: &str| value.get(key).and_then(Value::as_bool); + Ok(Profile { + email: value + .get("email") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + name: value.get("name").and_then(Value::as_str).map(str::to_owned), + can_create: flag("can_create_gresiq_apps") + .or_else(|| flag("can_manage_all_gresiq_apps")) + .unwrap_or(false), + }) + } + + pub async fn apps(&self, token: &AccessToken) -> Result> { + let value = self + .send( + self.request(Method::GET, "/api/gresiq/apps") + .bearer_auth(token.as_str()), + ) + .await?; + decode(value) + } + + pub async fn create_app(&self, token: &AccessToken, name: &str) -> Result { + let value = self + .send( + self.request(Method::POST, "/api/gresiq/apps") + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "name": name } })), + ) + .await?; + decode(value) + } + + /// Move a provisioning app to `active`. Onde Cloud refuses its key until then. + pub async fn activate(&self, token: &AccessToken, id: &str) -> Result { + let value = self + .send( + self.request(Method::PATCH, &format!("/api/gresiq/apps/{id}")) + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "status": "active" } })), + ) + .await?; + decode(value) + } + + /// Reuse the user's app named `name` (or create it), activate it if needed and return + /// its key. Calls made one after another reuse the app. Nothing makes list-then-create + /// atomic, so two concurrent first calls can each create one; both then list again and + /// settle on the same (oldest) app, so they return the same key. The extra app stays in + /// the account, because this client has no delete call. + pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result { + let apps = self.apps(token).await?; + let mut app = match pick(&apps, name) { + Some(app) => app.clone(), + // Whatever is left under this name is suspended. + None if apps.iter().any(|a| a.name == name) => { + return Err(Error::Suspended(name.to_owned())); + } + None => { + let created = self.create_app(token, name).await?; + // Another caller may have created one too: settle on the same app they do. + let apps = self.apps(token).await?; + pick(&apps, name).cloned().unwrap_or(created) + } + }; + if app.status != AppStatus::Active { + app = self.activate(token, &app.id).await?; + } + app.api_key() + .ok_or_else(|| Error::Unexpected("the app came back without a secret".into())) + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + let mut url = self.base_url.clone(); + url.set_path(path); + url.query_pairs_mut().append_pair("env", self.env.as_str()); + self.http.request(method, url) + } + + async fn send(&self, request: RequestBuilder) -> Result { + let response = request.send().await?; + let status = response.status(); + let text = response.text().await?; + let body: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if status.is_success() { + return Ok(body); + } + Err(match status { + StatusCode::UNAUTHORIZED => Error::Unauthorized, + StatusCode::FORBIDDEN => Error::Forbidden, + _ => Error::Http { + status: status.as_u16(), + message: ["message", "error"] + .iter() + .find_map(|k| body.get(*k).and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| { + body.get("errors") + .and_then(Value::as_array) + .map(|e| { + e.iter() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + }) + .filter(|m| !m.is_empty()) + }) + .unwrap_or_else(|| "request failed".to_owned()), + }, + }) + } +} + +fn decode Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| Error::Unexpected(e.to_string())) +} + +fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + other => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} + +fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + match Option::::deserialize(d)? { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(Value::Number(n)) => Ok(Some(n.to_string())), + Some(other) => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} + +/// The app every caller agrees on among those named `name`: an active one before a +/// provisioning one, then the lowest id (numeric when it parses). Suspended apps never win. +fn pick<'a>(apps: &'a [OndeApp], name: &str) -> Option<&'a OndeApp> { + apps.iter() + .filter(|a| a.name == name && a.status != AppStatus::Suspended) + .min_by_key(|a| { + let numeric = a.id.parse::().ok(); + ( + a.status != AppStatus::Active, + numeric.is_none(), + numeric, + a.id.as_str(), + ) + }) +} diff --git a/crates/ed-onde-account/tests/api.rs b/crates/ed-onde-account/tests/api.rs new file mode 100644 index 0000000..680a17d --- /dev/null +++ b/crates/ed-onde-account/tests/api.rs @@ -0,0 +1,267 @@ +//! The client against an in-process stand-in for onde-web's API. + +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use ed_onde_account::{AccessToken, Error, OndeAccount, OndeEnv, SignIn}; +use serde_json::{Value, json}; +use std::collections::HashMap; + +const TOKEN: &str = "jwt-123"; + +#[derive(Default)] +struct Mock { + apps: Vec, + /// An app another caller creates just before this server handles a `POST`. + rival: Option, + /// Every `(method, path, env)` the server saw. + calls: Vec, +} + +type Shared = Arc>; + +fn app(id: &str, name: &str, status: &str) -> Value { + json!({ "id": id, "name": name, "status": status, "app_id": id, "app_secret": format!("sec-{id}") }) +} + +fn authorized(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {TOKEN}")) +} + +async fn serve(apps: Vec) -> (OndeAccount, Shared) { + let state: Shared = Arc::new(Mutex::new(Mock { + apps, + rival: None, + calls: vec![], + })); + + async fn sign_in( + State(s): State, + Query(q): Query>, + Json(body): Json, + ) -> (StatusCode, Json) { + s.lock() + .unwrap() + .calls + .push(format!("sign-in env={}", q["env"])); + if body["email"] == "wrong@x.io" { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "message": "Invalid password." })), + ); + } + ( + StatusCode::OK, + Json(match body["email"].as_str().unwrap() { + "ready@x.io" => { + json!({ "status": "ready", "accessToken": format!("Bearer {TOKEN}") }) + } + "new@x.io" => json!({ "status": "notFound" }), + _ => json!({ "status": "incomplete", "errorCode": 100 }), + }), + ) + } + async fn me(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Unauthorized" })), + ); + } + ( + StatusCode::OK, + Json(json!({ "email": "ready@x.io", "name": "Ready", "can_create_gresiq_apps": true })), + ) + } + async fn list(State(s): State, headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + ( + StatusCode::OK, + Json(Value::Array(s.lock().unwrap().apps.clone())), + ) + } + async fn create(State(s): State, Json(body): Json) -> (StatusCode, Json) { + let mut s = s.lock().unwrap(); + s.calls.push("create".into()); + if let Some(rival) = s.rival.take() { + s.apps.push(rival); + } + let created = app( + "new-1", + body["gresiq_app"]["name"].as_str().unwrap(), + "provisioning", + ); + s.apps.push(created.clone()); + (StatusCode::CREATED, Json(created)) + } + async fn activate( + State(s): State, + Path(id): Path, + Json(body): Json, + ) -> Json { + let mut s = s.lock().unwrap(); + s.calls + .push(format!("activate {id} {}", body["gresiq_app"]["status"])); + let found = s.apps.iter_mut().find(|a| a["id"] == id).unwrap(); + found["status"] = json!("active"); + Json(found.clone()) + } + + let router = Router::new() + .route("/api/auth/sign-in", post(sign_in)) + .route("/api/auth/me", get(me)) + .route("/api/gresiq/apps", get(list).post(create)) + .route("/api/gresiq/apps/{id}", patch(activate)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let account = OndeAccount::with_base_url(&format!("http://{addr}")).unwrap(); + (account, state) +} + +#[tokio::test] +async fn sign_in_outcomes_and_token_normalization() { + let (account, state) = serve(vec![]).await; + + let SignIn::Ready(token) = account.sign_in("ready@x.io", "pw").await.unwrap() else { + panic!("expected ready"); + }; + assert_eq!(token.as_str(), TOKEN, "the Bearer prefix is stripped"); + assert_eq!(format!("{token:?}"), "AccessToken()"); + + assert_eq!( + account.sign_in("new@x.io", "pw").await.unwrap(), + SignIn::NotFound + ); + assert_eq!( + account.sign_in("unconfirmed@x.io", "pw").await.unwrap(), + SignIn::Incomplete { + error_code: Some(100) + } + ); + + assert!(matches!( + account.sign_in("wrong@x.io", "pw").await, + Err(Error::InvalidCredentials) + )); + + let dev = account.clone().with_env(OndeEnv::Development); + dev.sign_in("new@x.io", "pw").await.unwrap(); + let calls = state.lock().unwrap().calls.clone(); + assert_eq!(calls[0], "sign-in env=production"); + assert_eq!(calls[4], "sign-in env=development"); +} + +#[test] +fn access_token_strips_repeated_prefixes() { + assert_eq!(AccessToken::new(" bearer Bearer abc ").as_str(), "abc"); + assert_eq!(AccessToken::new("abc").as_str(), "abc"); +} + +#[tokio::test] +async fn me_reads_the_profile() { + let (account, _) = serve(vec![]).await; + let profile = account.me(&AccessToken::new(TOKEN)).await.unwrap(); + assert_eq!(profile.email, "ready@x.io"); + assert!(profile.can_create); +} + +#[tokio::test] +async fn ensure_key_reuses_an_active_app() { + let (account, state) = serve(vec![ + app("a1", "Other", "active"), + app("a2", "KaroKowe", "active"), + ]) + .await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a2:sec-a2"); + assert_eq!(format!("{key:?}"), "ApiKey()"); + assert!( + state.lock().unwrap().calls.is_empty(), + "nothing was created or patched" + ); +} + +#[tokio::test] +async fn ensure_key_activates_a_provisioning_app() { + let (account, state) = serve(vec![app("a1", "KaroKowe", "provisioning")]).await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a1:sec-a1"); + assert_eq!(state.lock().unwrap().calls, vec![r#"activate a1 "active""#]); +} + +#[tokio::test] +async fn ensure_key_creates_then_activates_when_none_exists() { + let (account, state) = serve(vec![app("a1", "Other", "active")]).await; + let token = AccessToken::new(TOKEN); + let key = account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(key.as_str(), "new-1:sec-new-1"); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate new-1 "active""#.to_owned()] + ); + // A second call finds it instead of creating another. + account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(state.lock().unwrap().calls.len(), 2); +} + +#[tokio::test] +async fn ensure_key_converges_when_a_create_races() { + let (account, state) = serve(vec![]).await; + // The other caller's create lands first, so its app has the lower id. + state.lock().unwrap().rival = Some(app("7", "KaroKowe", "provisioning")); + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!( + key.as_str(), + "7:sec-7", + "the oldest app wins, not the one just created" + ); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate 7 "active""#.to_owned()] + ); +} + +#[tokio::test] +async fn ensure_key_refuses_a_suspended_app() { + let (account, _) = serve(vec![app("a1", "KaroKowe", "suspended")]).await; + let err = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap_err(); + assert!(matches!(err, Error::Suspended(name) if name == "KaroKowe")); +} + +#[tokio::test] +async fn an_expired_token_is_unauthorized() { + let (account, _) = serve(vec![]).await; + let stale = AccessToken::new("expired"); + assert!(matches!(account.me(&stale).await, Err(Error::Unauthorized))); + assert!(matches!( + account.apps(&stale).await, + Err(Error::Unauthorized) + )); + assert!(matches!( + account.ensure_key(&stale, "KaroKowe").await, + Err(Error::Unauthorized) + )); +}