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
84/// Returns the gateway MAC address on native targets. On WASM/browser this always returns
85/// empty string — browsers cannot access network interfaces, gateway, or MAC addresses.
86fn get_mac_address() -> Result<String, DomainError> {
87    #[cfg(not(target_family = "wasm"))]
88    {
89        match default_net::get_default_gateway() {
90            Ok(gateway) => Ok(gateway.mac_addr.to_string()),
91            Err(_) => Err(DomainError::InvalidRequest("No gateway found")),
92        }
93    }
94
95    #[cfg(target_family = "wasm")]
96    {
97        // Browsers cannot access network interfaces, gateway IP, or MAC addresses.
98        // Return empty string so auth still works; server may use other identifiers.
99        Ok(String::new())
100    }
101}
102
103impl DiscoveryService {
104    pub fn new(api_url: &str, dds_url: &str, client_id: &str) -> Self {
105        let api_client = AuthClient::new(api_url, client_id);
106
107        Self {
108            dds_url: dds_url.to_string(),
109            client: Client::new(),
110            cache: Arc::new(Mutex::new(HashMap::new())),
111            api_client,
112            oidc_access_token: None,
113        }
114    }
115
116    /// List domains with domain server without issue token
117    ///
118    /// - org: (required) The organization to list domains from:
119    ///   - "own": returns domains in your own organization.
120    ///   - a UUID: returns domains in that specific organization.
121    ///   - "all": returns domains across all organizations.
122    ///     Otherwise, 'domain_server_id' is required and the domain server must belong to your org.
123    ///     Not available for app tokens.
124    /// - domain_server_id: (optional) UUID of the domain server to filter domains. Ignored if a portal filter is active.
125    ///
126    /// # Access control
127    ///   - App tokens can only see domains where the app is on the domain's app allowlist
128    ///     (or the domain has no app allowlist).
129    ///   - User tokens can see all domains in their own org. When requesting domains outside
130    ///     their org, they can only see domains where their org is on the domain's user-org
131    ///     allowlist (or the domain has no user-org allowlist).
132    ///
133    pub async fn list_domains(
134        &self,
135        org: &str,
136        domain_server_id: Option<&str>,
137    ) -> Result<ListDomainsResponse, DomainError> {
138        let access_token = self
139            .api_client
140            .get_dds_access_token(self.oidc_access_token.as_deref())
141            .await?;
142        let mut url = format!(
143            "{}/api/v1/domains?org={}&with=domain_server",
144            self.dds_url, org
145        );
146        if let Some(domain_server_id) = domain_server_id {
147            url.push_str(&format!("&domain_server_id={}", domain_server_id));
148        }
149        let response = self
150            .client
151            .get(&url)
152            .bearer_auth(access_token)
153            .header("Content-Type", "application/json")
154            .header("posemesh-client-id", self.api_client.client_id.clone())
155            .header("posemesh-sdk-version", crate::VERSION)
156            .header(
157                "posemesh-gateway-mac",
158                get_mac_address().unwrap_or_default(),
159            )
160            .send()
161            .await?;
162
163        if response.status().is_success() {
164            let domain_servers: ListDomainsResponse = response.json().await?;
165            Ok(domain_servers)
166        } else {
167            let status = response.status();
168            let text = response
169                .text()
170                .await
171                .unwrap_or_else(|_| "Unknown error".to_string());
172            Err(AukiErrorResponse {
173                status,
174                error: format!("Failed to list domains. {}", text),
175            }
176            .into())
177        }
178    }
179
180    pub async fn sign_in_with_auki_account(
181        &mut self,
182        email: &str,
183        password: &str,
184        remember_password: bool,
185    ) -> Result<String, DomainError> {
186        self.cache.lock().await.clear();
187        self.oidc_access_token = None;
188        let token = self.api_client.user_login(email, password).await?;
189        if remember_password {
190            let mut api_client = self.api_client.clone();
191            let email = email.to_string();
192            let password = password.to_string();
193            spawn(async move {
194                loop {
195                    let expires_at = api_client
196                        .get_expires_at()
197                        .await
198                        .inspect_err(|e| tracing::error!("Failed to get expires at: {}", e));
199                    if let Ok(expires_at) = expires_at {
200                        let expiration = {
201                            let now = now_unix_secs();
202                            let duration = expires_at - now;
203                            if duration > REFRESH_CACHE_TIME {
204                                Some(Duration::from_secs(duration))
205                            } else {
206                                None
207                            }
208                        };
209
210                        if let Some(expiration) = expiration {
211                            tracing::info!("Refreshing token in {} seconds", expiration.as_secs());
212                            sleep(expiration).await;
213                        }
214
215                        let _ = api_client
216                            .user_login(&email, &password)
217                            .await
218                            .inspect_err(|e| tracing::error!("Failed to relogin: {}", e));
219                    }
220                }
221            });
222        }
223        Ok(token)
224    }
225
226    pub async fn sign_in_as_auki_app(
227        &mut self,
228        app_key: &str,
229        app_secret: &str,
230    ) -> Result<String, DomainError> {
231        self.cache.lock().await.clear();
232        self.oidc_access_token = None;
233        self.api_client
234            .sign_in_with_app_credentials(app_key, app_secret)
235            .await
236    }
237
238    pub fn with_oidc_access_token(&self, oidc_access_token: &str) -> Self {
239        if let Some(cached_oidc_access_token) = self.oidc_access_token.as_deref()
240            && cached_oidc_access_token == oidc_access_token
241        {
242            return self.clone();
243        }
244        Self {
245            dds_url: self.dds_url.clone(),
246            client: self.client.clone(),
247            cache: Arc::new(Mutex::new(HashMap::new())),
248            api_client: AuthClient::new(&self.api_client.api_url, &self.api_client.client_id),
249            oidc_access_token: Some(oidc_access_token.to_string()),
250        }
251    }
252
253    pub async fn auth_domain(&self, domain_id: &str) -> Result<DomainWithToken, DomainError> {
254        let access_token = self
255            .api_client
256            .get_dds_access_token(self.oidc_access_token.as_deref())
257            .await?;
258        // Check cache first
259        let cache = if let Some(cached_domain) = self.cache.lock().await.get(domain_id) {
260            cached_domain.clone()
261        } else {
262            DomainWithToken {
263                domain: DomainWithServer {
264                    id: domain_id.to_string(),
265                    name: "".to_string(),
266                    organization_id: "".to_string(),
267                    domain_server_id: "".to_string(),
268                    redirect_url: None,
269                    domain_server: DomainServer {
270                        id: "".to_string(),
271                        organization_id: "".to_string(),
272                        name: "".to_string(),
273                        url: "".to_string(),
274                    },
275                },
276                expires_at: 0,
277                access_token: "".to_string(),
278            }
279        };
280
281        let cached = get_cached_or_fresh_token(&cache, || {
282            let client = self.client.clone();
283            let dds_url = self.dds_url.clone();
284            let client_id = self.api_client.client_id.clone();
285            async move {
286                let mac_address = get_mac_address().unwrap_or_default();
287                let response = client
288                    .post(format!("{}/api/v1/domains/{}/auth", dds_url, domain_id))
289                    .bearer_auth(access_token)
290                    .header("Content-Type", "application/json")
291                    .header("posemesh-client-id", client_id)
292                    .header("posemesh-sdk-version", crate::VERSION)
293                    .header("posemesh-gateway-mac", mac_address)
294                    .send()
295                    .await?;
296
297                if response.status().is_success() {
298                    let mut domain_with_token: DomainWithToken = response.json().await?;
299                    domain_with_token.expires_at =
300                        parse_jwt(&domain_with_token.get_access_token())?.exp;
301                    Ok(domain_with_token)
302                } else {
303                    let status = response.status();
304                    let text = response
305                        .text()
306                        .await
307                        .unwrap_or_else(|_| "Unknown error".to_string());
308                    Err(AukiErrorResponse {
309                        status,
310                        error: format!("Failed to auth domain. {}", text),
311                    }
312                    .into())
313                }
314            }
315        })
316        .await?;
317
318        // Cache the result
319        let mut cache = self.cache.lock().await;
320        cache.insert(domain_id.to_string(), cached.clone());
321        Ok(cached)
322    }
323
324    pub async fn create_domain(
325        &self,
326        name: &str,
327        domain_server_id: Option<String>,
328        domain_server_url: Option<String>,
329        redirect_url: Option<String>,
330    ) -> Result<DomainWithToken, DomainError> {
331        let domain_server_id = domain_server_id.unwrap_or_default();
332        let domain_server_url = domain_server_url.unwrap_or_default();
333        if domain_server_id.is_empty() && domain_server_url.is_empty() {
334            return Err(DomainError::InvalidRequest(
335                "domain_server_id or domain_server_url is required",
336            ));
337        }
338        let access_token: String = self
339            .api_client
340            .get_dds_access_token(self.oidc_access_token.as_deref())
341            .await?;
342        let response = self
343            .client
344            .post(format!("{}/api/v1/domains?issue_token=true", self.dds_url))
345            .bearer_auth(access_token)
346            .header("Content-Type", "application/json")
347            .header("posemesh-client-id", self.api_client.client_id.clone())
348            .header("posemesh-sdk-version", crate::VERSION)
349            .header(
350                "posemesh-gateway-mac",
351                get_mac_address().unwrap_or_default(),
352            )
353            .json(&CreateDomainRequest {
354                name: name.to_string(),
355                domain_server_id: domain_server_id.to_string(),
356                redirect_url,
357                domain_server_url: domain_server_url.to_string(),
358            })
359            .send()
360            .await?;
361
362        if response.status().is_success() {
363            let mut domain_with_token: DomainWithToken = response.json().await?;
364            domain_with_token.expires_at = parse_jwt(&domain_with_token.get_access_token())?.exp;
365            // Cache the result
366            let mut cache = self.cache.lock().await;
367            cache.insert(
368                domain_with_token.domain.id.clone(),
369                domain_with_token.clone(),
370            );
371            Ok(domain_with_token)
372        } else {
373            let status = response.status();
374            let text = response
375                .text()
376                .await
377                .unwrap_or_else(|_| "Unknown error".to_string());
378            Err(AukiErrorResponse {
379                status,
380                error: format!("Failed to create domain. {}", text),
381            }
382            .into())
383        }
384    }
385
386    /// List domains by portal, portal_id or portal_short_id is required
387    /// If org is not provided, it will list domains for the current authorized organization
388    /// If org is provided, it will list domains for the specified organization
389    /// Set org to `all` to list domains for all organizations
390    pub async fn list_domains_by_portal(
391        &self,
392        portal_id: Option<&str>,
393        portal_short_id: Option<&str>,
394        org: &str,
395    ) -> Result<ListDomainsResponse, DomainError> {
396        let access_token: String = self
397            .api_client
398            .get_dds_access_token(self.oidc_access_token.as_deref())
399            .await?;
400        if portal_id.is_none() && portal_short_id.is_none() {
401            return Err(DomainError::InvalidRequest(
402                "portal_id or portal_short_id is required",
403            ));
404        }
405        let id = portal_id.or(portal_short_id).unwrap();
406        let response = self
407            .client
408            .get(format!(
409                "{}/api/v1/lighthouses/{}/domains?with=domain_server,lighthouse&org={}",
410                self.dds_url, id, org
411            ))
412            .bearer_auth(access_token)
413            .header("Content-Type", "application/json")
414            .header("posemesh-client-id", self.api_client.client_id.clone())
415            .header("posemesh-sdk-version", crate::VERSION)
416            .header(
417                "posemesh-gateway-mac",
418                get_mac_address().unwrap_or_default(),
419            )
420            .send()
421            .await?;
422        if response.status().is_success() {
423            let domains: ListDomainsResponse = response.json().await?;
424            Ok(domains)
425        } else {
426            let status = response.status();
427            let text = response
428                .text()
429                .await
430                .unwrap_or_else(|_| "Unknown error".to_string());
431            Err(AukiErrorResponse {
432                status,
433                error: format!("Failed to list domains by portal. {}", text),
434            }
435            .into())
436        }
437    }
438
439    pub(crate) async fn delete_domain(
440        &self,
441        access_token: &str,
442        domain_id: &str,
443    ) -> Result<(), DomainError> {
444        let response = self
445            .client
446            .delete(format!("{}/api/v1/domains/{}", self.dds_url, domain_id))
447            .bearer_auth(access_token)
448            .header("Content-Type", "application/json")
449            .header("posemesh-client-id", self.api_client.client_id.clone())
450            .header("posemesh-sdk-version", crate::VERSION)
451            .header(
452                "posemesh-gateway-mac",
453                get_mac_address().unwrap_or_default(),
454            )
455            .send()
456            .await?;
457        if response.status().is_success() {
458            Ok(())
459        } else {
460            let status = response.status();
461            let text = response
462                .text()
463                .await
464                .unwrap_or_else(|_| "Unknown error".to_string());
465            Err(AukiErrorResponse {
466                status,
467                error: format!("Failed to delete domain. {}", text),
468            }
469            .into())
470        }
471    }
472}