Skip to main content

posemesh_domain_http/
discovery.rs

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    /// List domains with domain server without issue token
98    ///
99    /// - org: (required) The organization to list domains from:
100    ///   - "own": returns domains in your own organization.
101    ///   - a UUID: returns domains in that specific organization.
102    ///   - "all": returns domains across all organizations.
103    ///     Otherwise, 'domain_server_id' is required and the domain server must belong to your org.
104    ///     Not available for app tokens.
105    /// - domain_server_id: (optional) UUID of the domain server to filter domains. Ignored if a portal filter is active.
106    ///
107    /// # Access control
108    ///   - App tokens can only see domains where the app is on the domain's app allowlist
109    ///     (or the domain has no app allowlist).
110    ///   - User tokens can see all domains in their own org. When requesting domains outside
111    ///     their org, they can only see domains where their org is on the domain's user-org
112    ///     allowlist (or the domain has no user-org allowlist).
113    ///
114    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        // Check cache first
236        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        // Cache the result
294        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            // Cache the result
337            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    /// List domains by portal, portal_id or portal_short_id is required
358    /// If org is not provided, it will list domains for the current authorized organization
359    /// If org is provided, it will list domains for the specified organization
360    /// Set org to `all` to list domains for all organizations
361    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}