use anyhow::{Context, Result};
use base64::Engine;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION};
use reqwest::{Client, Method, Url};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
use super::jwt::bearer_authorization_value;
#[derive(Debug, thiserror::Error)]
pub enum BackendApiError {
#[error("message not found on {provider}: {message_id}")]
MessageNotFound {
provider: String,
message_id: String,
},
#[error("backend rejected session token on {method} {path}")]
Unauthorized {
method: String,
path: String,
},
}
pub fn flatten_authed_error(err: anyhow::Error) -> String {
match err.downcast_ref::<BackendApiError>() {
Some(BackendApiError::Unauthorized { method, path }) => {
format!("SESSION_EXPIRED: backend rejected session token on {method} {path}")
}
_ => format!("{err:#}"),
}
}
fn parse_message_path(path: &str) -> Option<(&str, &str)> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() == 4 && segments[0] == "channels" && segments[2] == "messages" {
return Some((segments[1], segments[3]));
}
for window in segments.windows(4) {
if window[0] == "channels" && window[2] == "messages" {
return Some((window[1], window[3]));
}
}
None
}
const CLIENT_VERSION_HEADER_MAX_LEN: usize = 64;
const BACKEND_API_BODY_SHAPE_MAX_BYTES: usize = 120;
fn backend_api_body_shape(body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
return "empty".to_string();
}
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(map)) => {
let total = map.len();
let mut safe: Vec<&str> = map
.keys()
.map(String::as_str)
.filter(|k| is_schema_like_key(k))
.collect();
safe.sort_unstable();
let redacted = total - safe.len();
let keys = crate::openhuman::util::truncate_at_byte_boundary(
&safe.join(","),
BACKEND_API_BODY_SHAPE_MAX_BYTES,
);
format!("object(keys={total},safe=[{keys}],redacted={redacted})")
}
Ok(Value::Array(_)) => "array".to_string(),
Ok(_) => "scalar".to_string(),
Err(_) => "non_json".to_string(),
}
}
fn is_schema_like_key(key: &str) -> bool {
const MAX_KEY_LEN: usize = 40;
!key.is_empty()
&& key.len() <= MAX_KEY_LEN
&& key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
fn sanitize_client_version(raw: &str) -> Option<String> {
let sanitized: String = raw
.trim()
.chars()
.filter(|c| matches!(c, '0'..='9' | 'A'..='Z' | 'a'..='z' | '.' | '_' | '+' | '-'))
.take(CLIENT_VERSION_HEADER_MAX_LEN)
.collect();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn build_backend_reqwest_client() -> Result<Client> {
let mut default_headers = HeaderMap::new();
if let Some(version) = sanitize_client_version(env!("CARGO_PKG_VERSION")) {
default_headers.insert(
HeaderName::from_static("x-core-version"),
HeaderValue::from_str(&version).context("invalid x-core-version header value")?,
);
}
if let Ok(raw) = std::env::var("OPENHUMAN_TAURI_VERSION") {
if let Some(version) = sanitize_client_version(&raw) {
default_headers.insert(
HeaderName::from_static("x-tauri-version"),
HeaderValue::from_str(&version).context("invalid x-tauri-version header value")?,
);
}
}
crate::openhuman::tls::tls_client_builder()
.default_headers(default_headers)
.http1_only()
.timeout(Duration::from_secs(120))
.connect_timeout(Duration::from_secs(15))
.build()
.map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))
}
fn parse_api_response_json(text: &str) -> Result<Value> {
let v: Value = serde_json::from_str(text).with_context(|| format!("parse API JSON: {text}"))?;
let Some(obj) = v.as_object() else {
return Ok(v);
};
if let Some(success) = obj.get("success").and_then(|x| x.as_bool()) {
if !success {
let msg = obj
.get("message")
.or_else(|| obj.get("error"))
.and_then(|x| x.as_str())
.unwrap_or("request unsuccessful");
anyhow::bail!("API request failed: {msg}");
}
if let Some(data) = obj.get("data") {
if !data.is_null() {
return Ok(data.clone());
}
}
if let Some(user) = obj.get("user") {
if !user.is_null() {
return Ok(user.clone());
}
}
let mut m = obj.clone();
m.remove("success");
return Ok(Value::Object(m));
}
Ok(v)
}
fn user_id_from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
for key in ["id", "_id", "userId"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
let t = s.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
pub fn user_id_from_profile_payload(payload: &Value) -> Option<String> {
let obj = payload.as_object()?;
if let Some(data) = obj.get("data").and_then(|v| v.as_object()) {
return user_id_from_object(data).or_else(|| {
data.get("user")
.and_then(|u| u.as_object())
.and_then(user_id_from_object)
});
}
user_id_from_object(obj).or_else(|| {
obj.get("user")
.and_then(|u| u.as_object())
.and_then(user_id_from_object)
})
}
pub fn user_id_from_auth_me_payload(payload: &Value) -> Option<String> {
user_id_from_profile_payload(payload)
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConnectResponse {
pub oauth_url: String,
pub state: String,
}
#[derive(Debug, Clone, Deserialize)]
struct ConnectEnvelope {
success: bool,
#[serde(default, alias = "oauthUrl")]
oauth_url: Option<String>,
#[serde(default)]
state: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct IntegrationsEnvelope {
success: bool,
data: IntegrationsData,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IntegrationsData {
integrations: Vec<IntegrationSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationSummary {
pub id: String,
pub provider: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
struct TokensEnvelope {
success: bool,
data: TokensData,
}
#[derive(Debug, Clone, Deserialize)]
struct TokensData {
encrypted: String,
}
#[derive(Debug, Clone, Deserialize)]
struct LoginTokenConsumeEnvelope {
success: bool,
data: LoginTokenConsumeData,
}
#[derive(Debug, Clone, Deserialize)]
struct LoginTokenConsumeData {
jwt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntegrationTokensHandoff {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
pub expires_at: String,
}
#[derive(Clone)]
pub struct BackendOAuthClient {
client: Client,
base: Url,
}
impl BackendOAuthClient {
pub fn new(api_base: &str) -> Result<Self> {
let mut base = Url::parse(api_base.trim()).context("Invalid API base URL")?;
anyhow::ensure!(
matches!(base.scheme(), "http" | "https") && base.host_str().is_some(),
"API base URL must be an absolute http(s) URL with host"
);
base.set_path("");
base.set_query(None);
base.set_fragment(None);
let client = build_backend_reqwest_client()?;
Ok(Self { client, base })
}
pub fn raw_client(&self) -> &Client {
&self.client
}
pub fn url_for(&self, path: &str) -> Result<Url> {
self.base
.join(path.trim_start_matches('/'))
.with_context(|| format!("build URL for {path}"))
}
pub fn login_url(&self, provider: &str) -> Result<Url> {
let p = provider.trim().trim_matches('/');
anyhow::ensure!(!p.is_empty(), "provider is required");
self.base
.join(&format!("auth/{p}/login"))
.context("build login URL")
}
pub async fn connect(
&self,
provider: &str,
bearer_jwt: &str,
skill_id: Option<&str>,
response_type: Option<&str>,
encryption_mode: Option<&str>,
) -> Result<ConnectResponse> {
let p = provider.trim().trim_matches('/');
anyhow::ensure!(!p.is_empty(), "provider is required");
let mut url = self
.base
.join(&format!("auth/{p}/connect"))
.context("build connect URL")?;
if let Some(s) = skill_id.filter(|s| !s.is_empty()) {
url.query_pairs_mut().append_pair("skillId", s);
}
if let Some(r) = response_type.filter(|r| !r.is_empty()) {
url.query_pairs_mut().append_pair("responseType", r);
}
if let Some(e) = encryption_mode.filter(|e| !e.is_empty()) {
url.query_pairs_mut().append_pair("encryptionMode", e);
}
let resp = self
.client
.get(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("auth connect request")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("auth connect failed ({status}): {text}");
}
let env: ConnectEnvelope =
serde_json::from_str(&text).with_context(|| format!("parse connect JSON: {text}"))?;
if !env.success {
anyhow::bail!("auth connect unsuccessful: {text}");
}
let oauth_url = env
.oauth_url
.filter(|u| !u.is_empty())
.context("missing oauthUrl in response")?;
let state = env
.state
.filter(|s| !s.is_empty())
.context("missing state")?;
Ok(ConnectResponse { oauth_url, state })
}
pub async fn fetch_current_user(&self, bearer_jwt: &str) -> Result<Value> {
let url = self.base.join("auth/me").context("build /auth/me URL")?;
let resp = self
.client
.get(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("GET /auth/me")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("GET /auth/me failed ({status}): {text}");
}
parse_api_response_json(&text)
}
pub async fn consume_login_token(&self, login_token: &str) -> Result<String> {
let token = login_token.trim();
anyhow::ensure!(!token.is_empty(), "login token is required");
let url = self
.base
.join("auth/login-token/consume")
.context("build login-token consume URL")?;
let resp = self
.client
.post(url)
.json(&serde_json::json!({ "token": token }))
.send()
.await
.context("consume login token")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("consume login token failed ({status}): {text}");
}
let env: LoginTokenConsumeEnvelope = serde_json::from_str(&text)
.with_context(|| format!("parse consume-login-token JSON: {text}"))?;
if !env.success {
anyhow::bail!("consume login token unsuccessful: {text}");
}
let jwt = env.data.jwt.trim().to_string();
anyhow::ensure!(!jwt.is_empty(), "consume login token response missing jwt");
Ok(jwt)
}
pub async fn validate_session_token(&self, bearer_jwt: &str) -> Result<()> {
let _ = self.fetch_current_user(bearer_jwt).await?;
Ok(())
}
pub async fn create_channel_link_token(
&self,
channel: &str,
bearer_jwt: &str,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded_channel = urlencoding::encode(channel);
let url = self
.base
.join(&format!("auth/channels/{encoded_channel}/link-token"))
.context("build channel link-token URL")?;
let resp = self
.client
.post(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("create channel link token")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("create channel link token failed ({status}): {text}");
}
parse_api_response_json(&text)
}
pub async fn authed_json(
&self,
bearer_jwt: &str,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value> {
let url = self
.base
.join(path.trim_start_matches('/'))
.with_context(|| format!("build URL for {path}"))?;
let mut request = self
.client
.request(method.clone(), url.clone())
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt));
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|e| {
let mut error_message = e.to_string();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = src {
error_message.push_str(" → ");
error_message.push_str(&s.to_string());
src = s.source();
}
if crate::core::observability::contains_transient_transport_phrase(&error_message) {
tracing::warn!(
domain = "backend_api",
operation = "authed_json",
method = method.as_str(),
path = url.path(),
failure = "transport",
error = %error_message,
"[backend_api] transient transport failure on {} {}: {}",
method.as_str(),
url.path(),
error_message,
);
} else {
crate::core::observability::report_error(
error_message.as_str(),
"backend_api",
"authed_json",
&[
("method", method.as_str()),
("path", url.path()),
("failure", "transport"),
],
);
}
anyhow::Error::new(e).context(format!(
"backend request {} {}",
method.as_str(),
url.path()
))
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
let status_code = status.as_u16();
let status_str = status_code.to_string();
if status_code == 401 {
tracing::info!(
domain = "backend_api",
operation = "authed_json",
method = method.as_str(),
path = url.path(),
status = status_code,
failure = "non_2xx",
"[backend_api] 401 on {} {} — session token rejected, surfacing typed error",
method.as_str(),
url.path(),
);
return Err(anyhow::Error::new(BackendApiError::Unauthorized {
method: method.as_str().to_string(),
path: url.path().to_string(),
}));
}
if status_code == 404 {
if let Some((provider, message_id)) = parse_message_path(url.path()) {
tracing::info!(
domain = "backend_api",
operation = "authed_json",
provider = provider,
message_id = message_id,
"[backend_api] message-not-found 404 on {} {} — surfacing typed error",
method.as_str(),
url.path(),
);
return Err(anyhow::Error::new(BackendApiError::MessageNotFound {
provider: provider.to_string(),
message_id: message_id.to_string(),
}));
}
if (method == Method::PATCH || method == Method::DELETE)
&& url.path().contains("/channels/")
&& url.path().contains("/messages/")
{
tracing::debug!(
domain = "backend_api",
operation = "authed_json",
"[backend_api] channel-message 404 on {} {} — path not matched by \
parse_message_path, suppressing Sentry (TAURI-R7 defense-in-depth)",
method.as_str(),
url.path(),
);
anyhow::bail!(
"channel message not found (404) on {} {}",
method.as_str(),
url.path(),
);
}
}
let is_transient_infra =
crate::core::observability::is_transient_http_status_code(status_code);
let is_budget_exhausted = status_code == 400
&& crate::openhuman::inference::provider::is_budget_exhausted_message(&text);
if is_budget_exhausted {
tracing::info!(
method = method.as_str(),
path = url.path(),
status = status_code,
failure = "non_2xx",
kind = "budget",
"[backend_api] budget-exhausted 400 on {} {} — not reporting to Sentry",
method.as_str(),
url.path(),
);
} else if is_transient_infra {
tracing::warn!(
domain = "backend_api",
operation = "authed_json",
method = method.as_str(),
path = url.path(),
status = status_code,
failure = "non_2xx",
"[backend_api] transient {status} on {} {} — not reporting to Sentry",
method.as_str(),
url.path(),
);
} else {
let host = url.host_str().unwrap_or("");
let body_shape = backend_api_body_shape(&text);
crate::core::observability::report_error(
format!(
"{} {} failed ({status}); response_body_len={}; body_shape={}",
method.as_str(),
url.path(),
text.len(),
body_shape,
)
.as_str(),
"backend_api",
"authed_json",
&[
("method", method.as_str()),
("path", url.path()),
("host", host),
("status", status_str.as_str()),
("failure", "non_2xx"),
],
);
}
anyhow::bail!(
"{} {} failed ({status}): {text}",
method.as_str(),
url.path()
);
}
parse_api_response_json(&text)
}
pub async fn list_integrations(&self, bearer_jwt: &str) -> Result<Vec<IntegrationSummary>> {
let url = self
.base
.join("auth/integrations")
.context("build integrations URL")?;
let resp = self
.client
.get(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("list integrations")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("list integrations failed ({status}): {text}");
}
let env: IntegrationsEnvelope = serde_json::from_str(&text)
.with_context(|| format!("parse integrations JSON: {text}"))?;
if !env.success {
anyhow::bail!("list integrations unsuccessful: {text}");
}
Ok(env.data.integrations)
}
pub async fn fetch_integration_tokens_handoff(
&self,
integration_id: &str,
bearer_jwt: &str,
encryption_key: &str,
) -> Result<IntegrationTokensHandoff> {
let id = integration_id.trim();
anyhow::ensure!(
!id.is_empty() && id.len() == 24,
"integrationId must be a 24-char hex id"
);
let url = self
.base
.join(&format!("auth/integrations/{id}/tokens"))
.context("build tokens URL")?;
let body = serde_json::json!({ "key": encryption_key.trim() });
let resp = self
.client
.post(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.json(&body)
.send()
.await
.context("integration tokens handoff")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("integration tokens failed ({status}): {text}");
}
let env: TokensEnvelope =
serde_json::from_str(&text).with_context(|| format!("parse tokens JSON: {text}"))?;
if !env.success {
anyhow::bail!("integration tokens unsuccessful: {text}");
}
let plaintext = decrypt_handoff_blob(&env.data.encrypted, encryption_key.trim())?;
serde_json::from_str(&plaintext).context("parse decrypted token JSON")
}
pub async fn fetch_client_key(&self, integration_id: &str, bearer_jwt: &str) -> Result<String> {
let id = integration_id.trim();
anyhow::ensure!(
!id.is_empty() && id.len() == 24,
"integrationId must be a 24-char hex id"
);
let url = self
.base
.join(&format!("auth/integrations/{id}/client-key"))
.context("build client-key URL")?;
let resp = self
.client
.post(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("fetch client key")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("fetch client key failed ({status}): {text}");
}
let v: Value = serde_json::from_str(&text)
.with_context(|| format!("parse client-key JSON: {text}"))?;
let obj = v.as_object().context("expected JSON object")?;
let success = obj
.get("success")
.and_then(|s| s.as_bool())
.unwrap_or(false);
if !success {
let msg = obj
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("client key retrieval unsuccessful");
anyhow::bail!("fetch client key failed: {msg}");
}
let client_key = obj
.get("data")
.and_then(|d| d.get("clientKey"))
.and_then(|k| k.as_str())
.context("missing data.clientKey in response")?;
Ok(client_key.to_string())
}
pub async fn send_channel_message(
&self,
channel: &str,
bearer_jwt: &str,
message_body: Value,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded = urlencoding::encode(channel);
self.authed_json(
bearer_jwt,
Method::POST,
&format!("channels/{encoded}/messages"),
Some(message_body),
)
.await
}
pub async fn send_channel_typing(&self, channel: &str, bearer_jwt: &str) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded = urlencoding::encode(channel);
self.authed_json(
bearer_jwt,
Method::POST,
&format!("channels/{encoded}/typing"),
Some(json!({})),
)
.await
}
pub async fn send_channel_edit(
&self,
channel: &str,
message_id: &str,
bearer_jwt: &str,
edit_body: Value,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
anyhow::ensure!(!message_id.is_empty(), "message_id is required");
let encoded_channel = urlencoding::encode(channel);
let encoded_id = urlencoding::encode(message_id);
self.authed_json(
bearer_jwt,
Method::PATCH,
&format!("channels/{encoded_channel}/messages/{encoded_id}"),
Some(edit_body),
)
.await
}
pub async fn send_channel_delete(
&self,
channel: &str,
message_id: &str,
bearer_jwt: &str,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
anyhow::ensure!(!message_id.is_empty(), "message_id is required");
let encoded_channel = urlencoding::encode(channel);
let encoded_id = urlencoding::encode(message_id);
self.authed_json(
bearer_jwt,
Method::DELETE,
&format!("channels/{encoded_channel}/messages/{encoded_id}"),
None,
)
.await
}
pub async fn send_channel_reaction(
&self,
channel: &str,
bearer_jwt: &str,
reaction_body: Value,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded = urlencoding::encode(channel);
self.authed_json(
bearer_jwt,
Method::POST,
&format!("channels/{encoded}/reactions"),
Some(reaction_body),
)
.await
}
pub async fn create_channel_thread(
&self,
channel: &str,
bearer_jwt: &str,
title: &str,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
anyhow::ensure!(!title.trim().is_empty(), "title is required");
let encoded = urlencoding::encode(channel);
let body = serde_json::json!({ "title": title.trim() });
self.authed_json(
bearer_jwt,
Method::POST,
&format!("channels/{encoded}/threads"),
Some(body),
)
.await
}
pub async fn update_channel_thread(
&self,
channel: &str,
bearer_jwt: &str,
thread_id: &str,
action: &str,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
anyhow::ensure!(!thread_id.trim().is_empty(), "threadId is required");
anyhow::ensure!(
action == "close" || action == "reopen",
"action must be 'close' or 'reopen'"
);
let encoded_channel = urlencoding::encode(channel);
let encoded_thread = urlencoding::encode(thread_id.trim());
let body = serde_json::json!({ "action": action });
self.authed_json(
bearer_jwt,
Method::PATCH,
&format!("channels/{encoded_channel}/threads/{encoded_thread}"),
Some(body),
)
.await
}
pub async fn list_channel_threads(
&self,
channel: &str,
bearer_jwt: &str,
active_filter: Option<bool>,
) -> Result<Value> {
let channel = channel.trim().trim_matches('/');
anyhow::ensure!(!channel.is_empty(), "channel is required");
let encoded = urlencoding::encode(channel);
let mut path = format!("channels/{encoded}/threads");
if let Some(active) = active_filter {
path.push_str(if active {
"?active=true"
} else {
"?active=false"
});
}
self.authed_json(bearer_jwt, Method::GET, &path, None).await
}
pub async fn revoke_integration(&self, integration_id: &str, bearer_jwt: &str) -> Result<()> {
let id = integration_id.trim();
anyhow::ensure!(!id.is_empty(), "integration id is required");
let url = self
.base
.join(&format!("auth/integrations/{id}"))
.context("build revoke URL")?;
let resp = self
.client
.delete(url)
.header(AUTHORIZATION, bearer_authorization_value(bearer_jwt))
.send()
.await
.context("revoke integration")?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("revoke integration failed ({status}): {text}");
}
Ok(())
}
}
pub fn decrypt_handoff_blob(b64_ciphertext: &str, key_str: &str) -> Result<String> {
let key = key_bytes_from_string(key_str)?;
let combined = base64::engine::general_purpose::STANDARD
.decode(b64_ciphertext.trim())
.context("base64-decode encrypted payload")?;
if combined.len() < 32 {
anyhow::bail!("encrypted payload too short");
}
let iv = &combined[0..16];
let tag = &combined[16..32];
let ciphertext = &combined[32..];
let mut ct_with_tag = Vec::with_capacity(ciphertext.len() + tag.len());
ct_with_tag.extend_from_slice(ciphertext);
ct_with_tag.extend_from_slice(tag);
use aes_gcm::aead::generic_array::typenum::U16;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::aes::Aes256;
use aes_gcm::AesGcm;
type Aes256Gcm16 = AesGcm<Aes256, U16>;
let cipher =
Aes256Gcm16::new_from_slice(&key).map_err(|e| anyhow::anyhow!("invalid AES key: {e}"))?;
let nonce = aes_gcm::aead::generic_array::GenericArray::from_slice(iv);
let plain = cipher
.decrypt(nonce, ct_with_tag.as_ref())
.map_err(|e| anyhow::anyhow!("AES-GCM decrypt failed: {e}"))?;
String::from_utf8(plain).context("handoff plaintext is not UTF-8")
}
fn key_bytes_from_string(key: &str) -> Result<Vec<u8>> {
let trimmed = key.trim();
if trimmed.len() == 32 && !trimmed.contains(['+', '/', '-', '_', '=']) {
return Ok(trimmed.as_bytes().to_vec());
}
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
macro_rules! try_decode {
($engine:expr) => {
if let Ok(decoded) = $engine.decode(trimmed) {
if decoded.len() == 32 {
return Ok(decoded);
}
}
};
}
try_decode!(URL_SAFE_NO_PAD);
try_decode!(URL_SAFE);
try_decode!(STANDARD);
try_decode!(STANDARD_NO_PAD);
anyhow::bail!(
"encryption key must decode to 32 raw bytes (raw, base64, or base64url accepted; got len={})",
trimmed.len()
);
}
#[cfg(test)]
#[path = "rest_tests.rs"]
mod key_bytes_from_string_tests;