Skip to main content

systemprompt_cloud/
credentials.rs

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