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