use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use crate::api::types::{
ApiKeyRecord, Application, AuthSessionResult, CliMe, CreateApiKeyBody, CreateApplicationBody,
CreateOrganizationBody, Organization, SessionRefresh,
};
const MAX_RETRIES: u32 = 3;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
pub struct DeveloperClient {
base: String,
http: reqwest::Client,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoginSession {
pub session_id: String,
pub session_secret: Option<String>,
pub user_code: Option<String>,
pub verification_url: String,
pub expires_in: u64,
pub poll_interval: u64,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct CreateAuthSessionResponse {
#[serde(rename = "sessionId", alias = "session_id")]
session_id: String,
#[serde(
default,
rename = "sessionSecret",
alias = "session_secret",
skip_serializing_if = "Option::is_none"
)]
session_secret: Option<String>,
#[serde(default, rename = "userCode", alias = "user_code")]
user_code: Option<String>,
#[serde(rename = "verificationUri", alias = "verification_url")]
verification_url: String,
#[serde(rename = "expiresIn", alias = "expires_in")]
expires_in: u64,
#[serde(rename = "interval", alias = "poll_interval")]
poll_interval: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AuthToken {
pub access_token: Option<String>,
pub api_key: Option<String>,
pub email: Option<String>,
pub dev_id: Option<i32>,
pub app_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SessionUser {
dev_id: i32,
email: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "status", rename_all = "lowercase")]
enum SessionStatus {
Pending,
Approved {
#[serde(default, alias = "token", alias = "accessToken")]
access_token: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(
default,
alias = "applicationId",
alias = "projectId",
alias = "project_id"
)]
application_id: Option<String>,
user: SessionUser,
},
Expired,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RevokeOutcome {
Revoked,
Shared,
NotRevoked(String),
UnknownKey,
Unconfirmed,
}
#[derive(Debug, Deserialize)]
struct RevokeBody {
revoked: bool,
#[serde(default)]
reason: Option<String>,
}
fn parse_revoke_body(body: &str) -> RevokeOutcome {
match serde_json::from_str::<RevokeBody>(body) {
Ok(RevokeBody { revoked: true, .. }) => RevokeOutcome::Revoked,
Ok(RevokeBody {
revoked: false,
reason,
}) => {
match reason.filter(|r| !r.trim().is_empty()) {
Some(r) if !r.to_ascii_lowercase().contains("shared") => {
RevokeOutcome::NotRevoked(r)
}
_ => RevokeOutcome::Shared,
}
}
Err(_) => RevokeOutcome::Unconfirmed,
}
}
#[derive(Debug, PartialEq)]
pub enum PollResult {
Pending,
Ready(AuthToken),
Denied,
Invalid,
}
impl DeveloperClient {
pub fn new(base: impl Into<String>) -> Result<Self> {
let base = base.into();
let http = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(format!("offline-cli/{}", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
Ok(Self { base, http })
}
fn developers_url(&self, path: &str) -> String {
let base = self.base.trim_end_matches('/');
format!("{base}/developers{path}")
}
fn cli_url(&self, path: &str) -> String {
self.developers_url(&format!("/cli{path}"))
}
pub async fn request_auth_code(&self, email: &str) -> Result<()> {
let url = self.developers_url("/auth-code");
let resp = self
.with_retry(|| {
let url = url.clone();
let body = serde_json::json!({ "email": email });
async move { self.http.post(url).json(&body).send().await }
})
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(api_error(resp.status(), resp).await)
}
}
pub async fn verify_auth_session(&self, email: &str, otp: &str) -> Result<AuthSessionResult> {
let url = self.developers_url("/sessions");
let resp = self
.with_retry(|| {
let url = url.clone();
let body = serde_json::json!({ "email": email, "otp": otp });
async move { self.http.post(url).json(&body).send().await }
})
.await?;
parse_envelope(resp).await
}
pub async fn refresh_session(&self, token: &str) -> Result<Option<String>> {
let url = self.developers_url("/sessions/refresh");
let resp = self
.with_retry(|| {
let url = url.clone();
let token = token.to_string();
async move { self.http.post(url).bearer_auth(token).send().await }
})
.await?;
let refresh: SessionRefresh = parse_envelope(resp).await?;
Ok(refresh
.renewed
.then_some(refresh.token)
.filter(|t| !t.is_empty()))
}
pub async fn revoke_session(&self, token: &str) -> Result<()> {
let url = self.developers_url("/sessions/revoke");
let resp = self
.with_retry(|| {
let url = url.clone();
let token = token.to_string();
async move { self.http.post(url).bearer_auth(token).send().await }
})
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(api_error(resp.status(), resp).await)
}
}
pub async fn create_auth_session(
&self,
device_name: &str,
callback_url: Option<&str>,
) -> Result<LoginSession> {
let url = self.cli_url("/sessions");
let resp = self
.with_retry(|| {
let url = url.clone();
let mut body = serde_json::json!({
"deviceName": device_name,
"device_name": device_name,
});
if let Some(callback) = callback_url {
body["callbackUrl"] = serde_json::json!(callback);
body["callback_url"] = serde_json::json!(callback);
}
async move { self.http.post(url).json(&body).send().await }
})
.await?;
let session: CreateAuthSessionResponse = parse_json_response(resp).await?;
Ok(LoginSession {
session_id: session.session_id,
session_secret: session.session_secret,
user_code: session.user_code,
verification_url: session.verification_url,
expires_in: session.expires_in,
poll_interval: session.poll_interval,
})
}
pub async fn poll_auth_session(
&self,
session_id: &str,
session_secret: Option<&str>,
) -> Result<PollResult> {
let url = self.cli_url(&format!("/sessions/{session_id}"));
let secret = session_secret.map(str::to_string);
let resp = self
.with_retry(|| {
let url = url.clone();
let secret = secret.clone();
async move {
let mut req = self.http.get(url);
if let Some(secret) = secret {
req = req.bearer_auth(secret);
}
req.send().await
}
})
.await?;
match resp.status() {
StatusCode::OK => {
let status: SessionStatus = resp
.json()
.await
.context("decoding session status response")?;
match status {
SessionStatus::Pending => Ok(PollResult::Pending),
SessionStatus::Expired => Ok(PollResult::Denied),
SessionStatus::Approved {
access_token,
api_key,
application_id,
user,
} => Ok(PollResult::Ready(AuthToken {
access_token: access_token.filter(|t| !t.trim().is_empty()),
api_key: api_key.filter(|k| !k.trim().is_empty()),
email: Some(user.email),
dev_id: Some(user.dev_id),
app_id: application_id,
})),
}
}
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => {
Ok(PollResult::Invalid)
}
status => Err(api_error(status, resp).await),
}
}
pub async fn cli_me(&self, api_key: &str) -> Result<CliMe> {
let url = self.cli_url("/me");
let resp = self
.with_retry(|| {
let url = url.clone();
let key = api_key.to_string();
async move { self.http.get(url).bearer_auth(key).send().await }
})
.await?;
parse_json_response(resp).await
}
#[allow(dead_code)]
pub async fn me(&self, api_key: &str) -> Result<CliMe> {
self.cli_me(api_key).await
}
pub async fn revoke_current_key(&self, api_key: &str) -> Result<RevokeOutcome> {
let url = self.cli_url("/keys/current");
let resp = self
.with_retry(|| {
let url = url.clone();
let key = api_key.to_string();
async move { self.http.delete(url).bearer_auth(key).send().await }
})
.await?;
let status = resp.status();
if status == StatusCode::NOT_FOUND {
return Ok(RevokeOutcome::Unconfirmed);
}
if status == StatusCode::UNAUTHORIZED {
return Ok(RevokeOutcome::UnknownKey);
}
if !status.is_success() {
return Err(api_error(status, resp).await);
}
let body = resp.text().await.unwrap_or_default();
Ok(parse_revoke_body(&body))
}
pub async fn health(&self) -> Result<()> {
let url = self.developers_url("/health");
let resp = self
.with_retry(|| {
let url = url.clone();
async move {
self.http
.get(url)
.timeout(Duration::from_secs(5))
.send()
.await
}
})
.await;
match resp {
Ok(_) => Ok(()),
Err(e) => bail!("unreachable: {e}"),
}
}
#[allow(dead_code)]
pub async fn developer_me(
&self,
access_token: &str,
) -> Result<crate::api::types::DeveloperProfile> {
#[derive(Deserialize)]
struct MeData {
developer: crate::api::types::DeveloperProfile,
}
let url = self.developers_url("/me");
let data: MeData = self.get_envelope(&url, access_token).await?;
Ok(data.developer)
}
pub async fn list_organizations(&self, access_token: &str) -> Result<Vec<Organization>> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
organizations: Vec<Organization>,
#[serde(default)]
teams: Vec<Organization>,
}
let url = self.developers_url("/organizations");
let data: Data = self.get_envelope(&url, access_token).await?;
if !data.organizations.is_empty() {
Ok(data.organizations)
} else {
Ok(data.teams)
}
}
pub async fn create_organization(
&self,
access_token: &str,
name: &str,
) -> Result<Organization> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
organization: Option<Organization>,
#[serde(default)]
team: Option<Organization>,
}
let url = self.developers_url("/organizations");
let body = CreateOrganizationBody { name };
let data: Data = self.post_envelope(&url, access_token, &body).await?;
data.organization
.or(data.team)
.ok_or_else(|| anyhow!("create organization response missing organization"))
}
pub async fn list_applications(&self, access_token: &str) -> Result<Vec<Application>> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
applications: Vec<Application>,
#[serde(default)]
projects: Vec<Application>,
}
let url = self.developers_url("/applications");
let data: Data = self.get_envelope(&url, access_token).await?;
if !data.applications.is_empty() {
Ok(data.applications)
} else {
Ok(data.projects)
}
}
pub async fn create_application(
&self,
access_token: &str,
name: &str,
org_id: &str,
description: Option<&str>,
) -> Result<Application> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
application: Option<Application>,
#[serde(default)]
project: Option<Application>,
}
let url = self.developers_url("/applications");
let body = CreateApplicationBody {
name,
organization_id: org_id,
description,
};
let data: Data = self.post_envelope(&url, access_token, &body).await?;
data.application
.or(data.project)
.ok_or_else(|| anyhow!("create application response missing application"))
}
pub async fn list_api_keys(
&self,
access_token: &str,
app_id: &str,
) -> Result<Vec<ApiKeyRecord>> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
keys: Vec<ApiKeyRecord>,
}
let url = self.developers_url(&format!("/applications/{app_id}/api-keys"));
let data: Data = self.get_envelope(&url, access_token).await?;
Ok(data.keys)
}
pub async fn create_api_key(
&self,
access_token: &str,
app_id: &str,
alias: &str,
product: &str,
permissions: &str,
) -> Result<ApiKeyRecord> {
#[derive(Deserialize)]
struct Data {
key: ApiKeyRecord,
}
let url = self.developers_url(&format!("/applications/{app_id}/api-keys"));
let body = CreateApiKeyBody {
alias,
product,
permissions,
};
let data: Data = self.post_envelope(&url, access_token, &body).await?;
Ok(data.key)
}
pub async fn get_application_json(
&self,
access_token: &str,
app_id: &str,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
application: Option<serde_json::Value>,
#[serde(default)]
project: Option<serde_json::Value>,
}
let url = self.developers_url(&format!("/applications/{}", urlencoding::encode(app_id)));
let data: Data = self.get_envelope(&url, access_token).await?;
data.application
.or(data.project)
.ok_or_else(|| anyhow!("application response missing application"))
}
pub async fn patch_application_json(
&self,
access_token: &str,
app_id: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
#[derive(Deserialize)]
struct Data {
#[serde(default)]
application: Option<serde_json::Value>,
#[serde(default)]
project: Option<serde_json::Value>,
}
let url = self.developers_url(&format!("/applications/{}", urlencoding::encode(app_id)));
let data: Data = self.patch_envelope(&url, access_token, body).await?;
data.application
.or(data.project)
.ok_or_else(|| anyhow!("update application response missing application"))
}
pub async fn get_product_settings_json(
&self,
access_token: &str,
app_id: &str,
product: &str,
) -> Result<serde_json::Value> {
let url = self.developers_url(&format!(
"/applications/{}/product-settings/{product}",
urlencoding::encode(app_id)
));
let data: serde_json::Value = self.get_envelope(&url, access_token).await?;
Ok(settings_of(data))
}
pub async fn patch_product_settings_json(
&self,
access_token: &str,
app_id: &str,
product: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = self.developers_url(&format!(
"/applications/{}/product-settings/{product}",
urlencoding::encode(app_id)
));
let data: serde_json::Value = self.patch_envelope(&url, access_token, body).await?;
Ok(settings_of(data))
}
pub async fn set_development_mode(
&self,
access_token: &str,
app_id: &str,
hours: Option<u32>,
) -> Result<serde_json::Value> {
let url = self.developers_url(&format!(
"/applications/{}/native-attestation/development-mode",
urlencoding::encode(app_id)
));
let resp = self
.with_retry(|| {
let url = url.clone();
let token = access_token.to_string();
async move {
match hours {
Some(h) => {
self.http
.put(url)
.bearer_auth(token)
.json(&serde_json::json!({ "hours": h }))
.send()
.await
}
None => self.http.delete(url).bearer_auth(token).send().await,
}
}
})
.await?;
let data: serde_json::Value = parse_envelope(resp).await?;
Ok(data
.get("development_mode")
.cloned()
.unwrap_or(serde_json::Value::Null))
}
pub async fn attestation_events(
&self,
access_token: &str,
app_id: &str,
limit: u32,
) -> Result<serde_json::Value> {
let url = self.developers_url(&format!(
"/applications/{}/native-attestation/events?limit={limit}",
urlencoding::encode(app_id)
));
let data: serde_json::Value = self.get_envelope(&url, access_token).await?;
Ok(data.get("events").cloned().unwrap_or(serde_json::json!([])))
}
async fn patch_envelope<T: DeserializeOwned>(
&self,
url: &str,
access_token: &str,
body: &serde_json::Value,
) -> Result<T> {
let resp = self
.with_retry(|| {
let url = url.to_string();
let token = access_token.to_string();
let body = body.clone();
async move {
self.http
.patch(url)
.bearer_auth(token)
.json(&body)
.send()
.await
}
})
.await?;
parse_envelope(resp).await
}
async fn get_envelope<T: DeserializeOwned>(&self, url: &str, access_token: &str) -> Result<T> {
let resp = self
.with_retry(|| {
let url = url.to_string();
let token = access_token.to_string();
async move { self.http.get(url).bearer_auth(token).send().await }
})
.await?;
parse_envelope(resp).await
}
async fn post_envelope<T, B: serde::Serialize>(
&self,
url: &str,
access_token: &str,
body: &B,
) -> Result<T>
where
T: DeserializeOwned,
{
let resp = self
.with_retry(|| {
let url = url.to_string();
let token = access_token.to_string();
let body = serde_json::to_value(body).expect("serialize body");
async move {
self.http
.post(url)
.bearer_auth(token)
.json(&body)
.send()
.await
}
})
.await?;
parse_envelope(resp).await
}
async fn with_retry<F, Fut>(&self, mut operation: F) -> Result<reqwest::Response>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
let mut attempt = 0u32;
loop {
match operation().await {
Ok(resp) => return Ok(resp),
Err(err) if is_retryable(&err) => {
attempt += 1;
if attempt >= MAX_RETRIES {
return Err(err).context("network request failed after retries");
}
tokio::time::sleep(Duration::from_secs(attempt as u64)).await;
}
Err(err) => return Err(err).context("network request failed"),
}
}
}
}
fn is_retryable(err: &reqwest::Error) -> bool {
err.is_connect() || err.is_timeout() || err.is_request()
}
async fn parse_json_response<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
if resp.status().is_success() {
resp.json().await.context("decoding API response")
} else {
Err(api_error(resp.status(), resp).await)
}
}
async fn parse_envelope<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
if !resp.status().is_success() {
return Err(api_error(resp.status(), resp).await);
}
#[derive(Deserialize)]
struct Envelope<T> {
data: T,
}
let envelope: Envelope<T> = resp.json().await.context("decoding API envelope")?;
Ok(envelope.data)
}
async fn api_error(status: StatusCode, resp: reqwest::Response) -> anyhow::Error {
let body = resp
.text()
.await
.unwrap_or_else(|_| "<unreadable body>".to_string());
anyhow!("{}", describe_api_error(status.as_u16(), &body))
}
fn settings_of(data: serde_json::Value) -> serde_json::Value {
for key in ["settings", "product_settings"] {
if let Some(v) = data.get(key) {
if v.is_object() {
return v.clone();
}
}
}
data
}
pub fn describe_api_error(status: u16, body: &str) -> String {
let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
return format!("API error {status}: {}", truncate(body, 300));
};
let code = v.get("error").and_then(|e| {
e.as_str()
.or_else(|| e.get("code").and_then(|c| c.as_str()))
});
let message = v
.get("message")
.and_then(|m| m.as_str())
.or_else(|| {
v.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
})
.filter(|m| !m.trim().is_empty());
let mut out = format!("API error {status}");
if let Some(code) = code {
out.push_str(&format!(" ({code})"));
}
match message {
Some(m) => out.push_str(&format!(": {}", truncate(m, 600))),
None if code.is_none() => out.push_str(&format!(": {}", truncate(body, 300))),
None => {}
}
let steps: Vec<&str> = v
.get("data")
.and_then(|d| d.get("next_steps"))
.and_then(|s| s.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str()).collect())
.unwrap_or_default();
if !steps.is_empty() {
out.push_str("\nnext:");
for step in steps {
out.push_str(&format!("\n {step}"));
}
}
out
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max {
s
} else {
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}
pub type AuthClient = DeveloperClient;
#[allow(dead_code)]
pub fn developers_cli_url(base: &str, path: &str) -> String {
let base = base.trim_end_matches('/');
format!("{base}/developers/cli{path}")
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn parse_revoke_body_reads_server_answers() {
assert_eq!(
parse_revoke_body(r#"{"revoked": true}"#),
RevokeOutcome::Revoked
);
assert_eq!(
parse_revoke_body(
r#"{"revoked": false, "reason": "key is shared with the application; revoke it in the portal"}"#
),
RevokeOutcome::Shared
);
assert_eq!(
parse_revoke_body(r#"{"revoked": false}"#),
RevokeOutcome::Shared
);
assert_eq!(
parse_revoke_body(r#"{"revoked": false, "reason": "key is pinned"}"#),
RevokeOutcome::NotRevoked("key is pinned".into())
);
assert_eq!(parse_revoke_body(""), RevokeOutcome::Unconfirmed);
assert_eq!(
parse_revoke_body(r#"{"ok": true}"#),
RevokeOutcome::Unconfirmed
);
}
async fn revoke_with(status: u16, body: Option<serde_json::Value>) -> Result<RevokeOutcome> {
let server = MockServer::start().await;
let mut response = ResponseTemplate::new(status);
if let Some(body) = body {
response = response.set_body_json(body);
}
Mock::given(method("DELETE"))
.and(path("/api/v1/developers/cli/keys/current"))
.and(header("authorization", "Bearer key_123"))
.respond_with(response)
.mount(&server)
.await;
let client = DeveloperClient::new(format!("{}/api/v1", server.uri())).unwrap();
client.revoke_current_key("key_123").await
}
#[tokio::test]
async fn revoke_current_key_maps_statuses() {
assert_eq!(
revoke_with(200, Some(serde_json::json!({ "revoked": true })))
.await
.unwrap(),
RevokeOutcome::Revoked
);
assert_eq!(
revoke_with(
200,
Some(serde_json::json!({
"revoked": false,
"reason": "key is shared with the application; revoke it in the portal"
}))
)
.await
.unwrap(),
RevokeOutcome::Shared
);
assert_eq!(
revoke_with(204, None).await.unwrap(),
RevokeOutcome::Unconfirmed
);
assert_eq!(
revoke_with(404, None).await.unwrap(),
RevokeOutcome::Unconfirmed
);
assert_eq!(
revoke_with(401, Some(serde_json::json!({ "error": "Invalid API key" })))
.await
.unwrap(),
RevokeOutcome::UnknownKey
);
let err = revoke_with(500, Some(serde_json::json!({ "error": "boom" })))
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[test]
fn developers_cli_url_builds_expected_path() {
assert_eq!(
developers_cli_url("http://example.test/api/v1", "/sessions"),
"http://example.test/api/v1/developers/cli/sessions"
);
}
}