1use std::{collections::HashMap, sync::Arc, time::Duration};
2
3use futures::lock::Mutex;
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6
7#[cfg(not(target_family = "wasm"))]
8use tokio::spawn;
9#[cfg(target_family = "wasm")]
10use wasm_bindgen_futures::spawn_local as spawn;
11
12use posemesh_utils::now_unix_secs;
13#[cfg(target_family = "wasm")]
14use posemesh_utils::sleep;
15#[cfg(not(target_family = "wasm"))]
16use tokio::time::sleep;
17
18use crate::{
19 auth::{AuthClient, REFRESH_CACHE_TIME, TokenCache, get_cached_or_fresh_token, parse_jwt},
20 errors::{AukiErrorResponse, DomainError},
21};
22pub const ALL_DOMAINS_ORG: &str = "all";
23pub const OWN_DOMAINS_ORG: &str = "own";
24
25#[derive(Debug, Deserialize, Clone, Serialize)]
26pub struct DomainServer {
27 pub id: String,
28 pub organization_id: String,
29 pub name: String,
30 pub url: String,
31}
32
33#[derive(Debug, Deserialize, Clone)]
34pub struct DomainWithToken {
35 #[serde(flatten)]
36 pub domain: DomainWithServer,
37 #[serde(skip)]
38 pub expires_at: u64,
39 access_token: String,
40}
41
42impl TokenCache for DomainWithToken {
43 fn get_access_token(&self) -> String {
44 self.access_token.clone()
45 }
46
47 fn get_expires_at(&self) -> u64 {
48 self.expires_at
49 }
50}
51
52#[derive(Debug, Deserialize, Clone, Serialize)]
53pub struct DomainWithServer {
54 pub id: String,
55 pub name: String,
56 pub organization_id: String,
57 pub domain_server_id: String,
58 pub redirect_url: Option<String>,
59 pub domain_server: DomainServer,
60}
61
62#[derive(Debug, Clone)]
63pub struct DiscoveryService {
64 dds_url: String,
65 client: Client,
66 cache: Arc<Mutex<HashMap<String, DomainWithToken>>>,
67 api_client: AuthClient,
68 oidc_access_token: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct ListDomainsResponse {
73 pub domains: Vec<DomainWithServer>,
74}
75
76#[derive(Debug, Serialize)]
77pub struct CreateDomainRequest {
78 pub name: String,
79 pub domain_server_id: String,
80 pub redirect_url: Option<String>,
81 domain_server_url: String,
82}
83
84impl DiscoveryService {
85 pub fn new(api_url: &str, dds_url: &str, client_id: &str) -> Self {
86 let api_client = AuthClient::new(api_url, client_id);
87
88 Self {
89 dds_url: dds_url.to_string(),
90 client: Client::new(),
91 cache: Arc::new(Mutex::new(HashMap::new())),
92 api_client,
93 oidc_access_token: None,
94 }
95 }
96
97 pub async fn list_domains(
115 &self,
116 org: &str,
117 domain_server_id: Option<&str>,
118 ) -> Result<ListDomainsResponse, DomainError> {
119 let access_token = self
120 .api_client
121 .get_dds_access_token(self.oidc_access_token.as_deref())
122 .await?;
123 let mut url = format!(
124 "{}/api/v1/domains?org={}&with=domain_server",
125 self.dds_url, org
126 );
127 if let Some(domain_server_id) = domain_server_id {
128 url.push_str(&format!("&domain_server_id={}", domain_server_id));
129 }
130 let response = self
131 .client
132 .get(&url)
133 .bearer_auth(access_token)
134 .header("Content-Type", "application/json")
135 .header("posemesh-client-id", self.api_client.client_id.clone())
136 .header("posemesh-sdk-version", crate::VERSION)
137 .send()
138 .await?;
139
140 if response.status().is_success() {
141 let domain_servers: ListDomainsResponse = response.json().await?;
142 Ok(domain_servers)
143 } else {
144 let status = response.status();
145 let text = response
146 .text()
147 .await
148 .unwrap_or_else(|_| "Unknown error".to_string());
149 Err(AukiErrorResponse {
150 status,
151 error: format!("Failed to list domains. {}", text),
152 }
153 .into())
154 }
155 }
156
157 pub async fn sign_in_with_auki_account(
158 &mut self,
159 email: &str,
160 password: &str,
161 remember_password: bool,
162 ) -> Result<String, DomainError> {
163 self.cache.lock().await.clear();
164 self.oidc_access_token = None;
165 let token = self.api_client.user_login(email, password).await?;
166 if remember_password {
167 let mut api_client = self.api_client.clone();
168 let email = email.to_string();
169 let password = password.to_string();
170 spawn(async move {
171 loop {
172 let expires_at = api_client
173 .get_expires_at()
174 .await
175 .inspect_err(|e| tracing::error!("Failed to get expires at: {}", e));
176 if let Ok(expires_at) = expires_at {
177 let expiration = {
178 let now = now_unix_secs();
179 let duration = expires_at - now;
180 if duration > REFRESH_CACHE_TIME {
181 Some(Duration::from_secs(duration))
182 } else {
183 None
184 }
185 };
186
187 if let Some(expiration) = expiration {
188 tracing::info!("Refreshing token in {} seconds", expiration.as_secs());
189 sleep(expiration).await;
190 }
191
192 let _ = api_client
193 .user_login(&email, &password)
194 .await
195 .inspect_err(|e| tracing::error!("Failed to relogin: {}", e));
196 }
197 }
198 });
199 }
200 Ok(token)
201 }
202
203 pub async fn sign_in_as_auki_app(
204 &mut self,
205 app_key: &str,
206 app_secret: &str,
207 ) -> Result<String, DomainError> {
208 self.cache.lock().await.clear();
209 self.oidc_access_token = None;
210 self.api_client
211 .sign_in_with_app_credentials(app_key, app_secret)
212 .await
213 }
214
215 pub fn with_oidc_access_token(&self, oidc_access_token: &str) -> Self {
216 if let Some(cached_oidc_access_token) = self.oidc_access_token.as_deref()
217 && cached_oidc_access_token == oidc_access_token
218 {
219 return self.clone();
220 }
221 Self {
222 dds_url: self.dds_url.clone(),
223 client: self.client.clone(),
224 cache: Arc::new(Mutex::new(HashMap::new())),
225 api_client: AuthClient::new(&self.api_client.api_url, &self.api_client.client_id),
226 oidc_access_token: Some(oidc_access_token.to_string()),
227 }
228 }
229
230 pub async fn auth_domain(&self, domain_id: &str) -> Result<DomainWithToken, DomainError> {
231 let access_token = self
232 .api_client
233 .get_dds_access_token(self.oidc_access_token.as_deref())
234 .await?;
235 let cache = if let Some(cached_domain) = self.cache.lock().await.get(domain_id) {
237 cached_domain.clone()
238 } else {
239 DomainWithToken {
240 domain: DomainWithServer {
241 id: domain_id.to_string(),
242 name: "".to_string(),
243 organization_id: "".to_string(),
244 domain_server_id: "".to_string(),
245 redirect_url: None,
246 domain_server: DomainServer {
247 id: "".to_string(),
248 organization_id: "".to_string(),
249 name: "".to_string(),
250 url: "".to_string(),
251 },
252 },
253 expires_at: 0,
254 access_token: "".to_string(),
255 }
256 };
257
258 let cached = get_cached_or_fresh_token(&cache, || {
259 let client = self.client.clone();
260 let dds_url = self.dds_url.clone();
261 let client_id = self.api_client.client_id.clone();
262 async move {
263 let response = client
264 .post(format!("{}/api/v1/domains/{}/auth", dds_url, domain_id))
265 .bearer_auth(access_token)
266 .header("Content-Type", "application/json")
267 .header("posemesh-client-id", client_id)
268 .header("posemesh-sdk-version", crate::VERSION)
269 .send()
270 .await?;
271
272 if response.status().is_success() {
273 let mut domain_with_token: DomainWithToken = response.json().await?;
274 domain_with_token.expires_at =
275 parse_jwt(&domain_with_token.get_access_token())?.exp;
276 Ok(domain_with_token)
277 } else {
278 let status = response.status();
279 let text = response
280 .text()
281 .await
282 .unwrap_or_else(|_| "Unknown error".to_string());
283 Err(AukiErrorResponse {
284 status,
285 error: format!("Failed to auth domain. {}", text),
286 }
287 .into())
288 }
289 }
290 })
291 .await?;
292
293 let mut cache = self.cache.lock().await;
295 cache.insert(domain_id.to_string(), cached.clone());
296 Ok(cached)
297 }
298
299 pub async fn create_domain(
300 &self,
301 name: &str,
302 domain_server_id: Option<String>,
303 domain_server_url: Option<String>,
304 redirect_url: Option<String>,
305 ) -> Result<DomainWithToken, DomainError> {
306 let domain_server_id = domain_server_id.unwrap_or_default();
307 let domain_server_url = domain_server_url.unwrap_or_default();
308 if domain_server_id.is_empty() && domain_server_url.is_empty() {
309 return Err(DomainError::InvalidRequest(
310 "domain_server_id or domain_server_url is required",
311 ));
312 }
313 let access_token: String = self
314 .api_client
315 .get_dds_access_token(self.oidc_access_token.as_deref())
316 .await?;
317 let response = self
318 .client
319 .post(format!("{}/api/v1/domains?issue_token=true", self.dds_url))
320 .bearer_auth(access_token)
321 .header("Content-Type", "application/json")
322 .header("posemesh-client-id", self.api_client.client_id.clone())
323 .header("posemesh-sdk-version", crate::VERSION)
324 .json(&CreateDomainRequest {
325 name: name.to_string(),
326 domain_server_id: domain_server_id.to_string(),
327 redirect_url,
328 domain_server_url: domain_server_url.to_string(),
329 })
330 .send()
331 .await?;
332
333 if response.status().is_success() {
334 let mut domain_with_token: DomainWithToken = response.json().await?;
335 domain_with_token.expires_at = parse_jwt(&domain_with_token.get_access_token())?.exp;
336 let mut cache = self.cache.lock().await;
338 cache.insert(
339 domain_with_token.domain.id.clone(),
340 domain_with_token.clone(),
341 );
342 Ok(domain_with_token)
343 } else {
344 let status = response.status();
345 let text = response
346 .text()
347 .await
348 .unwrap_or_else(|_| "Unknown error".to_string());
349 Err(AukiErrorResponse {
350 status,
351 error: format!("Failed to create domain. {}", text),
352 }
353 .into())
354 }
355 }
356
357 pub async fn list_domains_by_portal(
362 &self,
363 portal_id: Option<&str>,
364 portal_short_id: Option<&str>,
365 org: &str,
366 ) -> Result<ListDomainsResponse, DomainError> {
367 let access_token: String = self
368 .api_client
369 .get_dds_access_token(self.oidc_access_token.as_deref())
370 .await?;
371 if portal_id.is_none() && portal_short_id.is_none() {
372 return Err(DomainError::InvalidRequest(
373 "portal_id or portal_short_id is required",
374 ));
375 }
376 let id = portal_id.or(portal_short_id).unwrap();
377 let response = self
378 .client
379 .get(format!(
380 "{}/api/v1/lighthouses/{}/domains?with=domain_server,lighthouse&org={}",
381 self.dds_url, id, org
382 ))
383 .bearer_auth(access_token)
384 .header("Content-Type", "application/json")
385 .header("posemesh-client-id", self.api_client.client_id.clone())
386 .header("posemesh-sdk-version", crate::VERSION)
387 .send()
388 .await?;
389 if response.status().is_success() {
390 let domains: ListDomainsResponse = response.json().await?;
391 Ok(domains)
392 } else {
393 let status = response.status();
394 let text = response
395 .text()
396 .await
397 .unwrap_or_else(|_| "Unknown error".to_string());
398 Err(AukiErrorResponse {
399 status,
400 error: format!("Failed to list domains by portal. {}", text),
401 }
402 .into())
403 }
404 }
405
406 pub(crate) async fn delete_domain(
407 &self,
408 access_token: &str,
409 domain_id: &str,
410 ) -> Result<(), DomainError> {
411 let response = self
412 .client
413 .delete(format!("{}/api/v1/domains/{}", self.dds_url, domain_id))
414 .bearer_auth(access_token)
415 .header("Content-Type", "application/json")
416 .header("posemesh-client-id", self.api_client.client_id.clone())
417 .header("posemesh-sdk-version", crate::VERSION)
418 .send()
419 .await?;
420 if response.status().is_success() {
421 Ok(())
422 } else {
423 let status = response.status();
424 let text = response
425 .text()
426 .await
427 .unwrap_or_else(|_| "Unknown error".to_string());
428 Err(AukiErrorResponse {
429 status,
430 error: format!("Failed to delete domain. {}", text),
431 }
432 .into())
433 }
434 }
435}