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