Skip to main content

systemprompt_cloud/
credentials.rs

1//! On-disk representation of authenticated cloud credentials.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::fs;
7use std::path::Path;
8
9use chrono::{DateTime, Duration, Utc};
10use serde::{Deserialize, Serialize};
11use systemprompt_identifiers::{CloudAuthToken, Email};
12use systemprompt_logging::CliService;
13use systemprompt_models::net::{HTTP_AUTH_VERIFY_TIMEOUT, HTTP_CONNECT_TIMEOUT};
14use validator::Validate;
15
16use crate::auth;
17use crate::error::{CloudError, CloudResult};
18
19#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
20pub struct CloudCredentials {
21    pub api_token: CloudAuthToken,
22
23    #[validate(url(message = "API URL must be a valid URL"))]
24    pub api_url: String,
25
26    pub authenticated_at: DateTime<Utc>,
27
28    pub user_email: Email,
29
30    #[serde(default)]
31    pub last_validated_at: Option<DateTime<Utc>>,
32}
33
34impl CloudCredentials {
35    #[must_use]
36    pub fn new(api_token: CloudAuthToken, api_url: String, user_email: Email) -> Self {
37        let now = Utc::now();
38        Self {
39            api_token,
40            api_url,
41            authenticated_at: now,
42            user_email,
43            last_validated_at: Some(now),
44        }
45    }
46
47    #[must_use]
48    pub const fn token(&self) -> &CloudAuthToken {
49        &self.api_token
50    }
51
52    #[must_use]
53    pub fn is_token_expired(&self) -> bool {
54        auth::is_expired(self.token())
55    }
56
57    #[must_use]
58    pub fn expires_within(&self, duration: Duration) -> bool {
59        auth::expires_within(self.token(), duration)
60    }
61
62    pub fn load_and_validate_from_path(path: &Path) -> CloudResult<Self> {
63        let creds = Self::load_from_path(path)?;
64
65        creds
66            .validate()
67            .map_err(|e| CloudError::CredentialsCorrupted {
68                source: serde_json::Error::io(std::io::Error::new(
69                    std::io::ErrorKind::InvalidData,
70                    e.to_string(),
71                )),
72            })?;
73
74        if creds.is_token_expired() {
75            return Err(CloudError::TokenExpired);
76        }
77
78        if creds.expires_within(Duration::hours(1)) {
79            CliService::warning(
80                "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
81                 refresh.",
82            );
83        }
84
85        Ok(creds)
86    }
87
88    pub async fn validate_with_api(&self) -> CloudResult<bool> {
89        let client = reqwest::Client::builder()
90            .connect_timeout(HTTP_CONNECT_TIMEOUT)
91            .timeout(HTTP_AUTH_VERIFY_TIMEOUT)
92            .build()?;
93
94        let response = client
95            .get(format!("{}/api/v1/auth/me", self.api_url))
96            .header(
97                "Authorization",
98                format!("Bearer {}", self.api_token.as_str()),
99            )
100            .send()
101            .await?;
102
103        Ok(response.status().is_success())
104    }
105
106    pub fn load_from_path(path: &Path) -> CloudResult<Self> {
107        if !path.exists() {
108            return Err(CloudError::NotAuthenticated);
109        }
110
111        let content = fs::read_to_string(path)?;
112
113        let creds: Self = serde_json::from_str(&content)
114            .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
115
116        creds
117            .validate()
118            .map_err(|e| CloudError::CredentialsCorrupted {
119                source: serde_json::Error::io(std::io::Error::new(
120                    std::io::ErrorKind::InvalidData,
121                    e.to_string(),
122                )),
123            })?;
124
125        Ok(creds)
126    }
127
128    pub fn save_to_path(&self, path: &Path) -> CloudResult<()> {
129        self.validate()
130            .map_err(|e| CloudError::CredentialsCorrupted {
131                source: serde_json::Error::io(std::io::Error::new(
132                    std::io::ErrorKind::InvalidData,
133                    e.to_string(),
134                )),
135            })?;
136
137        if let Some(dir) = path.parent() {
138            fs::create_dir_all(dir)?;
139
140            let gitignore_path = dir.join(".gitignore");
141            if !gitignore_path.exists() {
142                fs::write(&gitignore_path, "*\n")?;
143            }
144        }
145
146        let content = serde_json::to_string_pretty(self)?;
147        fs::write(path, content)?;
148
149        #[cfg(unix)]
150        {
151            use std::os::unix::fs::PermissionsExt;
152            let mut perms = fs::metadata(path)?.permissions();
153            perms.set_mode(0o600);
154            fs::set_permissions(path, perms)?;
155        }
156
157        Ok(())
158    }
159
160    pub fn delete_from_path(path: &Path) -> CloudResult<()> {
161        if path.exists() {
162            fs::remove_file(path)?;
163        }
164        Ok(())
165    }
166}